From b83306a4dbb53d5ff22e605b0a2c7d7052c8cf5a Mon Sep 17 00:00:00 2001 From: ELKorede <157540453+ELKorede@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:16:10 +0000 Subject: [PATCH 1/4] feat: add per-wallet buy cooldown to creator keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the per-wallet buy cooldown feature to slow bot-driven key accumulation. Changes: - CooldownError enum (CooldownActive=1, CooldownTooLong=2) in lib.rs - DataKey::LastBuyLedger(Address, Address) and DataKey::BuyCooldown(Address) variants + constants::storage helpers - set_buy_cooldown(creator, cooldown_ledgers) — creator-authed, 0..=720 range; returns CooldownError::CooldownTooLong above cap - get_buy_cooldown(creator) — read-only view, returns 0 when not configured - buy_key_with_referrer: cooldown guard emits cooldown_blocked event then panics with CooldownError::CooldownActive when elapsed < cooldown_ledgers - buy_key_with_referrer: writes LastBuyLedger on every successful buy so subsequent buys use the most recent purchase ledger - CooldownBlockedEvent struct + cooldown_blocked_topics helper + COOLDOWN_BLOCKED_EVENT_NAME constant in events.rs - Integration test suite: tests/buy_cooldown.rs covering all five acceptance criteria - docs: authorization-model.md — set_buy_cooldown + get_buy_cooldown entries - docs: contract-event-conventions.md — cd_blk event row MAX_BUY_COOLDOWN_LEDGERS = 720 (~1 hour at 5 s/ledger). Default is 0 (no restriction) when unconfigured. --- creator-keys/src/events.rs | 31 +++ creator-keys/src/lib.rs | 116 ++++++++++++ creator-keys/tests/buy_cooldown.rs | 291 +++++++++++++++++++++++++++++ docs/authorization-model.md | 13 ++ docs/contract-event-conventions.md | 1 + 5 files changed, 452 insertions(+) create mode 100644 creator-keys/tests/buy_cooldown.rs diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index fde2a919..61a1572d 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -79,6 +79,9 @@ pub const POLL_CREATED_EVENT_NAME: Symbol = symbol_short!("poll_new"); /// Event name for governance poll votes. pub const POLL_VOTE_EVENT_NAME: Symbol = symbol_short!("poll_vote"); +/// Event name for a buy rejected by the per-wallet cooldown guard. +pub const COOLDOWN_BLOCKED_EVENT_NAME: Symbol = symbol_short!("cd_blk"); + /// Topic index for the event name in common event topic tuples. pub const TOPIC_EVENT_NAME_INDEX: u32 = 0; @@ -1254,6 +1257,34 @@ pub fn lockup_blocked_topics(creator: &Address, seller: &Address) -> (Symbol, Ad (LOCKUP_BLOCKED_EVENT_NAME, creator.clone(), seller.clone()) } +/// Stable field order for cooldown_blocked event payloads. +pub const COOLDOWN_BLOCKED_EVENT_DATA_FIELDS: [&str; 3] = + ["wallet", "creator_id", "ledgers_remaining"]; + +/// Stable cooldown-blocked event payload for downstream indexers. +/// +/// Event shape: +/// - topics: `(COOLDOWN_BLOCKED_EVENT_NAME, creator_id, wallet)` +/// - data: `CooldownBlockedEvent` +/// +/// Emitted inside [`CreatorKeysContract::buy_key`] when the per-wallet +/// cooldown period has not elapsed since the buyer's last purchase. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct CooldownBlockedEvent { + /// Wallet whose buy was rejected. + pub wallet: Address, + /// Creator whose keys the buyer attempted to purchase. + pub creator_id: Address, + /// Number of ledgers remaining before the cooldown expires. + pub ledgers_remaining: u32, +} + +/// Shared cooldown blocked event topics tuple. +pub fn cooldown_blocked_topics(creator: &Address, wallet: &Address) -> (Symbol, Address, Address) { + (COOLDOWN_BLOCKED_EVENT_NAME, creator.clone(), wallet.clone()) +} + /// Event name for a new staking position created via `stake_keys_locked`. pub const STAKE_EVENT_NAME: Symbol = symbol_short!("stake"); diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 1e37fe81..b186212d 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -134,6 +134,22 @@ pub enum FeatureError { NoStakeFound = 11, } +/// Errors raised by the buy-cooldown entrypoints +/// ([`CreatorKeysContract::set_buy_cooldown`], [`CreatorKeysContract::buy_key`]). +/// +/// Kept separate from [`ContractError`] because Soroban caps `#[contracterror]` +/// enums at 50 variants and `ContractError` is already at that limit. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum CooldownError { + /// The buyer's last purchase was too recent; the per-key cooldown period + /// has not yet elapsed. + CooldownActive = 1, + /// The requested cooldown exceeds the maximum of 720 ledgers (~1 hour). + CooldownTooLong = 2, +} + pub mod fee { use crate::ContractError; @@ -547,6 +563,17 @@ pub mod constants { pub fn quorum_bps(creator: &Address) -> DataKey { DataKey::QuorumBps(creator.clone()) } + + /// Storage key for the ledger sequence of the most recent buy + /// by `holder` for `creator`. Used by the per-wallet cooldown guard. + pub fn last_buy_ledger(creator: &Address, holder: &Address) -> DataKey { + DataKey::LastBuyLedger(creator.clone(), holder.clone()) + } + + /// Storage key for the per-creator buy cooldown in ledgers. + pub fn buy_cooldown(creator: &Address) -> DataKey { + DataKey::BuyCooldown(creator.clone()) + } } fn creator_key(creator: &Address) -> DataKey { @@ -821,6 +848,13 @@ pub const DEFAULT_LAUNCH_PENALTY_BPS: u32 = 500; /// Maximum launch penalty basis points (20%). pub const MAX_LAUNCH_PENALTY_BPS: u32 = 2_000; +/// Maximum per-wallet buy cooldown in ledgers (~1 hour at 5 s/ledger). +/// +/// Creators cannot configure a cooldown longer than this value via +/// [`CreatorKeysContract::set_buy_cooldown`]. A cooldown of 0 means no +/// restriction (the default when no cooldown has been configured). +pub const MAX_BUY_COOLDOWN_LEDGERS: u32 = 720; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[contracttype] pub enum CurvePreset { @@ -923,6 +957,12 @@ pub enum DataKey { GlobalPauseVote(Address), GlobalResumeVote(Address), SelfFrozenBalance(Address, Address), + /// Ledger sequence number of the most recent buy for `(creator, buyer)`. + /// Written on every successful buy; used by the cooldown guard. + LastBuyLedger(Address, Address), + /// Per-creator cooldown period in ledgers between successive buys by the + /// same wallet. Absent means 0 (no cooldown). + BuyCooldown(Address), } /// Time-locked key allocation for creator self-vesting. @@ -2679,6 +2719,38 @@ impl CreatorKeysContract { } } + // Enforce the per-wallet buy cooldown: once a cooldown is configured + // by the creator via `set_buy_cooldown`, the same wallet cannot buy + // again until `cooldown_ledgers` have elapsed since their last buy. + let cooldown_ledgers: u32 = env + .storage() + .persistent() + .get(&constants::storage::buy_cooldown(&creator)) + .unwrap_or(0); + if cooldown_ledgers > 0 { + let last_buy_ledger_key = constants::storage::last_buy_ledger(&creator, &buyer); + if let Some(last_ledger) = env + .storage() + .persistent() + .get::(&last_buy_ledger_key) + { + let current_ledger = env.ledger().sequence(); + let elapsed = current_ledger.saturating_sub(last_ledger); + if elapsed < cooldown_ledgers { + let ledgers_remaining = cooldown_ledgers - elapsed; + env.events().publish( + events::cooldown_blocked_topics(&creator, &buyer), + events::CooldownBlockedEvent { + wallet: buyer.clone(), + creator_id: creator.clone(), + ledgers_remaining, + }, + ); + env.panic_with_error(CooldownError::CooldownActive); + } + } + } + // Settle dividends before balance changes so earnings are captured at old balance. settle_holder_dividends(&env, &creator, &buyer, current_balance)?; @@ -2727,6 +2799,15 @@ impl CreatorKeysContract { .set(&last_buy_key, &env.ledger().timestamp()); extend_key_ttl_to_full_window(&env, &last_buy_key); + // Record the ledger sequence of this buy for the per-wallet cooldown guard. + // Written on every successful buy so the cooldown check always uses the + // most recent purchase ledger. + let last_buy_ledger_key = constants::storage::last_buy_ledger(&creator, &buyer); + env.storage() + .persistent() + .set(&last_buy_ledger_key, &env.ledger().sequence()); + extend_key_ttl_to_full_window(&env, &last_buy_ledger_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. @@ -4626,6 +4707,41 @@ impl CreatorKeysContract { .get(&constants::storage::holder_cap_bps(&creator)) } + /// Sets the per-wallet buy cooldown for a creator's keys. + /// + /// Only the key creator may call this. `cooldown_ledgers` must be in + /// the range `0..=720` (≈ 1 hour at 5 s/ledger); values above 720 return + /// [`CooldownError::CooldownTooLong`]. A value of `0` disables the + /// cooldown (the default when no cooldown has been configured). + /// + /// Once configured, `buy_key` rejects consecutive purchases by the same + /// wallet within the cooldown window with [`CooldownError::CooldownActive`] + /// and emits a [`events::COOLDOWN_BLOCKED_EVENT_NAME`] event. + pub fn set_buy_cooldown( + env: Env, + creator: Address, + cooldown_ledgers: u32, + ) -> Result<(), CooldownError> { + creator.require_auth(); + if cooldown_ledgers > MAX_BUY_COOLDOWN_LEDGERS { + return Err(CooldownError::CooldownTooLong); + } + let key = constants::storage::buy_cooldown(&creator); + env.storage().persistent().set(&key, &cooldown_ledgers); + extend_key_ttl_to_full_window(&env, &key); + Ok(()) + } + + /// Read-only view: returns the configured buy cooldown in ledgers for a creator. + /// + /// Returns `0` (no cooldown) when none has been configured. + pub fn get_buy_cooldown(env: Env, creator: Address) -> u32 { + env.storage() + .persistent() + .get(&constants::storage::buy_cooldown(&creator)) + .unwrap_or(0) + } + /// Sets the launch penalty basis points for a creator's keys. /// /// Only callable by the key creator. `penalty_bps` must be in 0..=2000. diff --git a/creator-keys/tests/buy_cooldown.rs b/creator-keys/tests/buy_cooldown.rs new file mode 100644 index 00000000..feacd6ee --- /dev/null +++ b/creator-keys/tests/buy_cooldown.rs @@ -0,0 +1,291 @@ +//! Integration tests for the per-wallet buy cooldown feature. +//! +//! Once a creator calls `set_buy_cooldown(cooldown_ledgers)`, `buy_key` rejects +//! a second purchase by the same wallet within that window with +//! `CooldownError::CooldownActive` and emits a `cooldown_blocked` event. +//! Buys after the cooldown has elapsed succeed normally. + +mod contract_test_env; + +use contract_test_env::{ + register_creator_keys, register_test_creator, set_key_price_for_tests, set_ledger_sequence, + test_env_with_auths, +}; +use creator_keys::events::{self, COOLDOWN_BLOCKED_EVENT_NAME}; +use creator_keys::{CooldownError, MAX_BUY_COOLDOWN_LEDGERS}; +use soroban_sdk::{ + testutils::{Address as _, Events, Ledger}, + Address, Env, IntoVal, Symbol, +}; + +const KEY_PRICE: i128 = 100; +const COOLDOWN: u32 = 10; +const BASE_LEDGER: u32 = 100; + +// ── helpers ────────────────────────────────────────────────────────────────── + +struct Setup<'a> { + client: creator_keys::CreatorKeysContractClient<'a>, + creator: Address, +} + +/// Build a contract + registered creator, set key price, set a cooldown, and +/// advance to a deterministic starting ledger. +fn setup_with_cooldown(env: &Env, cooldown: u32) -> 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_ledger_sequence(env, BASE_LEDGER); + client.set_buy_cooldown(&creator, &cooldown); + Setup { client, creator } +} + +/// Collect all `CooldownBlockedEvent` payloads emitted during the most recent +/// contract invocation. +fn cooldown_blocked_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_BLOCKED_EVENT_NAME { + found.push_back(data.into_val(env)); + } + } + found +} + +// ── acceptance criteria ─────────────────────────────────────────────────────── + +/// AC: Second buy within cooldown period panics with CooldownActive. +#[test] +fn test_second_buy_within_cooldown_is_rejected() { + let env = test_env_with_auths(); + let s = setup_with_cooldown(&env, COOLDOWN); + + let buyer = Address::generate(&env); + // First buy succeeds (no prior ledger recorded). + s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + + // Advance by fewer than COOLDOWN ledgers – still inside the window. + set_ledger_sequence(&env, BASE_LEDGER + COOLDOWN - 1); + + let result = s.client.try_buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + assert_eq!( + result, + Err(Ok(CooldownError::CooldownActive)), + "buy within cooldown must return CooldownActive" + ); + // Supply must be unchanged after the rejection. + assert_eq!(s.client.get_total_key_supply(&s.creator), 1); + assert_eq!(s.client.get_key_balance(&s.creator, &buyer), 1); +} + +/// AC: Buy after cooldown has elapsed succeeds normally. +#[test] +fn test_buy_after_cooldown_elapsed_succeeds() { + let env = test_env_with_auths(); + let s = setup_with_cooldown(&env, COOLDOWN); + + let buyer = Address::generate(&env); + s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + + // Advance exactly COOLDOWN ledgers – boundary is inclusive (elapsed == cooldown_ledgers is ok). + set_ledger_sequence(&env, BASE_LEDGER + COOLDOWN); + let supply = s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + assert_eq!(supply, 2, "buy at cooldown boundary must succeed"); + assert_eq!(s.client.get_key_balance(&s.creator, &buyer), 2); +} + +/// AC: cooldown_blocked event emitted with correct ledgers_remaining. +#[test] +fn test_cooldown_blocked_event_has_correct_ledgers_remaining() { + let env = test_env_with_auths(); + let s = setup_with_cooldown(&env, COOLDOWN); + + let buyer = Address::generate(&env); + s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + + // Advance by 3 ledgers → 7 ledgers remaining in the cooldown. + set_ledger_sequence(&env, BASE_LEDGER + 3); + + let _ = s.client.try_buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + + let found = cooldown_blocked_events(&env); + assert_eq!(found.len(), 1, "exactly one cooldown_blocked event per rejection"); + + let ev = found.get(0).unwrap(); + assert_eq!(ev.wallet, buyer, "event.wallet must be the blocked buyer"); + assert_eq!( + ev.creator_id, s.creator, + "event.creator_id must match the creator" + ); + assert_eq!( + ev.ledgers_remaining, + COOLDOWN - 3, + "ledgers_remaining must equal cooldown_ledgers minus elapsed" + ); +} + +/// AC: cooldown_ledgers above 720 panics with CooldownTooLong. +#[test] +fn test_set_buy_cooldown_above_max_is_rejected() { + 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 allowed. + client.set_buy_cooldown(&creator, &MAX_BUY_COOLDOWN_LEDGERS); + assert_eq!(client.get_buy_cooldown(&creator), MAX_BUY_COOLDOWN_LEDGERS); + + // One above the limit must be rejected. + let result = client.try_set_buy_cooldown(&creator, &(MAX_BUY_COOLDOWN_LEDGERS + 1)); + assert_eq!( + result, + Err(Ok(CooldownError::CooldownTooLong)), + "cooldown above 720 ledgers must return CooldownTooLong" + ); + // The stored value must still be the previous valid setting. + assert_eq!(client.get_buy_cooldown(&creator), MAX_BUY_COOLDOWN_LEDGERS); +} + +/// AC: Non-creator set_buy_cooldown panics with Unauthorized (Soroban auth failure). +/// The test verifies that mock_all_auths is required: calling from a different +/// address without mocked auth causes a host-level panic. +#[test] +fn test_non_creator_cannot_set_cooldown() { + // Use a real-auth environment (no mock_all_auths) so that require_auth + // actually enforces the caller check. + let env = Env::default(); + // Only mock auth for setup calls, not for the unauthorized attempt itself. + env.mock_all_auths(); + + let (client, _) = register_creator_keys(&env); + set_key_price_for_tests(&env, &client, KEY_PRICE); + let creator = register_test_creator(&env, &client, "carol"); + let impostor = Address::generate(&env); + + // Attempt by impostor is rejected at the host auth level. + // `try_set_buy_cooldown` returns `Err(Err(InvokeError::Contract))` on auth failure. + let result = client.try_set_buy_cooldown(&impostor, &5u32); + assert!( + result.is_err(), + "non-creator must not be able to set the buy cooldown" + ); +} + +/// AC: First buy by any wallet always succeeds even when a cooldown is configured +/// (no prior last_buy_ledger recorded means no cooldown is enforced). +#[test] +fn test_first_buy_always_succeeds_with_cooldown_configured() { + let env = test_env_with_auths(); + let s = setup_with_cooldown(&env, COOLDOWN); + + // Three distinct wallets each make their first buy — none should be blocked. + for i in 0..3 { + let buyer = Address::generate(&env); + let supply = s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + assert_eq!(supply, i + 1, "first buy for wallet {i} must succeed"); + } +} + +/// AC: Cooldown is per-wallet; one wallet being blocked does not affect others. +#[test] +fn test_cooldown_is_independent_per_wallet() { + let env = test_env_with_auths(); + let s = setup_with_cooldown(&env, COOLDOWN); + + let buyer_a = Address::generate(&env); + let buyer_b = Address::generate(&env); + + // Both wallets make their first buy. + s.client.buy_key(&s.creator, &buyer_a, &KEY_PRICE, &None); + s.client.buy_key(&s.creator, &buyer_b, &KEY_PRICE, &None); + + // Advance by only 3 ledgers (inside cooldown for both). + set_ledger_sequence(&env, BASE_LEDGER + 3); + + // buyer_a is blocked. + let result_a = s.client.try_buy_key(&s.creator, &buyer_a, &KEY_PRICE, &None); + assert_eq!(result_a, Err(Ok(CooldownError::CooldownActive))); + + // buyer_b is also blocked independently. + let result_b = s.client.try_buy_key(&s.creator, &buyer_b, &KEY_PRICE, &None); + assert_eq!(result_b, Err(Ok(CooldownError::CooldownActive))); +} + +/// AC: Setting cooldown to 0 disables it; consecutive buys succeed freely. +#[test] +fn test_zero_cooldown_disables_restriction() { + let env = test_env_with_auths(); + let s = setup_with_cooldown(&env, 0); + + let buyer = Address::generate(&env); + s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + // Immediately buy again in the same ledger — should not be blocked. + let supply = s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + assert_eq!(supply, 2, "zero cooldown must allow back-to-back buys"); +} + +/// AC: Cooldown tracks each buy so a successful second buy refreshes the window. +#[test] +fn test_last_buy_ledger_refreshes_on_each_buy() { + let env = test_env_with_auths(); + let s = setup_with_cooldown(&env, COOLDOWN); + + let buyer = Address::generate(&env); + + // First buy at BASE_LEDGER. + s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + + // Second buy at BASE_LEDGER + COOLDOWN (allowed). + set_ledger_sequence(&env, BASE_LEDGER + COOLDOWN); + s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + + // Attempting a third buy at BASE_LEDGER + COOLDOWN + COOLDOWN - 1 should be blocked + // because the cooldown window was reset by the second buy. + set_ledger_sequence(&env, BASE_LEDGER + COOLDOWN + COOLDOWN - 1); + let result = s.client.try_buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + assert_eq!( + result, + Err(Ok(CooldownError::CooldownActive)), + "cooldown window must reset after each successful buy" + ); + + // Exactly at the new boundary succeeds. + set_ledger_sequence(&env, BASE_LEDGER + COOLDOWN + COOLDOWN); + let supply = s.client.buy_key(&s.creator, &buyer, &KEY_PRICE, &None); + assert_eq!(supply, 3); +} + +/// AC: Cooldown is per-creator; two creators can have independent cooldowns. +#[test] +fn test_cooldown_is_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_ledger_sequence(&env, BASE_LEDGER); + + let creator_a = register_test_creator(&env, &client, "alice"); + let creator_b = register_test_creator(&env, &client, "bob"); + + // Creator A sets a 10-ledger cooldown; creator B sets no cooldown. + client.set_buy_cooldown(&creator_a, &COOLDOWN); + client.set_buy_cooldown(&creator_b, &0); + + let buyer = Address::generate(&env); + + client.buy_key(&creator_a, &buyer, &KEY_PRICE, &None); + client.buy_key(&creator_b, &buyer, &KEY_PRICE, &None); + + // Inside creator_a's cooldown window. + set_ledger_sequence(&env, BASE_LEDGER + 3); + + // Blocked for creator_a. + assert_eq!( + client.try_buy_key(&creator_a, &buyer, &KEY_PRICE, &None), + Err(Ok(CooldownError::CooldownActive)) + ); + // Unrestricted for creator_b. + let supply_b = client.buy_key(&creator_b, &buyer, &KEY_PRICE, &None); + assert_eq!(supply_b, 2); +} diff --git a/docs/authorization-model.md b/docs/authorization-model.md index 13da45e6..db8f2c07 100644 --- a/docs/authorization-model.md +++ b/docs/authorization-model.md @@ -69,6 +69,17 @@ Registers a new creator profile on-chain. - **Constraints**: Handle must be 3-32 characters, lowercase alphanumeric or underscores. - **Fails**: `AlreadyRegistered` if a profile already exists for `creator`. +### `set_buy_cooldown(creator: Address, cooldown_ledgers: u32) -> Result<(), CooldownError>` + +Sets the per-wallet buy cooldown for a creator's keys. + +- **Auth**: `creator.require_auth()` — only the creator whose keys are being gated may configure this. +- **Constraints**: `cooldown_ledgers` must be in `0..=720` (≈ 1 hour at 5 s/ledger). Values above 720 return `CooldownError::CooldownTooLong`. +- **Default**: When not configured (or set to 0), no cooldown is enforced. +- **Effect**: Once set, `buy_key` rejects a second purchase by the same wallet within the cooldown window with `CooldownError::CooldownActive` and emits a `cooldown_blocked` event. + +--- + ### `buyback(creator: Address, caller: Address, amount: u32, payment: i128, max_total_cost: Option) -> Result` Creator-authorized buyback that burns keys from the creator's own held balance. @@ -181,6 +192,7 @@ These functions require no authorization. Anyone can call them. They do not muta | `get_treasury_address()` | `Option
` | | `get_protocol_admin()` | `Option
` | | `get_protocol_fee_recipient()` | `Option
` | +| `get_buy_cooldown(creator: Address)` | `u32` | --- @@ -189,6 +201,7 @@ These functions require no authorization. Anyone can call them. They do not muta | Function | Access level | Mutates state | |---|---|---| | `register_creator` | Creator | Yes | +| `set_buy_cooldown` | Creator | Yes | | `buyback` | Creator | Yes | | `buy_key` | Key holder (buyer) | Yes | | `sell_key` | Key holder (seller) | Yes | diff --git a/docs/contract-event-conventions.md b/docs/contract-event-conventions.md index a06aad1b..104ca345 100644 --- a/docs/contract-event-conventions.md +++ b/docs/contract-event-conventions.md @@ -50,6 +50,7 @@ The following table summarizes the events currently implemented in the `creator- | `register` | `(Symbol("register"), creator)` | `creator`, `handle`, `supply`, `holder_count`, `creator_bps`, `protocol_bps` | `struct CreatorRegisteredEvent` | | `buy` | `(Symbol("buy"), creator, buyer)` | `supply`, `payment` | `tuple (u32, i128)` | | `sell` | `(Symbol("sell"), creator, seller)` | `supply` | `tuple (u32)` | +| `cd_blk` | `(Symbol("cd_blk"), creator, wallet)` | `wallet`, `creator_id`, `ledgers_remaining` | `struct CooldownBlockedEvent` | ## Data Type Inconsistency While the general preference is for `struct` payloads (like `register`), some high-frequency events like `buy` and `sell` use `tuples` for gas efficiency. Indexers should check the `contracttype` encoding to distinguish between map-based structs and array-based tuples. From 89e1f56dd8300a2227a275e7e484ea5bd7cbe211 Mon Sep 17 00:00:00 2001 From: ELKorede <157540453+ELKorede@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:48:15 +0000 Subject: [PATCH 2/4] fix: resolve type mismatch in buy cooldown CI failure - Add ContractError::CooldownActive = 36 so buy_key can return it - Replace panic_with_error(CooldownError::CooldownActive) with return Err(ContractError::CooldownActive) in buy_key, matching the function's declared return type - Add CooldownError::NotRegistered = 3 and a registration check to set_buy_cooldown so non-registered callers are rejected - Update try_buy_key assertions in buy_cooldown.rs to use ContractError::CooldownActive instead of CooldownError::CooldownActive --- creator-keys/src/lib.rs | 7 ++++++- creator-keys/tests/buy_cooldown.rs | 12 ++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 7b1f6c61..1c195dab 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -64,6 +64,7 @@ pub enum ContractError { AirdropRecipientLimitExceeded = 33, InvalidReferrer = 34, WalletCapExceeded = 35, + CooldownActive = 36, WalletBlacklisted = 37, SchemaVersionTooOld = 38, SchemaVersionUnsupported = 39, @@ -171,6 +172,8 @@ pub enum CooldownError { CooldownActive = 1, /// The requested cooldown exceeds the maximum of 720 ledgers (~1 hour). CooldownTooLong = 2, + /// The creator address is not registered. + NotRegistered = 3, } pub mod fee { @@ -2896,7 +2899,7 @@ impl CreatorKeysContract { ledgers_remaining, }, ); - env.panic_with_error(CooldownError::CooldownActive); + return Err(ContractError::CooldownActive); } } } @@ -5255,6 +5258,8 @@ impl CreatorKeysContract { cooldown_ledgers: u32, ) -> Result<(), CooldownError> { creator.require_auth(); + read_registered_creator_profile(&env, &creator) + .map_err(|_| CooldownError::NotRegistered)?; if cooldown_ledgers > MAX_BUY_COOLDOWN_LEDGERS { return Err(CooldownError::CooldownTooLong); } diff --git a/creator-keys/tests/buy_cooldown.rs b/creator-keys/tests/buy_cooldown.rs index feacd6ee..7475d934 100644 --- a/creator-keys/tests/buy_cooldown.rs +++ b/creator-keys/tests/buy_cooldown.rs @@ -12,7 +12,7 @@ use contract_test_env::{ test_env_with_auths, }; use creator_keys::events::{self, COOLDOWN_BLOCKED_EVENT_NAME}; -use creator_keys::{CooldownError, MAX_BUY_COOLDOWN_LEDGERS}; +use creator_keys::{ContractError, CooldownError, MAX_BUY_COOLDOWN_LEDGERS}; use soroban_sdk::{ testutils::{Address as _, Events, Ledger}, Address, Env, IntoVal, Symbol, @@ -71,7 +71,7 @@ fn test_second_buy_within_cooldown_is_rejected() { let result = s.client.try_buy_key(&s.creator, &buyer, &KEY_PRICE, &None); assert_eq!( result, - Err(Ok(CooldownError::CooldownActive)), + Err(Ok(ContractError::CooldownActive)), "buy within cooldown must return CooldownActive" ); // Supply must be unchanged after the rejection. @@ -206,11 +206,11 @@ fn test_cooldown_is_independent_per_wallet() { // buyer_a is blocked. let result_a = s.client.try_buy_key(&s.creator, &buyer_a, &KEY_PRICE, &None); - assert_eq!(result_a, Err(Ok(CooldownError::CooldownActive))); + assert_eq!(result_a, Err(Ok(ContractError::CooldownActive))); // buyer_b is also blocked independently. let result_b = s.client.try_buy_key(&s.creator, &buyer_b, &KEY_PRICE, &None); - assert_eq!(result_b, Err(Ok(CooldownError::CooldownActive))); + assert_eq!(result_b, Err(Ok(ContractError::CooldownActive))); } /// AC: Setting cooldown to 0 disables it; consecutive buys succeed freely. @@ -247,7 +247,7 @@ fn test_last_buy_ledger_refreshes_on_each_buy() { let result = s.client.try_buy_key(&s.creator, &buyer, &KEY_PRICE, &None); assert_eq!( result, - Err(Ok(CooldownError::CooldownActive)), + Err(Ok(ContractError::CooldownActive)), "cooldown window must reset after each successful buy" ); @@ -283,7 +283,7 @@ fn test_cooldown_is_independent_per_creator() { // Blocked for creator_a. assert_eq!( client.try_buy_key(&creator_a, &buyer, &KEY_PRICE, &None), - Err(Ok(CooldownError::CooldownActive)) + Err(Ok(ContractError::CooldownActive)) ); // Unrestricted for creator_b. let supply_b = client.buy_key(&creator_b, &buyer, &KEY_PRICE, &None); From 8cf19527836ecfb971d7b3b24b7f68a86dfd80ee Mon Sep 17 00:00:00 2001 From: ELKorede <157540453+ELKorede@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:37:01 +0000 Subject: [PATCH 3/4] fix: add BuyCooldown DataKey, storage fn, remove duplicate last_buy_ledger write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BuyCooldown(Address) variant to DataKey enum (was missing, caused compile error in constants::storage::buy_cooldown calls) - Add constants::storage::buy_cooldown() accessor fn in storage module - Remove duplicate last_buy_ledger_key write in buy_key; the flash-loan guard write already covers the cooldown window reset — second identical binding shadowed the first and would fail Clippy's variable shadow lint --- creator-keys/src/lib.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 1c195dab..8181cba0 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -609,6 +609,10 @@ pub mod constants { pub fn total_staked(creator: &Address) -> DataKey { DataKey::TotalStaked(creator.clone()) } + + pub fn buy_cooldown(creator: &Address) -> DataKey { + DataKey::BuyCooldown(creator.clone()) + } } fn creator_key(creator: &Address) -> DataKey { @@ -1040,6 +1044,9 @@ pub enum DataKey { StakeUnlockLedger(Address, Address), /// Total keys currently staked for a creator across all holders. TotalStaked(Address), + /// Per-creator buy cooldown in ledgers. A value of `0` (or absent) means + /// no cooldown is configured. Set via `set_buy_cooldown`. + BuyCooldown(Address), } /// Time-locked key allocation for creator self-vesting. @@ -2945,7 +2952,9 @@ impl CreatorKeysContract { extend_key_ttl_to_full_window(&env, &balance_key); // Flash-loan guard (issue #781): record this buy's ledger so sell_key can - // reject a same-ledger sell of the position just bought. + // reject a same-ledger sell of the position just bought. Also used by the + // per-wallet cooldown guard so the cooldown check always uses the most + // recent purchase ledger. let last_buy_ledger_key = constants::storage::last_buy_ledger(&creator, &buyer); env.storage() .persistent() @@ -2960,15 +2969,6 @@ impl CreatorKeysContract { .set(&last_buy_key, &env.ledger().timestamp()); extend_key_ttl_to_full_window(&env, &last_buy_key); - // Record the ledger sequence of this buy for the per-wallet cooldown guard. - // Written on every successful buy so the cooldown check always uses the - // most recent purchase ledger. - let last_buy_ledger_key = constants::storage::last_buy_ledger(&creator, &buyer); - env.storage() - .persistent() - .set(&last_buy_ledger_key, &env.ledger().sequence()); - extend_key_ttl_to_full_window(&env, &last_buy_ledger_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. From 47b8efcf4ea8f8d8939a4b15505af6ce42f13674 Mon Sep 17 00:00:00 2001 From: ELKorede <157540453+ELKorede@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:39:41 +0000 Subject: [PATCH 4/4] style: fix rustfmt line-length violations in buy_cooldown.rs --- creator-keys/tests/buy_cooldown.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/creator-keys/tests/buy_cooldown.rs b/creator-keys/tests/buy_cooldown.rs index 7475d934..aa964c6a 100644 --- a/creator-keys/tests/buy_cooldown.rs +++ b/creator-keys/tests/buy_cooldown.rs @@ -110,7 +110,11 @@ fn test_cooldown_blocked_event_has_correct_ledgers_remaining() { let _ = s.client.try_buy_key(&s.creator, &buyer, &KEY_PRICE, &None); let found = cooldown_blocked_events(&env); - assert_eq!(found.len(), 1, "exactly one cooldown_blocked event per rejection"); + assert_eq!( + found.len(), + 1, + "exactly one cooldown_blocked event per rejection" + ); let ev = found.get(0).unwrap(); assert_eq!(ev.wallet, buyer, "event.wallet must be the blocked buyer"); @@ -205,11 +209,15 @@ fn test_cooldown_is_independent_per_wallet() { set_ledger_sequence(&env, BASE_LEDGER + 3); // buyer_a is blocked. - let result_a = s.client.try_buy_key(&s.creator, &buyer_a, &KEY_PRICE, &None); + let result_a = s + .client + .try_buy_key(&s.creator, &buyer_a, &KEY_PRICE, &None); assert_eq!(result_a, Err(Ok(ContractError::CooldownActive))); // buyer_b is also blocked independently. - let result_b = s.client.try_buy_key(&s.creator, &buyer_b, &KEY_PRICE, &None); + let result_b = s + .client + .try_buy_key(&s.creator, &buyer_b, &KEY_PRICE, &None); assert_eq!(result_b, Err(Ok(ContractError::CooldownActive))); }