Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions creator-keys/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,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;

Expand Down Expand Up @@ -1290,6 +1293,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");

Expand Down
106 changes: 105 additions & 1 deletion creator-keys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ pub enum ContractError {
AirdropRecipientLimitExceeded = 33,
InvalidReferrer = 34,
WalletCapExceeded = 35,
CooldownActive = 36,
WalletBlacklisted = 37,
SchemaVersionTooOld = 38,
SchemaVersionUnsupported = 39,
Expand Down Expand Up @@ -157,6 +158,24 @@ pub enum FeatureError {
NoAuctionConfigured = 7,
}

/// 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,
/// The creator address is not registered.
NotRegistered = 3,
}

pub mod fee {
use crate::ContractError;

Expand Down Expand Up @@ -590,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 {
Expand Down Expand Up @@ -884,6 +907,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 {
Expand Down Expand Up @@ -1014,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.
Expand Down Expand Up @@ -2846,6 +2879,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::<DataKey, u32>(&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,
},
);
return Err(ContractError::CooldownActive);
}
}
}

// Settle dividends before balance changes so earnings are captured at old balance.
settle_holder_dividends(&env, &creator, &buyer, current_balance)?;

Expand Down Expand Up @@ -2887,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()
Expand Down Expand Up @@ -5175,6 +5242,43 @@ 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();
read_registered_creator_profile(&env, &creator)
.map_err(|_| CooldownError::NotRegistered)?;
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.
Expand Down
Loading
Loading