From 7c7d7dc30542ebdb9fc70bcc11cdd6850d27b44e Mon Sep 17 00:00:00 2001 From: Bathoul Mohammed Date: Sun, 27 Sep 2026 03:21:35 +0100 Subject: [PATCH 1/3] feat: add timestamp-based trade cooldown enforcement (issue #974) --- creator-keys/src/events.rs | 65 ++++++ creator-keys/src/lib.rs | 257 +++++++++++++++++++++- creator-keys/tests/trade_cooldown.rs | 318 +++++++++++++++++++++++++++ 3 files changed, 638 insertions(+), 2 deletions(-) create mode 100644 creator-keys/tests/trade_cooldown.rs diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index 502061e7..5c6547b3 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -3008,3 +3008,68 @@ pub struct EscalationConfigUpdatedEvent { 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()) +} diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 233fa274..75780c9a 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -206,6 +206,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 @@ -785,6 +792,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()) @@ -956,6 +973,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)] @@ -1283,6 +1313,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; @@ -1591,6 +1626,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), } #[derive(Clone, Debug, PartialEq)] @@ -4552,6 +4594,41 @@ 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); @@ -4749,6 +4826,15 @@ 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); + total_price = total_price .checked_add(key_price) .ok_or(ContractError::Overflow)?; @@ -4938,6 +5024,41 @@ 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); @@ -5156,6 +5277,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. @@ -5392,6 +5521,41 @@ 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() @@ -5542,6 +5706,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. @@ -8914,8 +9086,89 @@ 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..8fd7343c --- /dev/null +++ b/creator-keys/tests/trade_cooldown.rs @@ -0,0 +1,318 @@ +//! 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_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); + + // 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); + 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); +} From 050a88dea811bc398a52a736871cc4423d704213 Mon Sep 17 00:00:00 2001 From: Bathoul Mohammed Date: Thu, 1 Oct 2026 16:54:23 +0100 Subject: [PATCH 2/3] Fix syntax error and formatting in merge resolution - Removed extra closing brace in events.rs (line 3756) - Ran cargo fmt to fix formatting issues in lib.rs and trade_cooldown.rs Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- creator-keys/src/events.rs | 1 - creator-keys/src/lib.rs | 42 ++++++++-------------------- creator-keys/tests/trade_cooldown.rs | 4 +-- 3 files changed, 13 insertions(+), 34 deletions(-) diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index 60c35735..0833d48a 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -3753,4 +3753,3 @@ pub struct KeyRatedEvent { pub fn key_rated_topics(creator: &Address, rater: &Address) -> (Symbol, Address, Address) { (KEY_RATED_EVENT_NAME, creator.clone(), rater.clone()) } -} diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 2ca07d6e..eaa6003e 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -5774,13 +5774,9 @@ impl CreatorKeysContract { .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, - )) - { + 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 { @@ -6006,8 +6002,7 @@ impl CreatorKeysContract { // 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); + let last_trade_key = constants::storage::last_trade_timestamp(&creator, &buyer); env.storage() .persistent() .set(&last_trade_key, &env.ledger().timestamp()); @@ -6276,13 +6271,9 @@ impl CreatorKeysContract { .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, - )) - { + 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 { @@ -6782,13 +6773,9 @@ impl CreatorKeysContract { .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, - )) - { + 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 { @@ -10898,8 +10885,7 @@ impl CreatorKeysContract { duration_secs: u64, ) -> Result<(), CooldownError> { key_id.require_auth(); - read_registered_creator_profile(&env, &key_id) - .map_err(|_| CooldownError::NotRegistered)?; + read_registered_creator_profile(&env, &key_id).map_err(|_| CooldownError::NotRegistered)?; if duration_secs > MAX_TRADE_COOLDOWN_SECS { return Err(CooldownError::DurationTooLong); } @@ -10921,11 +10907,7 @@ impl CreatorKeysContract { /// - `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 { + pub fn get_cooldown_status(env: Env, key_id: Address, wallet: Address) -> CooldownStatus { let duration_secs: u64 = env .storage() .persistent() diff --git a/creator-keys/tests/trade_cooldown.rs b/creator-keys/tests/trade_cooldown.rs index 8fd7343c..9adbc81c 100644 --- a/creator-keys/tests/trade_cooldown.rs +++ b/creator-keys/tests/trade_cooldown.rs @@ -279,9 +279,7 @@ fn test_cooldown_independent_per_wallet() { ); // wallet_b has no prior trade — must succeed freely. - let supply = s - .client - .buy_key(&s.creator, &wallet_b, &KEY_PRICE, &None); + let supply = s.client.buy_key(&s.creator, &wallet_b, &KEY_PRICE, &None); assert_eq!(supply, 2); } From 9f95142393cbc1414d465f536d76005069be1352 Mon Sep 17 00:00:00 2001 From: Bathoul Mohammed Date: Fri, 2 Oct 2026 10:18:54 +0100 Subject: [PATCH 3/3] fix: advance ledger sequence in sell cooldown tests to bypass flash loan guard The flash loan guard checks ledger sequence numbers (not timestamps) and runs before the cooldown check in sell_key. Tests only advanced the timestamp, causing the flash loan guard to trigger first. Now tests advance both timestamp and ledger sequence to properly test cooldown behavior. Fixes failing tests: - test_sell_blocked_within_cooldown_window - test_sell_succeeds_after_cooldown_expires Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- creator-keys/tests/trade_cooldown.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/creator-keys/tests/trade_cooldown.rs b/creator-keys/tests/trade_cooldown.rs index 9adbc81c..02ebb81b 100644 --- a/creator-keys/tests/trade_cooldown.rs +++ b/creator-keys/tests/trade_cooldown.rs @@ -9,8 +9,8 @@ mod contract_test_env; use contract_test_env::{ - register_creator_keys, register_test_creator, set_key_price_for_tests, set_test_timestamp, - test_env_with_auths, DEFAULT_TEST_TIMESTAMP, + 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}; @@ -139,6 +139,8 @@ fn test_sell_blocked_within_cooldown_window() { // 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); @@ -162,6 +164,8 @@ fn test_sell_succeeds_after_cooldown_expires() { // 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"); }