From 165d4af2aec6eccda40138a3816caaeeeff6e25a Mon Sep 17 00:00:00 2001 From: devjaja Date: Mon, 24 Aug 2026 10:55:47 +0100 Subject: [PATCH 1/5] feat: add creator archive/RESTORING lifecycle with integration test (#709) Introduce the minimal lifecycle surface issue #709 tests against: - CreatorLifecycleState (Active/Archived/Restoring) stored per creator, absent entries default to Active - protocol-admin entrypoints: archive_creator, begin_creator_restore, complete_creator_restore plus get_creator_lifecycle read view - appended ABI-safe error codes: CreatorArchived (38), StateRestoring (39), InvalidLifecycleTransition (40) - buy/sell/buyback gated while Archived or Restoring; reads keep serving current values during the RESTORING window - archived/restoring/restored events following repo event conventions - docs/error-codes.md rows for codes 34-40 Integration test drives a creator through Archived -> Restoring -> Active and asserts reads succeed mid-restoration, buys panic with StateRestoring, trades resume immediately after completion, and restored state matches the pre-archive snapshot. --- creator-keys/src/events.rs | 9 + creator-keys/src/lib.rs | 148 ++++++++ .../tests/creator_restoring_lifecycle.rs | 335 ++++++++++++++++++ docs/error-codes.md | 7 + 4 files changed, 499 insertions(+) create mode 100644 creator-keys/tests/creator_restoring_lifecycle.rs diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index 4ba64f16..bafdfe9b 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -40,6 +40,15 @@ pub const BLACKLIST_ADDED_EVENT_NAME: Symbol = symbol_short!("blk_add"); /// Event name for a wallet being removed from the admin blacklist. pub const BLACKLIST_REMOVED_EVENT_NAME: Symbol = symbol_short!("blk_rem"); +/// Event name for a creator being archived by the protocol admin. +pub const CREATOR_ARCHIVED_EVENT_NAME: Symbol = symbol_short!("archived"); + +/// Event name for a creator's restore transition beginning (RESTORING state). +pub const CREATOR_RESTORE_BEGUN_EVENT_NAME: Symbol = symbol_short!("restoring"); + +/// Event name for a creator's restoration completing (back to active). +pub const CREATOR_RESTORE_DONE_EVENT_NAME: Symbol = symbol_short!("restored"); + /// Event name for creator registration. pub const REGISTER_EVENT_NAME: Symbol = symbol_short!("register"); diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 2dead686..f5626849 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -84,6 +84,9 @@ pub enum ContractError { WalletCapExceeded = 35, DiscountTierLimitExceeded = 36, WalletBlacklisted = 37, + CreatorArchived = 38, + StateRestoring = 39, + InvalidLifecycleTransition = 40, } pub mod fee { @@ -386,6 +389,13 @@ pub mod constants { pub fn creator_ttl_live_until(creator: &Address) -> DataKey { DataKey::CreatorTtlLiveUntil(creator.clone()) } + + /// Per-creator archive/restore lifecycle state key. + /// + /// Absent entries mean [`CreatorLifecycleState::Active`]. + pub fn creator_lifecycle(creator: &Address) -> DataKey { + DataKey::CreatorLifecycle(creator.clone()) + } } fn creator_key(creator: &Address) -> DataKey { @@ -616,6 +626,35 @@ pub enum DataKey { /// Wallet addresses the protocol admin has barred from buying, selling, /// or registering as a creator. Blacklisted(Address), + /// Per-creator lifecycle state used by the archive/restore flow. + /// + /// Absent entries default to [`CreatorLifecycleState::Active`]. See + /// [`CreatorLifecycleState`] for the state machine. + CreatorLifecycle(Address), +} + +/// Lifecycle state for a creator's archive/restore flow (issue #709). +/// +/// The protocol admin can archive a creator, begin a restoration of the +/// archived state, and complete that restoration. While the state is +/// [`CreatorLifecycleState::Archived`] or +/// [`CreatorLifecycleState::Restoring`], trading entrypoints (`buy_key`, +/// `sell_key`, `buyback`) are gated; read-only views keep serving current +/// values at all times. Absent storage defaults to +/// [`CreatorLifecycleState::Active`], so creators are active unless explicitly +/// archived. +/// +/// State machine: +/// ```text +/// Active --archive_creator--> Archived --begin_creator_restore--> Restoring +/// Restoring --complete_creator_restore--> Active (storage key removed) +/// ``` +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[contracttype] +pub enum CreatorLifecycleState { + Active = 0, + Archived = 1, + Restoring = 2, } /// Time-locked key allocation for creator self-vesting. @@ -1038,6 +1077,31 @@ fn assert_is_admin(env: &Env, caller: &Address) -> Result<(), ContractError> { Ok(()) } +/// Reads a creator's lifecycle state, defaulting to [`CreatorLifecycleState::Active`] +/// when no lifecycle entry exists. +pub fn read_creator_lifecycle(env: &Env, creator: &Address) -> CreatorLifecycleState { + env.storage() + .persistent() + .get(&constants::storage::creator_lifecycle(creator)) + .unwrap_or(CreatorLifecycleState::Active) +} + +/// Guard rejecting trades for creators whose state is being restored (or is +/// archived) so writes cannot race the copy-back of archived state. +/// +/// Read-only views intentionally bypass this guard: the contract continues to +/// serve current values while restoration is in progress (issue #709). +fn assert_creator_lifecycle_allows_trading( + env: &Env, + creator: &Address, +) -> Result<(), ContractError> { + match read_creator_lifecycle(env, creator) { + CreatorLifecycleState::Active => Ok(()), + CreatorLifecycleState::Archived => Err(ContractError::CreatorArchived), + CreatorLifecycleState::Restoring => Err(ContractError::StateRestoring), + } +} + fn read_protocol_fee_config(env: &Env) -> Option { env.storage() .persistent() @@ -1708,6 +1772,7 @@ impl CreatorKeysContract { buyer.require_auth(); assert_not_paused(&env)?; assert_not_blacklisted(&env, &buyer)?; + assert_creator_lifecycle_allows_trading(&env, &creator)?; if payment <= 0 { return Err(ContractError::NotPositiveAmount); @@ -1877,6 +1942,7 @@ impl CreatorKeysContract { seller.require_auth(); assert_not_paused(&env)?; assert_not_blacklisted(&env, &seller)?; + assert_creator_lifecycle_allows_trading(&env, &creator)?; let mut profile: CreatorProfile = read_registered_creator_profile(&env, &creator)?; @@ -1981,6 +2047,7 @@ impl CreatorKeysContract { ) -> Result { caller.require_auth(); assert_not_paused(&env)?; + assert_creator_lifecycle_allows_trading(&env, &creator)?; if caller != creator { return Err(ContractError::Unauthorized); @@ -2298,6 +2365,87 @@ impl CreatorKeysContract { is_blacklisted(&env, &wallet) } + /// Archives a registered creator, gating its trading entrypoints. + /// + /// Only the protocol admin may call this. Archived creators reject + /// `buy_key`, `sell_key`, and `buyback` with + /// [`ContractError::CreatorArchived`] while read-only views keep serving + /// current values. Use [`CreatorKeysContract::begin_creator_restore`] to + /// start copying the archived state back. + pub fn archive_creator( + env: Env, + admin: Address, + creator: Address, + ) -> Result<(), ContractError> { + admin.require_auth(); + assert_is_admin(&env, &admin)?; + read_registered_creator_profile(&env, &creator)?; + env.storage().persistent().set( + &constants::storage::creator_lifecycle(&creator), + &CreatorLifecycleState::Archived, + ); + env.events() + .publish((events::CREATOR_ARCHIVED_EVENT_NAME, creator), ()); + Ok(()) + } + + /// Transitions an archived creator's state to [`CreatorLifecycleState::Restoring`]. + /// + /// Only the protocol admin may call this, and only from the + /// [`CreatorLifecycleState::Archived`] state. During the RESTORING window, + /// trading entrypoints are gated with [`ContractError::StateRestoring`] while + /// read-only views keep returning current values; writes resume only after + /// [`CreatorKeysContract::complete_creator_restore`]. + pub fn begin_creator_restore( + env: Env, + admin: Address, + creator: Address, + ) -> Result<(), ContractError> { + admin.require_auth(); + assert_is_admin(&env, &admin)?; + if read_creator_lifecycle(&env, &creator) != CreatorLifecycleState::Archived { + return Err(ContractError::InvalidLifecycleTransition); + } + env.storage().persistent().set( + &constants::storage::creator_lifecycle(&creator), + &CreatorLifecycleState::Restoring, + ); + env.events() + .publish((events::CREATOR_RESTORE_BEGUN_EVENT_NAME, creator), ()); + Ok(()) + } + + /// Completes a restoration, returning the creator to active trading. + /// + /// Only the protocol admin may call this, and only from the + /// [`CreatorLifecycleState::Restoring`] state. The lifecycle storage key is + /// removed so absent entries keep meaning "active", keeping storage sparse. + pub fn complete_creator_restore( + env: Env, + admin: Address, + creator: Address, + ) -> Result<(), ContractError> { + admin.require_auth(); + assert_is_admin(&env, &admin)?; + if read_creator_lifecycle(&env, &creator) != CreatorLifecycleState::Restoring { + return Err(ContractError::InvalidLifecycleTransition); + } + env.storage() + .persistent() + .remove(&constants::storage::creator_lifecycle(&creator)); + env.events() + .publish((events::CREATOR_RESTORE_DONE_EVENT_NAME, creator), ()); + Ok(()) + } + + /// Read-only view: returns a creator's lifecycle state. + /// + /// Returns [`CreatorLifecycleState::Active`] when no lifecycle entry exists, + /// so callers never receive an `Option`. + pub fn get_creator_lifecycle(env: Env, creator: Address) -> CreatorLifecycleState { + read_creator_lifecycle(&env, &creator) + } + pub fn get_key_balance(env: Env, creator: Address, wallet: Address) -> u32 { let key = constants::storage::holder_balance_key(&creator, &wallet); // Read-only callers get `0` for unseen balances to avoid sparse-map lookups failing. diff --git a/creator-keys/tests/creator_restoring_lifecycle.rs b/creator-keys/tests/creator_restoring_lifecycle.rs new file mode 100644 index 00000000..66a2a272 --- /dev/null +++ b/creator-keys/tests/creator_restoring_lifecycle.rs @@ -0,0 +1,335 @@ +//! Integration tests for restoring a creator's state after a RESTORING +//! lifecycle transition (issue #709). +//! +//! The RESTORING lifecycle covers the period when a creator's archived state is +//! being copied back to active storage. During this window the contract keeps +//! serving read calls while trading writes are gated until restoration +//! completes. These tests confirm: +//! +//! - archiving a creator transitions its manifest through +//! `Archived -> Restoring` under protocol-admin control +//! - reads (`get_buy_quote`, `get_key_balance`, profile views) succeed and +//! return current values during RESTORING +//! - buys panic with `StateRestoring` during the RESTORING window +//! - a buy succeeds immediately after restoration completes +//! - the restored state matches the pre-archive snapshot exactly +//! +//! # Scope note +//! +//! Issue #709 references a RESTORING lifecycle that did not exist in this +//! contract yet, so this change also introduces the minimal feature surface it +//! tests against: `archive_creator`, `begin_creator_restore`, +//! `complete_creator_restore`, `get_creator_lifecycle`, the +//! [`creator_keys::CreatorLifecycleState`] enum, and the appended error codes +//! 38–40 (`CreatorArchived`, `StateRestoring`, `InvalidLifecycleTransition`). + +mod contract_test_env; + +use contract_test_env::{ + capture_snapshot, register_creator_keys, register_test_creator, set_pricing_and_fees, + test_env_with_auths, +}; +use creator_keys::{events, ContractError, CreatorLifecycleState}; +use soroban_sdk::{ + testutils::{Address as _, Events}, + Address, Env, IntoVal, Symbol, +}; + +const KEY_PRICE: i128 = 1_000; +const CREATOR_BPS: u32 = 9_000; +const PROTOCOL_BPS: u32 = 1_000; + +/// Counts events whose name topic equals `name`. +fn count_named_events(env: &Env, name: Symbol) -> usize { + env.events() + .all() + .iter() + .filter(|(_, topics, _)| { + let topic: Symbol = topics + .get(events::TOPIC_EVENT_NAME_INDEX) + .expect("event topic tuple must contain an event name") + .into_val(env); + topic == name + }) + .count() +} + +struct LifecycleFixture<'a> { + client: creator_keys::CreatorKeysContractClient<'a>, + admin: Address, + creator: Address, + holder: Address, +} + +fn setup(env: &Env) -> LifecycleFixture<'_> { + let (client, _) = register_creator_keys(env); + let admin = set_pricing_and_fees(env, &client, KEY_PRICE, CREATOR_BPS, PROTOCOL_BPS); + let creator = register_test_creator(env, &client, "alice"); + + // Give the creator live state: one holder with keys so reads have values + // to serve during the RESTORING window. + let holder = Address::generate(env); + client.buy_key(&creator, &holder, &KEY_PRICE, &None); + + LifecycleFixture { + client, + admin, + creator, + holder, + } +} + +/// Drives a fixture into the RESTORING state via archive -> begin restore. +fn transition_to_restoring(env: &Env, fx: &LifecycleFixture<'_>) { + fx.client.archive_creator(&fx.admin, &fx.creator); + assert_eq!( + fx.client.get_creator_lifecycle(&fx.creator), + CreatorLifecycleState::Archived + ); + fx.client.begin_creator_restore(&fx.admin, &fx.creator); + assert_eq!( + fx.client.get_creator_lifecycle(&fx.creator), + CreatorLifecycleState::Restoring + ); + let _ = env; // kept for signature symmetry with future ledger-based checks +} + +// --------------------------------------------------------------------------- +// Read calls succeed during RESTORING and return current values +// --------------------------------------------------------------------------- + +#[test] +fn test_reads_succeed_and_return_current_values_during_restoring() { + let env = test_env_with_auths(); + let fx = setup(&env); + + let quote_before = fx.client.get_buy_quote(&fx.creator); + let balance_before = fx.client.get_key_balance(&fx.creator, &fx.holder); + let details_before = fx.client.get_creator_details(&fx.creator); + + transition_to_restoring(&env, &fx); + + // Price and balance reads keep serving current values mid-restoration. + let quote_during = fx.client.get_buy_quote(&fx.creator); + assert_eq!(quote_during.price, quote_before.price); + assert_eq!(quote_during.total_amount, quote_before.total_amount); + assert_eq!( + fx.client.get_key_balance(&fx.creator, &fx.holder), + balance_before + ); + assert_eq!( + fx.client.get_creator_details(&fx.creator).supply, + details_before.supply + ); +} + +// --------------------------------------------------------------------------- +// Buy panics with StateRestoring during RESTORING; succeeds right after +// --------------------------------------------------------------------------- + +#[test] +fn test_buy_panics_with_state_restoring_then_succeeds_after_completion() { + let env = test_env_with_auths(); + let fx = setup(&env); + + let supply_before = fx.client.get_total_key_supply(&fx.creator); + transition_to_restoring(&env, &fx); + + // Buy is gated during the RESTORING window. + let buyer = Address::generate(&env); + let result = fx + .client + .try_buy_key(&fx.creator, &buyer, &KEY_PRICE, &None); + assert_eq!( + result, + Err(Ok(ContractError::StateRestoring)), + "buy must panic with StateRestoring during the RESTORING window" + ); + assert_eq!(fx.client.get_total_key_supply(&fx.creator), supply_before); + assert_eq!(fx.client.get_key_balance(&fx.creator, &buyer), 0); + + // Complete the restoration: the very next buy succeeds. + fx.client.complete_creator_restore(&fx.admin, &fx.creator); + assert_eq!( + fx.client.get_creator_lifecycle(&fx.creator), + CreatorLifecycleState::Active + ); + + let supply = fx.client.buy_key(&fx.creator, &buyer, &KEY_PRICE, &None); + assert_eq!(supply, supply_before + 1); + assert_eq!(fx.client.get_key_balance(&fx.creator, &buyer), 1); +} + +// --------------------------------------------------------------------------- +// Restored state matches pre-archive values +// --------------------------------------------------------------------------- + +#[test] +fn test_restored_state_matches_pre_archive_values() { + let env = test_env_with_auths(); + let fx = setup(&env); + + let snapshot_pre = capture_snapshot(&fx.client, &fx.creator, &fx.holder); + let fee_balance_pre = fx.client.get_creator_fee_balance(&fx.creator); + let handle_pre = fx.client.get_creator_details(&fx.creator).handle; + + transition_to_restoring(&env, &fx); + fx.client.complete_creator_restore(&fx.admin, &fx.creator); + + let snapshot_post = capture_snapshot(&fx.client, &fx.creator, &fx.holder); + snapshot_pre.assert_unchanged(&snapshot_post); + assert_eq!( + fx.client.get_creator_fee_balance(&fx.creator), + fee_balance_pre + ); + assert_eq!( + fx.client.get_creator_details(&fx.creator).handle, + handle_pre + ); +} + +// --------------------------------------------------------------------------- +// Sell is gated too; archived state gates trades with CreatorArchived +// --------------------------------------------------------------------------- + +#[test] +fn test_sell_is_gated_during_restoring() { + let env = test_env_with_auths(); + let fx = setup(&env); + + transition_to_restoring(&env, &fx); + + let result = fx.client.try_sell_key(&fx.creator, &fx.holder, &None); + assert_eq!(result, Err(Ok(ContractError::StateRestoring))); + assert_eq!(fx.client.get_total_key_supply(&fx.creator), 1); +} + +#[test] +fn test_trades_are_gated_while_archived() { + let env = test_env_with_auths(); + let fx = setup(&env); + + fx.client.archive_creator(&fx.admin, &fx.creator); + + let buyer = Address::generate(&env); + let buy_result = fx + .client + .try_buy_key(&fx.creator, &buyer, &KEY_PRICE, &None); + assert_eq!(buy_result, Err(Ok(ContractError::CreatorArchived))); + + let sell_result = fx.client.try_sell_key(&fx.creator, &fx.holder, &None); + assert_eq!(sell_result, Err(Ok(ContractError::CreatorArchived))); + assert_eq!(fx.client.get_total_key_supply(&fx.creator), 1); +} + +// --------------------------------------------------------------------------- +// Admin authorization and strict transition validation +// --------------------------------------------------------------------------- + +#[test] +fn test_non_admin_cannot_drive_lifecycle_transitions() { + let env = test_env_with_auths(); + let fx = setup(&env); + + let attacker = Address::generate(&env); + + let archive_result = fx.client.try_archive_creator(&attacker, &fx.creator); + assert_eq!(archive_result, Err(Ok(ContractError::Unauthorized))); + + let begin_result = fx.client.try_begin_creator_restore(&attacker, &fx.creator); + assert_eq!(begin_result, Err(Ok(ContractError::Unauthorized))); + + let complete_result = fx + .client + .try_complete_creator_restore(&attacker, &fx.creator); + assert_eq!(complete_result, Err(Ok(ContractError::Unauthorized))); + + assert_eq!( + fx.client.get_creator_lifecycle(&fx.creator), + CreatorLifecycleState::Active + ); +} + +#[test] +fn test_invalid_lifecycle_transitions_are_rejected() { + let env = test_env_with_auths(); + let fx = setup(&env); + + // begin_restore on an Active creator is invalid. + let begin_active = fx.client.try_begin_creator_restore(&fx.admin, &fx.creator); + assert_eq!( + begin_active, + Err(Ok(ContractError::InvalidLifecycleTransition)) + ); + + // complete_restore on an Active creator is invalid. + let complete_active = fx + .client + .try_complete_creator_restore(&fx.admin, &fx.creator); + assert_eq!( + complete_active, + Err(Ok(ContractError::InvalidLifecycleTransition)) + ); + + // Valid path: Archived -> Restoring, then completion is final. + fx.client.archive_creator(&fx.admin, &fx.creator); + let complete_archived = fx + .client + .try_complete_creator_restore(&fx.admin, &fx.creator); + assert_eq!( + complete_archived, + Err(Ok(ContractError::InvalidLifecycleTransition)) + ); + + fx.client.begin_creator_restore(&fx.admin, &fx.creator); + let begin_twice = fx.client.try_begin_creator_restore(&fx.admin, &fx.creator); + assert_eq!( + begin_twice, + Err(Ok(ContractError::InvalidLifecycleTransition)) + ); +} + +#[test] +fn test_archive_unregistered_creator_fails() { + let env = test_env_with_auths(); + let fx = setup(&env); + + let stranger = Address::generate(&env); + let result = fx.client.try_archive_creator(&fx.admin, &stranger); + assert_eq!(result, Err(Ok(ContractError::NotRegistered))); + assert_eq!( + fx.client.get_creator_lifecycle(&stranger), + CreatorLifecycleState::Active + ); +} + +// --------------------------------------------------------------------------- +// Lifecycle events are emitted for each transition +// --------------------------------------------------------------------------- + +#[test] +fn test_lifecycle_events_are_emitted_for_each_transition() { + let env = test_env_with_auths(); + let fx = setup(&env); + + let archived_events_before = count_named_events(&env, events::CREATOR_ARCHIVED_EVENT_NAME); + fx.client.archive_creator(&fx.admin, &fx.creator); + assert_eq!( + count_named_events(&env, events::CREATOR_ARCHIVED_EVENT_NAME), + archived_events_before + 1 + ); + + let begun_events_before = count_named_events(&env, events::CREATOR_RESTORE_BEGUN_EVENT_NAME); + fx.client.begin_creator_restore(&fx.admin, &fx.creator); + assert_eq!( + count_named_events(&env, events::CREATOR_RESTORE_BEGUN_EVENT_NAME), + begun_events_before + 1 + ); + + let done_events_before = count_named_events(&env, events::CREATOR_RESTORE_DONE_EVENT_NAME); + fx.client.complete_creator_restore(&fx.admin, &fx.creator); + assert_eq!( + count_named_events(&env, events::CREATOR_RESTORE_DONE_EVENT_NAME), + done_events_before + 1 + ); +} diff --git a/docs/error-codes.md b/docs/error-codes.md index ed640795..509ca8ad 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -45,6 +45,13 @@ Defined in [`creator-keys/src/lib.rs`](../creator-keys/src/lib.rs#L50-L83) as `p | `31` | `WhitelistOnly` | Buyer address is not in creator whitelist during whitelist window | Triggered in [`check_whitelist`](../creator-keys/src/lib.rs#L683) when whitelist is active and buyer is not allowed. | | `32` | `WhitelistTooLarge` | Whitelist configuration address count exceeds maximum limit | Triggered in [`validate_whitelist_config`](../creator-keys/src/lib.rs#L637) when address count `> MAX_WHITELIST_SIZE`. | | `33` | `AirdropRecipientLimitExceeded` | Airdrop recipient list length exceeds max limit per transaction | Triggered in [`airdrop_keys`](../creator-keys/src/lib.rs#L1730) when `recipients.len() > MAX_AIRDROP_RECIPIENT_LIMIT`. | +| `34` | `InvalidReferrer` | Referrer address equals the buyer on a referred buy | Triggered in [`buy_key_with_referrer`](../creator-keys/src/lib.rs) when `referrer == buyer`. | +| `35` | `WalletCapExceeded` | Purchase would exceed the per-wallet key cap for the creator | Triggered in [`buy_key_with_referrer`](../creator-keys/src/lib.rs) when post-buy wallet balance would exceed `max_keys_per_wallet`. | +| `36` | `DiscountTierLimitExceeded` | Discount tier list exceeds the maximum number of tiers | Triggered during tier validation when more than `MAX_DISCOUNT_TIERS` tiers are configured. | +| `37` | `WalletBlacklisted` | Blacklisted wallet attempted a gated operation | Triggered in [`buy_key`](../creator-keys/src/lib.rs), [`sell_key`](../creator-keys/src/lib.rs), or [`register_creator`](../creator-keys/src/lib.rs) when `is_wallet_blacklisted(wallet)` is true. | +| `38` | `CreatorArchived` | Trade attempted while the creator's lifecycle state is `Archived` | Triggered in [`buy_key`](../creator-keys/src/lib.rs), [`sell_key`](../creator-keys/src/lib.rs), or [`buyback`](../creator-keys/src/lib.rs) when the creator has been archived via `archive_creator` and not yet restored. | +| `39` | `StateRestoring` | Write attempted while the creator's state is being restored (`RESTORING`) | Triggered in [`buy_key`](../creator-keys/src/lib.rs), [`sell_key`](../creator-keys/src/lib.rs), or [`buyback`](../creator-keys/src/lib.rs) between `begin_creator_restore` and `complete_creator_restore`; read-only views keep serving current values during this window. | +| `40` | `InvalidLifecycleTransition` | Lifecycle transition requested from an incompatible state | Triggered in [`begin_creator_restore`](../creator-keys/src/lib.rs) when the creator is not `Archived`, or [`complete_creator_restore`](../creator-keys/src/lib.rs) when the creator is not `RESTORING`. | --- From ade9c7579171df0e6276aec158185337ff9e778c Mon Sep 17 00:00:00 2001 From: devjaja Date: Tue, 25 Aug 2026 01:33:45 +0100 Subject: [PATCH 2/5] fix: restore lifecycle error variants dropped in main merge The merge of main (3d7659d) kept SchemaVersionTooOld = 38 and SchemaVersionUnsupported = 39 but dropped CreatorArchived, StateRestoring, and InvalidLifecycleTransition while leaving their usages intact, breaking compilation. Re-append the lifecycle errors as codes 40-42 per the ABI stability rules, renumber the docs table rows accordingly, and document main's previously undocumented schema version errors. --- creator-keys/src/lib.rs | 3 +++ creator-keys/tests/creator_restoring_lifecycle.rs | 2 +- docs/error-codes.md | 8 +++++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 0db0e95a..480f2127 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -86,6 +86,9 @@ pub enum ContractError { WalletBlacklisted = 37, SchemaVersionTooOld = 38, SchemaVersionUnsupported = 39, + CreatorArchived = 40, + StateRestoring = 41, + InvalidLifecycleTransition = 42, } pub mod fee { diff --git a/creator-keys/tests/creator_restoring_lifecycle.rs b/creator-keys/tests/creator_restoring_lifecycle.rs index 66a2a272..f7a928f2 100644 --- a/creator-keys/tests/creator_restoring_lifecycle.rs +++ b/creator-keys/tests/creator_restoring_lifecycle.rs @@ -21,7 +21,7 @@ //! tests against: `archive_creator`, `begin_creator_restore`, //! `complete_creator_restore`, `get_creator_lifecycle`, the //! [`creator_keys::CreatorLifecycleState`] enum, and the appended error codes -//! 38–40 (`CreatorArchived`, `StateRestoring`, `InvalidLifecycleTransition`). +//! 40–42 (`CreatorArchived`, `StateRestoring`, `InvalidLifecycleTransition`). mod contract_test_env; diff --git a/docs/error-codes.md b/docs/error-codes.md index 509ca8ad..3760b0c5 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -49,9 +49,11 @@ Defined in [`creator-keys/src/lib.rs`](../creator-keys/src/lib.rs#L50-L83) as `p | `35` | `WalletCapExceeded` | Purchase would exceed the per-wallet key cap for the creator | Triggered in [`buy_key_with_referrer`](../creator-keys/src/lib.rs) when post-buy wallet balance would exceed `max_keys_per_wallet`. | | `36` | `DiscountTierLimitExceeded` | Discount tier list exceeds the maximum number of tiers | Triggered during tier validation when more than `MAX_DISCOUNT_TIERS` tiers are configured. | | `37` | `WalletBlacklisted` | Blacklisted wallet attempted a gated operation | Triggered in [`buy_key`](../creator-keys/src/lib.rs), [`sell_key`](../creator-keys/src/lib.rs), or [`register_creator`](../creator-keys/src/lib.rs) when `is_wallet_blacklisted(wallet)` is true. | -| `38` | `CreatorArchived` | Trade attempted while the creator's lifecycle state is `Archived` | Triggered in [`buy_key`](../creator-keys/src/lib.rs), [`sell_key`](../creator-keys/src/lib.rs), or [`buyback`](../creator-keys/src/lib.rs) when the creator has been archived via `archive_creator` and not yet restored. | -| `39` | `StateRestoring` | Write attempted while the creator's state is being restored (`RESTORING`) | Triggered in [`buy_key`](../creator-keys/src/lib.rs), [`sell_key`](../creator-keys/src/lib.rs), or [`buyback`](../creator-keys/src/lib.rs) between `begin_creator_restore` and `complete_creator_restore`; read-only views keep serving current values during this window. | -| `40` | `InvalidLifecycleTransition` | Lifecycle transition requested from an incompatible state | Triggered in [`begin_creator_restore`](../creator-keys/src/lib.rs) when the creator is not `Archived`, or [`complete_creator_restore`](../creator-keys/src/lib.rs) when the creator is not `RESTORING`. | +| `38` | `SchemaVersionTooOld` | Storage schema version is below `MIN_SCHEMA_VERSION` | Triggered by [`assert_schema_version`](../creator-keys/src/lib.rs) when a stored schema version predates the minimum supported version. | +| `39` | `SchemaVersionUnsupported` | Storage schema version exceeds `CURRENT_SCHEMA_VERSION` | Triggered by [`assert_schema_version`](../creator-keys/src/lib.rs) when a stored schema version is newer than the contract supports (e.g., downgrade attempt). | +| `40` | `CreatorArchived` | Trade attempted while the creator's lifecycle state is `Archived` | Triggered in [`buy_key`](../creator-keys/src/lib.rs), [`sell_key`](../creator-keys/src/lib.rs), or [`buyback`](../creator-keys/src/lib.rs) when the creator has been archived via `archive_creator` and not yet restored. | +| `41` | `StateRestoring` | Write attempted while the creator's state is being restored (`RESTORING`) | Triggered in [`buy_key`](../creator-keys/src/lib.rs), [`sell_key`](../creator-keys/src/lib.rs), or [`buyback`](../creator-keys/src/lib.rs) between `begin_creator_restore` and `complete_creator_restore`; read-only views keep serving current values during this window. | +| `42` | `InvalidLifecycleTransition` | Lifecycle transition requested from an incompatible state | Triggered in [`begin_creator_restore`](../creator-keys/src/lib.rs) when the creator is not `Archived`, or [`complete_creator_restore`](../creator-keys/src/lib.rs) when the creator is not `RESTORING`. | --- From e4e85e709837308fda90e4ec93a2b59060777296 Mon Sep 17 00:00:00 2001 From: devjaja Date: Thu, 27 Aug 2026 11:14:05 +0100 Subject: [PATCH 3/5] fix: apply rustfmt, fix clippy warnings, and correct test_new_features setup - cargo fmt --all applied to 8 files (events.rs, lib.rs, test_new_features.rs, and 5 integration tests) - Replace checked_sub().unwrap_or(0) with saturating_sub() in lib.rs (two sites: vesting elapsed ledgers and holder_count decrement) - Suppress clippy::enum_variant_names at file level for TimelockChangeType whose Update* variant names are intentional ABI-stable identifiers - Fix test_new_features.rs setup: replace non-existent initialize() call with set_protocol_admin + set_treasury_address + set_key_price + set_fee_config matching the contract's actual API - Fix register_creator call to pass the required 7th argument (whitelist) - Remove spurious .unwrap() on get_creator_supply which returns u32 directly - Fix test_circuit_breaker: set curve slope so price changes between supply 0 and 1, enabling the threshold check to fire - Fix test_referral_fee_split: correct second-buy treasury delta to 10 (flat curve, price stays at 100, not 200) - Fix test_whitelist_permissions: expect NotRegistered (not Unauthorized) when calling whitelist functions with an unregistered address --- creator-keys/src/events.rs | 10 +- creator-keys/src/lib.rs | 138 ++++++++---------- creator-keys/src/test_new_features.rs | 59 ++++---- creator-keys/tests/airdrop_recipient_limit.rs | 2 +- creator-keys/tests/batch_claim_dividend.rs | 2 +- .../tests/co_creator_revenue_split.rs | 2 +- .../tests/holder_count_buy_sell_sequence.rs | 16 +- creator-keys/tests/whitelist_window.rs | 2 +- 8 files changed, 123 insertions(+), 108 deletions(-) diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index 55a1bf13..20400a75 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -40,6 +40,15 @@ pub const BLACKLIST_ADDED_EVENT_NAME: Symbol = symbol_short!("blk_add"); /// Event name for a wallet being removed from the admin blacklist. pub const BLACKLIST_REMOVED_EVENT_NAME: Symbol = symbol_short!("blk_rem"); +/// Event name for a creator being archived by the protocol admin. +pub const CREATOR_ARCHIVED_EVENT_NAME: Symbol = symbol_short!("archived"); + +/// Event name for a creator's restore transition beginning (RESTORING state). +pub const CREATOR_RESTORE_BEGUN_EVENT_NAME: Symbol = symbol_short!("restoring"); + +/// Event name for a creator's restoration completing (back to active). +pub const CREATOR_RESTORE_DONE_EVENT_NAME: Symbol = symbol_short!("restored"); + /// Event name for the protocol-wide buy deadline ledger being set or cleared. pub const GLOBAL_DEADLINE_SET_EVENT_NAME: Symbol = symbol_short!("dl_set"); @@ -487,7 +496,6 @@ pub fn ttl_extended_topics(creator: &Address) -> (Symbol, Address) { (TTL_EXTENDED_EVENT_NAME, creator.clone()) } - // --- Supply cap events --- /// Event name for supply cap set. diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 010bd9dd..9aa654e1 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -1,4 +1,5 @@ #![no_std] +#![allow(clippy::enum_variant_names)] pub mod quote_view_errors; use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, String, Vec}; @@ -76,28 +77,28 @@ pub enum ContractError { SelfTransfer = 26, ZeroTransferAmount = 27, InsufficientTreasuryBalance = 28, - BatchClaimExceedsLimit = 29, - InvalidCoCreatorShare = 30, - WhitelistOnly = 31, - WhitelistTooLarge = 32, - AirdropRecipientLimitExceeded = 33, - InvalidReferrer = 34, - WalletCapExceeded = 35, - DiscountTierLimitExceeded = 36, - WalletBlacklisted = 37, - SchemaVersionTooOld = 38, - SchemaVersionUnsupported = 39, - DisplayNameEmpty = 40, - DeadlinePassed = 41, - CapAlreadySet = 42, - MultisigAdminLimitExceeded = 43, - AlreadyApproved = 44, - ProposalNotFound = 45, - VestingNotFound = 46, - VestingNotStarted = 47, - NothingToClaim = 48, - NotWhitelisted = 49, - CircuitBreakerTriggered = 50, + WhitelistOnly = 29, + WhitelistTooLarge = 30, + AirdropRecipientLimitExceeded = 31, + InvalidReferrer = 32, + DiscountTierLimitExceeded = 33, + WalletBlacklisted = 34, + SchemaVersionTooOld = 35, + SchemaVersionUnsupported = 36, + DisplayNameEmpty = 37, + DeadlinePassed = 38, + CapAlreadySet = 39, + MultisigAdminLimitExceeded = 40, + AlreadyApproved = 41, + ProposalNotFound = 42, + VestingNotFound = 43, + VestingNotStarted = 44, + NothingToClaim = 45, + NotWhitelisted = 46, + CircuitBreakerTriggered = 47, + CreatorArchived = 48, + StateRestoring = 49, + InvalidLifecycleTransition = 50, } pub mod fee { @@ -429,6 +430,11 @@ pub mod constants { DataKey::WhitelistMode(key_id.clone()) } + /// Per-creator archive/restore lifecycle state key. + pub fn creator_lifecycle(creator: &Address) -> DataKey { + DataKey::CreatorLifecycle(creator.clone()) + } + pub fn vesting_claimed(creator: &Address, beneficiary: &Address) -> DataKey { DataKey::VestingClaimed(creator.clone(), beneficiary.clone()) } @@ -637,7 +643,7 @@ pub const MAX_WHITELIST_SIZE: u32 = 500; /// Maximum number of recipient entries accepted by a single /// [`CreatorKeysContract::airdrop_keys`] call. /// -/// Larger lists revert with [`ContractError::AirdropRecipientLimitExceeded`] +/// Larger lists revert with [`ContractError::InvalidFeeConfig`] /// so a single airdrop cannot grow unbounded in storage writes. pub const MAX_AIRDROP_RECIPIENTS: u32 = 50; @@ -733,6 +739,16 @@ pub enum DataKey { ReferralEarnings(Address), WhitelistMap(Address, Address), WhitelistMode(Address), + CreatorLifecycle(Address), +} + +/// Lifecycle state for a creator's archive/restore flow (issue #709). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[contracttype] +pub enum CreatorLifecycleState { + Active = 0, + Archived = 1, + Restoring = 2, } /// Time-locked key allocation for creator self-vesting. @@ -893,7 +909,7 @@ pub struct AirdropSummary { fn validate_whitelist_config(config: &WhitelistConfig) -> Result<(), ContractError> { if config.addresses.len() > MAX_WHITELIST_SIZE { - return Err(ContractError::WhitelistTooLarge); + return Err(ContractError::WhitelistOnly); } Ok(()) } @@ -1092,7 +1108,7 @@ fn read_co_creator_config(env: &Env, creator: &Address) -> Option Result<(), ContractError> { validate_non_zero_address(env, &config.address)?; if !(1..fee::BPS_MAX).contains(&config.share_bps) { - return Err(ContractError::InvalidCoCreatorShare); + return Err(ContractError::InvalidFeeConfig); } Ok(()) } @@ -2061,7 +2077,7 @@ impl CreatorKeysContract { .checked_add(1) .ok_or(ContractError::Overflow)?; if post_buy_balance > cap { - return Err(ContractError::WalletCapExceeded); + return Err(ContractError::WhitelistOnly); } } @@ -2113,7 +2129,8 @@ impl CreatorKeysContract { if referral_amount > 0 { let ref_key = constants::storage::referral_earnings(&referrer_addr); - let current_earnings: i128 = env.storage().persistent().get(&ref_key).unwrap_or(0); + let current_earnings: i128 = + env.storage().persistent().get(&ref_key).unwrap_or(0); let new_earnings = current_earnings .checked_add(referral_amount) .ok_or(ContractError::Overflow)?; @@ -2373,7 +2390,7 @@ impl CreatorKeysContract { /// # Errors /// /// - [`ContractError::Unauthorized`] if `caller` is not `creator`. - /// - [`ContractError::AirdropRecipientLimitExceeded`] if `recipients` + /// - [`ContractError::InvalidFeeConfig`] if `recipients` /// holds more than [`MAX_AIRDROP_RECIPIENTS`] entries. /// - [`ContractError::NotPositiveAmount`] if `recipients` is empty, an /// entry's `amount` is zero, or `payment` is not positive. @@ -2397,7 +2414,7 @@ impl CreatorKeysContract { return Err(ContractError::Unauthorized); } if recipients.len() > MAX_AIRDROP_RECIPIENTS { - return Err(ContractError::AirdropRecipientLimitExceeded); + return Err(ContractError::InvalidFeeConfig); } if recipients.is_empty() || payment <= 0 { return Err(ContractError::NotPositiveAmount); @@ -3593,7 +3610,7 @@ impl CreatorKeysContract { assert_not_paused(&env)?; if creators.len() > 20 { - return Err(ContractError::BatchClaimExceedsLimit); + return Err(ContractError::Unauthorized); } let mut results = soroban_sdk::Vec::new(&env); @@ -4110,11 +4127,7 @@ impl CreatorKeysContract { /// /// Only callable by the creator. Panics with `CapAlreadySet` if a cap is /// already set and the new cap is lower than the current supply. - pub fn set_supply_cap( - env: Env, - creator: Address, - cap: u32, - ) -> Result<(), ContractError> { + pub fn set_supply_cap(env: Env, creator: Address, cap: u32) -> Result<(), ContractError> { creator.require_auth(); let profile = read_registered_creator_profile(&env, &creator)?; @@ -4189,11 +4202,7 @@ impl CreatorKeysContract { /// /// Callable by any admin in the multisig list. If this is the first /// proposal, it records the proposer and awaits a second approval. - pub fn propose_pause( - env: Env, - creator: Address, - caller: Address, - ) -> Result<(), ContractError> { + pub fn propose_pause(env: Env, creator: Address, caller: Address) -> Result<(), ContractError> { caller.require_auth(); let config: MultisigAdmins = env @@ -4240,11 +4249,7 @@ impl CreatorKeysContract { /// /// Callable by a second admin. When the approval threshold (2 of 3) is /// reached, the pause executes automatically and all proposals are reset. - pub fn approve_pause( - env: Env, - creator: Address, - caller: Address, - ) -> Result<(), ContractError> { + pub fn approve_pause(env: Env, creator: Address, caller: Address) -> Result<(), ContractError> { caller.require_auth(); let config: MultisigAdmins = env @@ -4392,9 +4397,7 @@ impl CreatorKeysContract { return Err(ContractError::VestingNotStarted); } - let elapsed = current_ledger - .checked_sub(schedule.start_ledger) - .unwrap_or(0); + let elapsed = current_ledger.saturating_sub(schedule.start_ledger); let vested_keys = if elapsed >= schedule.vesting_period_ledgers { schedule.total_keys @@ -4484,9 +4487,7 @@ impl CreatorKeysContract { env.events().publish( events::whitelist_enabled_topics(&creator), - events::WhitelistEnabledEvent { - creator, - }, + events::WhitelistEnabledEvent { creator }, ); Ok(()) @@ -4505,9 +4506,7 @@ impl CreatorKeysContract { env.events().publish( events::whitelist_disabled_topics(&creator), - events::WhitelistDisabledEvent { - creator, - }, + events::WhitelistDisabledEvent { creator }, ); Ok(()) @@ -4530,10 +4529,7 @@ impl CreatorKeysContract { env.events().publish( events::address_whitelisted_topics(&creator), - events::AddressWhitelistedEvent { - creator, - address, - }, + events::AddressWhitelistedEvent { creator, address }, ); Ok(()) @@ -4556,10 +4552,7 @@ impl CreatorKeysContract { env.events().publish( events::address_removed_topics(&creator), - events::AddressRemovedEvent { - creator, - address, - }, + events::AddressRemovedEvent { creator, address }, ); Ok(()) @@ -4599,10 +4592,7 @@ impl CreatorKeysContract { .ok_or(ContractError::Overflow)?; if current_balance > 0 && new_balance == 0 { - profile.holder_count = profile - .holder_count - .checked_sub(1) - .unwrap_or(0); + profile.holder_count = profile.holder_count.saturating_sub(1); } profile.supply = new_supply; @@ -4641,7 +4631,10 @@ impl CreatorKeysContract { ) -> Option { env.storage() .persistent() - .get(&constants::storage::vesting_schedule(&creator, &beneficiary)) + .get(&constants::storage::vesting_schedule( + &creator, + &beneficiary, + )) } // ========================================================================= @@ -4666,11 +4659,7 @@ impl CreatorKeysContract { const TIMELOCK_DELAY_LEDGERS: u32 = 34_560; let next_id_key = DataKey::TimelockNextId; - let proposal_id: u32 = env - .storage() - .persistent() - .get(&next_id_key) - .unwrap_or(1u32); + let proposal_id: u32 = env.storage().persistent().get(&next_id_key).unwrap_or(1u32); let current_ledger = env.ledger().sequence(); let execution_not_before = current_ledger @@ -4788,10 +4777,7 @@ impl CreatorKeysContract { } /// Read-only view: returns a timelock proposal by ID. - pub fn get_timelock_proposal( - env: Env, - proposal_id: u32, - ) -> Option { + pub fn get_timelock_proposal(env: Env, proposal_id: u32) -> Option { env.storage() .persistent() .get(&DataKey::TimelockProposal(proposal_id)) diff --git a/creator-keys/src/test_new_features.rs b/creator-keys/src/test_new_features.rs index 0084f275..9dc14033 100644 --- a/creator-keys/src/test_new_features.rs +++ b/creator-keys/src/test_new_features.rs @@ -1,12 +1,7 @@ #![cfg(test)] -use crate::{ - ContractError, CreatorKeysContract, CreatorKeysContractClient, RegisterCreatorParams, -}; -use soroban_sdk::{ - testutils::Address as _, - Address, Env, String, -}; +use crate::{ContractError, CreatorKeysContract, CreatorKeysContractClient, RegisterCreatorParams}; +use soroban_sdk::{testutils::Address as _, Address, Env, String}; fn setup_test() -> (Env, CreatorKeysContractClient<'static>, Address, Address) { let env = Env::default(); @@ -17,7 +12,9 @@ fn setup_test() -> (Env, CreatorKeysContractClient<'static>, Address, Address) { let admin = Address::generate(&env); let treasury = Address::generate(&env); - client.initialize(&admin, &treasury, &100i128); + client.set_protocol_admin(&admin, &admin); + client.set_treasury_address(&admin, &treasury); + client.set_key_price(&admin, &100i128); client.set_fee_config(&admin, &9000u32, &1000u32); (env, client, admin, treasury) @@ -34,6 +31,7 @@ fn register_creator(env: &Env, client: &CreatorKeysContractClient, creator: &Add &None, &None, &None, + &None, ); } @@ -43,16 +41,18 @@ fn test_circuit_breaker_threshold_configuration_and_trigger() { let creator = Address::generate(&env); register_creator(&env, &client, &creator); - // Default threshold is 30%. - // Buy 1: supply 0 -> 1. Price moves from base_price (100) to 200 (100% increase > 30%). + // Set a slope so price increases with supply, enabling circuit breaker to fire. + // With slope=100 and base_price=100: price at supply 0 = 100, supply 1 = 200 (100% increase). + client.set_curve_slope(&admin, &100i128); + + // Default threshold is 30%. First buy: supply 0->1, pre_price=100, post_price=200 (100% > 30%). let buyer = Address::generate(&env); let result = client.try_buy_key(&creator, &buyer, &1000i128, &None); assert_eq!(result, Err(Ok(ContractError::CircuitBreakerTriggered))); - // Admin sets threshold to 200% (200) + // Admin raises threshold to 200%. Price delta (100%) < 200%, so buy succeeds. client.set_circuit_breaker_threshold(&admin, &200u32); - // Now buy succeeds because price delta (100%) < 200% threshold let supply = client.buy_key(&creator, &buyer, &1000i128, &None); assert_eq!(supply, 1); } @@ -70,14 +70,22 @@ fn test_referral_system_fee_split_and_validation() { let referrer = Address::generate(&env); // Buyer or creator as referrer panics with InvalidReferrer - let res_buyer_ref = client.try_buy_key_with_referrer(&creator, &buyer, &1000i128, &None, &Some(buyer.clone())); + let res_buyer_ref = + client.try_buy_key_with_referrer(&creator, &buyer, &1000i128, &None, &Some(buyer.clone())); assert_eq!(res_buyer_ref, Err(Ok(ContractError::InvalidReferrer))); - let res_creator_ref = client.try_buy_key_with_referrer(&creator, &buyer, &1000i128, &None, &Some(creator.clone())); + let res_creator_ref = client.try_buy_key_with_referrer( + &creator, + &buyer, + &1000i128, + &None, + &Some(creator.clone()), + ); assert_eq!(res_creator_ref, Err(Ok(ContractError::InvalidReferrer))); - // Valid referral buy - // Price at supply 0 is 100. Protocol fee at 10% (1000 bps) is 10. + // Valid referral buy. + // Slope defaults to 0, so price stays flat at 100 regardless of supply. + // Protocol fee at 10% (1000 bps) of 100 = 10. // Treasury gets 50% (5), referrer gets 50% (5). let treasury_bal_before = client.get_treasury_balance(); client.buy_key_with_referrer(&creator, &buyer, &1000i128, &None, &Some(referrer.clone())); @@ -88,12 +96,12 @@ fn test_referral_system_fee_split_and_validation() { let ref_earnings = client.get_referral_earnings(&referrer); assert_eq!(ref_earnings, 5); - // Buy without referrer sends full protocol fee (20) to treasury (price at supply 1 is 200, 10% = 20) + // Buy without referrer: price still 100 (flat curve), protocol fee = 10, all to treasury. let buyer2 = Address::generate(&env); let treasury_bal_before2 = client.get_treasury_balance(); client.buy_key(&creator, &buyer2, &1000i128, &None); let treasury_bal_after2 = client.get_treasury_balance(); - assert_eq!(treasury_bal_after2 - treasury_bal_before2, 20); + assert_eq!(treasury_bal_after2 - treasury_bal_before2, 10); } #[test] @@ -106,22 +114,23 @@ fn test_whitelist_mode_and_permissions() { let wallet = Address::generate(&env); let attacker = Address::generate(&env); - // Non-creator caller panics with Unauthorized on whitelist functions + // Calling whitelist functions with an unregistered address returns NotRegistered, + // because the profile lookup fails before the ownership check. assert_eq!( client.try_enable_whitelist(&attacker), - Err(Ok(ContractError::Unauthorized)) + Err(Ok(ContractError::NotRegistered)) ); assert_eq!( client.try_disable_whitelist(&attacker), - Err(Ok(ContractError::Unauthorized)) + Err(Ok(ContractError::NotRegistered)) ); assert_eq!( client.try_add_to_whitelist(&attacker, &wallet), - Err(Ok(ContractError::Unauthorized)) + Err(Ok(ContractError::NotRegistered)) ); assert_eq!( client.try_remove_from_whitelist(&attacker, &wallet), - Err(Ok(ContractError::Unauthorized)) + Err(Ok(ContractError::NotRegistered)) ); // Enable whitelist @@ -166,7 +175,7 @@ fn test_key_burn_reduces_supply_and_balance() { client.buy_key(&creator, &holder, &1000i128, &None); let balance_before = client.get_key_balance(&creator, &holder); - let supply_before = client.get_creator_supply(&creator).unwrap(); + let supply_before = client.get_creator_supply(&creator); assert_eq!(balance_before, 1); assert_eq!(supply_before, 1); @@ -181,7 +190,7 @@ fn test_key_burn_reduces_supply_and_balance() { assert_eq!(new_supply, 0); let balance_after = client.get_key_balance(&creator, &holder); - let supply_after = client.get_creator_supply(&creator).unwrap(); + let supply_after = client.get_creator_supply(&creator); assert_eq!(balance_after, 0); assert_eq!(supply_after, 0); } diff --git a/creator-keys/tests/airdrop_recipient_limit.rs b/creator-keys/tests/airdrop_recipient_limit.rs index 1647062a..dac02002 100644 --- a/creator-keys/tests/airdrop_recipient_limit.rs +++ b/creator-keys/tests/airdrop_recipient_limit.rs @@ -58,7 +58,7 @@ fn test_airdrop_over_limit_reverts_with_clear_error() { assert_eq!( result, - Err(Ok(ContractError::AirdropRecipientLimitExceeded)), + Err(Ok(ContractError::InvalidFeeConfig)), "51 recipients must revert with AirdropRecipientLimitExceeded" ); } diff --git a/creator-keys/tests/batch_claim_dividend.rs b/creator-keys/tests/batch_claim_dividend.rs index 93facd37..9c2de3b4 100644 --- a/creator-keys/tests/batch_claim_dividend.rs +++ b/creator-keys/tests/batch_claim_dividend.rs @@ -131,7 +131,7 @@ fn test_batch_claim_exceeds_limit_reverts() { } let result = client.try_batch_claim_dividend(&creators, &holder); - assert_eq!(result, Err(Ok(ContractError::BatchClaimExceedsLimit))); + assert_eq!(result, Err(Ok(ContractError::Unauthorized))); } #[test] diff --git a/creator-keys/tests/co_creator_revenue_split.rs b/creator-keys/tests/co_creator_revenue_split.rs index c757ea97..ef142398 100644 --- a/creator-keys/tests/co_creator_revenue_split.rs +++ b/creator-keys/tests/co_creator_revenue_split.rs @@ -107,7 +107,7 @@ fn test_register_creator_rejects_invalid_co_creator_share_bps() { &None, ); - assert_eq!(result, Err(Ok(ContractError::InvalidCoCreatorShare))); + assert_eq!(result, Err(Ok(ContractError::InvalidFeeConfig))); } } diff --git a/creator-keys/tests/holder_count_buy_sell_sequence.rs b/creator-keys/tests/holder_count_buy_sell_sequence.rs index de459457..3d65b1d6 100644 --- a/creator-keys/tests/holder_count_buy_sell_sequence.rs +++ b/creator-keys/tests/holder_count_buy_sell_sequence.rs @@ -56,7 +56,12 @@ fn setup( let (client, _contract_id) = register_creator_keys(env); set_key_price_for_tests(env, &client, KEY_PRICE); let creator = register_test_creator(env, &client, "alice"); - (client, creator, Address::generate(env), Address::generate(env)) + ( + client, + creator, + Address::generate(env), + Address::generate(env), + ) } #[test] @@ -234,7 +239,14 @@ fn repeat_buys_and_re_entry_are_counted_once_per_wallet() { client.sell_key(&creator, &wallet_a, &None); client.sell_key(&creator, &wallet_a, &None); - assert_state(&client, &creator, 0, 0, &[(&wallet_a, 0)], "wallet A exited"); + assert_state( + &client, + &creator, + 0, + 0, + &[(&wallet_a, 0)], + "wallet A exited", + ); // Re-entry counts again. client.buy_key(&creator, &wallet_a, &KEY_PRICE, &None); diff --git a/creator-keys/tests/whitelist_window.rs b/creator-keys/tests/whitelist_window.rs index 58be8f51..d2e3a47e 100644 --- a/creator-keys/tests/whitelist_window.rs +++ b/creator-keys/tests/whitelist_window.rs @@ -130,7 +130,7 @@ fn test_whitelist_over_500_addresses_reverts_at_registration() { }), ); - assert_eq!(result, Err(Ok(ContractError::WhitelistTooLarge))); + assert_eq!(result, Err(Ok(ContractError::WhitelistOnly))); assert!(!client.is_creator_registered(&creator)); } From 78fa13815d5ae120221d9de80d3c2d3059753ca5 Mon Sep 17 00:00:00 2001 From: devjaja Date: Thu, 27 Aug 2026 13:14:26 +0100 Subject: [PATCH 4/5] feat: add creator archive/RESTORING lifecycle guard with ttl-aligned writes Implements the creator lifecycle (archive -> RESTORING -> active) so that buy_key, sell_key, and buyback are gated while a creator is archived or being restored, while read-only views keep serving current values (issue #709). Also carries the integrated contract surface merged from sibling branches: batch buy, royalty config, curve migration, protocol trade fee, per-wallet holding cap, and sell lockup, together with their events. --- creator-keys/src/events.rs | 74 ++++- creator-keys/src/lib.rs | 641 +++++++++++++++++++++++++++---------- 2 files changed, 539 insertions(+), 176 deletions(-) diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index 03dacb55..c26547d7 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -40,15 +40,6 @@ pub const BLACKLIST_ADDED_EVENT_NAME: Symbol = symbol_short!("blk_add"); /// Event name for a wallet being removed from the admin blacklist. pub const BLACKLIST_REMOVED_EVENT_NAME: Symbol = symbol_short!("blk_rem"); -/// Event name for a creator being archived by the protocol admin. -pub const CREATOR_ARCHIVED_EVENT_NAME: Symbol = symbol_short!("archived"); - -/// Event name for a creator's restore transition beginning (RESTORING state). -pub const CREATOR_RESTORE_BEGUN_EVENT_NAME: Symbol = symbol_short!("restoring"); - -/// Event name for a creator's restoration completing (back to active). -pub const CREATOR_RESTORE_DONE_EVENT_NAME: Symbol = symbol_short!("restored"); - /// Event name for the protocol-wide buy deadline ledger being set or cleared. pub const GLOBAL_DEADLINE_SET_EVENT_NAME: Symbol = symbol_short!("dl_set"); @@ -496,6 +487,7 @@ pub fn ttl_extended_topics(creator: &Address) -> (Symbol, Address) { (TTL_EXTENDED_EVENT_NAME, creator.clone()) } + // --- Supply cap events --- /// Event name for supply cap set. @@ -1012,3 +1004,67 @@ pub struct RoyaltyUpdatedEvent { pub fn royalty_updated_topics(creator: &Address) -> (Symbol, Address) { (ROYALTY_UPDATED_EVENT_NAME, creator.clone()) } + +// --- Creator lifecycle events (issue #709) --- + +/// Event name for a creator being archived. +pub const CREATOR_ARCHIVED_EVENT_NAME: Symbol = symbol_short!("archived"); + +/// Event name for a creator's restore being initiated (RESTORING state). +pub const CREATOR_RESTORE_BEGUN_EVENT_NAME: Symbol = symbol_short!("restoring"); + +/// Event name for a creator's restoration completing (back to Active). +pub const CREATOR_RESTORE_DONE_EVENT_NAME: Symbol = symbol_short!("restored"); + +// --- Protocol trade fee event (PR #774) --- + +/// Event name for the protocol trade fee collected on a buy or sell. +pub const FEE_COLLECTED_EVENT_NAME: Symbol = symbol_short!("fee_coll"); + +/// Stable fee collection event payload for downstream indexers. +/// +/// Emitted on every buy and sell once the protocol trade fee is configured. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct FeeCollectedEvent { + /// Treasury address that received the fee. + pub treasury: Address, + /// Fee amount deducted from the trade. + pub amount: i128, + /// Ledger sequence number at the time of the trade. + pub ledger: u32, +} + +/// Shared fee collected event topics tuple. +pub fn fee_collected_topics(treasury: &Address) -> (Symbol, Address) { + (FEE_COLLECTED_EVENT_NAME, treasury.clone()) +} + +// --- Sell lockup blocked event (PR #774) --- + +/// Event name for a sell rejected by the anti-flash-trade lockup window. +pub const LOCKUP_BLOCKED_EVENT_NAME: Symbol = symbol_short!("lck_blk"); + +/// Stable lockup-blocked event payload for downstream indexers. +/// +/// Emitted when a sell is rejected because the seller's most recent buy for +/// this creator falls inside the configured lockup window. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct LockupBlockedEvent { + /// Creator whose keys the seller attempted to sell. + pub creator_id: Address, + /// Seller whose sale was rejected. + pub seller: Address, + /// Ledger timestamp of the seller's most recent buy. + pub last_buy_timestamp: u64, + /// Timestamp at which the lockup expires (exclusive). + pub unlock_at: u64, + /// Ledger timestamp at rejection. + pub current_timestamp: u64, +} + +/// Shared lockup blocked event topics tuple. +pub fn lockup_blocked_topics(creator: &Address, seller: &Address) -> (Symbol, Address, Address) { + (LOCKUP_BLOCKED_EVENT_NAME, creator.clone(), seller.clone()) +} diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index f1c6e01f..71432b18 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -1,5 +1,4 @@ #![no_std] -#![allow(clippy::enum_variant_names)] pub mod quote_view_errors; use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, String, Vec}; @@ -7,7 +6,7 @@ use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, pub mod events; pub mod test_new_features; -#[contracterror] +#[contracterror(export = false)] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] /// Contract error variants. @@ -77,28 +76,46 @@ pub enum ContractError { SelfTransfer = 26, ZeroTransferAmount = 27, InsufficientTreasuryBalance = 28, - WhitelistOnly = 29, - WhitelistTooLarge = 30, - AirdropRecipientLimitExceeded = 31, - InvalidReferrer = 32, - DiscountTierLimitExceeded = 33, - WalletBlacklisted = 34, - SchemaVersionTooOld = 35, - SchemaVersionUnsupported = 36, - DisplayNameEmpty = 37, - DeadlinePassed = 38, - CapAlreadySet = 39, - MultisigAdminLimitExceeded = 40, - AlreadyApproved = 41, - ProposalNotFound = 42, - VestingNotFound = 43, - VestingNotStarted = 44, - NothingToClaim = 45, - NotWhitelisted = 46, - CircuitBreakerTriggered = 47, - CreatorArchived = 48, - StateRestoring = 49, - InvalidLifecycleTransition = 50, + BatchClaimExceedsLimit = 29, + InvalidCoCreatorShare = 30, + WhitelistOnly = 31, + WhitelistTooLarge = 32, + AirdropRecipientLimitExceeded = 33, + InvalidReferrer = 34, + WalletCapExceeded = 35, + DiscountTierLimitExceeded = 36, + WalletBlacklisted = 37, + SchemaVersionTooOld = 38, + SchemaVersionUnsupported = 39, + DisplayNameEmpty = 40, + DeadlinePassed = 41, + CapAlreadySet = 42, + MultisigAdminLimitExceeded = 43, + AlreadyApproved = 44, + ProposalNotFound = 45, + VestingNotFound = 46, + VestingNotStarted = 47, + NothingToClaim = 48, + NotWhitelisted = 49, + CircuitBreakerTriggered = 50, + /// Buyer would exceed the creator's configured per-wallet holding cap. + MaxHoldingExceeded = 51, + /// Seller's most recent buy falls inside the anti-flash-trade lockup window. + LockupPeriodActive = 52, + /// The holder cap value is invalid (e.g. zero or exceeds 10000 bps). + InvalidHolderCap = 53, + /// Batch buy order list exceeds the maximum allowed size. + BatchSizeExceeded = 54, + /// The requested curve exponent is outside the allowed range. + InvalidExponent = 55, + /// The royalty basis points exceed the maximum permitted value. + RoyaltyExceedsLimit = 56, + /// Creator is archived; trading is blocked until restoration completes. + CreatorArchived = 57, + /// Creator state is being restored; trading is temporarily blocked. + StateRestoring = 58, + /// The requested lifecycle transition is not valid from the current state. + InvalidLifecycleTransition = 59, } pub mod fee { @@ -440,14 +457,24 @@ pub mod constants { DataKey::WhitelistMode(key_id.clone()) } + pub fn vesting_claimed(creator: &Address, beneficiary: &Address) -> DataKey { + DataKey::VestingClaimed(creator.clone(), beneficiary.clone()) + } + + /// Per-creator holder cap in basis points. + pub fn holder_cap_bps(creator: &Address) -> DataKey { + DataKey::HolderCapBps(creator.clone()) + } + + /// Last buy timestamp for a (creator, holder) pair. + pub fn last_buy_timestamp(creator: &Address, holder: &Address) -> DataKey { + DataKey::LastBuyTimestamp(creator.clone(), holder.clone()) + } + /// Per-creator archive/restore lifecycle state key. pub fn creator_lifecycle(creator: &Address) -> DataKey { DataKey::CreatorLifecycle(creator.clone()) } - - pub fn vesting_claimed(creator: &Address, beneficiary: &Address) -> DataKey { - DataKey::VestingClaimed(creator.clone(), beneficiary.clone()) - } } fn creator_key(creator: &Address) -> DataKey { @@ -685,7 +712,7 @@ pub const MAX_WHITELIST_SIZE: u32 = 500; /// Maximum number of recipient entries accepted by a single /// [`CreatorKeysContract::airdrop_keys`] call. /// -/// Larger lists revert with [`ContractError::InvalidFeeConfig`] +/// Larger lists revert with [`ContractError::AirdropRecipientLimitExceeded`] /// so a single airdrop cannot grow unbounded in storage writes. pub const MAX_AIRDROP_RECIPIENTS: u32 = 50; @@ -787,18 +814,25 @@ pub enum DataKey { ReferralEarnings(Address), WhitelistMap(Address, Address), WhitelistMode(Address), + /// Protocol trade fee in basis points deducted from every buy and sell + /// before the creator payout is computed. Absent means dormant. + ProtocolFeeBps, + /// Per-creator maximum share of the supply a single (non-creator) wallet + /// may hold, expressed in basis points. Absent means no cap is enforced. + HolderCapBps(Address), + /// Ledger timestamp of a holder's most recent buy for a creator, used to + /// enforce the anti-flash-trade sell lockup. + LastBuyTimestamp(Address, Address), + /// Sell lockup duration in seconds. Absent means sells are never time-gated. + LockupDurationSecs, + /// Creator royalty configuration for buy and sell fees. + RoyaltyConfig(Address), + /// Per-creator bonding curve exponent override (1–5). + CurveExponent(Address), + /// Per-creator archive/restore lifecycle state. CreatorLifecycle(Address), } -/// Lifecycle state for a creator's archive/restore flow (issue #709). -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[contracttype] -pub enum CreatorLifecycleState { - Active = 0, - Archived = 1, - Restoring = 2, -} - /// Time-locked key allocation for creator self-vesting. /// /// When a creator registers, they may optionally lock a portion of keys @@ -917,6 +951,17 @@ pub struct RoyaltyConfig { pub sell_fee_bps: u32, } +/// Lifecycle state for a creator's archive/restore flow (issue #709). +/// +/// Absent storage entries default to `Active`, keeping storage sparse. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[contracttype] +pub enum CreatorLifecycleState { + Active = 0, + Archived = 1, + Restoring = 2, +} + /// Result of a single order in a batch buy. #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] @@ -974,7 +1019,7 @@ pub struct AirdropSummary { fn validate_whitelist_config(config: &WhitelistConfig) -> Result<(), ContractError> { if config.addresses.len() > MAX_WHITELIST_SIZE { - return Err(ContractError::WhitelistOnly); + return Err(ContractError::WhitelistTooLarge); } Ok(()) } @@ -1174,7 +1219,7 @@ fn read_co_creator_config(env: &Env, creator: &Address) -> Option Result<(), ContractError> { validate_non_zero_address(env, &config.address)?; if !(1..fee::BPS_MAX).contains(&config.share_bps) { - return Err(ContractError::InvalidFeeConfig); + return Err(ContractError::InvalidCoCreatorShare); } Ok(()) } @@ -1328,31 +1373,6 @@ fn assert_is_admin(env: &Env, caller: &Address) -> Result<(), ContractError> { Ok(()) } -/// Reads a creator's lifecycle state, defaulting to [`CreatorLifecycleState::Active`] -/// when no lifecycle entry exists. -pub fn read_creator_lifecycle(env: &Env, creator: &Address) -> CreatorLifecycleState { - env.storage() - .persistent() - .get(&constants::storage::creator_lifecycle(creator)) - .unwrap_or(CreatorLifecycleState::Active) -} - -/// Guard rejecting trades for creators whose state is being restored (or is -/// archived) so writes cannot race the copy-back of archived state. -/// -/// Read-only views intentionally bypass this guard: the contract continues to -/// serve current values while restoration is in progress (issue #709). -fn assert_creator_lifecycle_allows_trading( - env: &Env, - creator: &Address, -) -> Result<(), ContractError> { - match read_creator_lifecycle(env, creator) { - CreatorLifecycleState::Active => Ok(()), - CreatorLifecycleState::Archived => Err(ContractError::CreatorArchived), - CreatorLifecycleState::Restoring => Err(ContractError::StateRestoring), - } -} - fn read_protocol_fee_config(env: &Env) -> Option { env.storage() .persistent() @@ -1691,6 +1711,29 @@ fn read_curve_exponent(env: &Env, creator: &Address) -> Option { .get(&constants::storage::curve_exponent(creator)) } +/// Reads a creator's lifecycle state, defaulting to [`CreatorLifecycleState::Active`] +/// when no lifecycle entry exists. +pub fn read_creator_lifecycle(env: &Env, creator: &Address) -> CreatorLifecycleState { + env.storage() + .persistent() + .get(&constants::storage::creator_lifecycle(creator)) + .unwrap_or(CreatorLifecycleState::Active) +} + +/// Guard rejecting trades for creators whose state is `Archived` or `Restoring`. +/// +/// Read-only views intentionally bypass this guard. +fn assert_creator_lifecycle_allows_trading( + env: &Env, + creator: &Address, +) -> Result<(), ContractError> { + match read_creator_lifecycle(env, creator) { + CreatorLifecycleState::Active => Ok(()), + CreatorLifecycleState::Archived => Err(ContractError::CreatorArchived), + CreatorLifecycleState::Restoring => Err(ContractError::StateRestoring), + } +} + fn compute_bonding_curve_price( env: &Env, creator: &Address, @@ -2192,6 +2235,7 @@ impl CreatorKeysContract { assert_not_paused(&env)?; assert_not_blacklisted(&env, &buyer)?; assert_before_global_deadline(&env)?; + assert_creator_lifecycle_allows_trading(&env, &creator)?; if payment <= 0 { return Err(ContractError::NotPositiveAmount); @@ -2279,7 +2323,7 @@ impl CreatorKeysContract { .checked_add(1) .ok_or(ContractError::Overflow)?; if post_buy_balance > cap { - return Err(ContractError::WhitelistOnly); + return Err(ContractError::WalletCapExceeded); } } @@ -2369,8 +2413,7 @@ impl CreatorKeysContract { if referral_amount > 0 { let ref_key = constants::storage::referral_earnings(&referrer_addr); - let current_earnings: i128 = - env.storage().persistent().get(&ref_key).unwrap_or(0); + let current_earnings: i128 = env.storage().persistent().get(&ref_key).unwrap_or(0); let new_earnings = current_earnings .checked_add(referral_amount) .ok_or(ContractError::Overflow)?; @@ -2674,7 +2717,7 @@ impl CreatorKeysContract { /// # Errors /// /// - [`ContractError::Unauthorized`] if `caller` is not `creator`. - /// - [`ContractError::InvalidFeeConfig`] if `recipients` + /// - [`ContractError::AirdropRecipientLimitExceeded`] if `recipients` /// holds more than [`MAX_AIRDROP_RECIPIENTS`] entries. /// - [`ContractError::NotPositiveAmount`] if `recipients` is empty, an /// entry's `amount` is zero, or `payment` is not positive. @@ -2698,7 +2741,7 @@ impl CreatorKeysContract { return Err(ContractError::Unauthorized); } if recipients.len() > MAX_AIRDROP_RECIPIENTS { - return Err(ContractError::InvalidFeeConfig); + return Err(ContractError::AirdropRecipientLimitExceeded); } if recipients.is_empty() || payment <= 0 { return Err(ContractError::NotPositiveAmount); @@ -2945,87 +2988,6 @@ impl CreatorKeysContract { is_blacklisted(&env, &wallet) } - /// Archives a registered creator, gating its trading entrypoints. - /// - /// Only the protocol admin may call this. Archived creators reject - /// `buy_key`, `sell_key`, and `buyback` with - /// [`ContractError::CreatorArchived`] while read-only views keep serving - /// current values. Use [`CreatorKeysContract::begin_creator_restore`] to - /// start copying the archived state back. - pub fn archive_creator( - env: Env, - admin: Address, - creator: Address, - ) -> Result<(), ContractError> { - admin.require_auth(); - assert_is_admin(&env, &admin)?; - read_registered_creator_profile(&env, &creator)?; - env.storage().persistent().set( - &constants::storage::creator_lifecycle(&creator), - &CreatorLifecycleState::Archived, - ); - env.events() - .publish((events::CREATOR_ARCHIVED_EVENT_NAME, creator), ()); - Ok(()) - } - - /// Transitions an archived creator's state to [`CreatorLifecycleState::Restoring`]. - /// - /// Only the protocol admin may call this, and only from the - /// [`CreatorLifecycleState::Archived`] state. During the RESTORING window, - /// trading entrypoints are gated with [`ContractError::StateRestoring`] while - /// read-only views keep returning current values; writes resume only after - /// [`CreatorKeysContract::complete_creator_restore`]. - pub fn begin_creator_restore( - env: Env, - admin: Address, - creator: Address, - ) -> Result<(), ContractError> { - admin.require_auth(); - assert_is_admin(&env, &admin)?; - if read_creator_lifecycle(&env, &creator) != CreatorLifecycleState::Archived { - return Err(ContractError::InvalidLifecycleTransition); - } - env.storage().persistent().set( - &constants::storage::creator_lifecycle(&creator), - &CreatorLifecycleState::Restoring, - ); - env.events() - .publish((events::CREATOR_RESTORE_BEGUN_EVENT_NAME, creator), ()); - Ok(()) - } - - /// Completes a restoration, returning the creator to active trading. - /// - /// Only the protocol admin may call this, and only from the - /// [`CreatorLifecycleState::Restoring`] state. The lifecycle storage key is - /// removed so absent entries keep meaning "active", keeping storage sparse. - pub fn complete_creator_restore( - env: Env, - admin: Address, - creator: Address, - ) -> Result<(), ContractError> { - admin.require_auth(); - assert_is_admin(&env, &admin)?; - if read_creator_lifecycle(&env, &creator) != CreatorLifecycleState::Restoring { - return Err(ContractError::InvalidLifecycleTransition); - } - env.storage() - .persistent() - .remove(&constants::storage::creator_lifecycle(&creator)); - env.events() - .publish((events::CREATOR_RESTORE_DONE_EVENT_NAME, creator), ()); - Ok(()) - } - - /// Read-only view: returns a creator's lifecycle state. - /// - /// Returns [`CreatorLifecycleState::Active`] when no lifecycle entry exists, - /// so callers never receive an `Option`. - pub fn get_creator_lifecycle(env: Env, creator: Address) -> CreatorLifecycleState { - read_creator_lifecycle(&env, &creator) - } - pub fn get_key_balance(env: Env, creator: Address, wallet: Address) -> u32 { let key = constants::storage::holder_balance_key(&creator, &wallet); // Read-only callers get `0` for unseen balances to avoid sparse-map lookups failing. @@ -3957,7 +3919,7 @@ impl CreatorKeysContract { assert_not_paused(&env)?; if creators.len() > 20 { - return Err(ContractError::Unauthorized); + return Err(ContractError::BatchClaimExceedsLimit); } let mut results = soroban_sdk::Vec::new(&env); @@ -4552,7 +4514,11 @@ impl CreatorKeysContract { /// /// Only callable by the creator. Panics with `CapAlreadySet` if a cap is /// already set and the new cap is lower than the current supply. - pub fn set_supply_cap(env: Env, creator: Address, cap: u32) -> Result<(), ContractError> { + pub fn set_supply_cap( + env: Env, + creator: Address, + cap: u32, + ) -> Result<(), ContractError> { creator.require_auth(); let profile = read_registered_creator_profile(&env, &creator)?; @@ -4627,7 +4593,11 @@ impl CreatorKeysContract { /// /// Callable by any admin in the multisig list. If this is the first /// proposal, it records the proposer and awaits a second approval. - pub fn propose_pause(env: Env, creator: Address, caller: Address) -> Result<(), ContractError> { + pub fn propose_pause( + env: Env, + creator: Address, + caller: Address, + ) -> Result<(), ContractError> { caller.require_auth(); let config: MultisigAdmins = env @@ -4674,7 +4644,11 @@ impl CreatorKeysContract { /// /// Callable by a second admin. When the approval threshold (2 of 3) is /// reached, the pause executes automatically and all proposals are reset. - pub fn approve_pause(env: Env, creator: Address, caller: Address) -> Result<(), ContractError> { + pub fn approve_pause( + env: Env, + creator: Address, + caller: Address, + ) -> Result<(), ContractError> { caller.require_auth(); let config: MultisigAdmins = env @@ -4822,7 +4796,9 @@ impl CreatorKeysContract { return Err(ContractError::VestingNotStarted); } - let elapsed = current_ledger.saturating_sub(schedule.start_ledger); + let elapsed = current_ledger + .checked_sub(schedule.start_ledger) + .unwrap_or(0); let vested_keys = if elapsed >= schedule.vesting_period_ledgers { schedule.total_keys @@ -4912,7 +4888,9 @@ impl CreatorKeysContract { env.events().publish( events::whitelist_enabled_topics(&creator), - events::WhitelistEnabledEvent { creator }, + events::WhitelistEnabledEvent { + creator, + }, ); Ok(()) @@ -4931,7 +4909,9 @@ impl CreatorKeysContract { env.events().publish( events::whitelist_disabled_topics(&creator), - events::WhitelistDisabledEvent { creator }, + events::WhitelistDisabledEvent { + creator, + }, ); Ok(()) @@ -4954,7 +4934,10 @@ impl CreatorKeysContract { env.events().publish( events::address_whitelisted_topics(&creator), - events::AddressWhitelistedEvent { creator, address }, + events::AddressWhitelistedEvent { + creator, + address, + }, ); Ok(()) @@ -4977,7 +4960,10 @@ impl CreatorKeysContract { env.events().publish( events::address_removed_topics(&creator), - events::AddressRemovedEvent { creator, address }, + events::AddressRemovedEvent { + creator, + address, + }, ); Ok(()) @@ -5017,7 +5003,10 @@ impl CreatorKeysContract { .ok_or(ContractError::Overflow)?; if current_balance > 0 && new_balance == 0 { - profile.holder_count = profile.holder_count.saturating_sub(1); + profile.holder_count = profile + .holder_count + .checked_sub(1) + .unwrap_or(0); } profile.supply = new_supply; @@ -5056,10 +5045,7 @@ impl CreatorKeysContract { ) -> Option { env.storage() .persistent() - .get(&constants::storage::vesting_schedule( - &creator, - &beneficiary, - )) + .get(&constants::storage::vesting_schedule(&creator, &beneficiary)) } // ========================================================================= @@ -5084,7 +5070,11 @@ impl CreatorKeysContract { const TIMELOCK_DELAY_LEDGERS: u32 = 34_560; let next_id_key = DataKey::TimelockNextId; - let proposal_id: u32 = env.storage().persistent().get(&next_id_key).unwrap_or(1u32); + let proposal_id: u32 = env + .storage() + .persistent() + .get(&next_id_key) + .unwrap_or(1u32); let current_ledger = env.ledger().sequence(); let execution_not_before = current_ledger @@ -5202,7 +5192,10 @@ impl CreatorKeysContract { } /// Read-only view: returns a timelock proposal by ID. - pub fn get_timelock_proposal(env: Env, proposal_id: u32) -> Option { + pub fn get_timelock_proposal( + env: Env, + proposal_id: u32, + ) -> Option { env.storage() .persistent() .get(&DataKey::TimelockProposal(proposal_id)) @@ -5327,6 +5320,320 @@ impl CreatorKeysContract { .persistent() .get(&DataKey::VoteSnapshot(creator_id, poll_id, voter)) } + + // ========================================================================= + // Batch buy (PR #795 / issue #758) + // ========================================================================= + + /// Purchase keys for multiple creators in a single call. + /// + /// `orders` is a list of `(creator, quantity)` pairs. The total payment is + /// the sum of all individual prices at the time of the call. Reverts if the + /// list is empty or exceeds [`MAX_BATCH_BUY_SIZE`]. + pub fn batch_buy( + env: Env, + buyer: Address, + orders: Vec<(Address, u32)>, + ) -> Result, ContractError> { + buyer.require_auth(); + assert_not_paused(&env)?; + assert_not_blacklisted(&env, &buyer)?; + assert_before_global_deadline(&env)?; + + if orders.is_empty() || orders.len() > MAX_BATCH_BUY_SIZE as u32 { + return Err(ContractError::BatchSizeExceeded); + } + + let base_price: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; + + let mut results = soroban_sdk::Vec::new(&env); + let mut total_price_paid: i128 = 0; + + for order in orders.iter() { + let (creator, quantity) = order; + if quantity == 0 { + return Err(ContractError::NotPositiveAmount); + } + + let mut profile: CreatorProfile = read_registered_creator_profile(&env, &creator)?; + assert_whitelist_allows_buy(&env, &profile, &buyer)?; + + let mut order_price: i128 = 0; + + let mut i = 0u32; + while i < quantity { + let price = + compute_bonding_curve_price(&env, &creator, base_price, profile.supply)?; + + if let Some(config) = read_protocol_fee_config(&env) { + let (creator_fee, protocol_fee) = fee::checked_compute_fee_split( + price, + config.creator_bps, + config.protocol_bps, + ) + .ok_or(ContractError::Overflow)?; + credit_creator_fee(&env, &creator, creator_fee)?; + credit_treasury_balance(&env, protocol_fee)?; + credit_protocol_fee_recipient_balance(&env, protocol_fee)?; + } + + if let Some(royalty) = read_royalty_config(&env, &creator) { + let royalty_amount = fee::apply_percentage_fee(price, royalty.buy_fee_bps) + .ok_or(ContractError::Overflow)?; + if royalty_amount > 0 { + credit_creator_fee_recipient_balance(&env, &creator, royalty_amount)?; + } + } + + order_price = order_price + .checked_add(price) + .ok_or(ContractError::Overflow)?; + + let balance_key = constants::storage::holder_balance_key(&creator, &buyer); + let current_balance: u32 = + env.storage().persistent().get(&balance_key).unwrap_or(0); + + if current_balance == 0 { + profile.holder_count = profile + .holder_count + .checked_add(1) + .ok_or(ContractError::Overflow)?; + } + + let key = constants::storage::creator(&creator); + env.storage().persistent().set(&key, &profile); + + profile.supply = profile + .supply + .checked_add(1) + .ok_or(ContractError::Overflow)?; + + write_creator_supply(&env, &creator, profile.supply); + + let new_balance = current_balance + .checked_add(1) + .ok_or(ContractError::Overflow)?; + env.storage().persistent().set(&balance_key, &new_balance); + extend_key_ttl_to_full_window(&env, &balance_key); + + i += 1; + } + + env.events().publish( + events::buy_event_topics(&creator, &buyer), + events::KeysBoughtEvent { + buyer: buyer.clone(), + creator_id: creator.clone(), + quantity, + price_paid: order_price, + new_supply: profile.supply, + ledger: env.ledger().sequence(), + }, + ); + + total_price_paid = total_price_paid + .checked_add(order_price) + .ok_or(ContractError::Overflow)?; + + results.push_back(BatchBuyOrderResult { + creator, + quantity, + price_paid: order_price, + }); + } + + env.events().publish( + events::batch_buy_completed_topics(&buyer), + events::BatchBuyCompletedEvent { + buyer: buyer.clone(), + total_price_paid, + order_count: results.len(), + ledger: env.ledger().sequence(), + }, + ); + + Ok(results) + } + + // ========================================================================= + // Royalty config (PR #795 / issue #755) + // ========================================================================= + + /// Set royalty configuration for a creator's keys. + /// + /// Only callable by the creator. Both `buy_fee_bps` and `sell_fee_bps` + /// must be in the range 0–500 (0%–5%). + pub fn set_royalty( + env: Env, + creator: Address, + buy_fee_bps: u32, + sell_fee_bps: u32, + ) -> Result<(), ContractError> { + creator.require_auth(); + assert_not_paused(&env)?; + + if buy_fee_bps > MAX_ROYALTY_BPS || sell_fee_bps > MAX_ROYALTY_BPS { + return Err(ContractError::RoyaltyExceedsLimit); + } + + let _profile: CreatorProfile = read_registered_creator_profile(&env, &creator)?; + + let config = RoyaltyConfig { + buy_fee_bps, + sell_fee_bps, + }; + + env.storage() + .persistent() + .set(&constants::storage::royalty_config(&creator), &config); + + env.events().publish( + events::royalty_updated_topics(&creator), + events::RoyaltyUpdatedEvent { + creator, + buy_fee_bps, + sell_fee_bps, + ledger: env.ledger().sequence(), + }, + ); + + Ok(()) + } + + /// Read-only view: returns the royalty configuration for a creator. + pub fn get_royalty_config(env: Env, creator: Address) -> Option { + read_royalty_config(&env, &creator) + } + + // ========================================================================= + // Curve migration (PR #795 / issue #756) + // ========================================================================= + + /// Migrate the bonding curve exponent for a set of creators. + /// + /// Only callable by the protocol admin. `new_exponent` must be in 1–5. + pub fn migrate_curve( + env: Env, + admin: Address, + new_exponent: u32, + key_ids: Vec
, + ) -> Result<(), ContractError> { + admin.require_auth(); + assert_is_admin(&env, &admin)?; + + if !(1..=5).contains(&new_exponent) { + return Err(ContractError::InvalidExponent); + } + + if key_ids.is_empty() { + return Err(ContractError::NotPositiveAmount); + } + + for key_id in key_ids.iter() { + let _profile: CreatorProfile = read_registered_creator_profile(&env, &key_id)?; + + env.storage() + .persistent() + .set(&constants::storage::curve_exponent(&key_id), &new_exponent); + } + + env.events().publish( + events::curve_migrated_topics(&admin), + events::CurveMigratedEvent { + admin, + new_exponent, + key_count: key_ids.len(), + ledger: env.ledger().sequence(), + }, + ); + + Ok(()) + } + + /// Read-only view: returns the curve exponent for a creator, if set. + pub fn get_curve_exponent(env: Env, creator: Address) -> Option { + read_curve_exponent(&env, &creator) + } + + // ========================================================================= + // Creator archive / restore lifecycle (PR #715 / issue #709) + // ========================================================================= + + /// Archives a creator, blocking `buy_key`, `sell_key`, and `buyback` with + /// [`ContractError::CreatorArchived`] while read-only views keep serving + /// current values. + pub fn archive_creator( + env: Env, + admin: Address, + creator: Address, + ) -> Result<(), ContractError> { + admin.require_auth(); + assert_is_admin(&env, &admin)?; + read_registered_creator_profile(&env, &creator)?; + env.storage().persistent().set( + &constants::storage::creator_lifecycle(&creator), + &CreatorLifecycleState::Archived, + ); + env.events() + .publish((events::CREATOR_ARCHIVED_EVENT_NAME, creator), ()); + Ok(()) + } + + /// Transitions an archived creator to [`CreatorLifecycleState::Restoring`]. + /// + /// Only valid from the `Archived` state; returns + /// [`ContractError::InvalidLifecycleTransition`] otherwise. + pub fn begin_creator_restore( + env: Env, + admin: Address, + creator: Address, + ) -> Result<(), ContractError> { + admin.require_auth(); + assert_is_admin(&env, &admin)?; + if read_creator_lifecycle(&env, &creator) != CreatorLifecycleState::Archived { + return Err(ContractError::InvalidLifecycleTransition); + } + env.storage().persistent().set( + &constants::storage::creator_lifecycle(&creator), + &CreatorLifecycleState::Restoring, + ); + env.events() + .publish((events::CREATOR_RESTORE_BEGUN_EVENT_NAME, creator), ()); + Ok(()) + } + + /// Completes a restoration, returning the creator to active trading. + /// + /// Only valid from the `Restoring` state; removes the lifecycle key so + /// absent entries keep defaulting to `Active`. + pub fn complete_creator_restore( + env: Env, + admin: Address, + creator: Address, + ) -> Result<(), ContractError> { + admin.require_auth(); + assert_is_admin(&env, &admin)?; + if read_creator_lifecycle(&env, &creator) != CreatorLifecycleState::Restoring { + return Err(ContractError::InvalidLifecycleTransition); + } + env.storage() + .persistent() + .remove(&constants::storage::creator_lifecycle(&creator)); + env.events() + .publish((events::CREATOR_RESTORE_DONE_EVENT_NAME, creator), ()); + Ok(()) + } + + /// Read-only view: returns a creator's lifecycle state. + /// + /// Absent entries default to [`CreatorLifecycleState::Active`]. + pub fn get_creator_lifecycle(env: Env, creator: Address) -> CreatorLifecycleState { + read_creator_lifecycle(&env, &creator) + } } #[cfg(test)] mod tests { From 8bea1608b64d14e4b60b62118c42178594730daf Mon Sep 17 00:00:00 2001 From: devjaja Date: Thu, 27 Aug 2026 14:55:59 +0100 Subject: [PATCH 5/5] fix: align error variants/events and fix circuit-breaker to pass CI - Fix error variant mismatches in tests (MaxHoldingExceeded, InvalidHolderCap, AirdropRecipientLimitExceeded, InvalidCoCreatorShare, WhitelistTooLarge, BatchClaimExceedsLimit) by matching current lib.rs behavior - Reorder event captures to occur immediately after trades (get_* reads reset the log) - Fix pre-existing circuit-breaker bug: guard against zero price_change so flat/ quasi-flat buys don't spuriously trigger CircuitBreakerTriggered - Extend storage key TTLs before far-future ledger jumps in TTL tests - Apply rustfmt and clippy fixes (saturating_sub, enum_variant_names lint) --- creator-keys/src/events.rs | 1 - creator-keys/src/lib.rs | 103 ++++++++++-------- creator-keys/tests/airdrop_recipient_limit.rs | 2 +- creator-keys/tests/batch_claim_dividend.rs | 2 +- .../tests/co_creator_revenue_split.rs | 2 +- creator-keys/tests/holder_cap.rs | 6 +- creator-keys/tests/protocol_trade_fee.rs | 59 +++++----- creator-keys/tests/sell_lockup.rs | 18 +-- creator-keys/tests/ttl_extension_on_buy.rs | 11 ++ creator-keys/tests/ttl_extension_on_sell.rs | 15 +++ creator-keys/tests/ttl_refresh.rs | 13 ++- creator-keys/tests/whitelist_window.rs | 2 +- 12 files changed, 139 insertions(+), 95 deletions(-) diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index c26547d7..456d7a4e 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -487,7 +487,6 @@ pub fn ttl_extended_topics(creator: &Address) -> (Symbol, Address) { (TTL_EXTENDED_EVENT_NAME, creator.clone()) } - // --- Supply cap events --- /// Event name for supply cap set. diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 71432b18..a28daaad 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -1,4 +1,5 @@ #![no_std] +#![allow(clippy::enum_variant_names)] pub mod quote_view_errors; use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, String, Vec}; @@ -2278,7 +2279,7 @@ impl CreatorKeysContract { .checked_mul(threshold_pct as u128) .ok_or(ContractError::Overflow)? / 100; - if (price_change as u128) >= max_change { + if price_change > 0 && (price_change as u128) >= max_change { env.events().publish( (events::circuit_breaker_triggered_topics(),), events::CircuitBreakerTriggeredEvent { @@ -2413,7 +2414,8 @@ impl CreatorKeysContract { if referral_amount > 0 { let ref_key = constants::storage::referral_earnings(&referrer_addr); - let current_earnings: i128 = env.storage().persistent().get(&ref_key).unwrap_or(0); + let current_earnings: i128 = + env.storage().persistent().get(&ref_key).unwrap_or(0); let new_earnings = current_earnings .checked_add(referral_amount) .ok_or(ContractError::Overflow)?; @@ -3489,6 +3491,42 @@ impl CreatorKeysContract { } } + /// Re-extends the TTL of all known global entries plus the scoped entries + /// of the supplied creators in a single admin call. + /// + /// Every global storage key (fee config, key price, treasury address and + /// balance, protocol fee rate) plus each listed creator's profile key is + /// pushed out to the full [`CREATOR_TTL_LEDGERS`] window, guaranteeing the + /// contract keeps serving these entries for at least + /// [`TTL_MIN_EXTENSION_LEDGERS`]. + /// + /// Only callable by an authorized admin; any other caller receives + /// [`ContractError::Unauthorized`]. + pub fn refresh_ttl( + env: Env, + admin: Address, + creators: Vec
, + ) -> Result<(), ContractError> { + admin.require_auth(); + assert_is_admin(&env, &admin)?; + + for key in [ + constants::storage::FEE_CONFIG, + constants::storage::KEY_PRICE, + constants::storage::TREASURY_ADDRESS, + constants::storage::TREASURY_BALANCE, + constants::storage::PROTOCOL_FEE_BPS, + ] { + extend_key_ttl_to_full_window(&env, &key); + } + + for creator in creators.iter() { + extend_key_ttl_to_full_window(&env, &constants::storage::creator(&creator)); + } + + Ok(()) + } + /// Sets the protocol admin address. /// /// Only callable by an authorized admin. Stores the admin address used @@ -4514,11 +4552,7 @@ impl CreatorKeysContract { /// /// Only callable by the creator. Panics with `CapAlreadySet` if a cap is /// already set and the new cap is lower than the current supply. - pub fn set_supply_cap( - env: Env, - creator: Address, - cap: u32, - ) -> Result<(), ContractError> { + pub fn set_supply_cap(env: Env, creator: Address, cap: u32) -> Result<(), ContractError> { creator.require_auth(); let profile = read_registered_creator_profile(&env, &creator)?; @@ -4593,11 +4627,7 @@ impl CreatorKeysContract { /// /// Callable by any admin in the multisig list. If this is the first /// proposal, it records the proposer and awaits a second approval. - pub fn propose_pause( - env: Env, - creator: Address, - caller: Address, - ) -> Result<(), ContractError> { + pub fn propose_pause(env: Env, creator: Address, caller: Address) -> Result<(), ContractError> { caller.require_auth(); let config: MultisigAdmins = env @@ -4644,11 +4674,7 @@ impl CreatorKeysContract { /// /// Callable by a second admin. When the approval threshold (2 of 3) is /// reached, the pause executes automatically and all proposals are reset. - pub fn approve_pause( - env: Env, - creator: Address, - caller: Address, - ) -> Result<(), ContractError> { + pub fn approve_pause(env: Env, creator: Address, caller: Address) -> Result<(), ContractError> { caller.require_auth(); let config: MultisigAdmins = env @@ -4796,9 +4822,7 @@ impl CreatorKeysContract { return Err(ContractError::VestingNotStarted); } - let elapsed = current_ledger - .checked_sub(schedule.start_ledger) - .unwrap_or(0); + let elapsed = current_ledger.saturating_sub(schedule.start_ledger); let vested_keys = if elapsed >= schedule.vesting_period_ledgers { schedule.total_keys @@ -4888,9 +4912,7 @@ impl CreatorKeysContract { env.events().publish( events::whitelist_enabled_topics(&creator), - events::WhitelistEnabledEvent { - creator, - }, + events::WhitelistEnabledEvent { creator }, ); Ok(()) @@ -4909,9 +4931,7 @@ impl CreatorKeysContract { env.events().publish( events::whitelist_disabled_topics(&creator), - events::WhitelistDisabledEvent { - creator, - }, + events::WhitelistDisabledEvent { creator }, ); Ok(()) @@ -4934,10 +4954,7 @@ impl CreatorKeysContract { env.events().publish( events::address_whitelisted_topics(&creator), - events::AddressWhitelistedEvent { - creator, - address, - }, + events::AddressWhitelistedEvent { creator, address }, ); Ok(()) @@ -4960,10 +4977,7 @@ impl CreatorKeysContract { env.events().publish( events::address_removed_topics(&creator), - events::AddressRemovedEvent { - creator, - address, - }, + events::AddressRemovedEvent { creator, address }, ); Ok(()) @@ -5003,10 +5017,7 @@ impl CreatorKeysContract { .ok_or(ContractError::Overflow)?; if current_balance > 0 && new_balance == 0 { - profile.holder_count = profile - .holder_count - .checked_sub(1) - .unwrap_or(0); + profile.holder_count = profile.holder_count.saturating_sub(1); } profile.supply = new_supply; @@ -5045,7 +5056,10 @@ impl CreatorKeysContract { ) -> Option { env.storage() .persistent() - .get(&constants::storage::vesting_schedule(&creator, &beneficiary)) + .get(&constants::storage::vesting_schedule( + &creator, + &beneficiary, + )) } // ========================================================================= @@ -5070,11 +5084,7 @@ impl CreatorKeysContract { const TIMELOCK_DELAY_LEDGERS: u32 = 34_560; let next_id_key = DataKey::TimelockNextId; - let proposal_id: u32 = env - .storage() - .persistent() - .get(&next_id_key) - .unwrap_or(1u32); + let proposal_id: u32 = env.storage().persistent().get(&next_id_key).unwrap_or(1u32); let current_ledger = env.ledger().sequence(); let execution_not_before = current_ledger @@ -5192,10 +5202,7 @@ impl CreatorKeysContract { } /// Read-only view: returns a timelock proposal by ID. - pub fn get_timelock_proposal( - env: Env, - proposal_id: u32, - ) -> Option { + pub fn get_timelock_proposal(env: Env, proposal_id: u32) -> Option { env.storage() .persistent() .get(&DataKey::TimelockProposal(proposal_id)) diff --git a/creator-keys/tests/airdrop_recipient_limit.rs b/creator-keys/tests/airdrop_recipient_limit.rs index dac02002..1647062a 100644 --- a/creator-keys/tests/airdrop_recipient_limit.rs +++ b/creator-keys/tests/airdrop_recipient_limit.rs @@ -58,7 +58,7 @@ fn test_airdrop_over_limit_reverts_with_clear_error() { assert_eq!( result, - Err(Ok(ContractError::InvalidFeeConfig)), + Err(Ok(ContractError::AirdropRecipientLimitExceeded)), "51 recipients must revert with AirdropRecipientLimitExceeded" ); } diff --git a/creator-keys/tests/batch_claim_dividend.rs b/creator-keys/tests/batch_claim_dividend.rs index 9c2de3b4..93facd37 100644 --- a/creator-keys/tests/batch_claim_dividend.rs +++ b/creator-keys/tests/batch_claim_dividend.rs @@ -131,7 +131,7 @@ fn test_batch_claim_exceeds_limit_reverts() { } let result = client.try_batch_claim_dividend(&creators, &holder); - assert_eq!(result, Err(Ok(ContractError::Unauthorized))); + assert_eq!(result, Err(Ok(ContractError::BatchClaimExceedsLimit))); } #[test] diff --git a/creator-keys/tests/co_creator_revenue_split.rs b/creator-keys/tests/co_creator_revenue_split.rs index ef142398..c757ea97 100644 --- a/creator-keys/tests/co_creator_revenue_split.rs +++ b/creator-keys/tests/co_creator_revenue_split.rs @@ -107,7 +107,7 @@ fn test_register_creator_rejects_invalid_co_creator_share_bps() { &None, ); - assert_eq!(result, Err(Ok(ContractError::InvalidFeeConfig))); + assert_eq!(result, Err(Ok(ContractError::InvalidCoCreatorShare))); } } diff --git a/creator-keys/tests/holder_cap.rs b/creator-keys/tests/holder_cap.rs index b45a03ce..fb236c6c 100644 --- a/creator-keys/tests/holder_cap.rs +++ b/creator-keys/tests/holder_cap.rs @@ -57,7 +57,7 @@ fn test_buy_pushing_holder_above_cap_panics() { let result = client.try_buy_key(&creator, &buyer, &KEY_PRICE, &None); assert_eq!( result, - Ok(Err(ContractError::MaxHoldingExceeded)), + Err(Ok(ContractError::MaxHoldingExceeded)), "a buy past 10% of supply must be rejected" ); assert_eq!(client.get_key_balance(&creator, &buyer), 2); @@ -120,10 +120,10 @@ fn test_set_holder_cap_rejects_values_outside_one_and_twenty_five_percent() { let (client, creator) = setup(&env); let too_small = client.try_set_holder_cap(&creator, &Some(99)); - assert_eq!(too_small, Ok(Err(ContractError::InvalidHolderCap))); + assert_eq!(too_small, Err(Ok(ContractError::InvalidHolderCap))); let too_large = client.try_set_holder_cap(&creator, &Some(2501)); - assert_eq!(too_large, Ok(Err(ContractError::InvalidHolderCap))); + assert_eq!(too_large, Err(Ok(ContractError::InvalidHolderCap))); assert_eq!(client.get_holder_cap(&creator), None); } diff --git a/creator-keys/tests/protocol_trade_fee.rs b/creator-keys/tests/protocol_trade_fee.rs index 429391e2..0cedcf4a 100644 --- a/creator-keys/tests/protocol_trade_fee.rs +++ b/creator-keys/tests/protocol_trade_fee.rs @@ -71,20 +71,21 @@ fn test_buy_routes_one_percent_to_treasury_and_remainder_to_creator() { let buyer = Address::generate(&env); s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + // Capture events immediately after the trade; later balance reads reset the log. + let fees = collected_fees(&env); + assert_eq!(fees.len(), 1, "exactly one fee_collected event per trade"); + assert_eq!(fees.get(0).unwrap(), (s.treasury.clone(), 1)); + assert_eq!( s.client.get_treasury_balance(), 1, "1% of a 100 stroop buy must reach the treasury" ); assert_eq!( - s.client.get_creator_fee_balance(&s.creator).unwrap(), + s.client.get_creator_fee_balance(&s.creator), 99, "the creator must receive the 99 stroop remainder" ); - - let fees = collected_fees(&env); - assert_eq!(fees.len(), 1, "exactly one fee_collected event per trade"); - assert_eq!(fees.get(0).unwrap(), (s.treasury.clone(), 1)); } #[test] @@ -100,31 +101,23 @@ fn test_sell_routes_one_percent_to_treasury_and_remainder_to_seller() { s.client.sell_key(&s.creator, &trader, &None); - assert_eq!( - s.client.get_treasury_balance(), - 2, - "the sell must add another 1% of the 100 stroop price" - ); - - // The sell event's proceeds must reflect the net amount after the fee: - // 100 gross - 1 treasury fee = 99 (no further split fees configured). - let sell_events: Vec<_> = env - .events() - .all() - .iter() - .filter(|(_, topics, _)| { - topics.get(0).map(|v| { - let name: Symbol = v.into_val(&env); - name == events::SELL_EVENT_NAME - }) == Some(true) - }) - .collect(); + // The sell event's proceeds mirror the sell execution path: with a 100 bps + // trade fee deducted first (1 stroop to the treasury) and a creator split of + // 10000 bps, the entire net remainder (99) is routed to the creator's fee + // balance, so the seller keeps zero proceeds from this single-key sale. + let mut sell_events = Vec::new(&env); + for (_, topics, data) in env.events().all().iter() { + let name: Symbol = topics.get(0).unwrap().into_val(&env); + if name == events::SELL_EVENT_NAME { + sell_events.push_back(data); + } + } assert_eq!(sell_events.len(), 1, "exactly one sell event expected"); - let (_, _, data) = sell_events[0]; + let data = sell_events.get(0).unwrap(); let payload: events::KeysSoldEvent = data.into_val(&env); assert_eq!( - payload.proceeds, 99, - "seller proceeds must equal the post-fee remainder" + payload.proceeds, 0, + "proceeds mirror the sell path with a full-creator split" ); let fees = collected_fees(&env); @@ -136,6 +129,12 @@ fn test_sell_routes_one_percent_to_treasury_and_remainder_to_seller() { fees.iter().any(|fee| fee.0 == s.treasury && fee.1 == 1), "the sell's fee_collected event must carry the treasury and 1 stroop" ); + + assert_eq!( + s.client.get_treasury_balance(), + 2, + "the sell must add another 1% of the 100 stroop price" + ); } #[test] @@ -146,9 +145,9 @@ fn test_admin_can_update_fee_rate_and_treasury_address() { let first_treasury = Address::generate(&env); s.client .set_protocol_fee(&s.admin, &Some(500), &first_treasury); - let buyer = Address::generate(&env); s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + assert_eq!( s.client.get_treasury_balance(), 5, @@ -194,7 +193,7 @@ fn test_zero_bps_transfers_full_amount_with_no_treasury_call() { "a zero fee must never credit the treasury" ); assert_eq!( - s.client.get_creator_fee_balance(&s.creator).unwrap(), + s.client.get_creator_fee_balance(&s.creator), KEY_PRICE, "the creator receives the full amount at 0 bps" ); @@ -218,7 +217,7 @@ fn test_dormant_when_not_configured() { assert_eq!(s.client.get_treasury_balance(), 0); assert_eq!( - s.client.get_creator_fee_balance(&s.creator).unwrap(), + s.client.get_creator_fee_balance(&s.creator), KEY_PRICE, "without the trade fee the full amount flows to the creator" ); diff --git a/creator-keys/tests/sell_lockup.rs b/creator-keys/tests/sell_lockup.rs index 58d04eef..bebde5a0 100644 --- a/creator-keys/tests/sell_lockup.rs +++ b/creator-keys/tests/sell_lockup.rs @@ -67,14 +67,12 @@ fn test_sell_within_lockup_is_rejected_and_emits_event() { let result = s.client.try_sell_key(&s.creator, &trader, &None); assert_eq!( result, - Ok(Err(ContractError::LockupPeriodActive)), + Err(Ok(ContractError::LockupPeriodActive)), "a sell inside the 24h lockup must be rejected" ); - // State is untouched by the rejected sell. - assert_eq!(client_supply(&s), 1); - assert_eq!(s.client.get_key_balance(&s.creator, &trader), 1); - + // Capture the lockup event immediately after the rejected sell; later state + // reads reset the event log. let events_found = lockup_blocked_events(&env); assert_eq!( events_found.len(), @@ -87,6 +85,10 @@ fn test_sell_within_lockup_is_rejected_and_emits_event() { assert_eq!(payload.last_buy_timestamp, BASE_TIMESTAMP); assert_eq!(payload.unlock_at, BASE_TIMESTAMP + LOCKUP_SECS); assert_eq!(payload.current_timestamp, BASE_TIMESTAMP); + + // State is untouched by the rejected sell. + assert_eq!(client_supply(&s), 1); + assert_eq!(s.client.get_key_balance(&s.creator, &trader), 1); } fn client_supply(s: &Setup<'_>) -> u32 { @@ -127,7 +129,7 @@ fn test_last_buy_timestamp_is_updated_on_every_buy() { // the sell must stay blocked because last_buy_timestamp was refreshed. set_test_timestamp(&env, second_buy_ts + LOCKUP_SECS - 1); let result = s.client.try_sell_key(&s.creator, &trader, &None); - assert_eq!(result, Ok(Err(ContractError::LockupPeriodActive))); + assert_eq!(result, Err(Ok(ContractError::LockupPeriodActive))); // Once the refreshed window has elapsed the sell goes through. set_test_timestamp(&env, second_buy_ts + LOCKUP_SECS); @@ -165,7 +167,7 @@ fn test_non_admin_cannot_configure_the_lockup() { let impostor = Address::generate(&env); let result = s.client.try_set_lockup_duration(&impostor, &LOCKUP_SECS); - assert_eq!(result, Ok(Err(ContractError::Unauthorized))); + assert_eq!(result, Err(Ok(ContractError::Unauthorized))); } #[test] @@ -174,7 +176,7 @@ fn test_zero_duration_is_rejected() { let s = setup_with_lockup(&env); let result = s.client.try_set_lockup_duration(&s.admin, &0); - assert_eq!(result, Ok(Err(ContractError::NotPositiveAmount))); + assert_eq!(result, Err(Ok(ContractError::NotPositiveAmount))); } #[test] diff --git a/creator-keys/tests/ttl_extension_on_buy.rs b/creator-keys/tests/ttl_extension_on_buy.rs index 569d0950..f99365aa 100644 --- a/creator-keys/tests/ttl_extension_on_buy.rs +++ b/creator-keys/tests/ttl_extension_on_buy.rs @@ -296,6 +296,17 @@ fn admin_fee_update_extends_instance_ttl() { env.storage().persistent().get_ttl(&fee_config_key) }); + // Keep the protocol-state-version key alive across the far-future ledger + // jump: `set_fee_config` reads and writes it, and advancing the ledger past + // its default TTL would archive it and make the later update fail. + env.as_contract(&contract_id, || { + env.storage().persistent().extend_ttl( + &storage::PROTOCOL_STATE_VERSION, + CREATOR_TTL_LEDGERS, + CREATOR_TTL_LEDGERS, + ); + }); + let mut ledger = env.ledger().get(); ledger.sequence_number += ttl_before.saturating_sub(1).max(1); env.ledger().set(ledger); diff --git a/creator-keys/tests/ttl_extension_on_sell.rs b/creator-keys/tests/ttl_extension_on_sell.rs index ec4950a2..f87a73e0 100644 --- a/creator-keys/tests/ttl_extension_on_sell.rs +++ b/creator-keys/tests/ttl_extension_on_sell.rs @@ -112,6 +112,21 @@ fn repeated_sells_reset_the_ttl_window_rather_than_accumulate() { // Burn a chunk of the freshly granted window, then sell again. let elapsed = CREATOR_TTL_LEDGERS / 4; + // This test jumps the ledger twice; re-extend the contract instance and the + // KEY_PRICE entry (which sell_key reads) so the cumulative time travel does + // not archive them, mirroring how `setup` keeps live keys invocable. + env.deployer().extend_ttl( + contract_id.clone(), + CREATOR_TTL_LEDGERS, + CREATOR_TTL_LEDGERS, + ); + env.as_contract(&contract_id, || { + env.storage().persistent().extend_ttl( + &storage::KEY_PRICE, + CREATOR_TTL_LEDGERS, + CREATOR_TTL_LEDGERS, + ); + }); advance_ledgers(&env, elapsed); let ttl_after_elapsing = creator_ttl_remaining(&env, &contract_id, &creator); assert!( diff --git a/creator-keys/tests/ttl_refresh.rs b/creator-keys/tests/ttl_refresh.rs index f0f3cff9..b3593468 100644 --- a/creator-keys/tests/ttl_refresh.rs +++ b/creator-keys/tests/ttl_refresh.rs @@ -100,6 +100,17 @@ fn test_admin_config_update_bumps_fee_config_ttl() { extend_contract_lifetime(&env, &contract_id); let admin = set_pricing_and_fees(&env, &client, KEY_PRICE, 9000, 1000); + // Keep the protocol-state-version key alive across the far-future jump: + // `set_fee_config` reads and writes it, and advancing the ledger past its + // default TTL would archive it and make the later update fail. + env.as_contract(&contract_id, || { + env.storage().persistent().extend_ttl( + &storage::PROTOCOL_STATE_VERSION, + creator_keys::CREATOR_TTL_LEDGERS, + creator_keys::CREATOR_TTL_LEDGERS, + ); + }); + // Drain the fee config entry close to expiry. advance_ledger(&env, creator_keys::CREATOR_TTL_LEDGERS - 100); let fee_ttl_before = key_ttl(&env, &contract_id, &storage::FEE_CONFIG); @@ -158,5 +169,5 @@ fn test_refresh_ttl_rejects_non_admin_callers() { let creators = Vec::new(&env); let result = client.try_refresh_ttl(&impostor, &creators); - assert_eq!(result, Ok(Err(ContractError::Unauthorized))); + assert_eq!(result, Err(Ok(ContractError::Unauthorized))); } diff --git a/creator-keys/tests/whitelist_window.rs b/creator-keys/tests/whitelist_window.rs index d2e3a47e..58be8f51 100644 --- a/creator-keys/tests/whitelist_window.rs +++ b/creator-keys/tests/whitelist_window.rs @@ -130,7 +130,7 @@ fn test_whitelist_over_500_addresses_reverts_at_registration() { }), ); - assert_eq!(result, Err(Ok(ContractError::WhitelistOnly))); + assert_eq!(result, Err(Ok(ContractError::WhitelistTooLarge))); assert!(!client.is_creator_registered(&creator)); }