From 19087c64671af8ff510f314cef35cff1c5486491 Mon Sep 17 00:00:00 2001 From: bigben-7 Date: Sun, 26 Jul 2026 20:36:51 +0100 Subject: [PATCH] feat: monolith split plan, event migration, storage TTL audit, and overflow audit - SC-08: Created MONOLITH_SPLIT_PLAN.md identifying 6 extraction groups with dependency graph and phased approach; expanded membership_token crate with full token/transfer/metadata/renewal/pause logic; created staking_rewards crate; added 4 stub crates (subscription_tier, attendance_batch, token_upgrade, pause_control) - SC-09: Created EVENT_MIGRATION.md with full inventory of all event publish calls across all 5 contracts, migration patterns, and priority recommendations; added #[contractevent] annotations to resource_credits (CreditsMinted, CreditsTransferred, CreditsSpent) and workspace_booking (ContractInitialized, WorkspaceRegistered, WorkspaceAvailabilityChanged, WorkspaceRateChanged, WorkspaceBooked, BookingCancelled, BookingCompleted) - SC-10: Created STORAGE_AUDIT.md documenting all persistent storage entries across all contracts; added TTL bump/extend_ttl calls to every persistent write path in resource_credits, workspace_booking, membership_token (standalone + monolith), subscription, attendance_log, fractionalization, staking, staking_rewards, and upgrade modules - SC-11: Created OVERFLOW_AUDIT.md documenting all arithmetic operations; replaced unchecked arithmetic with checked_add/checked_mul in resource_credits and workspace_booking; replaced += with saturating_add for analytics counters in subscription, version increments in membership_token, and duration accumulators in attendance_log Closes #1524, #1525, #1526, #1527 --- contracts/Cargo.lock | 36 +- contracts/Cargo.toml | 7 +- contracts/EVENT_MIGRATION.md | 149 ++++++++ contracts/MONOLITH_SPLIT_PLAN.md | 123 +++++++ contracts/OVERFLOW_AUDIT.md | 76 ++++ contracts/STORAGE_AUDIT.md | 119 +++++++ contracts/attendance_batch/Cargo.toml | 15 + contracts/attendance_batch/src/lib.rs | 10 + contracts/manage_hub/src/attendance_log.rs | 21 +- contracts/manage_hub/src/fractionalization.rs | 13 + contracts/manage_hub/src/membership_token.rs | 33 +- contracts/manage_hub/src/staking.rs | 8 + contracts/manage_hub/src/subscription.rs | 48 ++- contracts/manage_hub/src/upgrade.rs | 18 + contracts/membership_token/Cargo.toml | 3 +- contracts/membership_token/src/errors.rs | 26 ++ contracts/membership_token/src/lib.rs | 322 +++++++++++++++-- contracts/membership_token/src/types.rs | 290 +++++++++++++++ contracts/pause_control/Cargo.toml | 15 + contracts/pause_control/src/lib.rs | 10 + contracts/resource_credits/src/errors.rs | 2 + contracts/resource_credits/src/lib.rs | 73 +++- contracts/staking_rewards/Cargo.toml | 18 + contracts/staking_rewards/src/errors.rs | 17 + contracts/staking_rewards/src/lib.rs | 330 ++++++++++++++++++ contracts/staking_rewards/src/types.rs | 36 ++ contracts/subscription_tier/Cargo.toml | 15 + contracts/subscription_tier/src/lib.rs | 12 + contracts/token_upgrade/Cargo.toml | 15 + contracts/token_upgrade/src/lib.rs | 10 + contracts/workspace_booking/src/errors.rs | 3 + contracts/workspace_booking/src/lib.rs | 126 +++++-- 32 files changed, 1905 insertions(+), 94 deletions(-) create mode 100644 contracts/EVENT_MIGRATION.md create mode 100644 contracts/MONOLITH_SPLIT_PLAN.md create mode 100644 contracts/OVERFLOW_AUDIT.md create mode 100644 contracts/STORAGE_AUDIT.md create mode 100644 contracts/attendance_batch/Cargo.toml create mode 100644 contracts/attendance_batch/src/lib.rs create mode 100644 contracts/membership_token/src/errors.rs create mode 100644 contracts/membership_token/src/types.rs create mode 100644 contracts/pause_control/Cargo.toml create mode 100644 contracts/pause_control/src/lib.rs create mode 100644 contracts/staking_rewards/Cargo.toml create mode 100644 contracts/staking_rewards/src/errors.rs create mode 100644 contracts/staking_rewards/src/lib.rs create mode 100644 contracts/staking_rewards/src/types.rs create mode 100644 contracts/subscription_tier/Cargo.toml create mode 100644 contracts/subscription_tier/src/lib.rs create mode 100644 contracts/token_upgrade/Cargo.toml create mode 100644 contracts/token_upgrade/src/lib.rs diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index a5dd572b..ba6611ef 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -157,6 +157,13 @@ dependencies = [ "rand", ] +[[package]] +name = "attendance_batch" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -848,7 +855,6 @@ dependencies = [ name = "membership_token" version = "1.0.0" dependencies = [ - "access_control", "soroban-sdk", ] @@ -927,6 +933,13 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pause_control" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "payment_escrow" version = "0.0.0" @@ -1464,6 +1477,13 @@ dependencies = [ "der", ] +[[package]] +name = "staking_rewards" +version = "1.0.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -1505,6 +1525,13 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subscription_tier" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1584,6 +1611,13 @@ dependencies = [ "time-core", ] +[[package]] +name = "token_upgrade" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "typenum" version = "1.18.0" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index a6c082ce..b20680b3 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -8,6 +8,11 @@ members = [ "workspace_booking", "payment_escrow", "resource_credits", + "staking_rewards", + "subscription_tier", + "attendance_batch", + "token_upgrade", + "pause_control", ] [workspace.dependencies] @@ -28,4 +33,4 @@ lto = true # For more information about this profile see https://soroban.stellar.org/docs/basic-tutorials/logging#cargotoml-profile [profile.release-with-logs] inherits = "release" -debug-assertions = true \ No newline at end of file +debug-assertions = true diff --git a/contracts/EVENT_MIGRATION.md b/contracts/EVENT_MIGRATION.md new file mode 100644 index 00000000..312b9356 --- /dev/null +++ b/contracts/EVENT_MIGRATION.md @@ -0,0 +1,149 @@ +# Event Migration Audit + +## Overview + +This document audits all `env.events().publish()` usage across the ManageHub contracts +and identifies migration targets for the `#[contractevent]` attribute macro. + +## Migration Pattern + +The deprecated pattern: +```rust +env.events().publish( + (symbol_short!("event_name"), key1, key2), + (data1, data2), +); +``` + +Should be migrated to: +```rust +#[contractevent] +pub struct EventName { + pub key1: Address, + pub key2: BytesN<32>, + pub data1: u64, + pub data2: i128, +} +``` + +## Event Inventory + +### `manage_hub` Contract + +| Location | Event Symbol | Keys | Data | Migration Status | +|----------|-------------|------|------|-----------------| +| `membership_token.rs:122` | `token_iss` | id, user | admin, timestamp, expiry, status | Candidate | +| `membership_token.rs:198` | `token_xfr` | id, new_user | old_user, timestamp | Candidate | +| `membership_token.rs:225` | `tok_sale` | id, new_user | sale_price, timestamp | Candidate | +| `membership_token.rs:320-327` | `token_xfr`/`token_dlg` | id, to/spender | old_user, timestamp, allowance | Candidate | +| `membership_token.rs:390` | `admin_set` | admin | timestamp | Candidate | +| `membership_token.rs:604` | `meta_set` | token_id, version | caller, timestamp | Candidate | +| `membership_token.rs:743` | `meta_upd` | token_id, version | caller, timestamp | Candidate | +| `membership_token.rs:819` | `meta_rmv` | token_id, version | caller, timestamp | Candidate | +| `membership_token.rs:915` | `rnw_cfg` | admin | grace_period, notice_days, enabled | Candidate | +| `membership_token.rs:1064` | `token_rnw` | id, user | payment_token, amount, old_expiry, new_expiry | Candidate | +| `membership_token.rs:1145` | `grace_in` | id, user | entered_at, expires_at | Candidate | +| `membership_token.rs:1198` | `auto_rnw` | token_id, user | enabled, payment_token | Candidate | +| `membership_token.rs:1343` | `auto_ok` | id, user | payment_token, amount, old_expiry, new_expiry | Candidate | +| `membership_token.rs:1401` | `emg_pause` | admin | timestamp, reason, auto_unpause_at, time_lock_until | Candidate | +| `membership_token.rs:1450` | `emg_unp` | admin | timestamp | Candidate | +| `membership_token.rs:1520` | `tok_pause` | token_id, admin | timestamp, reason | Candidate | +| `membership_token.rs:1568` | `tok_unp` | token_id, admin | timestamp | Candidate | +| `membership_token.rs:1603` | `grace_ar` | id, user | entered_at, expires_at, reason | Candidate | +| `allowance.rs:49` | `Approval` | token_id, owner, spender | amount, expires_at, timestamp | Candidate | +| `allowance.rs:71` | `AllowanceRevoked` | token_id, owner, spender | timestamp | Candidate | +| `allowance.rs:141` | `AllowanceUsed` | token_id, owner, spender | amount, remaining, timestamp | Candidate | +| `fractionalization.rs:65` | `Fractionalized` | token_id, user | total_shares, min_fraction, timestamp | Candidate | +| `fractionalization.rs:126` | `FractionTransferred` | token_id, from | to, share_amount, timestamp | Candidate | +| `fractionalization.rs:172` | `Recombined` | token_id, holder | timestamp | Candidate | +| `fractionalization.rs:289` | `DividendDistributed` | token_id, admin | total_amount, recipients, timestamp | Candidate | +| `royalty.rs:46` | `roy_set` | token_id | recipient_count, timestamp | Candidate | +| `royalty.rs:92` | `roy_paid` | token_id, recipient | payment_token, amount, timestamp | Candidate | +| `subscription.rs:172` | `sub_creat` | id, user | payment_token, amount, created_at, expires_at | Candidate | +| `subscription.rs:272` | `subscr` | id, user | PauseHistoryEntry | Candidate | +| `subscription.rs:372` | `subscr` | id, user | PauseHistoryEntry | Candidate | +| `subscription.rs:427` | `usdc_set` | usdc_address | admin, timestamp | Candidate | +| `subscription.rs:463` | `sub_cancl` | id, user | timestamp, old_status, new_status | Candidate | +| `subscription.rs:535` | `sub_renew` | id, user | payment_token, amount, old_expiry, new_expiry | Candidate | +| `subscription.rs:693` | `tier_crt` | tier_id, admin | name, level, price, timestamp | Candidate | +| `subscription.rs:747` | `tier_upd` | tier_id, admin | timestamp | Candidate | +| `subscription.rs:814` | `tier_dea` | tier_id, admin | timestamp | Candidate | +| `subscription.rs:902` | `sub_creat` | id, user | tier_id, final_price, created_at, expires_at | Candidate | +| `subscription.rs:1011` | `tier_chg` | change_id, user | from_tier, to_tier, change_type, prorated_amount | Candidate | +| `subscription.rs:1090` | `tier_cmp` | change_id, user | old_tier, new_tier, prorated_amount | Candidate | +| `subscription.rs:1135` | `tier_cnc` | change_id, user | timestamp | Candidate | +| `subscription.rs:1198` | `promo_cr` | promo_id, admin | tier_id, discount, start_date, end_date | Candidate | +| `batch.rs:25` | `bat_mint` | (none) | count, timestamp | Candidate | +| `batch.rs:44` | `bat_xfr` | (none) | count, timestamp | Candidate | +| `batch.rs:63` | `bat_upd` | (none) | count, timestamp | Candidate | +| `upgrade.rs:161` | `TokenUpgraded` | token_id, caller | from_version, to_version | Candidate | +| `attendance_log.rs:84` | `attend` | id, user_id | action | Candidate | + +### `resource_credits` Contract + +| Location | Event Symbol | Keys | Data | Migration Status | +|----------|-------------|------|------|-----------------| +| `lib.rs:80` | `mint` | recipient | amount | **Migrated below** | +| `lib.rs:121` | `transfer` | from, to | amount | **Migrated below** | +| `lib.rs:157` | `spend` | member | amount | **Migrated below** | + +### `workspace_booking` Contract + +| Location | Event Symbol | Keys | Data | Migration Status | +|----------|-------------|------|------|-----------------| +| `lib.rs:114` | `init` | (none) | admin, payment_token | **Migrated below** | +| `lib.rs:175` | `ws_reg` | id | name, type, capacity, hourly_rate | **Migrated below** | +| `lib.rs:207` | `ws_avail` | workspace_id | is_available | **Migrated below** | +| `lib.rs:236` | `ws_rate` | workspace_id | hourly_rate | **Migrated below** | +| `lib.rs:344` | `booked` | booking_id | member, workspace_id, start, end, amount | **Migrated below** | +| `lib.rs:385` | `cancel` | booking_id | caller, refund_amount | **Migrated below** | +| `lib.rs:414` | `complete` | booking_id | workspace_id, member | **Migrated below** | + +### `payment_escrow` Contract + +| Location | Event Symbol | Keys | Data | Migration Status | +|----------|-------------|------|------|-----------------| +| `lib.rs:114` | `init` | (none) | admin, payment_token, dispute_window | Candidate | +| `lib.rs:131` | `dw_set` | (none) | window_secs | Candidate | +| `lib.rs:218` | `created` | escrow_id | depositor, beneficiary, amount, release_after | Candidate | +| `lib.rs:247` | `released` | escrow_id | beneficiary, amount | Candidate | +| `lib.rs:274` | `refunded` | escrow_id | depositor, amount | Candidate | +| `lib.rs:313` | `disputed` | escrow_id | depositor, timestamp | Candidate | +| `lib.rs:358` | `resolved` | escrow_id | recipient, amount, release_to_beneficiary | Candidate | +| `lib.rs:400` | `claimed` | escrow_id | beneficiary, amount | Candidate | + +## Migration Recommendations + +### High Priority (value-handling contracts) + +1. **`resource_credits`** - Credit operations (mint, transfer, spend) should use + `#[contractevent]` for reliable off-chain indexing. These events carry financial + data that indexers must not miss. + +2. **`workspace_booking`** - Booking events (booked, cancel, complete) should use + `#[contractevent]` for reliable booking lifecycle tracking. + +3. **`manage_hub` subscription events** - Payment-related events (`sub_creat`, + `sub_renew`, `sub_cancl`, `tier_crt`) should be migrated early. + +### Medium Priority (operational events) + +4. **`manage_hub` token events** - Token lifecycle events (`token_iss`, `token_xfr`, + `token_rnw`) are important but less time-sensitive. + +5. **`payment_escrow`** - Escrow events are important for fund tracking. + +### Low Priority (diagnostic events) + +6. **Analytics/admin events** - Tier analytics updates, metadata changes, and + configuration events can be migrated later. + +## Migration Pattern for Remaining Contracts + +For contracts not yet migrated, maintain consistency by keeping `#[allow(deprecated)]` +at the top of the file and documenting the planned migration timeline. When migrating: + +1. Define the event struct with `#[contractevent]` (must be in a `#[contract]` module) +2. Use typed fields instead of tuples +3. The event name defaults to the struct name (PascalCase → SnakeCase for topic) +4. Ensure all fields are `IntoVal`/`FromVal` compatible with Soroban diff --git a/contracts/MONOLITH_SPLIT_PLAN.md b/contracts/MONOLITH_SPLIT_PLAN.md new file mode 100644 index 00000000..5616b42e --- /dev/null +++ b/contracts/MONOLITH_SPLIT_PLAN.md @@ -0,0 +1,123 @@ +# ManageHub Monolith Split Plan + +## Current State + +The `manage_hub` contract is a monolith with **11,018 lines** across 20 modules. +All logic lives in a single `#[contract]` with a single `#[contractimpl]` block. + +## Proposed Crate Extraction + +### Group 1: `membership_token` (expand existing crate) +**Modules:** `membership_token.rs` (1,614 lines), `allowance.rs` (160 lines), `fractionalization.rs` (337 lines) +**New crate:** `contracts/membership_token/` (expand existing) + +**Rationale:** Core token logic (issue, transfer, metadata, renewal, allowance, fractionalization) +forms a cohesive unit. The existing `membership_token` crate already provides a basic +standalone contract; this expansion brings in the full feature set from the monolith. + +**Dependencies on manage_hub:** Uses `errors::Error`, `guards::PauseGuard`, `types::*`. +After extraction, these will be local to the crate. + +### Group 2: `staking_rewards` (new crate) +**Modules:** `staking.rs` (396 lines), `rewards.rs` (65 lines), `staking_errors.rs` (43 lines) +**New crate:** `contracts/staking_rewards/` + +**Rationale:** Staking and rewards form a self-contained subsystem. They share storage keys, +types, and error handling but have minimal coupling to the rest of the monolith. + +**Dependencies on manage_hub:** Uses `membership_token::DataKey::Admin` for admin checks, +`types::StakeInfo`, `types::StakingConfig`, `types::StakingTier`. These will be moved to +the new crate. + +### Group 3: `subscription_tier` (stub) +**Modules:** `subscription.rs` (1,489 lines) +**Proposed crate:** `contracts/subscription_tier/` + +**Rationale:** Subscription management with tier support is large enough to warrant +its own crate. It includes tier CRUD, promotions, feature access, analytics, and pause/resume. + +**Key dependencies:** `membership_token::DataKey::Admin`, `attendance_log::AttendanceLogModule`, +`types::*`. Would need shared types in `common_types`. + +### Group 4: `attendance_batch` (stub) +**Modules:** `attendance_log.rs` (531 lines), `batch.rs` (72 lines), `validation.rs` (22 lines) +**Proposed crate:** `contracts/attendance_batch/` + +**Rationale:** Attendance logging with batch operations and analytics is a natural group. +Batch operations call into membership_token, so the interface boundary would need +cross-contract calls or trait abstractions. + +### Group 5: `token_upgrade` (stub) +**Modules:** `upgrade.rs` (430 lines), `migration.rs` (159 lines), `upgrade_errors.rs` (43 lines) +**Proposed crate:** `contracts/token_upgrade/` + +**Rationale:** Token versioning, snapshots, and rollback logic is self-contained except +for its dependency on `MembershipToken` type and `DataKey::Admin`. + +### Group 6: `pause_control` (stub) +**Modules:** `guards.rs` (130 lines), `pause_errors.rs` (32 lines) +**Proposed crate:** `contracts/pause_control/` + +**Rationale:** Pause state management is small but conceptually independent. Could be +shared across multiple contracts. + +## Dependency Graph + +``` + common_types + | + +-------+-------+-------+-------+ + | | | | | + pause_control | staking_rewards token_upgrade + | | | | | + v v v v v + membership_token <--- subscription_tier + | ^ ^ + | | | + v | | + attendance_batch royalty +``` + +## Extraction Phases + +### Phase 1 (This PR) +- [x] Expand `membership_token` crate with full types from monolith +- [x] Create `staking_rewards` crate +- [x] Create stub crates for remaining groups +- [x] Update workspace `Cargo.toml` +- [x] Document inter-module dependencies + +### Phase 2 (Future) +- [ ] Extract `subscription_tier` crate with full subscription logic +- [ ] Extract `attendance_batch` crate +- [ ] Extract `token_upgrade` crate +- [ ] Extract `pause_control` crate +- [ ] Convert monolith to a thin facade that calls extracted crates + +### Phase 3 (Future) +- [ ] Replace cross-crate type references with shared `common_types` +- [ ] Add integration tests across extracted crates +- [ ] Remove monolith facade entirely + +## Shared Types (move to `common_types`) + +Types that are used across multiple proposed crates should live in `common_types`: +- `MembershipStatus` (already in common_types) +- `StakeInfo`, `StakingConfig`, `StakingTier` +- `Subscription`, `SubscriptionTier`, `BillingCycle` +- `AttendanceAction`, `AttendanceSummary` +- `EmergencyPauseState`, `TokenPauseState` +- `UpgradeConfig`, `UpgradeRecord`, `TokenVersionSnapshot` +- `RenewalConfig`, `RenewalHistory`, `AutoRenewalSettings` +- `TokenAllowance`, `RoyaltyConfig`, `RoyaltyInfo` +- `FractionalTokenInfo`, `FractionHolder`, `DividendDistribution` +- All error enums + +## Risks and Mitigations + +| Risk | Mitigation | +|------|-----------| +| Breaking existing deployments | Keep monolith as facade; new crates are additive | +| Circular dependencies | Phase extraction carefully; use shared types crate | +| Test coverage gaps | Run existing test suite after each extraction | +| Storage key conflicts | Use crate-prefixed storage keys in new crates | diff --git a/contracts/OVERFLOW_AUDIT.md b/contracts/OVERFLOW_AUDIT.md new file mode 100644 index 00000000..e56d03b9 --- /dev/null +++ b/contracts/OVERFLOW_AUDIT.md @@ -0,0 +1,76 @@ +# Overflow Audit (SC-11) + +Audited: 2026-07-26 + +## Summary + +The workspace `Cargo.toml` enables `overflow-checks = true` in release mode, +which panics on arithmetic overflow. However, panicking on overflow is not +graceful — it aborts the transaction without a meaningful error code. All +financial and balance-sensitive arithmetic should use `checked_*` or +`saturating_*` to return explicit `Error` variants. + +## Findings + +### resource_credits — Fixed + +| Location | Operation | Risk | Fix | +|---|---|---|---| +| `mint_credits:92` | `bal + amount` | u128 overflow → panic | `checked_add` + `Error::Overflow` | +| `mint_credits:101` | `supply + amount` | u128 overflow → panic | `checked_add` + `Error::Overflow` | +| `transfer_credits:141` | `to_bal + amount` | u128 overflow → panic | `checked_add` + `Error::Overflow` | + +### workspace_booking — Fixed + +| Location | Operation | Risk | Fix | +|---|---|---|---| +| `book_workspace:345` | `hourly_rate * duration_hours` | u128 overflow → panic | `checked_mul` + `Error::Overflow` | + +### manage_hub/subscription.rs — Fixed + +| Location | Operation | Risk | Fix | +|---|---|---|---| +| `update_tier_analytics_on_subscribe:1357` | `active_subscribers += 1` | u32 overflow → panic | `saturating_add` | +| `update_tier_analytics_on_subscribe:1358` | `total_revenue += amount` | i128 overflow → panic | `saturating_add` | +| `update_tier_analytics_on_change:1381` | `downgrades_count += 1` | u32 overflow → panic | `saturating_add` | +| `update_tier_analytics_on_change:1391` | `active_subscribers += 1` | u32 overflow → panic | `saturating_add` | +| `update_tier_analytics_on_change:1393` | `upgrades_count += 1` | u32 overflow → panic | `saturating_add` | +| `apply_promotion:1264` | `current_redemptions += 1` | u32 overflow → panic | `saturating_add` | +| `proration:1455` | `price / (days)` | Division by zero if total_seconds/days == 0 | Guard + Error | + +### manage_hub/membership_token.rs — Fixed + +| Location | Operation | Risk | Fix | +|---|---|---|---| +| `set_token_metadata:537` | `existing_metadata.version + 1` | u32 overflow → panic | `saturating_add` | +| `update_token_metadata:712` | `metadata.version += 1` | u32 overflow → panic | `saturating_add` | +| `remove_metadata_attributes:809` | `metadata.version += 1` | u32 overflow → panic | `saturating_add` | + +### manage_hub/attendance_log.rs — Fixed + +| Location | Operation | Risk | Fix | +|---|---|---|---| +| `get_attendance_summary:157` | `total_duration += duration` | u64 overflow → panic | `saturating_add` | +| `get_user_statistics:315` | `total_duration += session.duration` | u64 overflow → panic | `saturating_add` | +| `analyze_peak_hours:389` | `count + 1` (u32 in Map) | u32 overflow → panic | `saturating_add` | +| `analyze_day_patterns:446` | `count + 1` (u32 in Map) | u32 overflow → panic | `saturating_add` | +| `calculate_attendance_frequency:256` | `checked_div` | Already safe | — | + +### Already Safe (no changes needed) + +| Contract | Reason | +|---|---| +| `manage_hub/staking.rs` | All arithmetic uses `checked_*` | +| `manage_hub/rewards.rs` | All arithmetic uses `checked_*` | +| `manage_hub/fractionalization.rs` | All arithmetic uses `checked_*` | +| `manage_hub/upgrade.rs` | Version increment uses `checked_add` | +| `staking_rewards` | All arithmetic uses `checked_*` | +| `membership_token` (standalone) | Simple operations, no complex arithmetic | + +## Approach + +- **Balance / financial arithmetic** → `checked_add`, `checked_sub`, `checked_mul`, + `checked_div` returning `Error::Overflow` (or contract-specific overflow errors). +- **Counters / analytics** → `saturating_add` / `saturating_sub` (clamping at + max value is acceptable for non-financial counters). +- **Duration / timestamp** → `checked_add` to prevent timestamp overflow. diff --git a/contracts/STORAGE_AUDIT.md b/contracts/STORAGE_AUDIT.md new file mode 100644 index 00000000..ec257c9d --- /dev/null +++ b/contracts/STORAGE_AUDIT.md @@ -0,0 +1,119 @@ +# Storage TTL Audit (SC-10) + +Audited: 2026-07-26 + +## Summary + +Soroban persistent storage entries expire after a default TTL if not extended. +All contracts below now have explicit `bump` / `extend_ttl` calls on every +persistent write path, with TTL constants chosen to match data lifetime. + +| Storage Class | Default TTL | Needs Bump? | +|---|---|---| +| `instance` | Lives as long as the contract | No | +| `persistent` | ~14 days (2,016,000 ledgers) | Yes, for active data | +| `temporary` | 409,600 ledgers (~23 days) | By design | + +## TTL Constants + +| Constant | Value (ledgers) | Approx. Days | Used For | +|---|---|---|---| +| `STAKE_TTL_LEDGERS` | 518,400 | ~30 | Staking stakes | +| `UPGRADE_HISTORY_TTL_LEDGERS` | 1,555,200 | ~90 | Upgrade history | +| `VERSION_SNAPSHOT_TTL_LEDGERS` | 1,555,200 | ~90 | Version snapshots | +| `TOKEN_TTL_LEDGERS` | 1,555,200 | ~90 | Membership tokens | +| `BALANCE_TTL_LEDGERS` | 1,555,200 | ~90 | Credit balances | +| `BOOKING_TTL_LEDGERS` | 518,400 | ~30 | Workspace bookings | +| `SUBSCRIPTION_TTL_LEDGERS` | 518,400 | ~30 | Subscriptions | +| `TIER_TTL_LEDGERS` | 1,555,200 | ~90 | Subscription tiers | +| `LOG_TTL_LEDGERS` | 518,400 | ~30 | Attendance logs | +| `PROMO_TTL_LEDGERS` | 518,400 | ~30 | Promotions | + +## Per-Contract Findings + +### resource_credits + +| Key | Storage | Had Bump? | Fixed? | +|---|---|---|---| +| `Balance(addr)` | persistent | No | **Yes** | +| `TotalSupply` | instance | N/A | — | +| `Admin` | instance | N/A | — | +| `PaymentToken` | instance | N/A | — | + +### workspace_booking + +| Key | Storage | Had Bump? | Fixed? | +|---|---|---|---| +| `Workspace(id)` | persistent | No | **Yes** | +| `Booking(id)` | persistent | No | **Yes** | +| `MemberBookings(addr)` | persistent | No | **Yes** | +| `WorkspaceBookings(id)` | persistent | No | **Yes** | +| `WorkspaceList` | instance | N/A | — | +| `Admin` | instance | N/A | — | +| `PaymentToken` | instance | N/A | — | + +### membership_token (standalone crate) + +| Key | Storage | Had Bump? | Fixed? | +|---|---|---|---| +| `Token(id)` | persistent | No | **Yes** | +| `Metadata(id)` | persistent | No | **Yes** | +| `MetadataHistory(id)` | persistent | No | **Yes** | +| `MetadataIndex(k,v)` | persistent | No | **Yes** | +| `RenewalConfig` | instance | N/A | — | +| `EmergencyPauseState` | instance | N/A | — | + +### manage_hub/membership_token.rs + +| Key | Storage | Had Bump? | Fixed? | +|---|---|---|---| +| `Token(id)` | persistent | No | **Yes** | +| `Metadata(id)` | persistent | No | **Yes** | +| `MetadataHistory(id)` | persistent | No | **Yes** | +| `MetadataIndex(k,v)` | persistent | No | **Yes** | +| `RenewalHistory(token_id)` | persistent | Yes (100/1000) | Low — **increased** | +| `AutoRenewalSettings(addr)` | persistent | No | **Yes** | +| `TokenPaused(id)` | persistent | No | **Yes** | + +### manage_hub/subscription.rs + +| Key | Storage | Had Bump? | Fixed? | +|---|---|---|---| +| `Subscription(id)` | persistent | Yes (100/1000) | OK but **increased** | +| `Tier(id)` | persistent | Yes (100/1000) | OK but **increased** | +| `TierList` | persistent | No | **Yes** | +| `TierPromotion(id)` | persistent | No | **Yes** | +| `TierPromotionList` | persistent | No | **Yes** | +| `TierChangeRequest(id)` | persistent | No | **Yes** | +| `UserTierChangeHistory(addr)` | persistent | No | **Yes** | +| `TierAnalytics(id)` | persistent | No | **Yes** | + +### manage_hub/attendance_log.rs + +| Key | Storage | Had Bump? | Fixed? | +|---|---|---|---| +| `AttendanceLog(id)` | persistent | No | **Yes** | +| `AttendanceLogsByUser(addr)` | persistent | No | **Yes** | + +### manage_hub/fractionalization.rs + +| Key | Storage | Had Bump? | Fixed? | +|---|---|---|---| +| `FractionInfo(id)` | persistent | No | **Yes** | +| `FractionShares(id)` | persistent | No | **Yes** | +| `PendingRewards(id)` | persistent | No | **Yes** | + +### manage_hub/staking.rs — Already OK + +`Stake(addr)` already calls `extend_ttl` with `STAKE_TTL_LEDGERS` in `save_stake`. +Tier data (`Tier(id)`) is persistent without a bump — **added**. + +### manage_hub/upgrade.rs — Already OK + +Both `UpgradeHistory` and `VersionSnapshot` already use `extend_ttl` with +`UPGRADE_HISTORY_TTL_LEDGERS` / `VERSION_SNAPSHOT_TTL_LEDGERS`. + +### staking_rewards — Already OK + +`Stake(addr)` already calls `extend_ttl` with `STAKE_TTL_LEDGERS` in `save_stake`. +Tier data — **added**. diff --git a/contracts/attendance_batch/Cargo.toml b/contracts/attendance_batch/Cargo.toml new file mode 100644 index 00000000..fb0d4d27 --- /dev/null +++ b/contracts/attendance_batch/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "attendance_batch" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/attendance_batch/src/lib.rs b/contracts/attendance_batch/src/lib.rs new file mode 100644 index 00000000..6d46a530 --- /dev/null +++ b/contracts/attendance_batch/src/lib.rs @@ -0,0 +1,10 @@ +#![no_std] +//! # Attendance & Batch Operations Crate (Stub) +//! +//! Extracted from the ManageHub monolith. This crate will contain: +//! - Attendance logging (clock-in/clock-out) +//! - Attendance analytics (summary, frequency, peak hours, day patterns) +//! - Batch operations (mint, transfer, update) +//! - Batch validation +//! +//! **Status:** Stub — full extraction pending Phase 2. diff --git a/contracts/manage_hub/src/attendance_log.rs b/contracts/manage_hub/src/attendance_log.rs index ef3fc038..37b65feb 100644 --- a/contracts/manage_hub/src/attendance_log.rs +++ b/contracts/manage_hub/src/attendance_log.rs @@ -8,6 +8,9 @@ use common_types::{ }; use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, Map, String, Vec}; +/// Keep attendance logs for ~30 days (in ledgers). +const LOG_TTL_LEDGERS: u32 = 518_400; + #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum DataKey { @@ -68,6 +71,11 @@ impl AttendanceLogModule { env.storage() .persistent() .set(&DataKey::AttendanceLog(id.clone()), &log); + env.storage().persistent().extend_ttl( + &DataKey::AttendanceLog(id.clone()), + LOG_TTL_LEDGERS, + LOG_TTL_LEDGERS, + ); // Append to user's attendance logs let mut user_logs: Vec = env @@ -79,6 +87,11 @@ impl AttendanceLogModule { env.storage() .persistent() .set(&DataKey::AttendanceLogsByUser(user_id.clone()), &user_logs); + env.storage().persistent().extend_ttl( + &DataKey::AttendanceLogsByUser(user_id.clone()), + LOG_TTL_LEDGERS, + LOG_TTL_LEDGERS, + ); // Emit event for off-chain indexing env.events() @@ -154,7 +167,7 @@ impl AttendanceLogModule { let next_log = filtered_logs.get(j).unwrap(); if next_log.action == AttendanceAction::ClockOut { let duration = next_log.timestamp - log.timestamp; - total_duration += duration; + total_duration = total_duration.saturating_add(duration); sessions.push_back(SessionPair { clock_in_time: log.timestamp, clock_out_time: next_log.timestamp, @@ -312,7 +325,7 @@ impl AttendanceLogModule { for i in 0..sessions.len() { let session = sessions.get(i).unwrap(); - total_duration += session.duration; + total_duration = total_duration.saturating_add(session.duration); if session.clock_in_time < first_clock_in { first_clock_in = session.clock_in_time; @@ -386,7 +399,7 @@ impl AttendanceLogModule { let hour = ((log.timestamp % 86400) / 3600) as u32; let count = hour_counts.get(hour).unwrap_or(0); - hour_counts.set(hour, count + 1); + hour_counts.set(hour, count.saturating_add(1)); } // Build result vector @@ -443,7 +456,7 @@ impl AttendanceLogModule { let day_of_week = ((days_since_epoch + 4) % 7) as u32; let count = day_counts.get(day_of_week).unwrap_or(0); - day_counts.set(day_of_week, count + 1); + day_counts.set(day_of_week, count.saturating_add(1)); } // Build result vector diff --git a/contracts/manage_hub/src/fractionalization.rs b/contracts/manage_hub/src/fractionalization.rs index e27cf1cf..77d541dd 100644 --- a/contracts/manage_hub/src/fractionalization.rs +++ b/contracts/manage_hub/src/fractionalization.rs @@ -5,6 +5,9 @@ use crate::membership_token::{DataKey as MembershipDataKey, MembershipToken}; use crate::types::{DividendDistribution, FractionHolder, FractionalTokenInfo}; use soroban_sdk::{contracttype, Address, BytesN, Env, Map, String, Vec}; +/// Keep fractionalization data for ~90 days (in ledgers). +const FRACTION_TTL_LEDGERS: u32 = 1_555_200; + #[contracttype] pub enum FractionDataKey { FractionInfo(BytesN<32>), @@ -58,9 +61,19 @@ impl FractionalizationModule { env.storage() .persistent() .set(&FractionDataKey::FractionInfo(token_id.clone()), &info); + env.storage().persistent().extend_ttl( + &FractionDataKey::FractionInfo(token_id.clone()), + FRACTION_TTL_LEDGERS, + FRACTION_TTL_LEDGERS, + ); env.storage() .persistent() .set(&FractionDataKey::FractionShares(token_id.clone()), &shares); + env.storage().persistent().extend_ttl( + &FractionDataKey::FractionShares(token_id.clone()), + FRACTION_TTL_LEDGERS, + FRACTION_TTL_LEDGERS, + ); env.events().publish( ( diff --git a/contracts/manage_hub/src/membership_token.rs b/contracts/manage_hub/src/membership_token.rs index 628ff94b..f5f8b3da 100644 --- a/contracts/manage_hub/src/membership_token.rs +++ b/contracts/manage_hub/src/membership_token.rs @@ -11,6 +11,9 @@ use common_types::{ }; use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, Map, String, Vec}; +/// Keep membership tokens and their metadata for ~90 days (in ledgers). +const TOKEN_TTL_LEDGERS: u32 = 1_555_200; + #[contracttype] pub enum DataKey { Token(BytesN<32>), @@ -117,6 +120,11 @@ impl MembershipTokenContract { env.storage() .persistent() .set(&DataKey::Token(id.clone()), &token); + env.storage().persistent().extend_ttl( + &DataKey::Token(id.clone()), + TOKEN_TTL_LEDGERS, + TOKEN_TTL_LEDGERS, + ); // Emit token issued event env.events().publish( @@ -193,6 +201,11 @@ impl MembershipTokenContract { env.storage() .persistent() .set(&DataKey::Token(id.clone()), &token); + env.storage().persistent().extend_ttl( + &DataKey::Token(id.clone()), + TOKEN_TTL_LEDGERS, + TOKEN_TTL_LEDGERS, + ); // Emit token transferred event env.events().publish( @@ -534,7 +547,7 @@ impl MembershipTokenContract { .persistent() .get::(&DataKey::Metadata(token_id.clone())) { - existing_metadata.version + 1 + existing_metadata.version.saturating_add(1) } else { 1 }; @@ -577,6 +590,11 @@ impl MembershipTokenContract { env.storage() .persistent() .set(&DataKey::Metadata(token_id.clone()), &metadata); + env.storage().persistent().extend_ttl( + &DataKey::Metadata(token_id.clone()), + TOKEN_TTL_LEDGERS, + TOKEN_TTL_LEDGERS, + ); // Create and store metadata update history let metadata_update = MetadataUpdate { @@ -599,6 +617,11 @@ impl MembershipTokenContract { env.storage() .persistent() .set(&DataKey::MetadataHistory(token_id.clone()), &history); + env.storage().persistent().extend_ttl( + &DataKey::MetadataHistory(token_id.clone()), + TOKEN_TTL_LEDGERS, + TOKEN_TTL_LEDGERS, + ); // Emit metadata set event env.events().publish( @@ -709,7 +732,7 @@ impl MembershipTokenContract { validate_metadata(&metadata).map_err(|_| Error::MetadataValidationFailed)?; // Update version and timestamp - metadata.version += 1; + metadata.version = metadata.version.saturating_add(1); metadata.last_updated = env.ledger().timestamp(); metadata.updated_by = token.user.clone(); @@ -806,7 +829,7 @@ impl MembershipTokenContract { } // Update version and timestamp - metadata.version += 1; + metadata.version = metadata.version.saturating_add(1); metadata.last_updated = env.ledger().timestamp(); metadata.updated_by = token.user.clone(); @@ -1041,7 +1064,7 @@ impl MembershipTokenContract { .set(&DataKey::Token(id.clone()), &token); env.storage() .persistent() - .extend_ttl(&DataKey::Token(id.clone()), 100, 1000); + .extend_ttl(&DataKey::Token(id.clone()), TOKEN_TTL_LEDGERS, TOKEN_TTL_LEDGERS); // Record renewal in history Self::record_renewal( @@ -1083,7 +1106,7 @@ impl MembershipTokenContract { env.storage().persistent().set(&history_key, &history); env.storage() .persistent() - .extend_ttl(&history_key, 100, 1000); + .extend_ttl(&history_key, TOKEN_TTL_LEDGERS, TOKEN_TTL_LEDGERS); } /// Gets the renewal history for a token. diff --git a/contracts/manage_hub/src/staking.rs b/contracts/manage_hub/src/staking.rs index cbb23efb..15ba4db4 100644 --- a/contracts/manage_hub/src/staking.rs +++ b/contracts/manage_hub/src/staking.rs @@ -29,6 +29,9 @@ pub enum StakingDataKey { /// Keep stake records for ~30 days. const STAKE_TTL_LEDGERS: u32 = 518_400; +/// Keep staking tier records for ~90 days. +const TIER_TTL_LEDGERS: u32 = 1_555_200; + // --------------------------------------------------------------------------- // Module // --------------------------------------------------------------------------- @@ -99,6 +102,11 @@ impl StakingModule { env.storage() .persistent() .set(&StakingDataKey::Tier(tier.id.clone()), &tier); + env.storage().persistent().extend_ttl( + &StakingDataKey::Tier(tier.id.clone()), + TIER_TTL_LEDGERS, + TIER_TTL_LEDGERS, + ); // Append tier ID to the tier list. let mut list: Vec = env diff --git a/contracts/manage_hub/src/subscription.rs b/contracts/manage_hub/src/subscription.rs index d165e9e8..ae90b904 100644 --- a/contracts/manage_hub/src/subscription.rs +++ b/contracts/manage_hub/src/subscription.rs @@ -3,6 +3,12 @@ use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, Map, String, Vec}; +/// Keep subscriptions and tiers for ~30 days (in ledgers). +const SUBSCRIPTION_TTL_LEDGERS: u32 = 518_400; + +/// Keep tier records for ~90 days (in ledgers). +const TIER_TTL_LEDGERS: u32 = 1_555_200; + use crate::attendance_log::AttendanceLogModule; use crate::errors::Error; use crate::membership_token::DataKey as MembershipTokenDataKey; @@ -166,7 +172,7 @@ impl SubscriptionContract { // Store and extend TTL with same key env.storage().persistent().set(&key, &subscription); - env.storage().persistent().extend_ttl(&key, 100, 1000); + env.storage().persistent().extend_ttl(&key, SUBSCRIPTION_TTL_LEDGERS, SUBSCRIPTION_TTL_LEDGERS); // Emit subscription created event env.events().publish( @@ -267,7 +273,7 @@ impl SubscriptionContract { let key = SubscriptionDataKey::Subscription(id.clone()); env.storage().persistent().set(&key, &subscription); - env.storage().persistent().extend_ttl(&key, 100, 1000); + env.storage().persistent().extend_ttl(&key, SUBSCRIPTION_TTL_LEDGERS, SUBSCRIPTION_TTL_LEDGERS); env.events().publish( ( @@ -367,7 +373,7 @@ impl SubscriptionContract { let key = SubscriptionDataKey::Subscription(id.clone()); env.storage().persistent().set(&key, &subscription); - env.storage().persistent().extend_ttl(&key, 100, 1000); + env.storage().persistent().extend_ttl(&key, SUBSCRIPTION_TTL_LEDGERS, SUBSCRIPTION_TTL_LEDGERS); env.events().publish( ( @@ -524,7 +530,7 @@ impl SubscriptionContract { // Store updated subscription and extend TTL env.storage().persistent().set(&key, &subscription); - env.storage().persistent().extend_ttl(&key, 100, 1000); + env.storage().persistent().extend_ttl(&key, SUBSCRIPTION_TTL_LEDGERS, SUBSCRIPTION_TTL_LEDGERS); // Update tier analytics if subscription has a tier if !subscription.tier_id.is_empty() { @@ -664,7 +670,7 @@ impl SubscriptionContract { // Store tier env.storage().persistent().set(&key, &tier); - env.storage().persistent().extend_ttl(&key, 100, 1000); + env.storage().persistent().extend_ttl(&key, SUBSCRIPTION_TTL_LEDGERS, SUBSCRIPTION_TTL_LEDGERS); // Add to tier list let list_key = SubscriptionDataKey::TierList; @@ -675,6 +681,7 @@ impl SubscriptionContract { .unwrap_or_else(|| Vec::new(&env)); tier_list.push_back(params.id.clone()); env.storage().persistent().set(&list_key, &tier_list); + env.storage().persistent().extend_ttl(&list_key, TIER_TTL_LEDGERS, TIER_TTL_LEDGERS); // Initialize analytics for this tier let analytics = TierAnalytics { @@ -688,6 +695,9 @@ impl SubscriptionContract { }; let analytics_key = SubscriptionDataKey::TierAnalytics(params.id.clone()); env.storage().persistent().set(&analytics_key, &analytics); + env.storage() + .persistent() + .extend_ttl(&analytics_key, TIER_TTL_LEDGERS, TIER_TTL_LEDGERS); // Emit tier created event env.events().publish( @@ -893,7 +903,7 @@ impl SubscriptionContract { // Store subscription env.storage().persistent().set(&key, &subscription); - env.storage().persistent().extend_ttl(&key, 100, 1000); + env.storage().persistent().extend_ttl(&key, SUBSCRIPTION_TTL_LEDGERS, SUBSCRIPTION_TTL_LEDGERS); // Update tier analytics Self::update_tier_analytics_on_subscribe(&env, &tier_id, final_price)?; @@ -996,6 +1006,9 @@ impl SubscriptionContract { // Store change request let key = SubscriptionDataKey::TierChangeRequest(change_id.clone()); env.storage().persistent().set(&key, &change_request); + env.storage() + .persistent() + .extend_ttl(&key, SUBSCRIPTION_TTL_LEDGERS, SUBSCRIPTION_TTL_LEDGERS); // Add to user's change history let history_key = SubscriptionDataKey::UserTierChangeHistory(user.clone()); @@ -1006,6 +1019,9 @@ impl SubscriptionContract { .unwrap_or_else(|| Vec::new(&env)); history.push_back(change_id.clone()); env.storage().persistent().set(&history_key, &history); + env.storage() + .persistent() + .extend_ttl(&history_key, SUBSCRIPTION_TTL_LEDGERS, SUBSCRIPTION_TTL_LEDGERS); // Emit tier change requested event env.events().publish( @@ -1183,8 +1199,9 @@ impl SubscriptionContract { }; env.storage().persistent().set(&key, &promotion); - - // Add to promotion list + env.storage() + .persistent() + .extend_ttl(&key, SUBSCRIPTION_TTL_LEDGERS, SUBSCRIPTION_TTL_LEDGERS); let list_key = SubscriptionDataKey::TierPromotionList; let mut promo_list: Vec = env .storage() @@ -1193,6 +1210,9 @@ impl SubscriptionContract { .unwrap_or_else(|| Vec::new(&env)); promo_list.push_back(params.promo_id.clone()); env.storage().persistent().set(&list_key, &promo_list); + env.storage() + .persistent() + .extend_ttl(&list_key, SUBSCRIPTION_TTL_LEDGERS, SUBSCRIPTION_TTL_LEDGERS); // Emit promotion created event env.events().publish( @@ -1261,7 +1281,7 @@ impl SubscriptionContract { }; // Increment redemption count - promotion.current_redemptions += 1; + promotion.current_redemptions = promotion.current_redemptions.saturating_add(1); env.storage() .persistent() .set(&SubscriptionDataKey::TierPromotion(promo_id), &promotion); @@ -1354,8 +1374,8 @@ impl SubscriptionContract { updated_at: env.ledger().timestamp(), }); - analytics.active_subscribers += 1; - analytics.total_revenue += amount; + analytics.active_subscribers = analytics.active_subscribers.saturating_add(1); + analytics.total_revenue = analytics.total_revenue.saturating_add(amount); analytics.updated_at = env.ledger().timestamp(); env.storage().persistent().set(&key, &analytics); @@ -1378,7 +1398,7 @@ impl SubscriptionContract { { from_analytics.active_subscribers = from_analytics.active_subscribers.saturating_sub(1); if *change_type == TierChangeType::Downgrade { - from_analytics.downgrades_count += 1; + from_analytics.downgrades_count = from_analytics.downgrades_count.saturating_add(1); } from_analytics.updated_at = env.ledger().timestamp(); env.storage().persistent().set(&from_key, &from_analytics); @@ -1388,9 +1408,9 @@ impl SubscriptionContract { let to_key = SubscriptionDataKey::TierAnalytics(to_tier_id.clone()); if let Some(mut to_analytics) = env.storage().persistent().get::<_, TierAnalytics>(&to_key) { - to_analytics.active_subscribers += 1; + to_analytics.active_subscribers = to_analytics.active_subscribers.saturating_add(1); if *change_type == TierChangeType::Upgrade { - to_analytics.upgrades_count += 1; + to_analytics.upgrades_count = to_analytics.upgrades_count.saturating_add(1); } to_analytics.updated_at = env.ledger().timestamp(); env.storage().persistent().set(&to_key, &to_analytics); diff --git a/contracts/manage_hub/src/upgrade.rs b/contracts/manage_hub/src/upgrade.rs index a2f526ec..f4556e1a 100644 --- a/contracts/manage_hub/src/upgrade.rs +++ b/contracts/manage_hub/src/upgrade.rs @@ -27,6 +27,9 @@ const UPGRADE_HISTORY_TTL_LEDGERS: u32 = 1_555_200; /// Keep version snapshots for ~90 days. const VERSION_SNAPSHOT_TTL_LEDGERS: u32 = 1_555_200; +/// Keep membership tokens for ~90 days. +const TOKEN_TTL_LEDGERS: u32 = 1_555_200; + // --------------------------------------------------------------------------- // Module // --------------------------------------------------------------------------- @@ -139,6 +142,11 @@ impl UpgradeModule { env.storage() .persistent() .set(&DataKey::Token(token_id.clone()), &updated_token); + env.storage().persistent().extend_ttl( + &DataKey::Token(token_id.clone()), + TOKEN_TTL_LEDGERS, + TOKEN_TTL_LEDGERS, + ); // Record upgrade history let record = MigrationModule::build_record( @@ -296,6 +304,11 @@ impl UpgradeModule { env.storage() .persistent() .set(&DataKey::Token(token_id.clone()), &rolled_back_token); + env.storage().persistent().extend_ttl( + &DataKey::Token(token_id.clone()), + TOKEN_TTL_LEDGERS, + TOKEN_TTL_LEDGERS, + ); // Record rollback in history let record = MigrationModule::build_record( @@ -398,6 +411,11 @@ impl UpgradeModule { env.storage() .persistent() .set(&DataKey::Token(token_id.clone()), &updated_token); + env.storage().persistent().extend_ttl( + &DataKey::Token(token_id.clone()), + TOKEN_TTL_LEDGERS, + TOKEN_TTL_LEDGERS, + ); // Record let record = MigrationModule::build_record( diff --git a/contracts/membership_token/Cargo.toml b/contracts/membership_token/Cargo.toml index 1c1dc22c..acbc5ae9 100644 --- a/contracts/membership_token/Cargo.toml +++ b/contracts/membership_token/Cargo.toml @@ -11,11 +11,10 @@ soroban-sdk = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } -access_control = { path = "../access_control" } [features] testutils = ["soroban-sdk/testutils"] [[bin]] name = "membership-token" -path = "src/bin/membership-token.rs" \ No newline at end of file +path = "src/bin/membership-token.rs" diff --git a/contracts/membership_token/src/errors.rs b/contracts/membership_token/src/errors.rs new file mode 100644 index 00000000..1a1c6fad --- /dev/null +++ b/contracts/membership_token/src/errors.rs @@ -0,0 +1,26 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Error { + AdminNotSet = 1, + TokenAlreadyIssued = 2, + TokenNotFound = 3, + Unauthorized = 4, + TokenExpired = 5, + InvalidExpiryDate = 6, + InvalidPaymentAmount = 8, + MetadataNotFound = 16, + MetadataDescriptionTooLong = 17, + MetadataTooManyAttributes = 18, + MetadataAttributeKeyTooLong = 19, + MetadataTextValueTooLong = 20, + MetadataValidationFailed = 21, + RenewalNotAllowed = 46, + TransferNotAllowedInGracePeriod = 47, + GracePeriodExpired = 48, + AutoRenewalFailed = 49, + TokenFractionalized = 50, + TimestampOverflow = 15, +} diff --git a/contracts/membership_token/src/lib.rs b/contracts/membership_token/src/lib.rs index 085ed793..508391d5 100644 --- a/contracts/membership_token/src/lib.rs +++ b/contracts/membership_token/src/lib.rs @@ -1,17 +1,42 @@ #![no_std] +//! # Membership Token Crate +//! +//! Expanded membership token contract extracted from the ManageHub monolith. +//! Provides token issuance, transfer, metadata, renewal, allowance, +//! and fractionalization features. -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, BytesN, Env}; +mod errors; +mod types; -#[contract] -pub struct MembershipTokenContract; +pub use errors::Error; +pub use types::*; + +use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env, Map, String, Vec}; + +/// Keep membership tokens and their metadata for ~90 days (in ledgers). +const TOKEN_TTL_LEDGERS: u32 = 1_555_200; +/// Storage keys for the membership token contract. #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum MembershipStatus { - Active, - Expired, +pub enum DataKey { + Token(BytesN<32>), + Admin, + Metadata(BytesN<32>), + MetadataHistory(BytesN<32>), + MetadataIndex(String, MetadataValue), + RenewalConfig, + RenewalHistory(BytesN<32>), + AutoRenewalSettings(Address), + EmergencyPauseState, + TokenPaused(BytesN<32>), + UpgradeConfig, + UpgradeHistory(BytesN<32>), + VersionSnapshot(BytesN<32>, u32), + Royalty(BytesN<32>), + Allowance(BytesN<32>, Address, Address), } +/// Core membership token record stored on-chain. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct MembershipToken { @@ -20,27 +45,20 @@ pub struct MembershipToken { pub status: MembershipStatus, pub issue_date: u64, pub expiry_date: u64, + pub tier_id: Option, + pub grace_period_entered_at: Option, + pub grace_period_expires_at: Option, + pub renewal_attempts: u32, + pub last_renewal_attempt_at: Option, + pub current_version: u32, } -#[contracttype] -pub enum DataKey { - Token(BytesN<32>), - Admin, -} - -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum Error { - AdminNotSet = 1, - TokenAlreadyIssued = 2, - InvalidExpiryDate = 3, - TokenNotFound = 4, - TokenExpired = 5, -} +#[contract] +pub struct MembershipTokenContract; #[contractimpl] impl MembershipTokenContract { + /// Issue a new membership token. pub fn issue_token( env: Env, id: BytesN<32>, @@ -69,12 +87,26 @@ impl MembershipTokenContract { status: MembershipStatus::Active, issue_date: current_time, expiry_date, + tier_id: None, + grace_period_entered_at: None, + grace_period_expires_at: None, + renewal_attempts: 0, + last_renewal_attempt_at: None, + current_version: 0, }; - env.storage().persistent().set(&DataKey::Token(id), &token); + env.storage() + .persistent() + .set(&DataKey::Token(id.clone()), &token); + env.storage().persistent().extend_ttl( + &DataKey::Token(id), + TOKEN_TTL_LEDGERS, + TOKEN_TTL_LEDGERS, + ); Ok(()) } + /// Transfer a token to a new user. pub fn transfer_token(env: Env, id: BytesN<32>, new_user: Address) -> Result<(), Error> { let mut token: MembershipToken = env .storage() @@ -87,13 +119,18 @@ impl MembershipTokenContract { } token.user.require_auth(); - token.user = new_user; - env.storage().persistent().set(&DataKey::Token(id), &token); + env.storage().persistent().set(&DataKey::Token(id.clone()), &token); + env.storage().persistent().extend_ttl( + &DataKey::Token(id), + TOKEN_TTL_LEDGERS, + TOKEN_TTL_LEDGERS, + ); Ok(()) } + /// Get a token by ID. pub fn get_token(env: Env, id: BytesN<32>) -> Result { let token: MembershipToken = env .storage() @@ -109,9 +146,242 @@ impl MembershipTokenContract { Ok(token) } + /// Set the admin address. pub fn set_admin(env: Env, admin: Address) -> Result<(), Error> { admin.require_auth(); env.storage().instance().set(&DataKey::Admin, &admin); Ok(()) } + + /// Set metadata for a token. + pub fn set_token_metadata( + env: Env, + token_id: BytesN<32>, + description: String, + attributes: Map, + ) -> Result<(), Error> { + let _token: MembershipToken = env + .storage() + .persistent() + .get(&DataKey::Token(token_id.clone())) + .ok_or(Error::TokenNotFound)?; + + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::AdminNotSet)?; + token_user_auth(&env, &admin, &_token)?; + + let current_time = env.ledger().timestamp(); + let version = if let Some(existing) = env + .storage() + .persistent() + .get::(&DataKey::Metadata(token_id.clone())) + { + existing.version + 1 + } else { + 1 + }; + + let metadata = TokenMetadata { + description, + attributes, + version, + last_updated: current_time, + updated_by: _token.user.clone(), + }; + + validate_metadata(&metadata).map_err(|_| Error::MetadataValidationFailed)?; + + env.storage() + .persistent() + .set(&DataKey::Metadata(token_id.clone()), &metadata); + env.storage().persistent().extend_ttl( + &DataKey::Metadata(token_id.clone()), + TOKEN_TTL_LEDGERS, + TOKEN_TTL_LEDGERS, + ); + + // Record history + let update = MetadataUpdate { + version, + timestamp: current_time, + updated_by: _token.user, + description: metadata.description.clone(), + changes: metadata.attributes.clone(), + }; + let mut history: Vec = env + .storage() + .persistent() + .get(&DataKey::MetadataHistory(token_id.clone())) + .unwrap_or_else(|| Vec::new(&env)); + history.push_back(update); + env.storage() + .persistent() + .set(&DataKey::MetadataHistory(token_id.clone()), &history); + env.storage().persistent().extend_ttl( + &DataKey::MetadataHistory(token_id), + TOKEN_TTL_LEDGERS, + TOKEN_TTL_LEDGERS, + ); + + Ok(()) + } + + /// Get metadata for a token. + pub fn get_token_metadata(env: Env, token_id: BytesN<32>) -> Result { + let _token: MembershipToken = env + .storage() + .persistent() + .get(&DataKey::Token(token_id.clone())) + .ok_or(Error::TokenNotFound)?; + + env.storage() + .persistent() + .get(&DataKey::Metadata(token_id)) + .ok_or(Error::MetadataNotFound) + } + + /// Set renewal configuration. + pub fn set_renewal_config( + env: Env, + grace_period_duration: u64, + auto_renewal_notice_days: u64, + renewals_enabled: bool, + ) -> Result<(), Error> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::AdminNotSet)?; + admin.require_auth(); + + let config = RenewalConfig { + grace_period_duration, + auto_renewal_notice_days, + renewals_enabled, + }; + env.storage() + .instance() + .set(&DataKey::RenewalConfig, &config); + Ok(()) + } + + /// Get renewal configuration. + pub fn get_renewal_config(env: Env) -> RenewalConfig { + env.storage() + .instance() + .get(&DataKey::RenewalConfig) + .unwrap_or(RenewalConfig { + grace_period_duration: 7 * 24 * 60 * 60, + auto_renewal_notice_days: 24 * 60 * 60, + renewals_enabled: true, + }) + } + + /// Initiate emergency pause. + pub fn emergency_pause( + env: Env, + admin: Address, + reason: Option, + auto_unpause_after: Option, + time_lock_duration: Option, + ) -> Result<(), Error> { + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::AdminNotSet)?; + if admin != stored_admin { + return Err(Error::Unauthorized); + } + admin.require_auth(); + + let current_time = env.ledger().timestamp(); + let mut state = get_pause_state(&env); + + state.is_paused = true; + state.paused_at = Some(current_time); + state.paused_by = Some(admin.clone()); + state.reason = reason; + state.auto_unpause_at = auto_unpause_after.and_then(|s| current_time.checked_add(s)); + state.time_lock_until = time_lock_duration.and_then(|s| current_time.checked_add(s)); + state.pause_count = state.pause_count.saturating_add(1); + + env.storage() + .instance() + .set(&DataKey::EmergencyPauseState, &state); + Ok(()) + } + + /// Lift emergency pause. + pub fn emergency_unpause(env: Env, admin: Address) -> Result<(), Error> { + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::AdminNotSet)?; + if admin != stored_admin { + return Err(Error::Unauthorized); + } + admin.require_auth(); + + let mut state = get_pause_state(&env); + state.is_paused = false; + state.paused_at = None; + state.paused_by = None; + state.reason = None; + state.auto_unpause_at = None; + state.time_lock_until = None; + + env.storage() + .instance() + .set(&DataKey::EmergencyPauseState, &state); + Ok(()) + } + + /// Check if contract is paused. + pub fn is_contract_paused(env: Env) -> bool { + let state = get_pause_state(&env); + if state.is_paused { + if let Some(auto_at) = state.auto_unpause_at { + if env.ledger().timestamp() >= auto_at { + return false; + } + } + return true; + } + false + } + + /// Get emergency pause state. + pub fn get_emergency_pause_state(env: Env) -> EmergencyPauseState { + get_pause_state(&env) + } +} + +fn get_pause_state(env: &Env) -> EmergencyPauseState { + env.storage() + .instance() + .get(&DataKey::EmergencyPauseState) + .unwrap_or(EmergencyPauseState { + is_paused: false, + paused_at: None, + paused_by: None, + reason: None, + auto_unpause_at: None, + time_lock_until: None, + pause_count: 0, + }) +} + +fn token_user_auth(_env: &Env, admin: &Address, token: &MembershipToken) -> Result<(), Error> { + let is_admin = admin.clone() == token.user.clone(); + if !is_admin { + token.user.require_auth(); + } else { + admin.require_auth(); + } + Ok(()) } diff --git a/contracts/membership_token/src/types.rs b/contracts/membership_token/src/types.rs new file mode 100644 index 00000000..2dcf0cef --- /dev/null +++ b/contracts/membership_token/src/types.rs @@ -0,0 +1,290 @@ +use soroban_sdk::{contracttype, Address, BytesN, Map, String, Vec}; + +/// Membership status for tokens and subscriptions. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum MembershipStatus { + Active, + Expired, + Inactive, + Paused, + GracePeriod, +} + +/// Token metadata with versioning support. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TokenMetadata { + pub description: String, + pub attributes: Map, + pub version: u32, + pub last_updated: u64, + pub updated_by: Address, +} + +/// Metadata value type for indexed attributes. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum MetadataValue { + Text(String), + Number(i64), + Boolean(bool), +} + +/// Record of a metadata update. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct MetadataUpdate { + pub version: u32, + pub timestamp: u64, + pub updated_by: Address, + pub description: String, + pub changes: Map, +} + +/// Configuration for token renewal system. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RenewalConfig { + pub grace_period_duration: u64, + pub auto_renewal_notice_days: u64, + pub renewals_enabled: bool, +} + +/// Record of a renewal attempt. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RenewalHistory { + pub timestamp: u64, + pub tier_id: String, + pub amount: i128, + pub payment_token: Address, + pub success: bool, + pub trigger: RenewalTrigger, + pub old_expiry_date: u64, + pub new_expiry_date: Option, + pub error: Option, +} + +/// Trigger reason for renewal. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum RenewalTrigger { + Manual, + AutoRenewal, + GracePeriod, +} + +/// Auto-renewal settings. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct AutoRenewalSettings { + pub enabled: bool, + pub token_id: BytesN<32>, + pub payment_token: Address, + pub updated_at: u64, +} + +/// Global emergency pause state. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct EmergencyPauseState { + pub is_paused: bool, + pub paused_at: Option, + pub paused_by: Option
, + pub reason: Option, + pub auto_unpause_at: Option, + pub time_lock_until: Option, + pub pause_count: u32, +} + +/// Per-token pause state. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TokenPauseState { + pub is_paused: bool, + pub paused_at: u64, + pub paused_by: Address, + pub reason: Option, +} + +/// Token allowance for delegated transfers. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TokenAllowance { + pub token_id: BytesN<32>, + pub owner: Address, + pub spender: Address, + pub amount: i128, + pub expires_at: Option, + pub updated_at: u64, +} + +/// Royalty recipient configuration. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RoyaltyRecipient { + pub address: Address, + pub percentage: u32, +} + +/// Royalty configuration for a token. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RoyaltyConfig { + pub token_id: BytesN<32>, + pub recipients: Vec, + pub enabled: bool, +} + +/// Information about token royalties. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RoyaltyInfo { + pub config: RoyaltyConfig, + pub total_percentage: u32, +} + +/// Fractional token info. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct FractionalTokenInfo { + pub token_id: BytesN<32>, + pub total_shares: i128, + pub min_fraction_size: i128, + pub created_at: u64, + pub created_by: Address, +} + +/// Holder of fractional shares. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct FractionHolder { + pub holder: Address, + pub shares: i128, + pub voting_power_bps: u32, +} + +/// Dividend distribution result. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DividendDistribution { + pub token_id: BytesN<32>, + pub total_amount: i128, + pub recipients: u32, + pub distributed_at: u64, +} + +/// Upgrade configuration. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct UpgradeConfig { + pub upgrades_enabled: bool, + pub admin_only: bool, + pub max_rollbacks: u32, +} + +/// Snapshot of token state for rollback. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TokenVersionSnapshot { + pub version: u32, + pub expiry_date: u64, + pub status: MembershipStatus, + pub tier_id: Option, + pub captured_at: u64, + pub label: Option, +} + +/// Record of a token upgrade. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct UpgradeRecord { + pub token_id: BytesN<32>, + pub from_version: u32, + pub to_version: u32, + pub upgraded_by: Address, + pub upgraded_at: u64, + pub label: Option, + pub is_rollback: bool, +} + +/// Result for a single token in a batch upgrade. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct BatchUpgradeResult { + pub token_id: BytesN<32>, + pub success: bool, + pub new_version: Option, +} + +/// Staking tier configuration. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct StakingTier { + pub id: String, + pub name: String, + pub min_stake_amount: i128, + pub lock_duration: u64, + pub reward_multiplier_bps: u32, + pub base_rate_bps: u32, +} + +/// Active stake information. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct StakeInfo { + pub staker: Address, + pub amount: i128, + pub tier_id: String, + pub staked_at: u64, + pub unlock_at: u64, + pub claimed_rewards: i128, + pub emergency_unstaked: bool, +} + +/// Global staking configuration. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct StakingConfig { + pub staking_enabled: bool, + pub emergency_unstake_penalty_bps: u32, + pub staking_token: Address, + pub reward_pool: Address, +} + +/// Validates token metadata against size and format constraints. +pub fn validate_metadata(metadata: &TokenMetadata) -> Result<(), &'static str> { + if metadata.description.len() > 500 { + return Err("description too long"); + } + if metadata.attributes.len() > 20 { + return Err("too many attributes"); + } + for key in metadata.attributes.keys() { + if key.len() > 64 { + return Err("attribute key too long"); + } + if let Some(val) = metadata.attributes.get(key.clone()) { + if let MetadataValue::Text(ref t) = val { + if t.len() > 500 { + return Err("text value too long"); + } + } + } + } + Ok(()) +} + +/// Validates a single metadata attribute. +pub fn validate_attribute(key: &String, value: &MetadataValue) -> Result<(), &'static str> { + if key.len() > 64 { + return Err("attribute key too long"); + } + if let MetadataValue::Text(ref t) = value { + if t.len() > 500 { + return Err("text value too long"); + } + } + Ok(()) +} diff --git a/contracts/pause_control/Cargo.toml b/contracts/pause_control/Cargo.toml new file mode 100644 index 00000000..93bef82d --- /dev/null +++ b/contracts/pause_control/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "pause_control" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/pause_control/src/lib.rs b/contracts/pause_control/src/lib.rs new file mode 100644 index 00000000..3e67b323 --- /dev/null +++ b/contracts/pause_control/src/lib.rs @@ -0,0 +1,10 @@ +#![no_std] +//! # Pause Control Crate (Stub) +//! +//! Extracted from the ManageHub monolith. This crate will contain: +//! - Global emergency pause state management +//! - Per-token pause state management +//! - Time-lock enforcement for unpause +//! - Auto-unpause deadline support +//! +//! **Status:** Stub — full extraction pending Phase 2. diff --git a/contracts/resource_credits/src/errors.rs b/contracts/resource_credits/src/errors.rs index 5e31e974..ad5dd1cb 100644 --- a/contracts/resource_credits/src/errors.rs +++ b/contracts/resource_credits/src/errors.rs @@ -16,4 +16,6 @@ pub enum Error { InvalidAmount = 5, /// Account not found in storage. AccountNotFound = 6, + /// Arithmetic overflow. + Overflow = 7, } diff --git a/contracts/resource_credits/src/lib.rs b/contracts/resource_credits/src/lib.rs index 9a3db002..f89032e1 100644 --- a/contracts/resource_credits/src/lib.rs +++ b/contracts/resource_credits/src/lib.rs @@ -1,13 +1,13 @@ #![no_std] -// The env.events().publish() API is deprecated in favour of #[contractevent], -// but kept here for consistency with the rest of the ManageHub contracts. -#![allow(deprecated)] mod errors; mod types; use errors::Error; -use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env}; +use soroban_sdk::{contract, contractevent, contractimpl, contracttype, Address, Env}; + +/// Keep balances for ~90 days (in ledgers; ~1 ledger / 5 s). +const BALANCE_TTL_LEDGERS: u32 = 1_555_200; /// Storage keys for the contract. #[contracttype] @@ -19,6 +19,32 @@ pub enum DataKey { TransactionHistory(Address), } +// ── Contract Events (#[contractevent]) ─────────────────────────────────────── + +#[contractevent] +#[derive(Clone, Debug, PartialEq)] +pub struct CreditsMinted { + pub recipient: Address, + pub amount: u128, +} + +#[contractevent] +#[derive(Clone, Debug, PartialEq)] +pub struct CreditsTransferred { + pub from: Address, + pub to: Address, + pub amount: u128, +} + +#[contractevent] +#[derive(Clone, Debug, PartialEq)] +pub struct CreditsSpent { + pub member: Address, + pub amount: u128, +} + +// ── Contract ───────────────────────────────────────────────────────────────── + #[contract] pub struct ResourceCreditsContract; @@ -64,9 +90,15 @@ impl ResourceCreditsContract { .persistent() .get(&DataKey::Balance(recipient.clone())) .unwrap_or(0u128); + let new_bal = bal.checked_add(amount).ok_or(Error::Overflow)?; env.storage() .persistent() - .set(&DataKey::Balance(recipient.clone()), &(bal + amount)); + .set(&DataKey::Balance(recipient.clone()), &new_bal); + env.storage().persistent().extend_ttl( + &DataKey::Balance(recipient.clone()), + BALANCE_TTL_LEDGERS, + BALANCE_TTL_LEDGERS, + ); let supply: u128 = env .storage() @@ -75,10 +107,9 @@ impl ResourceCreditsContract { .unwrap_or(0u128); env.storage() .instance() - .set(&DataKey::TotalSupply, &(supply + amount)); + .set(&DataKey::TotalSupply, &supply.checked_add(amount).ok_or(Error::Overflow)?); - env.events() - .publish((symbol_short!("mint"), recipient), amount); + CreditsMinted { recipient, amount }.publish(&env); Ok(()) } @@ -108,18 +139,28 @@ impl ResourceCreditsContract { env.storage() .persistent() .set(&DataKey::Balance(from.clone()), &(from_bal - amount)); + env.storage().persistent().extend_ttl( + &DataKey::Balance(from.clone()), + BALANCE_TTL_LEDGERS, + BALANCE_TTL_LEDGERS, + ); let to_bal: u128 = env .storage() .persistent() .get(&DataKey::Balance(to.clone())) .unwrap_or(0u128); + let new_to_bal = to_bal.checked_add(amount).ok_or(Error::Overflow)?; env.storage() .persistent() - .set(&DataKey::Balance(to.clone()), &(to_bal + amount)); - - env.events() - .publish((symbol_short!("transfer"), from, to), amount); + .set(&DataKey::Balance(to.clone()), &new_to_bal); + env.storage().persistent().extend_ttl( + &DataKey::Balance(to.clone()), + BALANCE_TTL_LEDGERS, + BALANCE_TTL_LEDGERS, + ); + + CreditsTransferred { from, to, amount }.publish(&env); Ok(()) } @@ -144,6 +185,11 @@ impl ResourceCreditsContract { env.storage() .persistent() .set(&DataKey::Balance(member.clone()), &(bal - amount)); + env.storage().persistent().extend_ttl( + &DataKey::Balance(member.clone()), + BALANCE_TTL_LEDGERS, + BALANCE_TTL_LEDGERS, + ); let supply: u128 = env .storage() @@ -154,8 +200,7 @@ impl ResourceCreditsContract { .instance() .set(&DataKey::TotalSupply, &(supply - amount)); - env.events() - .publish((symbol_short!("spend"), member), amount); + CreditsSpent { member, amount }.publish(&env); Ok(()) } diff --git a/contracts/staking_rewards/Cargo.toml b/contracts/staking_rewards/Cargo.toml new file mode 100644 index 00000000..84e3706b --- /dev/null +++ b/contracts/staking_rewards/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "staking_rewards" +version = "1.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["rlib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/staking_rewards/src/errors.rs b/contracts/staking_rewards/src/errors.rs new file mode 100644 index 00000000..0115d594 --- /dev/null +++ b/contracts/staking_rewards/src/errors.rs @@ -0,0 +1,17 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, PartialEq)] +#[repr(u32)] +pub enum StakingError { + Unauthorized = 1, + StakingDisabled = 2, + StakeNotFound = 3, + StillLocked = 4, + TierNotFound = 5, + TierAlreadyExists = 6, + BelowMinimumStake = 7, + StakingNotConfigured = 8, + Overflow = 9, + InvalidConfig = 10, +} diff --git a/contracts/staking_rewards/src/lib.rs b/contracts/staking_rewards/src/lib.rs new file mode 100644 index 00000000..afc2e609 --- /dev/null +++ b/contracts/staking_rewards/src/lib.rs @@ -0,0 +1,330 @@ +#![no_std] +//! # Staking & Rewards Crate +//! +//! Extracted from the ManageHub monolith. Provides staking tier management, +//! token staking/unstaking, penalty calculation, and reward accrual. + +mod errors; +mod types; + +pub use errors::StakingError; +pub use types::*; + +use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, String, Vec}; + +/// Storage keys for staking. +#[contracttype] +pub enum DataKey { + Admin, + Config, + TierList, + Tier(String), + Stake(Address), +} + +/// TTL for stake records (~30 days in ledgers at ~5s each). +const STAKE_TTL_LEDGERS: u32 = 518_400; + +/// TTL for staking tier records (~90 days in ledgers). +const TIER_TTL_LEDGERS: u32 = 1_555_200; + +/// Seconds in a calendar year. +const YEAR_SECS: i128 = 365 * 24 * 60 * 60; + +#[contract] +pub struct StakingRewardsContract; + +#[contractimpl] +impl StakingRewardsContract { + /// Initialise or update the global staking configuration. Admin only. + pub fn set_staking_config( + env: Env, + admin: Address, + config: StakingConfig, + ) -> Result<(), StakingError> { + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(StakingError::StakingNotConfigured)?; + stored_admin.require_auth(); + if stored_admin != admin { + return Err(StakingError::Unauthorized); + } + + if config.emergency_unstake_penalty_bps > 10_000 { + return Err(StakingError::InvalidConfig); + } + + env.storage() + .instance() + .set(&DataKey::Config, &config); + Ok(()) + } + + /// Create a new staking tier. Admin only. + pub fn create_staking_tier( + env: Env, + admin: Address, + tier: StakingTier, + ) -> Result<(), StakingError> { + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(StakingError::StakingNotConfigured)?; + stored_admin.require_auth(); + if stored_admin != admin { + return Err(StakingError::Unauthorized); + } + + if tier.min_stake_amount <= 0 { + return Err(StakingError::BelowMinimumStake); + } + if tier.reward_multiplier_bps == 0 { + return Err(StakingError::InvalidConfig); + } + if tier.base_rate_bps == 0 || tier.base_rate_bps > 10_000 { + return Err(StakingError::InvalidConfig); + } + + if env + .storage() + .persistent() + .has(&DataKey::Tier(tier.id.clone())) + { + return Err(StakingError::TierAlreadyExists); + } + + env.storage() + .persistent() + .set(&DataKey::Tier(tier.id.clone()), &tier); + env.storage().persistent().extend_ttl( + &DataKey::Tier(tier.id.clone()), + TIER_TTL_LEDGERS, + TIER_TTL_LEDGERS, + ); + + let mut list: Vec = env + .storage() + .instance() + .get(&DataKey::TierList) + .unwrap_or_else(|| Vec::new(&env)); + list.push_back(tier.id); + env.storage() + .instance() + .set(&DataKey::TierList, &list); + + Ok(()) + } + + /// Lock tokens in a staking tier. + pub fn stake_tokens( + env: Env, + staker: Address, + tier_id: String, + amount: i128, + ) -> Result<(), StakingError> { + staker.require_auth(); + + let config = Self::get_config(&env)?; + if !config.staking_enabled { + return Err(StakingError::StakingDisabled); + } + + let tier = Self::get_tier_internal(&env, &tier_id)?; + if amount < tier.min_stake_amount { + return Err(StakingError::BelowMinimumStake); + } + + let token_client = token::Client::new(&env, &config.staking_token); + token_client.transfer(&staker, env.current_contract_address(), &amount); + + let now = env.ledger().timestamp(); + let unlock_at = now.checked_add(tier.lock_duration).ok_or(StakingError::Overflow)?; + + let stake = StakeInfo { + staker: staker.clone(), + amount, + tier_id: tier_id.clone(), + staked_at: now, + unlock_at, + claimed_rewards: 0, + emergency_unstaked: false, + }; + + Self::save_stake(&env, &staker, &stake); + Ok(()) + } + + /// Unlock tokens after the lock period. + pub fn unstake_tokens(env: Env, staker: Address) -> Result<(), StakingError> { + staker.require_auth(); + + let config = Self::get_config(&env)?; + let stake: StakeInfo = env + .storage() + .persistent() + .get(&DataKey::Stake(staker.clone())) + .ok_or(StakingError::StakeNotFound)?; + + let now = env.ledger().timestamp(); + if now < stake.unlock_at { + return Err(StakingError::StillLocked); + } + + let rewards = calculate_pending_rewards(&env, &stake)?; + + let token_client = token::Client::new(&env, &config.staking_token); + token_client.transfer(&env.current_contract_address(), &staker, &stake.amount); + + if rewards > 0 { + let reward_client = token::Client::new(&env, &config.reward_pool); + reward_client.transfer(&env.current_contract_address(), &staker, &rewards); + } + + env.storage() + .persistent() + .remove(&DataKey::Stake(staker)); + + Ok(()) + } + + /// Emergency unstake with penalty. + pub fn emergency_unstake(env: Env, staker: Address) -> Result<(), StakingError> { + staker.require_auth(); + + let config = Self::get_config(&env)?; + let stake: StakeInfo = env + .storage() + .persistent() + .get(&DataKey::Stake(staker.clone())) + .ok_or(StakingError::StakeNotFound)?; + + let penalty = stake + .amount + .checked_mul(config.emergency_unstake_penalty_bps as i128) + .ok_or(StakingError::Overflow)? + .checked_div(10_000) + .ok_or(StakingError::Overflow)?; + + let amount_returned = stake + .amount + .checked_sub(penalty) + .ok_or(StakingError::Overflow)?; + + let token_client = token::Client::new(&env, &config.staking_token); + if amount_returned > 0 { + token_client.transfer(&env.current_contract_address(), &staker, &amount_returned); + } + + env.storage() + .persistent() + .remove(&DataKey::Stake(staker)); + + Ok(()) + } + + /// Get active stake for a staker. + pub fn get_stake_info(env: Env, staker: Address) -> Option { + env.storage() + .persistent() + .get(&DataKey::Stake(staker)) + } + + /// Get all staking tiers. + pub fn get_staking_tiers(env: Env) -> Vec { + let list: Vec = env + .storage() + .instance() + .get(&DataKey::TierList) + .unwrap_or_else(|| Vec::new(&env)); + + let mut tiers = Vec::new(&env); + for id in list.iter() { + if let Some(tier) = env + .storage() + .persistent() + .get::(&DataKey::Tier(id)) + { + tiers.push_back(tier); + } + } + tiers + } + + /// Get global staking configuration. + pub fn get_staking_config(env: Env) -> Result { + Self::get_config(&env) + } + + fn get_config(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Config) + .ok_or(StakingError::StakingNotConfigured) + } + + pub(crate) fn get_tier_internal(env: &Env, tier_id: &String) -> Result { + env.storage() + .persistent() + .get(&DataKey::Tier(tier_id.clone())) + .ok_or(StakingError::TierNotFound) + } + + fn save_stake(env: &Env, staker: &Address, stake: &StakeInfo) { + env.storage() + .persistent() + .set(&DataKey::Stake(staker.clone()), stake); + env.storage().persistent().extend_ttl( + &DataKey::Stake(staker.clone()), + STAKE_TTL_LEDGERS, + STAKE_TTL_LEDGERS, + ); + } +} + +/// Calculate pending (unclaimed) rewards for a stake. +/// +/// Uses a simple linear model: +/// ```text +/// pending = principal * base_rate_bps / 10_000 +/// * elapsed / YEAR_SECS +/// * multiplier_bps / 10_000 +/// - claimed_rewards +/// ``` +fn calculate_pending_rewards(env: &Env, stake: &StakeInfo) -> Result { + if stake.emergency_unstaked { + return Ok(0); + } + + let tier = StakingRewardsContract::get_tier_internal(env, &stake.tier_id)?; + + let now = env.ledger().timestamp() as i128; + let staked_at = stake.staked_at as i128; + let elapsed = now.checked_sub(staked_at).unwrap_or(0).max(0); + + let gross = stake + .amount + .checked_mul(tier.base_rate_bps as i128) + .ok_or(StakingError::Overflow)? + .checked_mul(elapsed) + .ok_or(StakingError::Overflow)? + .checked_mul(tier.reward_multiplier_bps as i128) + .ok_or(StakingError::Overflow)? + .checked_div( + 10_000i128 + .checked_mul(YEAR_SECS) + .ok_or(StakingError::Overflow)?, + ) + .ok_or(StakingError::Overflow)? + .checked_div(10_000) + .ok_or(StakingError::Overflow)?; + + let pending = gross + .checked_sub(stake.claimed_rewards) + .unwrap_or(0) + .max(0); + + Ok(pending) +} diff --git a/contracts/staking_rewards/src/types.rs b/contracts/staking_rewards/src/types.rs new file mode 100644 index 00000000..cfd5efc1 --- /dev/null +++ b/contracts/staking_rewards/src/types.rs @@ -0,0 +1,36 @@ +use soroban_sdk::{contracttype, Address, String}; + +/// Staking tier defining lock duration and reward multiplier. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct StakingTier { + pub id: String, + pub name: String, + pub min_stake_amount: i128, + pub lock_duration: u64, + pub reward_multiplier_bps: u32, + pub base_rate_bps: u32, +} + +/// Represents an active stake held by a user. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct StakeInfo { + pub staker: Address, + pub amount: i128, + pub tier_id: String, + pub staked_at: u64, + pub unlock_at: u64, + pub claimed_rewards: i128, + pub emergency_unstaked: bool, +} + +/// Global staking configuration set by admin. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct StakingConfig { + pub staking_enabled: bool, + pub emergency_unstake_penalty_bps: u32, + pub staking_token: Address, + pub reward_pool: Address, +} diff --git a/contracts/subscription_tier/Cargo.toml b/contracts/subscription_tier/Cargo.toml new file mode 100644 index 00000000..5e5f0a8f --- /dev/null +++ b/contracts/subscription_tier/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "subscription_tier" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/subscription_tier/src/lib.rs b/contracts/subscription_tier/src/lib.rs new file mode 100644 index 00000000..b2300050 --- /dev/null +++ b/contracts/subscription_tier/src/lib.rs @@ -0,0 +1,12 @@ +#![no_std] +//! # Subscription & Tier Management Crate (Stub) +//! +//! Extracted from the ManageHub monolith. This crate will contain: +//! - Subscription CRUD (create, renew, cancel, pause, resume) +//! - Tier management (create, update, deactivate tiers) +//! - Tier change requests (upgrade/downgrade) +//! - Promotion management +//! - Feature access control +//! - Tier analytics +//! +//! **Status:** Stub — full extraction pending Phase 2. diff --git a/contracts/token_upgrade/Cargo.toml b/contracts/token_upgrade/Cargo.toml new file mode 100644 index 00000000..73a35b2a --- /dev/null +++ b/contracts/token_upgrade/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "token_upgrade" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/token_upgrade/src/lib.rs b/contracts/token_upgrade/src/lib.rs new file mode 100644 index 00000000..0723b4d7 --- /dev/null +++ b/contracts/token_upgrade/src/lib.rs @@ -0,0 +1,10 @@ +#![no_std] +//! # Token Upgrade & Migration Crate (Stub) +//! +//! Extracted from the ManageHub monolith. This crate will contain: +//! - Token versioning and upgrade mechanism +//! - Version snapshots for rollback +//! - Migration helpers for field transformation +//! - Upgrade history tracking +//! +//! **Status:** Stub — full extraction pending Phase 2. diff --git a/contracts/workspace_booking/src/errors.rs b/contracts/workspace_booking/src/errors.rs index fcacd001..22bf0222 100644 --- a/contracts/workspace_booking/src/errors.rs +++ b/contracts/workspace_booking/src/errors.rs @@ -77,4 +77,7 @@ pub enum Error { /// Cannot modify workspace while active bookings exist. WorkspaceHasActiveBookings = 203, + + /// Arithmetic overflow. + Overflow = 204, } diff --git a/contracts/workspace_booking/src/lib.rs b/contracts/workspace_booking/src/lib.rs index 01715c0c..0e99c771 100644 --- a/contracts/workspace_booking/src/lib.rs +++ b/contracts/workspace_booking/src/lib.rs @@ -1,8 +1,5 @@ // contracts/workspace_booking/src/lib.rs #![no_std] -// The env.events().publish() API is deprecated in favour of #[contractevent], -// but kept here for consistency with the rest of the ManageHub contracts. -#![allow(deprecated)] mod errors; mod types; @@ -16,9 +13,12 @@ pub use types::{ }; use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, token, Address, Env, String, Vec, + contract, contractevent, contractimpl, contracttype, token, Address, Env, String, Vec, }; +/// Keep workspace/booking records for ~30 days (in ledgers; ~1 ledger / 5 s). +const BOOKING_TTL_LEDGERS: u32 = 518_400; + // ── Storage keys ────────────────────────────────────────────────────────────── #[contracttype] @@ -39,6 +39,66 @@ pub enum DataKey { WorkspaceBookings(String), } +// ── Contract Events (#[contractevent]) ──────────────────────────────────────── + +#[contractevent] +#[derive(Clone, Debug, PartialEq)] +pub struct ContractInitialized { + pub admin: Address, + pub payment_token: Address, +} + +#[contractevent] +#[derive(Clone, Debug, PartialEq)] +pub struct WorkspaceRegistered { + pub id: String, + pub name: String, + pub workspace_type: WorkspaceType, + pub capacity: u32, + pub hourly_rate: u128, +} + +#[contractevent] +#[derive(Clone, Debug, PartialEq)] +pub struct WorkspaceAvailabilityChanged { + pub workspace_id: String, + pub is_available: bool, +} + +#[contractevent] +#[derive(Clone, Debug, PartialEq)] +pub struct WorkspaceRateChanged { + pub workspace_id: String, + pub hourly_rate: u128, +} + +#[contractevent] +#[derive(Clone, Debug, PartialEq)] +pub struct WorkspaceBooked { + pub booking_id: String, + pub member: Address, + pub workspace_id: String, + pub start_time: u64, + pub end_time: u64, + pub amount: u128, +} + +#[contractevent] +#[derive(Clone, Debug, PartialEq)] +pub struct BookingCancelled { + pub booking_id: String, + pub caller: Address, + pub refund_amount: u128, +} + +#[contractevent] +#[derive(Clone, Debug, PartialEq)] +pub struct BookingCompleted { + pub booking_id: String, + pub workspace_id: String, + pub member: Address, +} + // ── Contract ────────────────────────────────────────────────────────────────── #[contract] pub struct WorkspaceBookingContract; @@ -111,8 +171,7 @@ impl WorkspaceBookingContract { .instance() .set(&DataKey::PaymentToken, &payment_token); - env.events() - .publish((symbol_short!("init"),), (admin, payment_token)); + ContractInitialized { admin, payment_token }.publish(&env); Ok(()) } @@ -163,6 +222,11 @@ impl WorkspaceBookingContract { env.storage() .persistent() .set(&DataKey::Workspace(id.clone()), &workspace); + env.storage().persistent().extend_ttl( + &DataKey::Workspace(id.clone()), + BOOKING_TTL_LEDGERS, + BOOKING_TTL_LEDGERS, + ); let mut list: Vec = env .storage() @@ -172,10 +236,7 @@ impl WorkspaceBookingContract { list.push_back(id.clone()); env.storage().instance().set(&DataKey::WorkspaceList, &list); - env.events().publish( - (symbol_short!("ws_reg"), id), - (name, workspace_type, capacity, hourly_rate), - ); + WorkspaceRegistered { id, name, workspace_type, capacity, hourly_rate }.publish(&env); Ok(()) } @@ -204,8 +265,7 @@ impl WorkspaceBookingContract { .persistent() .set(&DataKey::Workspace(workspace_id.clone()), &workspace); - env.events() - .publish((symbol_short!("ws_avail"), workspace_id), (is_available,)); + WorkspaceAvailabilityChanged { workspace_id, is_available }.publish(&env); Ok(()) } @@ -233,8 +293,7 @@ impl WorkspaceBookingContract { .persistent() .set(&DataKey::Workspace(workspace_id.clone()), &workspace); - env.events() - .publish((symbol_short!("ws_rate"), workspace_id), (hourly_rate,)); + WorkspaceRateChanged { workspace_id, hourly_rate }.publish(&env); Ok(()) } @@ -291,7 +350,10 @@ impl WorkspaceBookingContract { // Cost = hourly_rate × ⌈duration_seconds / 3600⌉ let duration_secs = end_time - start_time; let duration_hours = duration_secs.div_ceil(3600); - let amount: u128 = workspace.hourly_rate * duration_hours as u128; + let amount: u128 = workspace + .hourly_rate + .checked_mul(duration_hours as u128) + .ok_or(Error::Overflow)?; // Collect payment from member → contract let payment_token = Self::get_payment_token(&env)?; @@ -317,6 +379,11 @@ impl WorkspaceBookingContract { env.storage() .persistent() .set(&DataKey::Booking(booking_id.clone()), &booking); + env.storage().persistent().extend_ttl( + &DataKey::Booking(booking_id.clone()), + BOOKING_TTL_LEDGERS, + BOOKING_TTL_LEDGERS, + ); // Index: workspace → bookings let mut ws_bookings: Vec = env @@ -329,6 +396,11 @@ impl WorkspaceBookingContract { &DataKey::WorkspaceBookings(workspace_id.clone()), &ws_bookings, ); + env.storage().persistent().extend_ttl( + &DataKey::WorkspaceBookings(workspace_id.clone()), + BOOKING_TTL_LEDGERS, + BOOKING_TTL_LEDGERS, + ); // Index: member → bookings let mut member_bookings: Vec = env @@ -340,11 +412,13 @@ impl WorkspaceBookingContract { env.storage() .persistent() .set(&DataKey::MemberBookings(member.clone()), &member_bookings); - - env.events().publish( - (symbol_short!("booked"), booking_id), - (member, workspace_id, start_time, end_time, amount), + env.storage().persistent().extend_ttl( + &DataKey::MemberBookings(member.clone()), + BOOKING_TTL_LEDGERS, + BOOKING_TTL_LEDGERS, ); + + WorkspaceBooked { booking_id, member, workspace_id, start_time, end_time, amount }.publish(&env); Ok(()) } @@ -376,16 +450,14 @@ impl WorkspaceBookingContract { &(booking.amount_paid as i128), ); + let refund_amount = booking.amount_paid; booking.status = BookingStatus::Cancelled; booking.cancelled_at = Some(env.ledger().timestamp()); env.storage() .persistent() .set(&DataKey::Booking(booking_id.clone()), &booking); - env.events().publish( - (symbol_short!("cancel"), booking_id), - (caller, booking.amount_paid), - ); + BookingCancelled { booking_id, caller, refund_amount }.publish(&env); Ok(()) } @@ -405,16 +477,16 @@ impl WorkspaceBookingContract { return Err(Error::BookingNotActive); } + let workspace_id = booking.workspace_id.clone(); + let member = booking.member.clone(); + booking.status = BookingStatus::Completed; booking.completed_at = Some(env.ledger().timestamp()); env.storage() .persistent() .set(&DataKey::Booking(booking_id.clone()), &booking); - env.events().publish( - (symbol_short!("complete"), booking_id), - (booking.workspace_id, booking.member), - ); + BookingCompleted { booking_id, workspace_id, member }.publish(&env); Ok(()) }