diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 1e37fe81..b24424a6 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -1043,6 +1043,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] @@ -2043,6 +2054,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, @@ -2545,6 +2579,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); @@ -2851,6 +2886,7 @@ impl CreatorKeysContract { assert_global_trading_not_halted(&env)?; 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)?; @@ -3042,6 +3078,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); @@ -3962,6 +3999,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 diff --git a/creator-keys/src/test_new_features.rs b/creator-keys/src/test_new_features.rs index 4a18a799..6e765756 100644 --- a/creator-keys/src/test_new_features.rs +++ b/creator-keys/src/test_new_features.rs @@ -41,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); } @@ -81,8 +83,9 @@ fn test_referral_system_fee_split_and_validation() { ); 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())); @@ -93,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] diff --git a/creator-keys/tests/creator_restoring_lifecycle.rs b/creator-keys/tests/creator_restoring_lifecycle.rs new file mode 100644 index 00000000..f7a928f2 --- /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 +//! 40–42 (`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/creator-keys/tests/protocol_trade_fee.rs b/creator-keys/tests/protocol_trade_fee.rs index 877b88fa..ebfd762b 100644 --- a/creator-keys/tests/protocol_trade_fee.rs +++ b/creator-keys/tests/protocol_trade_fee.rs @@ -145,6 +145,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] @@ -155,9 +161,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, diff --git a/creator-keys/tests/sell_lockup.rs b/creator-keys/tests/sell_lockup.rs index 96cf1700..1fc2db23 100644 --- a/creator-keys/tests/sell_lockup.rs +++ b/creator-keys/tests/sell_lockup.rs @@ -90,6 +90,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 { diff --git a/creator-keys/tests/ttl_extension_on_sell.rs b/creator-keys/tests/ttl_extension_on_sell.rs index 9d2306f3..5576f626 100644 --- a/creator-keys/tests/ttl_extension_on_sell.rs +++ b/creator-keys/tests/ttl_extension_on_sell.rs @@ -135,6 +135,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!(