diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index c99c5040..0833d48a 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -3240,6 +3240,71 @@ pub fn escalation_config_updated_topics(admin: &Address) -> (Symbol, Address) { (ESCALATION_CONFIG_UPDATED_EVENT_NAME, admin.clone()) } +// ============================================================================ +// Trade cooldown violation (issue #974) +// ============================================================================ + +/// Event name emitted when a buy or sell is blocked by the per-wallet trade +/// cooldown configured via `set_cooldown`. +pub const COOLDOWN_VIOLATION_EVENT_NAME: Symbol = symbol_short!("cd_viol"); + +/// Event name emitted when a creator updates their trade cooldown duration via +/// `set_cooldown`. +pub const COOLDOWN_SET_EVENT_NAME: Symbol = symbol_short!("cd_set"); + +/// Payload for a blocked trade due to the per-wallet trade cooldown (issue #974). +/// +/// Event shape: +/// - topics: `(COOLDOWN_VIOLATION_EVENT_NAME, creator_id, wallet)` +/// - data: `CooldownViolationEvent` +/// +/// Emitted inside `buy_keys_with_referrer` and `sell_key` when a trade is +/// rejected because the per-wallet cooldown window has not yet elapsed. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct CooldownViolationEvent { + /// Wallet whose trade was blocked. + pub wallet: Address, + /// Creator whose key was being traded. + pub creator_id: Address, + /// Unix timestamp at which the cooldown expires and trading resumes. + pub expires_at: u64, + /// Seconds remaining until the cooldown expires. + pub seconds_remaining: u64, +} + +/// Shared cooldown-violation event topics tuple. +pub fn cooldown_violation_topics( + creator: &Address, + wallet: &Address, +) -> (Symbol, Address, Address) { + ( + COOLDOWN_VIOLATION_EVENT_NAME, + creator.clone(), + wallet.clone(), + ) +} + +/// Payload emitted when a creator sets (or updates) their trade cooldown +/// duration via `set_cooldown` (issue #974). +/// +/// Event shape: +/// - topics: `(COOLDOWN_SET_EVENT_NAME, creator_id)` +/// - data: `CooldownSetEvent` +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct CooldownSetEvent { + /// Creator whose cooldown was updated. + pub creator_id: Address, + /// New cooldown duration in seconds (`0` disables the cooldown). + pub duration_secs: u64, +} + +/// Shared cooldown-set event topics tuple. +pub fn cooldown_set_topics(creator: &Address) -> (Symbol, Address) { + (COOLDOWN_SET_EVENT_NAME, creator.clone()) +} + /// Event name for configuring a graduated bonding curve with supply milestones. pub const GRADUATED_CURVE_CONFIGURED_EVENT_NAME: Symbol = symbol_short!("grad_crv"); diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index efb81e65..eaa6003e 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -265,6 +265,13 @@ pub enum CooldownError { CooldownTooLong = 2, /// The creator address is not registered. NotRegistered = 3, + /// A trade (buy or sell) was blocked because the per-wallet trade cooldown + /// window has not yet elapsed since the last trade. The number of seconds + /// remaining is emitted in the accompanying `CooldownViolationEvent`. + CooldownViolation = 4, + /// The requested cooldown duration exceeds the allowed maximum of + /// [`MAX_TRADE_COOLDOWN_SECS`]. + DurationTooLong = 5, } /// Errors raised by the reputation-scoring entrypoints @@ -885,6 +892,16 @@ pub mod constants { DataKey::BuyCooldown(creator.clone()) } + /// Storage key for the per-creator trade cooldown duration in seconds (issue #974). + pub fn cooldown_duration(creator: &Address) -> DataKey { + DataKey::CooldownDuration(creator.clone()) + } + + /// Storage key for the (creator, wallet) last-trade Unix timestamp (issue #974). + pub fn last_trade_timestamp(creator: &Address, wallet: &Address) -> DataKey { + DataKey::LastTradeTimestamp(creator.clone(), wallet.clone()) + } + /// Storage key for a creator's deprecation marker; value is `buyback_price_per_key` (i128). pub fn deprecated_key(creator: &Address) -> DataKey { DataKey::DeprecatedKey(creator.clone()) @@ -1127,6 +1144,19 @@ pub mod constants { /// Stable, non-optional view of the protocol fee configuration. /// +/// Returned by [`CreatorKeysContract::get_cooldown_status`] (issue #974). +/// Describes whether a wallet is currently within the per-wallet trade cooldown +/// window for a creator's key. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct CooldownStatus { + /// `true` when the wallet is inside the cooldown window and trades are blocked. + pub active: bool, + /// Unix timestamp at which the cooldown expires and trading resumes. + /// `0` when no cooldown is active or none is configured for this creator. + pub expires_at: u64, +} + /// Returned by [`CreatorKeysContract::get_protocol_fee_view`] for indexer-friendly consumption. /// When `is_configured` is `false`, both bps fields are `0` and no fee config has been stored. #[derive(Clone)] @@ -1483,6 +1513,11 @@ pub const MAX_LAUNCH_PENALTY_BPS: u32 = 2_000; /// restriction (the default when no cooldown has been configured). pub const MAX_BUY_COOLDOWN_LEDGERS: u32 = 720; +/// Maximum allowed trade cooldown duration in seconds for +/// [`CreatorKeysContract::set_cooldown`] (issue #974). +/// 86 400 seconds = 24 hours. +pub const MAX_TRADE_COOLDOWN_SECS: u64 = 86_400; + /// Maximum allowed per-transaction buy quantity limit. pub const MAX_BUY_QUANTITY_LIMIT: u32 = 10_000; @@ -1844,6 +1879,13 @@ pub enum DataKey { BuybackPoolAddress, /// Protocol-wide poll quorum-escalation configuration. EscalationConfig, + /// Per-creator trade cooldown duration in seconds (issue #974). + /// A value of `0` (or absent) means no cooldown is configured. + /// Set via `set_cooldown`. Applies to both buy and sell. + CooldownDuration(Address), + /// (creator, wallet) -> Unix timestamp of the wallet's most recent trade + /// (buy or sell) for this creator (issue #974). + LastTradeTimestamp(Address, Address), // --- Staking and stake-receipt NFT --- /// (creator, owner) -> total keys staked -> `u32`. StakedKeys(Address, Address), @@ -5723,6 +5765,37 @@ impl CreatorKeysContract { let mut profile: CreatorProfile = read_registered_creator_profile(&env, &creator)?; assert_whitelist_allows_buy(&env, &profile, &buyer)?; + // Enforce per-wallet trade cooldown (issue #974): check timestamp-based + // cooldown before any price or balance changes. + { + let duration_secs: u64 = env + .storage() + .persistent() + .get(&constants::storage::cooldown_duration(&creator)) + .unwrap_or(0); + if duration_secs > 0 { + if let Some(last_ts) = env.storage().persistent().get::( + &constants::storage::last_trade_timestamp(&creator, &buyer), + ) { + let now = env.ledger().timestamp(); + let expires_at = last_ts.saturating_add(duration_secs); + if now < expires_at { + let seconds_remaining = expires_at - now; + env.events().publish( + events::cooldown_violation_topics(&creator, &buyer), + events::CooldownViolationEvent { + wallet: buyer.clone(), + creator_id: creator.clone(), + expires_at, + seconds_remaining, + }, + ); + return Err(ContractError::CooldownActive); + } + } + } + } + let auction_config_key = constants::storage::auction_config(&creator); let mut auction_config: Option = env.storage().persistent().get(&auction_config_key); @@ -5927,6 +6000,14 @@ impl CreatorKeysContract { .set(&last_buy_key, &env.ledger().timestamp()); extend_key_ttl_to_full_window(&env, &last_buy_key); + // Update the per-wallet last-trade timestamp used by the trade + // cooldown guard (issue #974). + let last_trade_key = constants::storage::last_trade_timestamp(&creator, &buyer); + env.storage() + .persistent() + .set(&last_trade_key, &env.ledger().timestamp()); + extend_key_ttl_to_full_window(&env, &last_trade_key); + // SupplyCapReached (#997): fire exactly once, on the key that fills // the configured cap (a partial fill that reaches the cap emits it). if let Some(cap) = env @@ -6181,6 +6262,37 @@ impl CreatorKeysContract { let mut profile: CreatorProfile = read_registered_creator_profile(&env, &creator)?; assert_whitelist_allows_buy(&env, &profile, &buyer)?; + // Enforce per-wallet trade cooldown (issue #974): check timestamp-based + // cooldown before any price or balance changes. + { + let duration_secs: u64 = env + .storage() + .persistent() + .get(&constants::storage::cooldown_duration(&creator)) + .unwrap_or(0); + if duration_secs > 0 { + if let Some(last_ts) = env.storage().persistent().get::( + &constants::storage::last_trade_timestamp(&creator, &buyer), + ) { + let now = env.ledger().timestamp(); + let expires_at = last_ts.saturating_add(duration_secs); + if now < expires_at { + let seconds_remaining = expires_at - now; + env.events().publish( + events::cooldown_violation_topics(&creator, &buyer), + events::CooldownViolationEvent { + wallet: buyer.clone(), + creator_id: creator.clone(), + expires_at, + seconds_remaining, + }, + ); + return Err(ContractError::CooldownActive); + } + } + } + } + let auction_config_key = constants::storage::auction_config(&creator); let mut auction_config: Option = env.storage().persistent().get(&auction_config_key); @@ -6426,6 +6538,14 @@ impl CreatorKeysContract { .set(&last_buy_key, &env.ledger().timestamp()); extend_key_ttl_to_full_window(&env, &last_buy_key); + // Update the per-wallet last-trade timestamp used by the trade + // cooldown guard (issue #974). + let last_trade_key = constants::storage::last_trade_timestamp(&creator, &buyer); + env.storage() + .persistent() + .set(&last_trade_key, &env.ledger().timestamp()); + extend_key_ttl_to_full_window(&env, &last_trade_key); + // Deduct the protocol trade fee before computing the creator payout so // the fee collector is paid ahead of every other participant. A share // of the fee is routed into the creator's staking rewards pool. @@ -6644,6 +6764,37 @@ impl CreatorKeysContract { } } + // Enforce per-wallet trade cooldown (issue #974): check timestamp-based + // cooldown before any price or balance changes. + { + let duration_secs: u64 = env + .storage() + .persistent() + .get(&constants::storage::cooldown_duration(&creator)) + .unwrap_or(0); + if duration_secs > 0 { + if let Some(last_ts) = env.storage().persistent().get::( + &constants::storage::last_trade_timestamp(&creator, &seller), + ) { + let now = env.ledger().timestamp(); + let expires_at = last_ts.saturating_add(duration_secs); + if now < expires_at { + let seconds_remaining = expires_at - now; + env.events().publish( + events::cooldown_violation_topics(&creator, &seller), + events::CooldownViolationEvent { + wallet: seller.clone(), + creator_id: creator.clone(), + expires_at, + seconds_remaining, + }, + ); + return Err(ContractError::CooldownActive); + } + } + } + } + let base_price: i128 = env .storage() .persistent() @@ -6798,6 +6949,14 @@ impl CreatorKeysContract { sell_event_data, ); + // Update the per-wallet last-trade timestamp used by the trade + // cooldown guard (issue #974). + let last_trade_key = constants::storage::last_trade_timestamp(&creator, &seller); + env.storage() + .persistent() + .set(&last_trade_key, &env.ledger().timestamp()); + extend_key_ttl_to_full_window(&env, &last_trade_key); + record_trade_price_snapshot(&env, &creator); // Update analytics accumulators atomically with the trade. @@ -10704,8 +10863,84 @@ impl CreatorKeysContract { .unwrap_or(0) } - /// Sets the maximum number of keys a single buy transaction may purchase - /// for this creator's keys. + // ------------------------------------------------------------------------- + // Trade cooldown — timestamp-based, applies to both buy and sell (issue #974) + // ------------------------------------------------------------------------- + + /// Sets the per-wallet trade cooldown duration for a creator's keys. + /// + /// Once set, both `buy_key` and `sell_key` will reject trades by the same + /// wallet within `duration_secs` seconds of their last trade, returning + /// [`CooldownError::CooldownViolation`] and emitting a + /// [`events::COOLDOWN_VIOLATION_EVENT_NAME`] event that carries + /// `seconds_remaining`. + /// + /// Only the key creator may call this. `duration_secs` must be in + /// `0..=MAX_TRADE_COOLDOWN_SECS` (86 400 s = 24 h); values above that + /// return [`CooldownError::DurationTooLong`]. A value of `0` disables + /// the cooldown (the default when none has been configured). + pub fn set_cooldown( + env: Env, + key_id: Address, + duration_secs: u64, + ) -> Result<(), CooldownError> { + key_id.require_auth(); + read_registered_creator_profile(&env, &key_id).map_err(|_| CooldownError::NotRegistered)?; + if duration_secs > MAX_TRADE_COOLDOWN_SECS { + return Err(CooldownError::DurationTooLong); + } + let key = constants::storage::cooldown_duration(&key_id); + env.storage().persistent().set(&key, &duration_secs); + extend_key_ttl_to_full_window(&env, &key); + env.events().publish( + events::cooldown_set_topics(&key_id), + events::CooldownSetEvent { + creator_id: key_id.clone(), + duration_secs, + }, + ); + Ok(()) + } + + /// Returns the cooldown status for a specific wallet on a creator's key. + /// + /// - `active`: `true` when the wallet is currently within its cooldown window. + /// - `expires_at`: Unix timestamp at which the cooldown expires + /// (`0` when no cooldown is active or none is configured). + pub fn get_cooldown_status(env: Env, key_id: Address, wallet: Address) -> CooldownStatus { + let duration_secs: u64 = env + .storage() + .persistent() + .get(&constants::storage::cooldown_duration(&key_id)) + .unwrap_or(0); + + if duration_secs == 0 { + return CooldownStatus { + active: false, + expires_at: 0, + }; + } + + let last_ts: Option = env + .storage() + .persistent() + .get(&constants::storage::last_trade_timestamp(&key_id, &wallet)); + + match last_ts { + None => CooldownStatus { + active: false, + expires_at: 0, + }, + Some(last) => { + let expires_at = last.saturating_add(duration_secs); + let now = env.ledger().timestamp(); + CooldownStatus { + active: now < expires_at, + expires_at, + } + } + } + } /// /// Only callable by the key creator. `max_qty` must be in 1..=10 000. /// A value of 0 disables the limit (no per-tx cap). diff --git a/creator-keys/tests/trade_cooldown.rs b/creator-keys/tests/trade_cooldown.rs new file mode 100644 index 00000000..02ebb81b --- /dev/null +++ b/creator-keys/tests/trade_cooldown.rs @@ -0,0 +1,320 @@ +//! Integration tests for the timestamp-based per-wallet trade cooldown (issue #974). +//! +//! `set_cooldown(key_id, duration_secs)` configures a per-creator cooldown that +//! blocks both buys and sells from the same wallet within `duration_secs` seconds +//! of their last trade. The entrypoint `get_cooldown_status` reflects the live +//! state, and blocked trades emit a `CooldownViolationEvent` containing +//! `seconds_remaining`. + +mod contract_test_env; + +use contract_test_env::{ + register_creator_keys, register_test_creator, set_key_price_for_tests, set_ledger_sequence, + set_test_timestamp, test_env_with_auths, DEFAULT_TEST_TIMESTAMP, +}; +use creator_keys::events::{self, COOLDOWN_VIOLATION_EVENT_NAME}; +use creator_keys::{ContractError, CooldownError, CooldownStatus, MAX_TRADE_COOLDOWN_SECS}; +use soroban_sdk::{ + testutils::{Address as _, Events}, + Address, Env, IntoVal, Symbol, +}; + +const KEY_PRICE: i128 = 100; +const COOLDOWN_SECS: u64 = 60; // 1 minute + +// ── helpers ────────────────────────────────────────────────────────────────── + +struct Setup<'a> { + client: creator_keys::CreatorKeysContractClient<'a>, + creator: Address, +} + +fn setup_with_cooldown(env: &Env, duration_secs: u64) -> Setup<'_> { + let (client, _) = register_creator_keys(env); + set_key_price_for_tests(env, &client, KEY_PRICE); + let creator = register_test_creator(env, &client, "alice"); + set_test_timestamp(env, DEFAULT_TEST_TIMESTAMP); + client.set_cooldown(&creator, &duration_secs); + Setup { client, creator } +} + +/// Collect all `CooldownViolationEvent` payloads from the most recent invocation. +fn violation_events(env: &Env) -> soroban_sdk::Vec { + let mut found = soroban_sdk::Vec::new(env); + for (_, topics, data) in env.events().all().iter() { + let name: Symbol = topics.get(0).unwrap().into_val(env); + if name == COOLDOWN_VIOLATION_EVENT_NAME { + found.push_back(data.into_val(env)); + } + } + found +} + +// ── set_cooldown validation ─────────────────────────────────────────────────── + +/// AC: set_cooldown rejects duration above MAX_TRADE_COOLDOWN_SECS. +#[test] +fn test_set_cooldown_rejects_duration_above_max() { + let env = test_env_with_auths(); + let (client, _) = register_creator_keys(&env); + set_key_price_for_tests(&env, &client, KEY_PRICE); + let creator = register_test_creator(&env, &client, "bob"); + + // Exactly at the limit is fine. + client.set_cooldown(&creator, &MAX_TRADE_COOLDOWN_SECS); + + // One above must fail. + let result = client.try_set_cooldown(&creator, &(MAX_TRADE_COOLDOWN_SECS + 1)); + assert_eq!( + result, + Err(Ok(CooldownError::DurationTooLong)), + "duration above max must return DurationTooLong" + ); +} + +/// AC: set_cooldown of 0 is accepted and disables the cooldown. +#[test] +fn test_set_cooldown_zero_disables() { + let env = test_env_with_auths(); + let s = setup_with_cooldown(&env, 0); + + let buyer = Address::generate(&env); + // Two back-to-back buys must succeed with zero cooldown. + s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + assert_eq!(s.client.get_total_key_supply(&s.creator), 2); +} + +// ── buy blocked within cooldown ─────────────────────────────────────────────── + +/// AC: Trade blocked correctly within cooldown window (buy path). +#[test] +fn test_buy_blocked_within_cooldown_window() { + let env = test_env_with_auths(); + let s = setup_with_cooldown(&env, COOLDOWN_SECS); + + let buyer = Address::generate(&env); + // First buy succeeds — no prior trade recorded. + s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + + // Advance time but stay inside the window. + set_test_timestamp(&env, DEFAULT_TEST_TIMESTAMP + COOLDOWN_SECS - 1); + + let result = s.client.try_buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + assert_eq!( + result, + Err(Ok(ContractError::CooldownActive)), + "buy within cooldown window must be rejected" + ); + // Supply must be unchanged. + assert_eq!(s.client.get_total_key_supply(&s.creator), 1); +} + +/// AC: Trade succeeds after cooldown expires (buy path). +#[test] +fn test_buy_succeeds_after_cooldown_expires() { + let env = test_env_with_auths(); + let s = setup_with_cooldown(&env, COOLDOWN_SECS); + + let buyer = Address::generate(&env); + s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + + // Advance time to exactly the expiry boundary — should succeed. + set_test_timestamp(&env, DEFAULT_TEST_TIMESTAMP + COOLDOWN_SECS); + let supply = s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + assert_eq!(supply, 2, "buy at cooldown boundary must succeed"); +} + +// ── sell blocked within cooldown ───────────────────────────────────────────── + +/// AC: Sell is blocked when called within the cooldown window after a buy. +#[test] +fn test_sell_blocked_within_cooldown_window() { + let env = test_env_with_auths(); + let s = setup_with_cooldown(&env, COOLDOWN_SECS); + + let trader = Address::generate(&env); + // Buy at t=0 — stamps last_trade_timestamp. + s.client.buy_key(&s.creator, &trader, &KEY_PRICE, &None); + + // Advance time but remain inside the cooldown window. + set_test_timestamp(&env, DEFAULT_TEST_TIMESTAMP + COOLDOWN_SECS - 1); + // Advance ledger to bypass flash loan guard (which checks ledger sequence). + set_ledger_sequence(&env, 1); + + // Sell within the cooldown must be rejected. + let result = s.client.try_sell_key(&s.creator, &trader, &None); + assert_eq!( + result, + Err(Ok(ContractError::CooldownActive)), + "sell within cooldown window must be rejected" + ); + // Balance unchanged — sell was rolled back. + assert_eq!(s.client.get_key_balance(&s.creator, &trader), 1); +} + +/// AC: Sell succeeds after the cooldown window expires. +#[test] +fn test_sell_succeeds_after_cooldown_expires() { + let env = test_env_with_auths(); + let s = setup_with_cooldown(&env, COOLDOWN_SECS); + + let trader = Address::generate(&env); + s.client.buy_key(&s.creator, &trader, &KEY_PRICE, &None); + + // Advance to exactly the expiry boundary. + set_test_timestamp(&env, DEFAULT_TEST_TIMESTAMP + COOLDOWN_SECS); + // Advance ledger to bypass flash loan guard (which checks ledger sequence). + set_ledger_sequence(&env, 1); + let supply = s.client.sell_key(&s.creator, &trader, &None); + assert_eq!(supply, 0, "sell at cooldown boundary must succeed"); +} + +// ── CooldownViolationEvent ──────────────────────────────────────────────────── + +/// AC: CooldownViolation event includes seconds_remaining when buy is blocked. +#[test] +fn test_violation_event_has_correct_seconds_remaining_on_buy() { + let env = test_env_with_auths(); + let s = setup_with_cooldown(&env, COOLDOWN_SECS); + + let buyer = Address::generate(&env); + s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + + // Advance by 10 seconds → 50 seconds remaining. + let elapsed: u64 = 10; + set_test_timestamp(&env, DEFAULT_TEST_TIMESTAMP + elapsed); + + let _ = s.client.try_buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + + let evts = violation_events(&env); + assert_eq!(evts.len(), 1, "exactly one violation event must be emitted"); + + let ev = evts.get(0).unwrap(); + assert_eq!(ev.wallet, buyer); + assert_eq!(ev.creator_id, s.creator); + assert_eq!( + ev.seconds_remaining, + COOLDOWN_SECS - elapsed, + "seconds_remaining must equal cooldown_secs minus elapsed" + ); + assert_eq!( + ev.expires_at, + DEFAULT_TEST_TIMESTAMP + COOLDOWN_SECS, + "expires_at must be last_trade_ts + duration_secs" + ); +} + +// ── get_cooldown_status ─────────────────────────────────────────────────────── + +/// AC: get_cooldown_status returns active=true and correct expires_at inside window. +#[test] +fn test_get_cooldown_status_active_inside_window() { + let env = test_env_with_auths(); + let s = setup_with_cooldown(&env, COOLDOWN_SECS); + + let trader = Address::generate(&env); + s.client.buy_key(&s.creator, &trader, &KEY_PRICE, &None); + + // Still inside the window. + set_test_timestamp(&env, DEFAULT_TEST_TIMESTAMP + 5); + + let status: CooldownStatus = s.client.get_cooldown_status(&s.creator, &trader); + assert!(status.active, "status must be active inside the window"); + assert_eq!( + status.expires_at, + DEFAULT_TEST_TIMESTAMP + COOLDOWN_SECS, + "expires_at must be last_trade_ts + duration_secs" + ); +} + +/// AC: get_cooldown_status returns active=false after cooldown expires. +#[test] +fn test_get_cooldown_status_inactive_after_expiry() { + let env = test_env_with_auths(); + let s = setup_with_cooldown(&env, COOLDOWN_SECS); + + let trader = Address::generate(&env); + s.client.buy_key(&s.creator, &trader, &KEY_PRICE, &None); + + // Past expiry. + set_test_timestamp(&env, DEFAULT_TEST_TIMESTAMP + COOLDOWN_SECS + 1); + + let status: CooldownStatus = s.client.get_cooldown_status(&s.creator, &trader); + assert!(!status.active, "status must be inactive after expiry"); +} + +/// AC: get_cooldown_status returns active=false for a wallet that has never traded. +#[test] +fn test_get_cooldown_status_inactive_for_new_wallet() { + let env = test_env_with_auths(); + let s = setup_with_cooldown(&env, COOLDOWN_SECS); + + let fresh_wallet = Address::generate(&env); + let status: CooldownStatus = s.client.get_cooldown_status(&s.creator, &fresh_wallet); + assert!( + !status.active, + "wallet with no trade history must not be in cooldown" + ); + assert_eq!(status.expires_at, 0); +} + +// ── independence guarantees ─────────────────────────────────────────────────── + +/// AC: Cooldown is per-wallet; blocking one wallet does not affect another. +#[test] +fn test_cooldown_independent_per_wallet() { + let env = test_env_with_auths(); + let s = setup_with_cooldown(&env, COOLDOWN_SECS); + + let wallet_a = Address::generate(&env); + let wallet_b = Address::generate(&env); + + s.client.buy_key(&s.creator, &wallet_a, &KEY_PRICE, &None); + + // wallet_b hasn't traded yet — advance into wallet_a's cooldown window. + set_test_timestamp(&env, DEFAULT_TEST_TIMESTAMP + 5); + + // wallet_a is blocked. + assert_eq!( + s.client + .try_buy_key(&s.creator, &wallet_a, &KEY_PRICE, &None), + Err(Ok(ContractError::CooldownActive)) + ); + + // wallet_b has no prior trade — must succeed freely. + let supply = s.client.buy_key(&s.creator, &wallet_b, &KEY_PRICE, &None); + assert_eq!(supply, 2); +} + +/// AC: Cooldown is per-creator; the same wallet can trade on a different creator's key. +#[test] +fn test_cooldown_independent_per_creator() { + let env = test_env_with_auths(); + let (client, _) = register_creator_keys(&env); + set_key_price_for_tests(&env, &client, KEY_PRICE); + set_test_timestamp(&env, DEFAULT_TEST_TIMESTAMP); + + let creator_a = register_test_creator(&env, &client, "alice"); + let creator_b = register_test_creator(&env, &client, "bob"); + + // Only creator_a has a cooldown. + client.set_cooldown(&creator_a, &COOLDOWN_SECS); + + let buyer = Address::generate(&env); + client.buy_key(&creator_a, &buyer, &KEY_PRICE, &None); + client.buy_key(&creator_b, &buyer, &KEY_PRICE, &None); + + // Move into creator_a's cooldown window. + set_test_timestamp(&env, DEFAULT_TEST_TIMESTAMP + 5); + + // Blocked for creator_a. + assert_eq!( + client.try_buy_key(&creator_a, &buyer, &KEY_PRICE, &None), + Err(Ok(ContractError::CooldownActive)) + ); + + // Unrestricted for creator_b (no cooldown set). + let supply_b = client.buy_key(&creator_b, &buyer, &KEY_PRICE, &None); + assert_eq!(supply_b, 2); +}