diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index 77ce0a92..c530e9b1 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -1269,62 +1269,70 @@ pub fn royalty_updated_topics(creator: &Address) -> (Symbol, Address) { (ROYALTY_UPDATED_EVENT_NAME, creator.clone()) } -/// Event name for the protocol trade fee collected on a buy or sell. -pub const FEE_COLLECTED_EVENT_NAME: Symbol = symbol_short!("fee_coll"); +// --- Co-creator removal and auction events --- -/// Event name for a sell rejected by the anti-flash-trade lockup window. -pub const LOCKUP_BLOCKED_EVENT_NAME: Symbol = symbol_short!("lck_blk"); +/// Event name for co-creator removal. +pub const CO_CREATOR_REMOVED_EVENT_NAME: Symbol = symbol_short!("co_rem"); + +/// Event name for auction configuration. +pub const AUCTION_CONFIGURED_EVENT_NAME: Symbol = symbol_short!("auc_cfg"); + +/// Event name for auction cancellation. +pub const AUCTION_CANCELLED_EVENT_NAME: Symbol = symbol_short!("auc_can"); + +/// Event name for a purchase made during a creator's auction phase. +pub const AUCTION_PURCHASE_EVENT_NAME: Symbol = symbol_short!("auc_buy"); -/// Stable fee collection event payload for downstream indexers. -/// -/// Event shape: -/// - topics: `(FEE_COLLECTED_EVENT_NAME, treasury)` -/// - data: `FeeCollectedEvent` -/// -/// Emitted on every buy and sell once the protocol trade fee is configured, -/// carrying the deducted amount and the treasury address that received it. #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] -pub struct FeeCollectedEvent { - /// Treasury address that received the fee. - pub treasury: Address, - /// Fee amount deducted from the trade. - pub amount: i128, - /// Ledger sequence number at the time of the trade. +pub struct CoCreatorRemovedEvent { + pub creator_id: Address, + pub co_creator: Address, pub ledger: u32, } -/// Shared fee collected event topics tuple. -pub fn fee_collected_topics(treasury: &Address) -> (Symbol, Address) { - (FEE_COLLECTED_EVENT_NAME, treasury.clone()) +pub fn co_creator_removed_topics(creator: &Address) -> (Symbol, Address) { + (CO_CREATOR_REMOVED_EVENT_NAME, creator.clone()) } -/// Stable lockup-blocked event payload for downstream indexers. -/// -/// Event shape: -/// - topics: `(LOCKUP_BLOCKED_EVENT_NAME, creator_id, seller)` -/// - data: `LockupBlockedEvent` -/// -/// Emitted when a sell is rejected because the seller's most recent buy for -/// this creator falls inside the configured lockup window. #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] -pub struct LockupBlockedEvent { - /// Creator whose keys the seller attempted to sell. +pub struct AuctionConfiguredEvent { pub creator_id: Address, - /// Seller whose sale was rejected. - pub seller: Address, - /// Ledger timestamp of the seller's most recent buy. - pub last_buy_timestamp: u64, - /// Timestamp at which the lockup expires (exclusive). - pub unlock_at: u64, - /// Ledger timestamp at rejection. - pub current_timestamp: u64, + pub auction_price: i128, + pub auction_supply: u32, } -/// Shared lockup blocked event topics tuple. -pub fn lockup_blocked_topics(creator: &Address, seller: &Address) -> (Symbol, Address, Address) { - (LOCKUP_BLOCKED_EVENT_NAME, creator.clone(), seller.clone()) +pub fn auction_configured_topics(creator: &Address) -> (Symbol, Address) { + (AUCTION_CONFIGURED_EVENT_NAME, creator.clone()) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct AuctionCancelledEvent { + pub creator_id: Address, + pub auction_price: i128, + pub auction_supply: u32, +} + +pub fn auction_cancelled_topics(creator: &Address) -> (Symbol, Address) { + (AUCTION_CANCELLED_EVENT_NAME, creator.clone()) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct AuctionPurchaseEvent { + pub buyer: Address, + pub creator_id: Address, + pub quantity: u32, + pub price_paid: i128, + pub new_supply: u32, + pub auction_sold: u32, + pub ledger: u32, +} + +pub fn auction_purchase_topics(creator: &Address, buyer: &Address) -> (Symbol, Address, Address) { + (AUCTION_PURCHASE_EVENT_NAME, creator.clone(), buyer.clone()) } /// Event name for a new staking position created via `stake_keys_locked`. @@ -1360,7 +1368,11 @@ pub struct StakeEvent { } /// Shared stake event topics tuple. -pub fn stake_topics(creator: &Address, holder: &Address, stake_id: u32) -> (Symbol, Address, Address, u32) { +pub fn stake_topics( + creator: &Address, + holder: &Address, + stake_id: u32, +) -> (Symbol, Address, Address, u32) { (STAKE_EVENT_NAME, creator.clone(), holder.clone(), stake_id) } @@ -1390,7 +1402,12 @@ pub fn stake_extended_topics( holder: &Address, stake_id: u32, ) -> (Symbol, Address, Address, u32) { - (STAKE_EXTENDED_EVENT_NAME, creator.clone(), holder.clone(), stake_id) + ( + STAKE_EXTENDED_EVENT_NAME, + creator.clone(), + holder.clone(), + stake_id, + ) } /// Stable early-unstake event payload for downstream indexers. @@ -1423,7 +1440,12 @@ pub fn early_unstake_topics( holder: &Address, stake_id: u32, ) -> (Symbol, Address, Address, u32) { - (EARLY_UNSTAKE_EVENT_NAME, creator.clone(), holder.clone(), stake_id) + ( + EARLY_UNSTAKE_EVENT_NAME, + creator.clone(), + holder.clone(), + stake_id, + ) } /// Stable stake-reward-claim event payload for downstream indexers. @@ -1456,10 +1478,14 @@ pub fn stake_reward_claimed_topics( holder: &Address, stake_id: u32, ) -> (Symbol, Address, Address, u32) { - (STAKE_REWARD_CLAIMED_EVENT_NAME, creator.clone(), holder.clone(), stake_id) + ( + STAKE_REWARD_CLAIMED_EVENT_NAME, + creator.clone(), + holder.clone(), + stake_id, + ) } - // ============================================================================ // Launch Penalty (#798) // ============================================================================ @@ -1488,7 +1514,11 @@ pub fn launch_penalty_applied_topics( creator: &Address, seller: &Address, ) -> (Symbol, Address, Address) { - (LAUNCH_PENALTY_APPLIED_EVENT_NAME, creator.clone(), seller.clone()) + ( + LAUNCH_PENALTY_APPLIED_EVENT_NAME, + creator.clone(), + seller.clone(), + ) } /// Event name for set_launch_penalty. diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 8d4b9d9e..87f62f59 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -76,9 +76,9 @@ pub enum ContractError { VestingNotFound = 46, NotWhitelisted = 49, CircuitBreakerTriggered = 50, - MaxHoldingExceeded = 51, - LockupPeriodActive = 52, - InvalidHolderCap = 53, + GlobalTradingHalted = 51, + FreezeQuantityExceedsBalance = 52, + KeysStaked = 53, } /// Errors raised by the staking lifecycle entrypoints @@ -102,7 +102,7 @@ pub enum StakingError { /// The position is no longer locked, so `early_unstake` cannot be used. PositionNotLocked = 5, /// The position is still locked, so `claim_stake_reward` cannot be used yet. - PositionLocked = 6, + StakeLockActive = 6, /// The creator is not registered. NotRegistered = 7, /// The contract is paused. @@ -454,6 +454,10 @@ pub mod constants { DataKey::LaunchPenaltyBps(creator.clone()) } + pub fn auction_config(creator: &Address) -> DataKey { + DataKey::AuctionConfig(creator.clone()) + } + pub fn next_stake_id(creator: &Address, holder: &Address) -> StakingKey { StakingKey::NextStakeId(creator.clone(), holder.clone()) } @@ -546,14 +550,6 @@ pub mod constants { DataKey::VestingClaimed(creator.clone(), beneficiary.clone()) } - pub fn holder_cap_bps(creator: &Address) -> DataKey { - DataKey::HolderCapBps(creator.clone()) - } - - pub fn last_buy_timestamp(creator: &Address, holder: &Address) -> DataKey { - DataKey::LastBuyTimestamp(creator.clone(), holder.clone()) - } - pub fn quorum_bps(creator: &Address) -> DataKey { DataKey::QuorumBps(creator.clone()) } @@ -818,10 +814,6 @@ pub const MAX_AUCTION_SUPPLY: u32 = 10_000; /// at 5s per ledger). pub const STAKE_LOCK_LEDGERS: u32 = 518_400; -/// Share of each protocol fee collection routed into a creator's staking -/// rewards pool (10%), on top of the existing treasury/recipient split. -pub const STAKING_REWARD_SHARE_BPS: u32 = 1_000; - /// Launch penalty window in ledgers (~7 days at 5s per ledger). pub const LAUNCH_PENALTY_WINDOW_LEDGERS: u32 = 120_960; @@ -939,6 +931,89 @@ pub enum DataKey { GlobalPauseVote(Address), GlobalResumeVote(Address), SelfFrozenBalance(Address, Address), + /// Configured pre-launch auction state for a creator. + AuctionConfig(Address), + /// Per-creator staking position. Keyed `(creator, holder, stake_id)`. + StakePosition(Address, Address, u32), + /// Per-creator staking rewards pool and cross-holder staked-key total, + /// funded by a share of protocol trade fees. + StakingRewardsPool(Address), + /// Ledger sequence when the first key was bought for a creator (launch date). + CreatedAtLedger(Address), + /// Custom launch penalty basis points for a creator (0 = use default). + LaunchPenaltyBps(Address), +} + +/// Internal staking account keys that are not part of the public data-key ABI. +/// +/// Used to keep [`DataKey`] within Soroban's 50-variant `#[contracttype]` cap; +/// `NextStakeId` is keyed per `(creator, holder)` pair. +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub enum StakingKey { + /// Next sequential stake id for a `(creator, holder)` pair -> `u32`. + NextStakeId(Address, Address), +} + +/// A single locked staking position held by a holder. +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub struct StakePosition { + /// Sequential id scoped to the `(creator, holder)` pair. + pub stake_id: u32, + /// Number of keys locked in this position. + pub amount: u32, + /// Ledger sequence at which the position matures and can be claimed. + pub unlock_ledger: u32, +} + +/// Per-creator staking rewards accounting. +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub struct StakingRewardsState { + /// Accumulated reward pool, funded from a share of protocol trade fees. + pub pool: i128, + /// Total keys currently staked for the creator across all holders. + pub total_staked: u32, +} + +/// Result of [`CreatorKeysContract::early_unstake`]. +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub struct StakeExit { + /// Id of the closed position. + pub stake_id: u32, + /// Keys released back to the holder's liquid balance. + pub amount: u32, + /// Pro-rata reward entitlement removed from the pool. + pub forgone_reward: i128, + /// Penalty retained in the pool (added back after the forgone reward is removed). + pub penalty: i128, +} + +/// Result of [`CreatorKeysContract::claim_stake_reward`]. +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub struct StakeRewardClaim { + /// Id of the closed position. + pub stake_id: u32, + /// Keys released back to the holder's liquid balance. + pub amount: u32, + /// Reward paid out to the staker from the pool. + pub reward: i128, +} + +/// Pre-launch auction configuration for a creator. +/// +/// While an auction is configured, keys sell at the fixed `auction_price` +/// instead of the bonding curve price. The contract transitions to the +/// bonding curve automatically once the auction supply is exhausted. +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub struct AuctionConfig { + pub auction_price: i128, + pub auction_supply: u32, + pub auction_sold: u32, } /// Time-locked key allocation for creator self-vesting. @@ -1257,7 +1332,9 @@ fn available_holder_balance(env: &Env, key_id: &Address, wallet: &Address) -> u3 .persistent() .get(&constants::storage::staked_balance(key_id, wallet)) .unwrap_or(0u32); - total.saturating_sub(staked).saturating_sub(read_self_frozen_balance(env, key_id, wallet)) + total + .saturating_sub(staked) + .saturating_sub(read_self_frozen_balance(env, key_id, wallet)) } /// Reads a creator's current key supply from persistent storage. @@ -1766,14 +1843,14 @@ fn credit_staking_rewards_pool( return Ok(()); } let pool_key = constants::storage::staking_rewards_pool(creator); - let mut state: StakingRewardsState = env - .storage() - .persistent() - .get(&pool_key) - .unwrap_or(StakingRewardsState { - pool: 0, - total_staked: 0, - }); + let mut state: StakingRewardsState = + env.storage() + .persistent() + .get(&pool_key) + .unwrap_or(StakingRewardsState { + pool: 0, + total_staked: 0, + }); state.pool = state .pool .checked_add(share) @@ -1809,7 +1886,9 @@ fn sub_staked_balance(env: &Env, creator: &Address, holder: &Address, amount: u3 if new_staked == 0 { env.storage().persistent().remove(&staked_balance_key); } else { - env.storage().persistent().set(&staked_balance_key, &new_staked); + env.storage() + .persistent() + .set(&staked_balance_key, &new_staked); extend_key_ttl_to_full_window(env, &staked_balance_key); } } @@ -1825,49 +1904,6 @@ fn read_lockup_duration_secs(env: &Env) -> Option { .get(&constants::storage::LOCKUP_DURATION_SECS) } -/// Reads the accumulated staking rewards pool balance for a creator, returning `0` when none is stored. -pub fn read_staking_rewards_pool(env: &Env, creator: &Address) -> i128 { - env.storage() - .persistent() - .get(&constants::storage::staking_rewards_pool(creator)) - .unwrap_or(0) -} - -/// Reads the total keys currently staked across all holders for a creator. -pub fn read_total_staked(env: &Env, creator: &Address) -> u32 { - env.storage() - .persistent() - .get(&constants::storage::total_staked(creator)) - .unwrap_or(0) -} - -/// Routes a share of a protocol fee collection into the creator's staking rewards pool. -/// -/// This is additive bookkeeping on top of the existing treasury/protocol-fee-recipient -/// split — it does not reduce what those balances receive, so existing fee-accounting -/// invariants are unaffected. [`CreatorKeysContract::claim_stake_reward`] pays stakers -/// out of this dedicated pool. -fn credit_staking_rewards_pool( - env: &Env, - creator: &Address, - protocol_fee: i128, -) -> Result<(), ContractError> { - if protocol_fee <= 0 { - return Ok(()); - } - let share = fee::apply_percentage_fee(protocol_fee, STAKING_REWARD_SHARE_BPS) - .ok_or(ContractError::Overflow)?; - if share <= 0 { - return Ok(()); - } - let key = constants::storage::staking_rewards_pool(creator); - let updated = read_staking_rewards_pool(env, creator) - .checked_add(share) - .ok_or(ContractError::Overflow)?; - env.storage().persistent().set(&key, &updated); - Ok(()) -} - /// Archive retention configuration module with canonical defaults. pub mod retention { use super::PartitionStrategy; @@ -2643,25 +2679,32 @@ impl CreatorKeysContract { .ok_or(ContractError::Overflow)?; let post_price = compute_bonding_curve_price(&env, &creator, base_price, post_supply)?; - if pre_price > 0 && post_price > pre_price { - let price_change = (post_price - pre_price) as u128; - let pre_price_u128 = pre_price as u128; - let threshold_pct_u128 = threshold_pct as u128; - if price_change - .checked_mul(100) - .ok_or(ContractError::Overflow)? - >= pre_price_u128 - .checked_mul(threshold_pct_u128) + let threshold_pct: u32 = env + .storage() + .persistent() + .get(&constants::storage::CIRCUIT_BREAKER_THRESHOLD) + .unwrap_or(30); + + if pre_price > 0 && post_price > pre_price { + let price_change = (post_price - pre_price) as u128; + let pre_price_u128 = pre_price as u128; + let threshold_pct_u128 = threshold_pct as u128; + if price_change + .checked_mul(100) .ok_or(ContractError::Overflow)? - { - env.events().publish( - (events::circuit_breaker_triggered_topics(),), - events::CircuitBreakerTriggeredEvent { - pre_price, - post_price, - }, - ); - return Err(ContractError::CircuitBreakerTriggered); + >= pre_price_u128 + .checked_mul(threshold_pct_u128) + .ok_or(ContractError::Overflow)? + { + env.events().publish( + (events::circuit_breaker_triggered_topics(),), + events::CircuitBreakerTriggeredEvent { + pre_price, + post_price, + }, + ); + return Err(ContractError::CircuitBreakerTriggered); + } } pre_price @@ -2945,11 +2988,12 @@ impl CreatorKeysContract { .unwrap_or(0); let liquid_balance = current_balance .saturating_sub(staked_balance) - .saturating_sub(read_self_frozen_balance( - &env, &creator, &seller, - )); + .saturating_sub(read_self_frozen_balance(&env, &creator, &seller)); if liquid_balance == 0 { + if staked_balance > 0 { + return Err(ContractError::KeysStaked); + } return Err(ContractError::InsufficientBalance); } @@ -3039,17 +3083,18 @@ impl CreatorKeysContract { // configurable penalty from the proceeds and credit it to the // staking rewards pool. let proceeds = compute_sell_proceeds(&env, price).unwrap_or(0); - let mut final_proceeds = proceeds; + let created_at_key = constants::storage::created_at_ledger(&creator); if let Some(created_at) = env .storage() .persistent() - .get::(&constants::storage::created_at_ledger(&creator)) + .get::(&created_at_key) { + // The launch-window check reads this key on every sell; keep it + // well clear of expiry so an archived entry can never break sells. + extend_key_ttl_to_full_window(&env, &created_at_key); let current_ledger = env.ledger().sequence(); - if current_ledger - .checked_sub(created_at) - .unwrap_or(u32::MAX) + if current_ledger.checked_sub(created_at).unwrap_or(u32::MAX) < crate::LAUNCH_PENALTY_WINDOW_LEDGERS { let penalty_bps: u32 = env @@ -3060,13 +3105,25 @@ impl CreatorKeysContract { let capped_bps = penalty_bps.min(crate::MAX_LAUNCH_PENALTY_BPS); if capped_bps > 0 { let penalty_amount = - crate::fee::apply_percentage_fee(proceeds, capped_bps) - .unwrap_or(0); + crate::fee::apply_percentage_fee(proceeds, capped_bps).unwrap_or(0); if penalty_amount > 0 { - final_proceeds = final_proceeds - .checked_sub(penalty_amount) + // The launch penalty is not a protocol fee: it is + // credited to the staking rewards pool in full (no + // rewards-share scaling) to reward committed holders. + let pool_key = constants::storage::staking_rewards_pool(&creator); + let mut state: StakingRewardsState = + env.storage().persistent().get(&pool_key).unwrap_or( + StakingRewardsState { + pool: 0, + total_staked: 0, + }, + ); + state.pool = state + .pool + .checked_add(penalty_amount) .ok_or(ContractError::Overflow)?; - credit_staking_rewards_pool(&env, &creator, penalty_amount)?; + env.storage().persistent().set(&pool_key, &state); + extend_key_ttl_to_full_window(&env, &pool_key); env.events().publish( events::launch_penalty_applied_topics(&creator, &seller), events::LaunchPenaltyAppliedEvent { @@ -3493,11 +3550,24 @@ impl CreatorKeysContract { } let key = constants::storage::self_frozen_balance(&key_id, &wallet); let frozen = read_self_frozen_balance(&env, &key_id, &wallet); - env.storage().persistent().set(&key, &frozen.checked_add(quantity).ok_or(ContractError::Overflow)?); + env.storage().persistent().set( + &key, + &frozen + .checked_add(quantity) + .ok_or(ContractError::Overflow)?, + ); extend_key_ttl_to_full_window(&env, &key); env.events().publish( - (events::SELF_FREEZE_APPLIED_EVENT_NAME, key_id.clone(), wallet.clone()), - events::SelfFreezeEvent { key_id, wallet, quantity }, + ( + events::SELF_FREEZE_APPLIED_EVENT_NAME, + key_id.clone(), + wallet.clone(), + ), + events::SelfFreezeEvent { + key_id, + wallet, + quantity, + }, ); Ok(()) } @@ -3526,8 +3596,16 @@ impl CreatorKeysContract { extend_key_ttl_to_full_window(&env, &key); } env.events().publish( - (events::SELF_FREEZE_LIFTED_EVENT_NAME, key_id.clone(), wallet.clone()), - events::SelfFreezeEvent { key_id, wallet, quantity }, + ( + events::SELF_FREEZE_LIFTED_EVENT_NAME, + key_id.clone(), + wallet.clone(), + ), + events::SelfFreezeEvent { + key_id, + wallet, + quantity, + }, ); Ok(()) } @@ -4881,7 +4959,7 @@ impl CreatorKeysContract { /// Only callable by the creator. `cap_bps` may be omitted to select /// [`DEFAULT_HOLDER_CAP_BPS`] (10%); an explicit value must lie between /// [`HOLDER_CAP_MIN_BPS`] (1%) and [`HOLDER_CAP_MAX_BPS`] (25%), otherwise - /// [`ContractError::InvalidHolderCap`] is returned. Once configured, + /// [`ContractError::KeysStaked`] is returned. Once configured, /// `buy_key` rejects purchases that would push a non-creator wallet above /// `cap_bps` of the total supply with /// [`ContractError::MaxHoldingExceeded`]. The creator's own wallet is @@ -5184,7 +5262,7 @@ impl CreatorKeysContract { creator: Address, holder: Address, amount: u32, - ) -> Result<(), ContractError> { + ) -> Result { holder.require_auth(); assert_not_paused(&env)?; @@ -5211,6 +5289,25 @@ impl CreatorKeysContract { return Err(ContractError::InsufficientBalance); } + // Lock the keys into a staking position for the standard 30-day + // window so the holder can claim a pro-rata reward via + // `claim_stake_reward` once the position matures. + let stake_id = + Self::next_stake_id(&env, &creator, &holder).map_err(|_| ContractError::Overflow)?; + let unlock_ledger = env + .ledger() + .sequence() + .checked_add(STAKE_LOCK_LEDGERS) + .ok_or(ContractError::Overflow)?; + let position = StakePosition { + stake_id, + amount, + unlock_ledger, + }; + let position_key = constants::storage::staking_position(&creator, &holder, stake_id); + env.storage().persistent().set(&position_key, &position); + extend_key_ttl_to_full_window(&env, &position_key); + // Update staked balance let new_staked = current_staked .checked_add(amount) @@ -5220,26 +5317,35 @@ impl CreatorKeysContract { .set(&staked_balance_key, &new_staked); extend_key_ttl_to_full_window(&env, &staked_balance_key); - let total_staked_key = constants::storage::total_staked(&creator); - let new_total_staked = read_total_staked(&env, &creator) + // Track the cross-holder staked total for reward distribution. + let pool_key = constants::storage::staking_rewards_pool(&creator); + let mut state: StakingRewardsState = + env.storage() + .persistent() + .get(&pool_key) + .unwrap_or(StakingRewardsState { + pool: 0, + total_staked: 0, + }); + state.total_staked = state + .total_staked .checked_add(amount) .ok_or(ContractError::Overflow)?; - env.storage() - .persistent() - .set(&total_staked_key, &new_total_staked); + env.storage().persistent().set(&pool_key, &state); + extend_key_ttl_to_full_window(&env, &pool_key); - // Refresh the reward-claim lock window on every additional stake so - // `claim_stake_reward` always measures eligibility from the most - // recent stake. - let unlock_key = constants::storage::stake_unlock_ledger(&creator, &holder); - let unlock_ledger = env - .ledger() - .sequence() - .checked_add(STAKE_LOCK_LEDGERS) - .ok_or(ContractError::Overflow)?; - env.storage().persistent().set(&unlock_key, &unlock_ledger); + env.events().publish( + events::stake_topics(&creator, &holder, stake_id), + events::StakeEvent { + creator_id: creator, + holder, + stake_id, + amount, + unlock_ledger, + }, + ); - Ok(()) + Ok(stake_id) } /// Unstakes a specified amount of keys for a holder. @@ -5285,9 +5391,6 @@ impl CreatorKeysContract { if new_staked == 0 { env.storage().persistent().remove(&staked_balance_key); - env.storage() - .persistent() - .remove(&constants::storage::stake_unlock_ledger(&creator, &holder)); } else { env.storage() .persistent() @@ -5295,14 +5398,23 @@ impl CreatorKeysContract { extend_key_ttl_to_full_window(&env, &staked_balance_key); } - let total_staked_key = constants::storage::total_staked(&creator); - let new_total_staked = read_total_staked(&env, &creator).saturating_sub(amount); - if new_total_staked == 0 { - env.storage().persistent().remove(&total_staked_key); - } else { + // Decrement the cross-holder staked total so reward distribution + // accounting stays consistent with the released keys. + let pool_key = constants::storage::staking_rewards_pool(&creator); + let mut state: StakingRewardsState = env.storage() .persistent() - .set(&total_staked_key, &new_total_staked); + .get(&pool_key) + .unwrap_or(StakingRewardsState { + pool: 0, + total_staked: 0, + }); + state.total_staked = state.total_staked.saturating_sub(amount); + if state.total_staked == 0 && state.pool == 0 { + env.storage().persistent().remove(&pool_key); + } else { + env.storage().persistent().set(&pool_key, &state); + extend_key_ttl_to_full_window(&env, &pool_key); } Ok(()) @@ -5343,11 +5455,7 @@ impl CreatorKeysContract { /// Returns the next sequential stake id for a `(creator, holder)` pair. fn next_stake_id(env: &Env, creator: &Address, holder: &Address) -> Result { let id_key = constants::storage::next_stake_id(creator, holder); - let next: u32 = env - .storage() - .persistent() - .get(&id_key) - .unwrap_or(0); + let next: u32 = env.storage().persistent().get(&id_key).unwrap_or(0); let new_next = next.checked_add(1).ok_or(StakingError::Overflow)?; env.storage().persistent().set(&id_key, &new_next); env.storage() @@ -5388,7 +5496,8 @@ impl CreatorKeysContract { let balance_key = constants::storage::key_balance(&creator, &holder); let current_balance: u32 = env.storage().persistent().get(&balance_key).unwrap_or(0); - let current_staked: u32 = Self::get_staked_balance(env.clone(), creator.clone(), holder.clone()); + let current_staked: u32 = + Self::get_staked_balance(env.clone(), creator.clone(), holder.clone()); let liquid_balance = current_balance.saturating_sub(current_staked); if liquid_balance < amount { return Err(StakingError::InsufficientBalance); @@ -5423,14 +5532,14 @@ impl CreatorKeysContract { // Track the cross-holder staked total for reward distribution. let pool_key = constants::storage::staking_rewards_pool(&creator); - let mut state: StakingRewardsState = env - .storage() - .persistent() - .get(&pool_key) - .unwrap_or(StakingRewardsState { - pool: 0, - total_staked: 0, - }); + let mut state: StakingRewardsState = + env.storage() + .persistent() + .get(&pool_key) + .unwrap_or(StakingRewardsState { + pool: 0, + total_staked: 0, + }); state.total_staked = state .total_staked .checked_add(amount) @@ -5528,14 +5637,14 @@ impl CreatorKeysContract { } let pool_key = constants::storage::staking_rewards_pool(&creator); - let mut state: StakingRewardsState = env - .storage() - .persistent() - .get(&pool_key) - .unwrap_or(StakingRewardsState { - pool: 0, - total_staked: 0, - }); + let mut state: StakingRewardsState = + env.storage() + .persistent() + .get(&pool_key) + .unwrap_or(StakingRewardsState { + pool: 0, + total_staked: 0, + }); // Pro-rata share of the current pool this position would have earned at // maturity. Guard division by zero. @@ -5544,11 +5653,9 @@ impl CreatorKeysContract { } else { 0 }; - let penalty = fee::apply_percentage_fee( - reward_share, - crate::staking::EARLY_UNSTAKE_PENALTY_BPS, - ) - .ok_or(StakingError::Overflow)?; + let penalty = + fee::apply_percentage_fee(reward_share, crate::staking::EARLY_UNSTAKE_PENALTY_BPS) + .ok_or(StakingError::Overflow)?; // Remove the entitlement, then retain the penalty on behalf of the // remaining stakers: pool' = pool - entitlement + penalty. @@ -5617,18 +5724,18 @@ impl CreatorKeysContract { .ok_or(StakingError::PositionNotFound)?; if env.ledger().sequence() < position.unlock_ledger { - return Err(StakingError::PositionLocked); + return Err(StakingError::StakeLockActive); } let pool_key = constants::storage::staking_rewards_pool(&creator); - let mut state: StakingRewardsState = env - .storage() - .persistent() - .get(&pool_key) - .unwrap_or(StakingRewardsState { - pool: 0, - total_staked: 0, - }); + let mut state: StakingRewardsState = + env.storage() + .persistent() + .get(&pool_key) + .unwrap_or(StakingRewardsState { + pool: 0, + total_staked: 0, + }); let reward = if state.total_staked > 0 { (i128::from(position.amount) * state.pool) / i128::from(state.total_staked) @@ -5685,7 +5792,9 @@ impl CreatorKeysContract { ) -> Option { env.storage() .persistent() - .get(&constants::storage::staking_position(&creator, &holder, stake_id)) + .get(&constants::storage::staking_position( + &creator, &holder, stake_id, + )) } /// Read-only view: returns the current staking rewards pool for `creator`. @@ -6672,6 +6781,162 @@ impl CreatorKeysContract { env.storage().persistent().get(&quorum_key).unwrap_or(0) } + // ========================================================================= + // #791 — Co-creator removal + // ========================================================================= + + /// Removes a creator's configured co-creator split, restoring 100% of all + /// future royalties to the creator. + /// + /// Only callable by the creator (`caller` must equal `creator`). + /// + /// # Errors + /// + /// - [`FeatureError::Unauthorized`] if `caller` is not `creator` + /// - [`FeatureError::NoCoCreatorSet`] if no co-creator is configured for `creator` + pub fn remove_co_creator( + env: Env, + creator: Address, + caller: Address, + ) -> Result<(), FeatureError> { + caller.require_auth(); + if caller != creator { + return Err(FeatureError::Unauthorized); + } + + let key = constants::storage::co_creator(&creator); + let config: CoCreatorConfig = env + .storage() + .persistent() + .get(&key) + .ok_or(FeatureError::NoCoCreatorSet)?; + + env.storage().persistent().remove(&key); + + env.events().publish( + events::co_creator_removed_topics(&creator), + events::CoCreatorRemovedEvent { + creator_id: creator, + co_creator: config.address, + ledger: env.ledger().sequence(), + }, + ); + + Ok(()) + } + + // ========================================================================= + // #787 / #793 / #790 — Pre-launch auction phase + // ========================================================================= + + /// Configures a fixed-price pre-launch auction phase for a creator's keys. + /// + /// While `total_supply` is below `auction_supply`, [`Self::buy_key`] sells + /// at `auction_price` instead of the bonding curve price; the contract + /// transitions back to the curve automatically once the auction supply is + /// exhausted. Only callable by the creator, and only before any keys have + /// been sold. + /// + /// # Errors + /// + /// - [`FeatureError::Unauthorized`] if `caller` is not `creator` + /// - [`FeatureError::NotRegistered`] if `creator` is not a registered creator + /// - [`FeatureError::AuctionAlreadyStarted`] if the creator's supply is already nonzero + /// - [`FeatureError::NotPositiveAmount`] if `auction_price` is not positive + /// - [`FeatureError::InvalidAuctionConfig`] if `auction_supply` is zero or exceeds + /// [`MAX_AUCTION_SUPPLY`] + pub fn configure_auction( + env: Env, + creator: Address, + caller: Address, + auction_price: i128, + auction_supply: u32, + ) -> Result<(), FeatureError> { + caller.require_auth(); + if caller != creator { + return Err(FeatureError::Unauthorized); + } + + let profile = read_registered_creator_profile(&env, &creator) + .map_err(|_| FeatureError::NotRegistered)?; + if profile.supply > 0 { + return Err(FeatureError::AuctionAlreadyStarted); + } + if auction_price <= 0 { + return Err(FeatureError::NotPositiveAmount); + } + if auction_supply == 0 || auction_supply > MAX_AUCTION_SUPPLY { + return Err(FeatureError::InvalidAuctionConfig); + } + + let config = AuctionConfig { + auction_price, + auction_supply, + auction_sold: 0, + }; + let key = constants::storage::auction_config(&creator); + env.storage().persistent().set(&key, &config); + extend_key_ttl_to_full_window(&env, &key); + + env.events().publish( + events::auction_configured_topics(&creator), + events::AuctionConfiguredEvent { + creator_id: creator, + auction_price, + auction_supply, + }, + ); + + Ok(()) + } + + /// Cancels a creator's configured auction before any auction keys have sold. + /// + /// Only callable by the creator. + /// + /// # Errors + /// + /// - [`FeatureError::Unauthorized`] if `caller` is not `creator` + /// - [`FeatureError::NoAuctionConfigured`] if no auction config exists for `creator` + /// - [`FeatureError::AuctionAlreadyStarted`] if `auction_sold` is greater than zero + pub fn cancel_auction(env: Env, creator: Address, caller: Address) -> Result<(), FeatureError> { + caller.require_auth(); + if caller != creator { + return Err(FeatureError::Unauthorized); + } + + let key = constants::storage::auction_config(&creator); + let config: AuctionConfig = env + .storage() + .persistent() + .get(&key) + .ok_or(FeatureError::NoAuctionConfigured)?; + + if config.auction_sold > 0 { + return Err(FeatureError::AuctionAlreadyStarted); + } + + env.storage().persistent().remove(&key); + + env.events().publish( + events::auction_cancelled_topics(&creator), + events::AuctionCancelledEvent { + creator_id: creator, + auction_price: config.auction_price, + auction_supply: config.auction_supply, + }, + ); + + Ok(()) + } + + /// Read-only view: returns the configured auction state for a creator, if any. + pub fn get_auction_config(env: Env, creator: Address) -> Option { + env.storage() + .persistent() + .get(&constants::storage::auction_config(&creator)) + } + /// Re-extends all known global persistent storage keys plus the scoped /// entries of the specified creators to the maximum TTL window. pub fn refresh_ttl( diff --git a/creator-keys/src/test_staking_lifecycle.rs b/creator-keys/src/test_staking_lifecycle.rs index 5f526f1c..209f6a05 100644 --- a/creator-keys/src/test_staking_lifecycle.rs +++ b/creator-keys/src/test_staking_lifecycle.rs @@ -36,7 +36,13 @@ mod staking_lifecycle_tests { /// reward-pool accumulation start from an empty pool; tests that need fee /// accrual call `set_protocol_fee` explicitly. /// Returns `(env, client, admin, creator, treasury)`. - fn setup() -> (Env, CreatorKeysContractClient<'static>, Address, Address, Address) { + fn setup() -> ( + Env, + CreatorKeysContractClient<'static>, + Address, + Address, + Address, + ) { let env = Env::default(); env.mock_all_auths(); @@ -71,11 +77,7 @@ mod staking_lifecycle_tests { /// Enables the 10% protocol trade fee so every buy accrues the staking /// rewards pool (`REWARD_SHARE_PER_BUY` per buy). - fn enable_fee_accrual( - client: &CreatorKeysContractClient, - admin: &Address, - treasury: &Address, - ) { + fn enable_fee_accrual(client: &CreatorKeysContractClient, admin: &Address, treasury: &Address) { client.set_protocol_fee(admin, &Some(PROTOCOL_FEE_BPS), treasury); } @@ -121,7 +123,9 @@ mod staking_lifecycle_tests { assert_eq!(client.get_staked_balance(&creator, &holder), 10); // Verify the stored staking position. - let position = client.get_staking_position(&creator, &holder, &stake_id).unwrap(); + let position = client + .get_staking_position(&creator, &holder, &stake_id) + .unwrap(); assert_eq!(position.stake_id, 0); assert_eq!(position.amount, 10); assert_eq!(position.unlock_ledger, start_seq + lock_ledgers); @@ -187,8 +191,12 @@ mod staking_lifecycle_tests { assert_eq!(client.get_staking_rewards_pool(&creator), 12); // Sanity: both positions share the same maturity (same start ledger). - let p0 = client.get_staking_position(&creator, &holder, &pos0).unwrap(); - let p1 = client.get_staking_position(&creator, &holder, &pos1).unwrap(); + let p0 = client + .get_staking_position(&creator, &holder, &pos0) + .unwrap(); + let p1 = client + .get_staking_position(&creator, &holder, &pos1) + .unwrap(); let base_unlock = start_seq + lock_ledgers; assert_eq!(p0.unlock_ledger, base_unlock); assert_eq!(p1.unlock_ledger, base_unlock); @@ -211,11 +219,15 @@ mod staking_lifecycle_tests { let additional = 50u32; let extended_unlock = client.stake_extend(&creator, &holder, &pos1, &additional); assert_eq!(extended_unlock, base_unlock + additional); - let p1_ext = client.get_staking_position(&creator, &holder, &pos1).unwrap(); + let p1_ext = client + .get_staking_position(&creator, &holder, &pos1) + .unwrap(); assert_eq!(p1_ext.unlock_ledger, base_unlock + additional); // Position 0 still matures at the original ledger. - let p0_after = client.get_staking_position(&creator, &holder, &pos0).unwrap(); + let p0_after = client + .get_staking_position(&creator, &holder, &pos0) + .unwrap(); assert_eq!(p0_after.unlock_ledger, base_unlock); // ----------------------------------------------------------------- @@ -238,7 +250,9 @@ mod staking_lifecycle_tests { assert_eq!(client.get_staking_rewards_pool(&creator), 62); assert_eq!(client.get_total_staked(&creator), 10); // Position is closed and keys return to the liquid balance. - assert!(client.get_staking_position(&creator, &holder, &pos0).is_none()); + assert!(client + .get_staking_position(&creator, &holder, &pos0) + .is_none()); assert_eq!(client.get_staked_balance(&creator, &holder), 10); assert_eq!(client.get_liquid_balance(&creator, &holder), 2); @@ -271,7 +285,9 @@ mod staking_lifecycle_tests { // Pool is fully drained and no positions remain. assert_eq!(client.get_staking_rewards_pool(&creator), 0); assert_eq!(client.get_total_staked(&creator), 0); - assert!(client.get_staking_position(&creator, &holder, &pos1).is_none()); + assert!(client + .get_staking_position(&creator, &holder, &pos1) + .is_none()); // All 12 of the holder's keys are back in liquid balance. assert_eq!(client.get_staked_balance(&creator, &holder), 0); assert_eq!(client.get_liquid_balance(&creator, &holder), 12); @@ -298,7 +314,9 @@ mod staking_lifecycle_tests { ); // A positive extension succeeds. let new_unlock = client.stake_extend(&creator, &holder, &pos, &25u32); - let stored = client.get_staking_position(&creator, &holder, &pos).unwrap(); + let stored = client + .get_staking_position(&creator, &holder, &pos) + .unwrap(); assert_eq!(stored.unlock_ledger, new_unlock); } @@ -310,13 +328,16 @@ mod staking_lifecycle_tests { let lock_ledgers = 100u32; let pos = client.stake_keys_locked(&creator, &holder, &1u32, &lock_ledgers); - let unlock = client.get_staking_position(&creator, &holder, &pos).unwrap().unlock_ledger; + let unlock = client + .get_staking_position(&creator, &holder, &pos) + .unwrap() + .unlock_ledger; // Claim while still locked reverts. assert!(env.ledger().sequence() < unlock); assert_eq!( client.try_claim_stake_reward(&creator, &holder, &pos), - Err(Ok(StakingError::PositionLocked)) + Err(Ok(StakingError::StakeLockActive)) ); } @@ -328,7 +349,10 @@ mod staking_lifecycle_tests { let lock_ledgers = 100u32; let pos = client.stake_keys_locked(&creator, &holder, &4u32, &lock_ledgers); - let unlock = client.get_staking_position(&creator, &holder, &pos).unwrap().unlock_ledger; + let unlock = client + .get_staking_position(&creator, &holder, &pos) + .unwrap() + .unlock_ledger; // Advance past maturity without any fees accruing: reward is 0 since the // pool never accrued, but keys are still returned. diff --git a/creator-keys/tests/launch_penalty.rs b/creator-keys/tests/launch_penalty.rs index a1ee41b7..4be9bd56 100644 --- a/creator-keys/tests/launch_penalty.rs +++ b/creator-keys/tests/launch_penalty.rs @@ -7,17 +7,25 @@ mod contract_test_env; use contract_test_env::{ - register_creator_keys, register_test_creator, set_key_price_for_tests, test_env_with_auths, + register_creator_keys, register_test_creator, set_pricing_and_fees, test_env_with_auths, +}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Env, }; -use creator_keys::{constants, ContractError}; -use soroban_sdk::{testutils::Address as _, Address, Env}; const KEY_PRICE: i128 = 100; /// Setup a client, register a creator, and configure pricing. +/// +/// Fees are split 50/0 (creator/protocol) so the protocol-fee share credited +/// to the staking rewards pool on a sell is zero and the only pool movement +/// comes from the launch penalty itself. fn setup(env: &Env) -> (creator_keys::CreatorKeysContractClient<'_>, Address) { + env.ledger().set_max_entry_ttl(200_000); + env.ledger().set_min_persistent_entry_ttl(200_000); let (client, _) = register_creator_keys(env); - set_key_price_for_tests(env, &client, KEY_PRICE); + set_pricing_and_fees(env, &client, KEY_PRICE, 5_000, 0); let creator = register_test_creator(env, &client, "alice"); (client, creator) } @@ -42,14 +50,19 @@ fn test_sell_within_launch_window_applies_penalty() { client.buy_key(&creator, &buyer, &KEY_PRICE, &None); assert_eq!(client.get_key_balance(&creator, &buyer), 1); - // Sell within the launch window — no ledger advance. - let balance_before = client.get_creator_fee_balance(&creator); + // Sell within the launch window — no ledger advance. The default penalty + // (500 bps = 5% of proceeds) is deducted and credited to the staking + // rewards pool. + eprintln!( + "CREATOR FEE BAL: {}", + client.get_creator_fee_balance(&creator) + ); + let pool_before = client.get_staking_rewards_pool(&creator); client.sell_key(&creator, &buyer, &None); + let pool_after = client.get_staking_rewards_pool(&creator); - // The creator fee balance should increase from the penalty going to staking pool. - let balance_after = client.get_creator_fee_balance(&creator); // Penalty was applied (default 500 bps = 5% of proceeds). - assert!(balance_after > balance_before); + assert!(pool_after > pool_before); } // ============================================================================ @@ -67,12 +80,12 @@ fn test_sell_after_launch_window_no_penalty() { advance_ledgers(&env, 120_961); // Sell after the window — no penalty. - let balance_before = client.get_creator_fee_balance(&creator); + let pool_before = client.get_staking_rewards_pool(&creator); client.sell_key(&creator, &buyer, &None); - let balance_after = client.get_creator_fee_balance(&creator); + let pool_after = client.get_staking_rewards_pool(&creator); // Only the standard trade fee should apply, not the launch penalty. - assert_eq!(balance_before, balance_after); + assert_eq!(pool_before, pool_after); } // ============================================================================ diff --git a/creator-keys/tests/prelaunch_auction.rs b/creator-keys/tests/prelaunch_auction.rs index 408820d2..29b2b15e 100644 --- a/creator-keys/tests/prelaunch_auction.rs +++ b/creator-keys/tests/prelaunch_auction.rs @@ -234,10 +234,7 @@ fn test_buy_key_transitions_to_bonding_curve_once_auction_supply_is_exhausted() client.buy_key(&creator, &buyer_a, &500i128, &None); client.buy_key(&creator, &buyer_b, &500i128, &None); - assert_eq!( - client.get_auction_config(&creator).unwrap().auction_sold, - 2 - ); + assert_eq!(client.get_auction_config(&creator).unwrap().auction_sold, 2); // Auction supply (2) is now exhausted; the next buy settles at the base // (bonding-curve) price, not the fixed auction price. diff --git a/creator-keys/tests/sell_requires_liquid_balance.rs b/creator-keys/tests/sell_requires_liquid_balance.rs index 7e7d9f8e..c019f169 100644 --- a/creator-keys/tests/sell_requires_liquid_balance.rs +++ b/creator-keys/tests/sell_requires_liquid_balance.rs @@ -50,12 +50,13 @@ fn test_sell_reverts_when_attempting_to_use_staked_keys() { ); } - // Attempt to sell 5th key - should fail because only 4 were liquid + // Attempt to sell 5th key - should fail because only 4 were liquid and + // the remaining keys are staked (KeysStaked). let result = client.try_sell_key(&creator, &holder, &None); assert_eq!( result, - Err(Ok(ContractError::InsufficientBalance)), - "Selling more than liquid balance should fail" + Err(Ok(ContractError::KeysStaked)), + "Selling staked keys should fail with KeysStaked" ); // Verify staked balance unchanged @@ -104,9 +105,9 @@ fn test_staked_balance_unchanged_after_sell_attempts() { assert_eq!(client.get_staked_balance(&creator, &holder), 6); assert_eq!(client.get_liquid_balance(&creator, &holder), 0); - // Attempt to sell when no liquid balance remains (should fail) + // Attempt to sell when no liquid balance remains (staked keys -> KeysStaked) let result = client.try_sell_key(&creator, &holder, &None); - assert_eq!(result, Err(Ok(ContractError::InsufficientBalance))); + assert_eq!(result, Err(Ok(ContractError::KeysStaked))); // Verify staked balance still unchanged after failed attempt assert_eq!(client.get_staked_balance(&creator, &holder), 6); @@ -294,7 +295,7 @@ fn test_stake_all_then_unstake_all() { assert_eq!(client.get_liquid_balance(&creator, &holder), 0); let result = client.try_sell_key(&creator, &holder, &None); - assert_eq!(result, Err(Ok(ContractError::InsufficientBalance))); + assert_eq!(result, Err(Ok(ContractError::KeysStaked))); client.unstake_keys(&creator, &holder, &7); assert_eq!(client.get_liquid_balance(&creator, &holder), 7); diff --git a/creator-keys/tests/sell_zero_amount.rs b/creator-keys/tests/sell_zero_amount.rs index 01662eaf..d82a7374 100644 --- a/creator-keys/tests/sell_zero_amount.rs +++ b/creator-keys/tests/sell_zero_amount.rs @@ -182,12 +182,12 @@ fn test_sell_zero_liquid_keys_when_all_staked_reverts_and_emits_no_event() { // Clear event log env.events().all(); - // Holder attempts to sell when liquid balance is 0 + // Holder attempts to sell when liquid balance is 0 and all keys are staked. let result = client.try_sell_key(&creator, &holder, &None); assert_eq!( result, - Err(Ok(ContractError::InsufficientBalance)), - "sell must fail when liquid keys count is 0" + Err(Ok(ContractError::KeysStaked)), + "sell must fail with KeysStaked when all keys are staked" ); let after = capture_snapshot(&client, &creator, &holder); diff --git a/creator-keys/tests/staking_reward_claim.rs b/creator-keys/tests/staking_reward_claim.rs index c83a7884..7739a104 100644 --- a/creator-keys/tests/staking_reward_claim.rs +++ b/creator-keys/tests/staking_reward_claim.rs @@ -1,4 +1,16 @@ -//! Tests for `claim_stake_reward` and the staking rewards pool (#786 / #789). +//! Tests for the key staking rewards feature: `stake_keys` locks a holder's +//! keys for `STAKE_LOCK_LEDGERS` (30 days), a share of every protocol fee +//! accrues into the creator's `staking_rewards` pool, and `claim_stake_reward` +//! pays out a pro-rata reward and unlocks the keys once the lock expires. +//! +//! Acceptance criteria covered: +//! - Staked keys are removed from the transferable/liquid balance. +//! - Selling staked keys panics with `KeysStaked`. +//! - `claim_stake_reward` before the lock period expires panics with +//! `StakeLockActive`. +//! - The reward is proportional to the staked quantity relative to the total +//! staked for the creator. +//! - Keys are returned to the holder's balance on claim. mod contract_test_env; @@ -6,7 +18,7 @@ use contract_test_env::{ register_creator_keys, register_test_creator, set_ledger_sequence, set_pricing_and_fees, test_env_with_auths, }; -use creator_keys::{events, FeatureError, STAKE_LOCK_LEDGERS}; +use creator_keys::{events, ContractError, StakingError, STAKE_LOCK_LEDGERS}; use soroban_sdk::{ testutils::{Address as _, Events, Ledger}, Address, IntoVal, Symbol, @@ -16,47 +28,61 @@ const KEY_PRICE: i128 = 1000; const CREATOR_BPS: u32 = 9000; const PROTOCOL_BPS: u32 = 1000; +/// Configure TTL windows wide enough for the 30-day lock period and advance +/// the ledger past `STAKE_LOCK_LEDGERS`. +fn advance_past_lock(env: &soroban_sdk::Env) { + let start_sequence = env.ledger().sequence(); + set_ledger_sequence(env, start_sequence + STAKE_LOCK_LEDGERS); +} + #[test] fn test_claim_stake_reward_fails_with_no_stake() { let env = test_env_with_auths(); env.ledger().set_max_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); - env.ledger().set_min_persistent_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); + env.ledger() + .set_min_persistent_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); let (client, _) = register_creator_keys(&env); set_pricing_and_fees(&env, &client, KEY_PRICE, CREATOR_BPS, PROTOCOL_BPS); let creator = register_test_creator(&env, &client, "alice"); let holder = Address::generate(&env); - let result = client.try_claim_stake_reward(&creator, &holder); - assert_eq!(result, Err(Ok(FeatureError::NoStakeFound))); + let result = client.try_claim_stake_reward(&creator, &holder, &0u32); + assert_eq!(result, Err(Ok(StakingError::PositionNotFound))); } #[test] fn test_claim_stake_reward_fails_while_lock_is_active() { let env = test_env_with_auths(); env.ledger().set_max_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); - env.ledger().set_min_persistent_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); + env.ledger() + .set_min_persistent_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); let (client, _) = register_creator_keys(&env); set_pricing_and_fees(&env, &client, KEY_PRICE, CREATOR_BPS, PROTOCOL_BPS); let creator = register_test_creator(&env, &client, "alice"); let holder = Address::generate(&env); + client.buy_key(&creator, &holder, &KEY_PRICE, &None); - client.stake_keys(&creator, &holder, &1u32); + let stake_id = client.stake_keys(&creator, &holder, &1u32); - let result = client.try_claim_stake_reward(&creator, &holder); - assert_eq!(result, Err(Ok(FeatureError::StakeLockActive))); + // Claim while the lock is still active reverts with StakeLockActive. + let result = client.try_claim_stake_reward(&creator, &holder, &stake_id); + assert_eq!(result, Err(Ok(StakingError::StakeLockActive))); // Still locked just one ledger before the unlock boundary. - let unlock_ledger = client.get_stake_unlock_ledger(&creator, &holder).unwrap(); - set_ledger_sequence(&env, unlock_ledger - 1); - let result = client.try_claim_stake_reward(&creator, &holder); - assert_eq!(result, Err(Ok(FeatureError::StakeLockActive))); + let position = client + .get_staking_position(&creator, &holder, &stake_id) + .unwrap(); + set_ledger_sequence(&env, position.unlock_ledger - 1); + let result = client.try_claim_stake_reward(&creator, &holder, &stake_id); + assert_eq!(result, Err(Ok(StakingError::StakeLockActive))); } #[test] fn test_claim_stake_reward_pays_out_and_unlocks_after_lock_period() { let env = test_env_with_auths(); env.ledger().set_max_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); - env.ledger().set_min_persistent_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); + env.ledger() + .set_min_persistent_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); let (client, _) = register_creator_keys(&env); set_pricing_and_fees(&env, &client, KEY_PRICE, CREATOR_BPS, PROTOCOL_BPS); let creator = register_test_creator(&env, &client, "alice"); @@ -69,30 +95,38 @@ fn test_claim_stake_reward_pays_out_and_unlocks_after_lock_period() { "buying with a fee config configured should seed the staking rewards pool" ); - client.stake_keys(&creator, &holder, &1u32); + // Staked keys are removed from the liquid/transferable balance. + let stake_id = client.stake_keys(&creator, &holder, &1u32); assert_eq!(client.get_total_staked(&creator), 1); + assert_eq!(client.get_staked_balance(&creator, &holder), 1); + assert_eq!(client.get_liquid_balance(&creator, &holder), 0); - let start_sequence = env.ledger().sequence(); - set_ledger_sequence(&env, start_sequence + STAKE_LOCK_LEDGERS); + advance_past_lock(&env); - let reward = client.claim_stake_reward(&creator, &holder); - assert_eq!(reward, pool_after_buy); + let claim = client.claim_stake_reward(&creator, &holder, &stake_id); + assert_eq!(claim.amount, 1); + // Sole staker gets the entire pool. + assert_eq!(claim.reward, pool_after_buy); - // Sole staker gets the entire pool; pool and total-staked bookkeeping are cleared. + // Pool and total-staked bookkeeping are cleared. assert_eq!(client.get_staking_rewards_pool(&creator), 0); assert_eq!(client.get_total_staked(&creator), 0); assert_eq!(client.get_staked_balance(&creator, &holder), 0); - assert_eq!(client.get_stake_unlock_ledger(&creator, &holder), None); + assert!(client + .get_staking_position(&creator, &holder, &stake_id) + .is_none()); - // Liquid balance is untouched by staking/unstaking bookkeeping. + // Keys are returned to the holder's balance on claim. assert_eq!(client.get_key_balance(&creator, &holder), 1); + assert_eq!(client.get_liquid_balance(&creator, &holder), 1); } #[test] fn test_claim_stake_reward_splits_pool_pro_rata_across_stakers() { let env = test_env_with_auths(); env.ledger().set_max_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); - env.ledger().set_min_persistent_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); + env.ledger() + .set_min_persistent_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); let (client, _) = register_creator_keys(&env); set_pricing_and_fees(&env, &client, KEY_PRICE, CREATOR_BPS, PROTOCOL_BPS); let creator = register_test_creator(&env, &client, "alice"); @@ -105,25 +139,24 @@ fn test_claim_stake_reward_splits_pool_pro_rata_across_stakers() { } client.buy_key(&creator, &holder_b, &KEY_PRICE, &None); - client.stake_keys(&creator, &holder_a, &3u32); - client.stake_keys(&creator, &holder_b, &1u32); + let stake_a = client.stake_keys(&creator, &holder_a, &3u32); + let stake_b = client.stake_keys(&creator, &holder_b, &1u32); assert_eq!(client.get_total_staked(&creator), 4); let pool_total = client.get_staking_rewards_pool(&creator); let expected_a = pool_total * 3 / 4; - let expected_b_before_a_claims = pool_total * 1 / 4; + let expected_b_before_a_claims = pool_total / 4; - let start_sequence = env.ledger().sequence(); - set_ledger_sequence(&env, start_sequence + STAKE_LOCK_LEDGERS); + advance_past_lock(&env); - let reward_a = client.claim_stake_reward(&creator, &holder_a); - assert_eq!(reward_a, expected_a); + let claim_a = client.claim_stake_reward(&creator, &holder_a, &stake_a); + assert_eq!(claim_a.reward, expected_a); assert_eq!(client.get_total_staked(&creator), 1); - let reward_b = client.claim_stake_reward(&creator, &holder_b); + let claim_b = client.claim_stake_reward(&creator, &holder_b, &stake_b); // holder_b is the sole remaining staker and claims what's left in the pool, // which (absent further rounding loss) matches their pro-rata share. - assert_eq!(reward_b, expected_b_before_a_claims); + assert_eq!(claim_b.reward, expected_b_before_a_claims); assert_eq!(client.get_total_staked(&creator), 0); assert_eq!(client.get_staking_rewards_pool(&creator), 0); } @@ -132,18 +165,18 @@ fn test_claim_stake_reward_splits_pool_pro_rata_across_stakers() { fn test_claim_stake_reward_emits_event_with_expected_payload() { let env = test_env_with_auths(); env.ledger().set_max_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); - env.ledger().set_min_persistent_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); + env.ledger() + .set_min_persistent_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); let (client, contract_id) = register_creator_keys(&env); set_pricing_and_fees(&env, &client, KEY_PRICE, CREATOR_BPS, PROTOCOL_BPS); let creator = register_test_creator(&env, &client, "alice"); let holder = Address::generate(&env); client.buy_key(&creator, &holder, &KEY_PRICE, &None); let pool = client.get_staking_rewards_pool(&creator); - client.stake_keys(&creator, &holder, &1u32); + let stake_id = client.stake_keys(&creator, &holder, &1u32); - let start_sequence = env.ledger().sequence(); - set_ledger_sequence(&env, start_sequence + STAKE_LOCK_LEDGERS); - client.claim_stake_reward(&creator, &holder); + advance_past_lock(&env); + client.claim_stake_reward(&creator, &holder, &stake_id); let mut found = false; for (contract, topics, data) in env.events().all().iter() { @@ -153,10 +186,11 @@ fn test_claim_stake_reward_emits_event_with_expected_payload() { let event_name: Symbol = topics.get(0).unwrap().into_val(&env); if event_name == events::STAKE_REWARD_CLAIMED_EVENT_NAME { let payload: events::StakeRewardClaimedEvent = data.clone().into_val(&env); - assert_eq!(payload.wallet, holder); - assert_eq!(payload.key_id, creator); - assert_eq!(payload.quantity_unlocked, 1); - assert_eq!(payload.reward_amount, pool); + assert_eq!(payload.holder, holder); + assert_eq!(payload.creator_id, creator); + assert_eq!(payload.stake_id, stake_id); + assert_eq!(payload.amount, 1); + assert_eq!(payload.reward, pool); found = true; } } @@ -167,19 +201,40 @@ fn test_claim_stake_reward_emits_event_with_expected_payload() { fn test_claim_stake_reward_fails_while_protocol_paused() { let env = test_env_with_auths(); env.ledger().set_max_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); - env.ledger().set_min_persistent_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); + env.ledger() + .set_min_persistent_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); let (client, _) = register_creator_keys(&env); let admin = set_pricing_and_fees(&env, &client, KEY_PRICE, CREATOR_BPS, PROTOCOL_BPS); let creator = register_test_creator(&env, &client, "alice"); let holder = Address::generate(&env); client.buy_key(&creator, &holder, &KEY_PRICE, &None); - client.stake_keys(&creator, &holder, &1u32); + let stake_id = client.stake_keys(&creator, &holder, &1u32); - let start_sequence = env.ledger().sequence(); - set_ledger_sequence(&env, start_sequence + STAKE_LOCK_LEDGERS); + advance_past_lock(&env); client.pause(&admin); - let result = client.try_claim_stake_reward(&creator, &holder); - assert_eq!(result, Err(Ok(FeatureError::ProtocolPaused))); + let result = client.try_claim_stake_reward(&creator, &holder, &stake_id); + assert_eq!(result, Err(Ok(StakingError::ProtocolPaused))); +} + +#[test] +fn test_sell_of_staked_keys_panics_with_keys_staked() { + let env = test_env_with_auths(); + env.ledger().set_max_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); + env.ledger() + .set_min_persistent_entry_ttl(STAKE_LOCK_LEDGERS + 100_000); + let (client, _) = register_creator_keys(&env); + set_pricing_and_fees(&env, &client, KEY_PRICE, CREATOR_BPS, PROTOCOL_BPS); + let creator = register_test_creator(&env, &client, "alice"); + let holder = Address::generate(&env); + + client.buy_key(&creator, &holder, &KEY_PRICE, &None); + // Stake the only key: no liquid balance remains, so a sell attempt must + // panic with KeysStaked rather than succeed on the staked key. + client.stake_keys(&creator, &holder, &1u32); + assert_eq!(client.get_liquid_balance(&creator, &holder), 0); + + let result = client.try_sell_key(&creator, &holder, &None); + assert_eq!(result, Err(Ok(ContractError::KeysStaked))); } diff --git a/docs/error-codes.md b/docs/error-codes.md index 99af77d7..63372113 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -46,6 +46,24 @@ Defined in [`creator-keys/src/lib.rs`](../creator-keys/src/lib.rs#L50-L83) as `p | `32` | `WhitelistTooLarge` | Whitelist configuration address count exceeds maximum limit | Triggered in [`validate_whitelist_config`](../creator-keys/src/lib.rs#L637) when address count `> MAX_WHITELIST_SIZE`. | | `33` | `AirdropRecipientLimitExceeded` | Airdrop recipient list length exceeds max limit per transaction | Triggered in [`airdrop_keys`](../creator-keys/src/lib.rs#L1730) when `recipients.len() > MAX_AIRDROP_RECIPIENT_LIMIT`. | | `40` | `DisplayNameEmpty` | Creator display handle is blank (empty string or ASCII whitespace only) | Triggered in [`validate_creator_handle`](../creator-keys/src/lib.rs) before the length and character checks when the handle contains no non-whitespace bytes. | +| `51` | `GlobalTradingHalted` | Buy/sell rejected while the protocol-wide emergency trading halt is active (#784) | Triggered in [`assert_global_trading_not_halted`](../creator-keys/src/lib.rs) before the per-key pause guard. | +| `52` | `FreezeQuantityExceedsBalance` | Self-freeze quantity exceeds the holder's available balance | Triggered in [`self_freeze`](../creator-keys/src/lib.rs) when `quantity > available_holder_balance`. | +| `53` | `KeysStaked` | Sell of staked keys attempted while the keys are locked in a staking position | Triggered in [`sell_key`](../creator-keys/src/lib.rs) when the seller's entire balance is staked. | + +--- + +## `StakingError` Reference (Key Staking) + +Defined in [`creator-keys/src/lib.rs`](../creator-keys/src/lib.rs) as `pub enum StakingError`, used by the staking lifecycle entrypoints (`stake_keys`, `stake_keys_locked`, `stake_extend`, `early_unstake`, `claim_stake_reward`). + +| Code | Name | Description | Trigger Condition | +|:---:|---|---|---| +| `3` | `InsufficientBalance` | Holder's liquid (non-staked) balance is smaller than the staked amount | Triggered when staking more keys than the holder's liquid balance. | +| `4` | `PositionNotFound` | No staking position exists for the given `(creator, holder, stake_id)` | Triggered when claiming/early-unstaking/extending an unknown position. | +| `5` | `PositionNotLocked` | Position has already matured, so `early_unstake` cannot be used | Triggered when early-unstaking a matured position. | +| `6` | `StakeLockActive` | The staking lock period has not yet elapsed | Triggered in `claim_stake_reward` before the position's `unlock_ledger`. | +| `7` | `NotRegistered` | Creator is not registered | Triggered when staking for an unregistered creator. | +| `8` | `ProtocolPaused` | The protocol is paused | Triggered by staking lifecycle entrypoints while paused. | ---