From d51c5b3ee99230f40af83dc44bd66aca0ce84310 Mon Sep 17 00:00:00 2001 From: Syed Ghufran Hassan Date: Wed, 24 Jun 2026 15:49:50 +0500 Subject: [PATCH 01/11] feat: implement dividend distribution and claim functionality for key holders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add distribute_dividend (callable by anyone) to deposit XLM, deduct protocol fee, and split the remainder proportionally among all current key holders of a creator. - Add claim_dividend for holders to withdraw accrued dividends; resets claimable balance to zero before transfer (reentrancy-safe). - Add get_claimable_dividend view to read unclaimed amounts for a (creator, holder) pair. - Maintain a HoldersList per creator, updated on buy/sell to efficiently iterate current holders for distributions. - Extend DataKey with HoldersList and DividendClaimable storage keys. - Append new error variants: ZeroClaimable, NoHolders, DividendAmountZero (at the end, preserving ABI). - Define and emit DividendDistributed and DividendClaimed events with stable fields. - Add corresponding error string constants in quote_view_errors. - Ensure holder list is kept in sync with key balance changes (add when balance goes 0→1, remove when 1→0). --- creator-keys/src/lib.rs | 141 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 1 deletion(-) diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 7323609..60a4c54 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] pub mod quote_view_errors; -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, String}; +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, String, Vec}; pub mod events; @@ -65,6 +65,9 @@ pub enum ContractError { SlippageExceeded = 16, ProtocolPaused = 17, Unauthorized = 18, + ZeroClaimable = 19, + NoHolders = 20, + DividendAmountZero = 21, } pub mod fee { @@ -368,6 +371,8 @@ pub enum DataKey { CreatorFeeBalance(Address), ProtocolStateVersion, Paused, + HoldersList(Address), + DividendClaimable(Address, Address), } #[derive(Clone, Debug, PartialEq)] @@ -794,6 +799,11 @@ impl CreatorKeysContract { .checked_add(1) .ok_or(ContractError::Overflow)?; } + let mut holders = read_holders_list(&env, &creator); + if !holders.iter().any(|h| h == &buyer) { + holders.push_back(buyer.clone()); + write_holders_list(&env, &creator, &holders); + } profile.supply = profile .supply @@ -859,6 +869,11 @@ impl CreatorKeysContract { .holder_count .checked_sub(1) .ok_or(ContractError::SellUnderflow)?; + let mut holders = read_holders_list(&env, &creator); + if let Some(pos) = holders.iter().position(|h| h == &seller) { + holders.remove(pos); + write_holders_list(&env, &creator, &holders); + } } let key = constants::storage::creator(&creator); @@ -969,6 +984,111 @@ impl CreatorKeysContract { } } + pub fn distribute_dividend( + env: Env, + creator: Address, + amount: i128, +) -> Result<(), ContractError> { + let caller = env.invoker(); + + // Transfer XLM from caller to contract + let native = env.native_token(); + native.transfer(&caller, &env.current_contract_address(), &amount); + + // Load creator profile and validate + let profile = read_registered_creator_profile(&env, &creator)?; + let total_supply = profile.supply as i128; + if total_supply == 0 { + return Err(ContractError::NoHolders); + } + + // Load fee config and compute protocol fee + let config = read_required_protocol_fee_config(&env)?; + let protocol_fee = fee::apply_percentage_fee(amount, config.protocol_bps) + .ok_or(ContractError::Overflow)?; + let distribute_amount = amount + .checked_sub(protocol_fee) + .ok_or(ContractError::Overflow)?; + if distribute_amount <= 0 { + return Err(ContractError::DividendAmountZero); + } + + // Credit protocol fee to the protocol fee recipient balance + credit_protocol_fee_recipient_balance(&env, protocol_fee)?; + + // Iterate over all holders and distribute proportionally + let holders = read_holders_list(&env, &creator); + let ledger = env.ledger().sequence(); + + for holder in holders.iter() { + let balance = Self::get_key_balance(env.clone(), creator.clone(), holder.clone()) as i128; + if balance == 0 { + continue; + } + let share = (balance * distribute_amount) / total_supply; + if share > 0 { + let current = read_claimable_dividend(&env, &creator, &holder); + let new_claim = current + .checked_add(share) + .ok_or(ContractError::Overflow)?; + write_claimable_dividend(&env, &creator, &holder, new_claim); + } + } + + // Emit event + env.events().publish( + events::dividend_distributed_topics(&creator), + events::DividendDistributedEvent { + creator_id: creator, + total_amount: amount, + snapshot_supply: total_supply, + ledger, + protocol_fee, + distributed_amount: distribute_amount, + }, + ); + + Ok(()) +} + +/// Claims accrued dividends for the caller. +/// +/// Transfers the claimable amount from the contract to the caller's wallet. +/// Resets the claimable balance to zero. +/// Emits a `DividendClaimed` event. +pub fn claim_dividend(env: Env, creator: Address) -> Result<(), ContractError> { + let caller = env.invoker(); + + let claimable = read_claimable_dividend(&env, &creator, &caller); + if claimable == 0 { + return Err(ContractError::ZeroClaimable); + } + + // Reset claimable to zero before transfer (reentrancy-safe) + write_claimable_dividend(&env, &creator, &caller, 0); + + // Transfer XLM from contract to caller + let native = env.native_token(); + native.transfer(&env.current_contract_address(), &caller, &claimable); + + // Emit event + env.events().publish( + events::dividend_claimed_topics(&creator, &caller), + events::DividendClaimedEvent { + creator_id: creator, + claimant: caller, + amount: claimable, + }, + ); + + Ok(()) +} + +/// Returns the unclaimed dividend balance for a specific (creator, holder) pair. +pub fn get_claimable_dividend(env: Env, creator: Address, wallet: Address) -> i128 { + read_claimable_dividend(&env, &creator, &wallet) +} + /// Read-only batch view: returns [`CreatorDetailsView`] for each address in `creators`. /// /// Iterates the provided addresses in order and fetches each creator's profile @@ -1435,6 +1555,25 @@ impl CreatorKeysContract { let (creator_fee, protocol_fee) = Self::compute_fees_for_payment(env.clone(), price)?; checked_format_quote_response(price, creator_fee, protocol_fee, false) } + fn read_holders_list(env: &Env, creator: &Address) -> Vec
{ + let key = DataKey::HoldersList(creator.clone()); + env.storage().persistent().get(&key).unwrap_or(Vec::new(env)) +} + +fn write_holders_list(env: &Env, creator: &Address, holders: &Vec
) { + let key = DataKey::HoldersList(creator.clone()); + env.storage().persistent().set(&key, holders); +} + +fn read_claimable_dividend(env: &Env, creator: &Address, holder: &Address) -> i128 { + let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); + env.storage().persistent().get(&key).unwrap_or(0) +} + +fn write_claimable_dividend(env: &Env, creator: &Address, holder: &Address, amount: i128) { + let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); + env.storage().persistent().set(&key, &amount); +} } #[cfg(test)] From 00606bd640fadd5caca6975bbab011c7feb52c48 Mon Sep 17 00:00:00 2001 From: Syed Ghufran Hassan Date: Wed, 24 Jun 2026 16:12:18 +0500 Subject: [PATCH 02/11] Update lib.rs --- creator-keys/src/lib.rs | 3376 ++++++++++++++++++++------------------- 1 file changed, 1755 insertions(+), 1621 deletions(-) diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 60a4c54..c6b9816 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -1,7 +1,10 @@ #![no_std] pub mod quote_view_errors; -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, String, Vec}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, Address, Env, String, + Vec, +}; pub mod events; @@ -47,215 +50,226 @@ pub mod events; /// } /// ``` pub enum ContractError { - AlreadyRegistered = 1, - NotRegistered = 2, - Overflow = 3, - InsufficientPayment = 4, - KeyPriceNotSet = 5, - NotPositiveAmount = 6, - FeeConfigNotSet = 7, - InvalidFeeConfig = 8, - InsufficientBalance = 9, - SellUnderflow = 10, - ProtocolFeeExceedsCap = 11, - HandleTooShort = 12, - HandleTooLong = 13, - InvalidHandleCharacter = 14, - ZeroAddress = 15, - SlippageExceeded = 16, - ProtocolPaused = 17, - Unauthorized = 18, - ZeroClaimable = 19, - NoHolders = 20, - DividendAmountZero = 21, + AlreadyRegistered = 1, + NotRegistered = 2, + Overflow = 3, + InsufficientPayment = 4, + KeyPriceNotSet = 5, + NotPositiveAmount = 6, + FeeConfigNotSet = 7, + InvalidFeeConfig = 8, + InsufficientBalance = 9, + SellUnderflow = 10, + ProtocolFeeExceedsCap = 11, + HandleTooShort = 12, + HandleTooLong = 13, + InvalidHandleCharacter = 14, + ZeroAddress = 15, + SlippageExceeded = 16, + ProtocolPaused = 17, + Unauthorized = 18, + ZeroClaimable = 19, + NoHolders = 20, + DividendAmountZero = 21, } pub mod fee { - use crate::ContractError; + use crate::ContractError; - use soroban_sdk::contracttype; + use soroban_sdk::contracttype; - /// Basis points per 100% (10000 = 100%). - pub const BPS_MAX: u32 = 10_000; + /// Basis points per 100% (10000 = 100%). + pub const BPS_MAX: u32 = 10_000; - /// Maximum safe amount to prevent overflow in fee calculations. - pub const MAX_SAFE_AMOUNT: i128 = i128::MAX / BPS_MAX as i128; + /// Maximum safe amount to prevent overflow in fee calculations. + pub const MAX_SAFE_AMOUNT: i128 = i128::MAX / BPS_MAX as i128; - /// Maximum protocol share when configuring fees via [`assert_valid_fee_bps`]. - /// - /// Caps the on-chain configured protocol take at 50% so fee settings stay within - /// expected economic bounds before they affect market logic. - pub const PROTOCOL_BPS_MAX: u32 = 5_000; + /// Maximum protocol share when configuring fees via [`assert_valid_fee_bps`]. + /// + /// Caps the on-chain configured protocol take at 50% so fee settings stay within + /// expected economic bounds before they affect market logic. + pub const PROTOCOL_BPS_MAX: u32 = 5_000; - #[derive(Clone, Eq, PartialEq)] - #[contracttype] - pub struct FeeConfig { - pub creator_bps: u32, - pub protocol_bps: u32, - } - - /// Validates creator and protocol basis points for storage and fee-setting entrypoints. - pub fn validate_fee_bps(creator_bps: u32, protocol_bps: u32) -> bool { - let Some(sum) = creator_bps.checked_add(protocol_bps) else { - return false; - }; - if sum != BPS_MAX { - return false; - } - if protocol_bps > PROTOCOL_BPS_MAX { - return false; - } - true - } - - /// Shared guard for fee config updates that need structured contract errors. - pub fn assert_valid_fee_bps(creator_bps: u32, protocol_bps: u32) -> Result<(), ContractError> { - let Some(sum) = creator_bps.checked_add(protocol_bps) else { - return Err(ContractError::InvalidFeeConfig); - }; - if sum != BPS_MAX { - return Err(ContractError::InvalidFeeConfig); - } - if protocol_bps > PROTOCOL_BPS_MAX { - return Err(ContractError::ProtocolFeeExceedsCap); - } - Ok(()) - } - - /// Computes the fee split for a given total amount. - /// - /// Returns `(creator_amount, protocol_amount)`. Remainder from integer division - /// is assigned to the creator. Ensures creator_amount + protocol_amount == total. - pub fn compute_fee_split(total: i128, _creator_bps: u32, protocol_bps: u32) -> (i128, i128) { - if total <= 0 { - return (0, 0); - } - let protocol_amount = (total * protocol_bps as i128) / BPS_MAX as i128; - let creator_amount = total - protocol_amount; - (creator_amount, protocol_amount) - } - - /// Safely applies a percentage-based fee to an amount. - /// - /// Returns `None` if the multiplication overflows. Rounding is performed via - /// floor division towards zero. - pub fn apply_percentage_fee(amount: i128, bps: u32) -> Option { - if amount <= 0 { - return Some(0); - } - checked_div_i128(amount.checked_mul(bps as i128)?, BPS_MAX as i128) - } - - /// Computes the fee split safely, returning `None` if multiplication or subtraction overflows. - pub fn checked_compute_fee_split( - total: i128, - _creator_bps: u32, - protocol_bps: u32, - ) -> Option<(i128, i128)> { - if total <= 0 { - return Some((0, 0)); - } - let protocol_amount = apply_percentage_fee(total, protocol_bps)?; - let creator_amount = checked_sub_i128(total, protocol_amount)?; - Some((creator_amount, protocol_amount)) - } - - /// Performs checked integer multiplication for quote math helpers. - pub fn checked_mul_i128(a: i128, b: i128) -> Option { - a.checked_mul(b) - } - - /// Performs checked integer division for quote math helpers. - pub fn checked_div_i128(dividend: i128, divisor: i128) -> Option { - if divisor == 0 { - return None; - } - dividend.checked_div(divisor) - } - - /// Performs checked integer subtraction for quote math helpers. - pub fn checked_sub_i128(left: i128, right: i128) -> Option { - left.checked_sub(right) - } + #[derive(Clone, Eq, PartialEq)] + #[contracttype] + pub struct FeeConfig { + pub creator_bps: u32, + pub protocol_bps: u32, + } - /// Performs checked integer addition for quote math helpers. - pub fn checked_add_i128(left: i128, right: i128) -> Option { - left.checked_add(right) + /// Validates creator and protocol basis points for storage and fee-setting entrypoints. + pub fn validate_fee_bps(creator_bps: u32, protocol_bps: u32) -> bool { + let Some(sum) = creator_bps.checked_add(protocol_bps) else { + return false; + }; + if sum != BPS_MAX { + return false; + } + if protocol_bps > PROTOCOL_BPS_MAX { + return false; + } + true + } + + /// Shared guard for fee config updates that need structured contract errors. + pub fn assert_valid_fee_bps( + creator_bps: u32, + protocol_bps: u32, + ) -> Result<(), ContractError> { + let Some(sum) = creator_bps.checked_add(protocol_bps) else { + return Err(ContractError::InvalidFeeConfig); + }; + if sum != BPS_MAX { + return Err(ContractError::InvalidFeeConfig); } - - /// Computes the checked sum of creator and protocol fee components. - /// - /// Returns `None` if the addition would overflow. Use this helper wherever - /// fee components are combined before being compared against a price or total, - /// to keep the overflow guard consistent across buy and sell quote paths. - /// - /// # Naming convention - /// - /// Quote helpers in this module follow a `checked_*` prefix convention: - /// - `checked_*` functions return `Option` and propagate `None` on overflow. - /// - `compute_*` functions return the result directly (may panic on overflow in - /// debug builds; use only where inputs are already validated). - /// - `apply_*` functions apply a rate or percentage to a single amount. - /// - /// `checked_fee_sum` belongs to the `checked_*` family: it is the canonical - /// helper for summing two fee components before they are used in total-amount - /// arithmetic, replacing ad-hoc inline `checked_add` calls at each call site. - pub fn checked_fee_sum(creator_fee: i128, protocol_fee: i128) -> Option { - creator_fee.checked_add(protocol_fee) + if protocol_bps > PROTOCOL_BPS_MAX { + return Err(ContractError::ProtocolFeeExceedsCap); } + Ok(()) + } + + /// Computes the fee split for a given total amount. + /// + /// Returns `(creator_amount, protocol_amount)`. Remainder from integer division + /// is assigned to the creator. Ensures creator_amount + protocol_amount == total. + pub fn compute_fee_split( + total: i128, + _creator_bps: u32, + protocol_bps: u32, + ) -> (i128, i128) { + if total <= 0 { + return (0, 0); + } + let protocol_amount = (total * protocol_bps as i128) / BPS_MAX as i128; + let creator_amount = total - protocol_amount; + (creator_amount, protocol_amount) + } + + /// Safely applies a percentage-based fee to an amount. + /// + /// Returns `None` if the multiplication overflows. Rounding is performed via + /// floor division towards zero. + pub fn apply_percentage_fee(amount: i128, bps: u32) -> Option { + if amount <= 0 { + return Some(0); + } + checked_div_i128(amount.checked_mul(bps as i128)?, BPS_MAX as i128) + } + + /// Computes the fee split safely, returning `None` if multiplication or subtraction overflows. + pub fn checked_compute_fee_split( + total: i128, + _creator_bps: u32, + protocol_bps: u32, + ) -> Option<(i128, i128)> { + if total <= 0 { + return Some((0, 0)); + } + let protocol_amount = apply_percentage_fee(total, protocol_bps)?; + let creator_amount = checked_sub_i128(total, protocol_amount)?; + Some((creator_amount, protocol_amount)) + } + + /// Performs checked integer multiplication for quote math helpers. + pub fn checked_mul_i128(a: i128, b: i128) -> Option { + a.checked_mul(b) + } + + /// Performs checked integer division for quote math helpers. + pub fn checked_div_i128(dividend: i128, divisor: i128) -> Option { + if divisor == 0 { + return None; + } + dividend.checked_div(divisor) + } + + /// Performs checked integer subtraction for quote math helpers. + pub fn checked_sub_i128(left: i128, right: i128) -> Option { + left.checked_sub(right) + } + + /// Performs checked integer addition for quote math helpers. + pub fn checked_add_i128(left: i128, right: i128) -> Option { + left.checked_add(right) + } + + /// Computes the checked sum of creator and protocol fee components. + /// + /// Returns `None` if the addition would overflow. Use this helper wherever + /// fee components are combined before being compared against a price or total, + /// to keep the overflow guard consistent across buy and sell quote paths. + /// + /// # Naming convention + /// + /// Quote helpers in this module follow a `checked_*` prefix convention: + /// - `checked_*` functions return `Option` and propagate `None` on overflow. + /// - `compute_*` functions return the result directly (may panic on overflow in + /// debug builds; use only where inputs are already validated). + /// - `apply_*` functions apply a rate or percentage to a single amount. + /// + /// `checked_fee_sum` belongs to the `checked_*` family: it is the canonical + /// helper for summing two fee components before they are used in total-amount + /// arithmetic, replacing ad-hoc inline `checked_add` calls at each call site. + pub fn checked_fee_sum( + creator_fee: i128, + protocol_fee: i128, + ) -> Option { + creator_fee.checked_add(protocol_fee) + } } pub mod constants { - use super::DataKey; - use soroban_sdk::Address; - - pub mod storage { - use super::{creator_key, key_balance_key, DataKey}; - use soroban_sdk::Address; - - pub const FEE_CONFIG: DataKey = DataKey::FeeConfig; - pub const KEY_PRICE: DataKey = DataKey::KeyPrice; - pub const TREASURY_ADDRESS: DataKey = DataKey::TreasuryAddress; - pub const ADMIN_ADDRESS: DataKey = DataKey::AdminAddress; - pub const PROTOCOL_FEE_RECIPIENT: DataKey = DataKey::ProtocolFeeRecipient; - pub const PROTOCOL_FEE_RECIPIENT_BALANCE: DataKey = DataKey::ProtocolFeeRecipientBalance; - pub const PROTOCOL_STATE_VERSION: DataKey = DataKey::ProtocolStateVersion; - pub const PAUSED: DataKey = DataKey::Paused; - - pub fn creator_fee_balance(creator: &Address) -> DataKey { - DataKey::CreatorFeeBalance(creator.clone()) - } - - pub fn creator(creator: &Address) -> DataKey { - creator_key(creator) - } - - pub fn key_balance(creator: &Address, holder: &Address) -> DataKey { - key_balance_key(creator, holder) - } - } + use super::DataKey; + use soroban_sdk::Address; - fn creator_key(creator: &Address) -> DataKey { - DataKey::Creator(creator.clone()) - } - - fn key_balance_key(creator: &Address, holder: &Address) -> DataKey { - DataKey::KeyBalance(creator.clone(), holder.clone()) - } + pub mod storage { + use super::{creator_key, key_balance_key, DataKey}; + use soroban_sdk::Address; - pub mod creator_reads { - pub const DETAILS: &str = "get_creator_details"; - pub const FEE_BPS: &str = "get_creator_fee_bps"; - pub const FEE_CONFIG: &str = "get_creator_fee_config"; - pub const FEE_RECIPIENT: &str = "get_creator_fee_recipient"; - pub const FEE_RECIPIENT_BALANCE: &str = "get_creator_fee_balance"; - pub const HOLDER_KEY_COUNT: &str = "get_holder_key_count"; - pub const PROFILE: &str = "get_creator"; - pub const SUPPLY: &str = "get_creator_supply"; - pub const TREASURY_SHARE: &str = "get_creator_treasury_share"; - pub const NAME: &str = "get_key_name"; - pub const SYMBOL: &str = "get_key_symbol"; - } + pub const FEE_CONFIG: DataKey = DataKey::FeeConfig; + pub const KEY_PRICE: DataKey = DataKey::KeyPrice; + pub const TREASURY_ADDRESS: DataKey = DataKey::TreasuryAddress; + pub const ADMIN_ADDRESS: DataKey = DataKey::AdminAddress; + pub const PROTOCOL_FEE_RECIPIENT: DataKey = DataKey::ProtocolFeeRecipient; + pub const PROTOCOL_FEE_RECIPIENT_BALANCE: DataKey = + DataKey::ProtocolFeeRecipientBalance; + pub const PROTOCOL_STATE_VERSION: DataKey = DataKey::ProtocolStateVersion; + pub const PAUSED: DataKey = DataKey::Paused; + + pub fn creator_fee_balance(creator: &Address) -> DataKey { + DataKey::CreatorFeeBalance(creator.clone()) + } + + pub fn creator(creator: &Address) -> DataKey { + creator_key(creator) + } + + pub fn key_balance(creator: &Address, holder: &Address) -> DataKey { + key_balance_key(creator, holder) + } + } + + fn creator_key(creator: &Address) -> DataKey { + DataKey::Creator(creator.clone()) + } + + fn key_balance_key(creator: &Address, holder: &Address) -> DataKey { + DataKey::KeyBalance(creator.clone(), holder.clone()) + } + + pub mod creator_reads { + pub const DETAILS: &str = "get_creator_details"; + pub const FEE_BPS: &str = "get_creator_fee_bps"; + pub const FEE_CONFIG: &str = "get_creator_fee_config"; + pub const FEE_RECIPIENT: &str = "get_creator_fee_recipient"; + pub const FEE_RECIPIENT_BALANCE: &str = "get_creator_fee_balance"; + pub const HOLDER_KEY_COUNT: &str = "get_holder_key_count"; + pub const PROFILE: &str = "get_creator"; + pub const SUPPLY: &str = "get_creator_supply"; + pub const TREASURY_SHARE: &str = "get_creator_treasury_share"; + pub const NAME: &str = "get_key_name"; + pub const SYMBOL: &str = "get_key_symbol"; + } } /// Stable, non-optional view of the protocol fee configuration. @@ -265,9 +279,9 @@ pub mod constants { #[derive(Clone)] #[contracttype] pub struct ProtocolFeeView { - pub creator_bps: u32, - pub protocol_bps: u32, - pub is_configured: bool, + pub creator_bps: u32, + pub protocol_bps: u32, + pub is_configured: bool, } /// Stable, non-optional view of creator details. @@ -285,17 +299,17 @@ pub struct ProtocolFeeView { #[derive(Clone)] #[contracttype] pub struct CreatorDetailsView { - pub creator: Address, - pub handle: String, - pub supply: u32, - pub is_registered: bool, - /// Ledger sequence number at the time the creator registered. - /// - /// Set to `env.ledger().sequence()` inside [`CreatorKeysContract::register_creator`]. - /// Returns `0` for unregistered addresses so callers never receive an `Option`. - /// Clients can use this field to sort a marketplace grid chronologically without - /// maintaining a separate off-chain index. - pub registered_at: u32, + pub creator: Address, + pub handle: String, + pub supply: u32, + pub is_registered: bool, + /// Ledger sequence number at the time the creator registered. + /// + /// Set to `env.ledger().sequence()` inside [`CreatorKeysContract::register_creator`]. + /// Returns `0` for unregistered addresses so callers never receive an `Option`. + /// Clients can use this field to sort a marketplace grid chronologically without + /// maintaining a separate off-chain index. + pub registered_at: u32, } /// Stable, non-optional view of a creator's fee configuration. /// @@ -305,10 +319,10 @@ pub struct CreatorDetailsView { #[derive(Clone)] #[contracttype] pub struct CreatorFeeView { - pub creator_bps: u32, - pub protocol_bps: u32, - pub is_registered: bool, - pub is_configured: bool, + pub creator_bps: u32, + pub protocol_bps: u32, + pub is_registered: bool, + pub is_configured: bool, } /// Stable, non-optional view of a holder's key count for a creator. @@ -319,10 +333,10 @@ pub struct CreatorFeeView { #[derive(Clone)] #[contracttype] pub struct HolderKeyCountView { - pub creator: Address, - pub holder: Address, - pub key_count: u32, - pub creator_exists: bool, + pub creator: Address, + pub holder: Address, + pub key_count: u32, + pub creator_exists: bool, } /// Stable, non-optional view of a buy or sell quote. @@ -331,10 +345,10 @@ pub struct HolderKeyCountView { #[derive(Clone, Debug, PartialEq)] #[contracttype] pub struct QuoteResponse { - pub price: i128, - pub creator_fee: i128, - pub protocol_fee: i128, - pub total_amount: i128, + pub price: i128, + pub creator_fee: i128, + pub protocol_fee: i128, + pub total_amount: i128, } /// Shared result type for read-only quote methods. @@ -360,46 +374,50 @@ pub const HANDLE_LEN_MAX: u32 = 32; #[derive(Clone)] #[contracttype] pub enum DataKey { - Creator(Address), - FeeConfig, - KeyPrice, - KeyBalance(Address, Address), - TreasuryAddress, - AdminAddress, - ProtocolFeeRecipient, - ProtocolFeeRecipientBalance, - CreatorFeeBalance(Address), - ProtocolStateVersion, - Paused, - HoldersList(Address), - DividendClaimable(Address, Address), + Creator(Address), + FeeConfig, + KeyPrice, + KeyBalance(Address, Address), + TreasuryAddress, + AdminAddress, + ProtocolFeeRecipient, + ProtocolFeeRecipientBalance, + CreatorFeeBalance(Address), + ProtocolStateVersion, + Paused, + HoldersList(Address), + DividendClaimable(Address, Address), } #[derive(Clone, Debug, PartialEq)] #[contracttype] pub struct CreatorProfile { - pub creator: Address, - pub handle: String, - pub supply: u32, - pub holder_count: u32, - pub fee_recipient: Address, - /// Ledger sequence number captured at registration time via `env.ledger().sequence()`. - /// - /// Stored as the last field so existing serialised profiles written before this - /// field was added deserialise correctly — the Soroban persistent storage layer - /// reads structs by field index, so appending is the only safe extension pattern. - pub registered_at: u32, + pub creator: Address, + pub handle: String, + pub supply: u32, + pub holder_count: u32, + pub fee_recipient: Address, + /// Ledger sequence number captured at registration time via `env.ledger().sequence()`. + /// + /// Stored as the last field so existing serialised profiles written before this + /// field was added deserialise correctly — the Soroban persistent storage layer + /// reads structs by field index, so appending is the only safe extension pattern. + pub registered_at: u32, } /// Reads a creator profile from storage, returning `None` for unregistered creators. /// /// Use this helper wherever repeated creator read logic is needed to keep /// missing-creator behavior consistent across the contract. -pub fn read_creator_profile(env: &Env, creator: &Address) -> Option { - let key = constants::storage::creator(creator); - env.storage() - .persistent() - .get::(&key) +pub fn read_creator_profile( + env: &Env, + creator: &Address, +) -> Option { + let key = constants::storage::creator(creator); + env + .storage() + .persistent() + .get::(&key) } /// Reads a registered creator profile, returning an error when the creator is missing. @@ -407,10 +425,10 @@ pub fn read_creator_profile(env: &Env, creator: &Address) -> Option Result { - read_creator_profile(env, creator).ok_or(ContractError::NotRegistered) + read_creator_profile(env, creator).ok_or(ContractError::NotRegistered) } /// Reads the key balance (supply) for a creator, returning `0` for unregistered creators. @@ -418,9 +436,9 @@ pub fn read_registered_creator_profile( /// Use this helper wherever repeated key balance read logic is needed to keep /// missing-balance behavior consistent across the contract. pub fn read_key_balance(env: &Env, creator: &Address) -> u32 { - read_creator_profile(env, creator) - .map(|p| p.supply) - .unwrap_or(0) + read_creator_profile(env, creator) + .map(|p| p.supply) + .unwrap_or(0) } /// Reads an empty string for use as a default in read-only view methods. @@ -428,7 +446,7 @@ pub fn read_key_balance(env: &Env, creator: &Address) -> u32 { /// Use this helper wherever an empty string is needed to maintain consistency /// and reduce duplication of string allocation logic. pub fn read_none_string(env: &Env) -> String { - String::from_str(env, "") + String::from_str(env, "") } /// Reads the handle for a creator, returning an empty string for unregistered creators. @@ -436,88 +454,93 @@ pub fn read_none_string(env: &Env) -> String { /// Use this helper wherever repeated handle read logic is needed to maintain /// missing-handle behavior consistency across the contract. pub fn read_creator_handle(env: &Env, creator: &Address) -> String { - read_creator_profile(env, creator) - .map(|p| p.handle) - .unwrap_or_else(|| read_none_string(env)) + read_creator_profile(env, creator) + .map(|p| p.handle) + .unwrap_or_else(|| read_none_string(env)) } /// Reads accrued creator fee balance for a creator, returning `0` when none is stored. -pub fn read_creator_fee_recipient_balance(env: &Env, creator: &Address) -> i128 { - let key = constants::storage::creator_fee_balance(creator); - env.storage().persistent().get(&key).unwrap_or(0) +pub fn read_creator_fee_recipient_balance( + env: &Env, + creator: &Address, +) -> i128 { + let key = constants::storage::creator_fee_balance(creator); + env.storage().persistent().get(&key).unwrap_or(0) } /// Credits `amount` to the creator fee recipient balance for `creator`. fn credit_creator_fee_recipient_balance( - env: &Env, - creator: &Address, - amount: i128, + env: &Env, + creator: &Address, + amount: i128, ) -> Result<(), ContractError> { - if amount <= 0 { - return Ok(()); - } - let key = constants::storage::creator_fee_balance(creator); - let current = read_creator_fee_recipient_balance(env, creator); - let updated = current.checked_add(amount).ok_or(ContractError::Overflow)?; - env.storage().persistent().set(&key, &updated); - Ok(()) + if amount <= 0 { + return Ok(()); + } + let key = constants::storage::creator_fee_balance(creator); + let current = read_creator_fee_recipient_balance(env, creator); + let updated = current.checked_add(amount).ok_or(ContractError::Overflow)?; + env.storage().persistent().set(&key, &updated); + Ok(()) } fn is_valid_handle_byte(byte: u8) -> bool { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' } fn validate_creator_handle(handle: &String) -> Result<(), ContractError> { - let len = handle.len(); - if len < HANDLE_LEN_MIN { - return Err(ContractError::HandleTooShort); - } - if len > HANDLE_LEN_MAX { - return Err(ContractError::HandleTooLong); - } - - let mut bytes = [0u8; HANDLE_LEN_MAX as usize]; - handle.copy_into_slice(&mut bytes[..len as usize]); - if bytes[..len as usize] - .iter() - .any(|byte| !is_valid_handle_byte(*byte)) - { - return Err(ContractError::InvalidHandleCharacter); - } - - Ok(()) + let len = handle.len(); + if len < HANDLE_LEN_MIN { + return Err(ContractError::HandleTooShort); + } + if len > HANDLE_LEN_MAX { + return Err(ContractError::HandleTooLong); + } + + let mut bytes = [0u8; HANDLE_LEN_MAX as usize]; + handle.copy_into_slice(&mut bytes[..len as usize]); + if bytes[..len as usize] + .iter() + .any(|byte| !is_valid_handle_byte(*byte)) + { + return Err(ContractError::InvalidHandleCharacter); + } + + Ok(()) } fn is_paused(env: &Env) -> bool { - env.storage() - .persistent() - .get::(&constants::storage::PAUSED) - .unwrap_or(false) + env + .storage() + .persistent() + .get::(&constants::storage::PAUSED) + .unwrap_or(false) } fn assert_not_paused(env: &Env) -> Result<(), ContractError> { - if is_paused(env) { - return Err(ContractError::ProtocolPaused); - } - Ok(()) + if is_paused(env) { + return Err(ContractError::ProtocolPaused); + } + Ok(()) } fn assert_is_admin(env: &Env, caller: &Address) -> Result<(), ContractError> { - let admin: Address = env - .storage() - .persistent() - .get(&constants::storage::ADMIN_ADDRESS) - .ok_or(ContractError::Unauthorized)?; - if *caller != admin { - return Err(ContractError::Unauthorized); - } - Ok(()) + let admin: Address = env + .storage() + .persistent() + .get(&constants::storage::ADMIN_ADDRESS) + .ok_or(ContractError::Unauthorized)?; + if *caller != admin { + return Err(ContractError::Unauthorized); + } + Ok(()) } fn read_protocol_fee_config(env: &Env) -> Option { - env.storage() - .persistent() - .get(&constants::storage::FEE_CONFIG) + env + .storage() + .persistent() + .get(&constants::storage::FEE_CONFIG) } /// Validates that an address is not the Stellar zero address. @@ -525,119 +548,139 @@ fn read_protocol_fee_config(env: &Env) -> Option { /// The zero address (`GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF`) /// is the all-zero public key. Setting it as a fee recipient would silently /// burn all protocol fees. This helper rejects it at the point of assignment. -fn validate_non_zero_address(env: &Env, addr: &Address) -> Result<(), ContractError> { - let zero_str = String::from_str( - env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ); - let zero_addr = Address::from_string(&zero_str); - if *addr == zero_addr { - return Err(ContractError::ZeroAddress); - } - Ok(()) +fn validate_non_zero_address( + env: &Env, + addr: &Address, +) -> Result<(), ContractError> { + let zero_str = String::from_str( + env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ); + let zero_addr = Address::from_string(&zero_str); + if *addr == zero_addr { + return Err(ContractError::ZeroAddress); + } + Ok(()) } -fn read_required_protocol_fee_config(env: &Env) -> Result { - read_protocol_fee_config(env).ok_or(ContractError::FeeConfigNotSet) +fn read_required_protocol_fee_config( + env: &Env, +) -> Result { + read_protocol_fee_config(env).ok_or(ContractError::FeeConfigNotSet) } fn read_protocol_fee_recipient_balance(env: &Env) -> i128 { - env.storage() - .persistent() - .get(&constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE) - .unwrap_or(0) + env + .storage() + .persistent() + .get(&constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE) + .unwrap_or(0) } -fn credit_protocol_fee_recipient_balance(env: &Env, amount: i128) -> Result<(), ContractError> { - if amount <= 0 { - return Ok(()); - } - let updated = read_protocol_fee_recipient_balance(env) - .checked_add(amount) - .ok_or(ContractError::Overflow)?; - env.storage().persistent().set( - &constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE, - &updated, - ); - Ok(()) +fn credit_protocol_fee_recipient_balance( + env: &Env, + amount: i128, +) -> Result<(), ContractError> { + if amount <= 0 { + return Ok(()); + } + let updated = read_protocol_fee_recipient_balance(env) + .checked_add(amount) + .ok_or(ContractError::Overflow)?; + env.storage().persistent().set( + &constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE, + &updated, + ); + Ok(()) } -fn assert_buy_price_slippage(price: i128, max_price: Option) -> Result<(), ContractError> { - if let Some(max) = max_price { - if price > max { - return Err(ContractError::SlippageExceeded); - } +fn assert_buy_price_slippage( + price: i128, + max_price: Option, +) -> Result<(), ContractError> { + if let Some(max) = max_price { + if price > max { + return Err(ContractError::SlippageExceeded); } - Ok(()) + } + Ok(()) } -fn compute_sell_proceeds(env: &Env, price: i128) -> Result { - let (creator_fee, protocol_fee) = - CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?; - let fees = fee::checked_fee_sum(creator_fee, protocol_fee).ok_or(ContractError::Overflow)?; - fee::checked_sub_i128(price, fees).ok_or(ContractError::SellUnderflow) +fn compute_sell_proceeds( + env: &Env, + price: i128, +) -> Result { + let (creator_fee, protocol_fee) = + CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?; + let fees = fee::checked_fee_sum(creator_fee, protocol_fee) + .ok_or(ContractError::Overflow)?; + fee::checked_sub_i128(price, fees).ok_or(ContractError::SellUnderflow) } fn assert_sell_proceeds_slippage( - env: &Env, - min_proceeds: Option, + env: &Env, + min_proceeds: Option, ) -> Result<(), ContractError> { - if let Some(min) = min_proceeds { - let price: i128 = env - .storage() - .persistent() - .get(&constants::storage::KEY_PRICE) - .ok_or(ContractError::KeyPriceNotSet)?; - let proceeds = compute_sell_proceeds(env, price)?; - if proceeds < min { - return Err(ContractError::SlippageExceeded); - } - } - Ok(()) + if let Some(min) = min_proceeds { + let price: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; + let proceeds = compute_sell_proceeds(env, price)?; + if proceeds < min { + return Err(ContractError::SlippageExceeded); + } + } + Ok(()) } fn accrue_sell_protocol_fee(env: &Env) -> Result<(), ContractError> { - if env - .storage() - .persistent() - .get::(&constants::storage::PROTOCOL_FEE_RECIPIENT) - .is_none() - { - return Ok(()); - } - - let Some(price) = env - .storage() - .persistent() - .get(&constants::storage::KEY_PRICE) - else { - return Ok(()); - }; - - if read_protocol_fee_config(env).is_none() { - return Ok(()); - } - - let (_, protocol_fee) = CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?; - credit_protocol_fee_recipient_balance(env, protocol_fee) + if env + .storage() + .persistent() + .get::(&constants::storage::PROTOCOL_FEE_RECIPIENT) + .is_none() + { + return Ok(()); + } + + let Some(price) = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + else { + return Ok(()); + }; + + if read_protocol_fee_config(env).is_none() { + return Ok(()); + } + + let (_, protocol_fee) = + CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?; + credit_protocol_fee_recipient_balance(env, protocol_fee) } /// Resolves and validates the shared inputs required by read-only quote methods. /// /// Reads the key price from storage and confirms the creator is registered. /// Returns `(price)` on success, or the appropriate [`ContractError`] on failure. -fn resolve_quote_inputs(env: &Env, creator: &Address) -> Result, ContractError> { - let price: i128 = env - .storage() - .persistent() - .get(&constants::storage::KEY_PRICE) - .ok_or(ContractError::KeyPriceNotSet)?; - - if read_creator_profile(env, creator).is_none() { - return Err(ContractError::NotRegistered); - } - - normalize_quote_amount(price) +fn resolve_quote_inputs( + env: &Env, + creator: &Address, +) -> Result, ContractError> { + let price: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; + + if read_creator_profile(env, creator).is_none() { + return Err(ContractError::NotRegistered); + } + + normalize_quote_amount(price) } /// Normalizes quote amounts before fee math is applied. @@ -646,53 +689,54 @@ fn resolve_quote_inputs(env: &Env, creator: &Address) -> Result, Co /// Negative quote amounts are rejected consistently across buy and sell paths. /// Amounts exceeding MAX_SAFE_AMOUNT are rejected to prevent overflow in fee calculations. fn normalize_quote_amount(amount: i128) -> Result, ContractError> { - if amount < 0 { - return Err(ContractError::NotPositiveAmount); - } + if amount < 0 { + return Err(ContractError::NotPositiveAmount); + } - if amount == 0 { - return Ok(None); - } + if amount == 0 { + return Ok(None); + } - if amount > fee::MAX_SAFE_AMOUNT { - return Err(ContractError::Overflow); - } + if amount > fee::MAX_SAFE_AMOUNT { + return Err(ContractError::Overflow); + } - Ok(Some(amount)) + Ok(Some(amount)) } fn zero_quote_response() -> QuoteResponse { - QuoteResponse { - price: 0, - creator_fee: 0, - protocol_fee: 0, - total_amount: 0, - } + QuoteResponse { + price: 0, + creator_fee: 0, + protocol_fee: 0, + total_amount: 0, + } } /// Formats a quote response with overflow-safe total amount calculation. /// /// Returns `Err(ContractError::Overflow)` if any addition or subtraction would overflow. fn checked_format_quote_response( - price: i128, - creator_fee: i128, - protocol_fee: i128, - is_buy: bool, + price: i128, + creator_fee: i128, + protocol_fee: i128, + is_buy: bool, ) -> QuoteViewResult { - let fees = fee::checked_fee_sum(creator_fee, protocol_fee).ok_or(ContractError::Overflow)?; - - let total_amount = if is_buy { - price.checked_add(fees).ok_or(ContractError::Overflow)? - } else { - fee::checked_sub_i128(price, fees).ok_or(ContractError::SellUnderflow)? - }; - - Ok(QuoteResponse { - price, - creator_fee, - protocol_fee, - total_amount, - }) + let fees = fee::checked_fee_sum(creator_fee, protocol_fee) + .ok_or(ContractError::Overflow)?; + + let total_amount = if is_buy { + price.checked_add(fees).ok_or(ContractError::Overflow)? + } else { + fee::checked_sub_i128(price, fees).ok_or(ContractError::SellUnderflow)? + }; + + Ok(QuoteResponse { + price, + creator_fee, + protocol_fee, + total_amount, + }) } #[contract] @@ -700,295 +744,313 @@ pub struct CreatorKeysContract; #[contractimpl] impl CreatorKeysContract { - /// Registers a new creator profile. This is a contract initialization - /// entrypoint; the contract has no single `initialize` call, so the - /// init-time parameter validation lives on the individual setters. - /// - /// Parameter validation: - /// - `creator`: must authorize the call (`require_auth`). A profile must not - /// already exist for this address, otherwise - /// [`ContractError::AlreadyRegistered`]. - /// - `handle`: validated by [`validate_creator_handle`] — below the minimum - /// length returns [`ContractError::HandleTooShort`], above the maximum - /// returns [`ContractError::HandleTooLong`], and any disallowed byte - /// returns [`ContractError::InvalidHandleCharacter`]. - pub fn register_creator( - env: Env, - creator: Address, - handle: String, - ) -> Result<(), ContractError> { - creator.require_auth(); - assert_not_paused(&env)?; - - validate_creator_handle(&handle)?; - - let key = constants::storage::creator(&creator); - // Creator profile storage is a single source of truth keyed by creator address. - // Once written, this key's existence is the registration invariant. - if env.storage().persistent().has(&key) { - return Err(ContractError::AlreadyRegistered); - } - - let profile = CreatorProfile { - creator: creator.clone(), - handle, - supply: 0, - holder_count: 0, - fee_recipient: creator.clone(), - registered_at: env.ledger().sequence(), - }; - - let fee_config = read_protocol_fee_config(&env).unwrap_or(fee::FeeConfig { - creator_bps: 0, - protocol_bps: 0, - }); - - // Persist profile before event publication so indexers reading contract state - // after this tx observe the same registration payload that was emitted. - env.storage().persistent().set(&key, &profile); - env.events().publish( - events::register_event_topics(&profile.creator), - events::CreatorRegisteredEvent { - creator: profile.creator.clone(), - handle: profile.handle.clone(), - supply: profile.supply, - holder_count: profile.holder_count, - creator_bps: fee_config.creator_bps, - protocol_bps: fee_config.protocol_bps, - }, - ); - - Ok(()) - } + /// Registers a new creator profile. This is a contract initialization + /// entrypoint; the contract has no single `initialize` call, so the + /// init-time parameter validation lives on the individual setters. + /// + /// Parameter validation: + /// - `creator`: must authorize the call (`require_auth`). A profile must not + /// already exist for this address, otherwise + /// [`ContractError::AlreadyRegistered`]. + /// - `handle`: validated by [`validate_creator_handle`] — below the minimum + /// length returns [`ContractError::HandleTooShort`], above the maximum + /// returns [`ContractError::HandleTooLong`], and any disallowed byte + /// returns [`ContractError::InvalidHandleCharacter`]. + pub fn register_creator( + env: Env, + creator: Address, + handle: String, + ) -> Result<(), ContractError> { + creator.require_auth(); + assert_not_paused(&env)?; + + validate_creator_handle(&handle)?; + + let key = constants::storage::creator(&creator); + // Creator profile storage is a single source of truth keyed by creator address. + // Once written, this key's existence is the registration invariant. + if env.storage().persistent().has(&key) { + return Err(ContractError::AlreadyRegistered); + } + + let profile = CreatorProfile { + creator: creator.clone(), + handle, + supply: 0, + holder_count: 0, + fee_recipient: creator.clone(), + registered_at: env.ledger().sequence(), + }; - pub fn buy_key( - env: Env, - creator: Address, - buyer: Address, - payment: i128, - max_price: Option, - ) -> Result { - buyer.require_auth(); - assert_not_paused(&env)?; - - if payment <= 0 { - return Err(ContractError::NotPositiveAmount); - } - - let price: i128 = env - .storage() - .persistent() - .get(&constants::storage::KEY_PRICE) - .ok_or(ContractError::KeyPriceNotSet)?; - - assert_buy_price_slippage(price, max_price)?; - - if payment < price { - return Err(ContractError::InsufficientPayment); - } - - let mut profile: CreatorProfile = read_registered_creator_profile(&env, &creator)?; - - let balance_key = constants::storage::key_balance(&creator, &buyer); - // Missing balance entries are treated as zero to keep storage sparse. - let current_balance: u32 = env.storage().persistent().get(&balance_key).unwrap_or(0); - - if current_balance == 0 { - profile.holder_count = profile - .holder_count - .checked_add(1) - .ok_or(ContractError::Overflow)?; - } - let mut holders = read_holders_list(&env, &creator); - if !holders.iter().any(|h| h == &buyer) { - holders.push_back(buyer.clone()); - write_holders_list(&env, &creator, &holders); - } + let fee_config = read_protocol_fee_config(&env).unwrap_or(fee::FeeConfig { + creator_bps: 0, + protocol_bps: 0, + }); - profile.supply = profile - .supply - .checked_add(1) - .ok_or(ContractError::Overflow)?; - - let key = constants::storage::creator(&creator); - // Supply and holder_count must always move together with buyer balance writes. - env.storage().persistent().set(&key, &profile); - - let new_balance = current_balance - .checked_add(1) - .ok_or(ContractError::Overflow)?; - // Balance key is scoped by (creator, holder) so creator positions cannot collide. - env.storage().persistent().set(&balance_key, &new_balance); - - if let Some(config) = read_protocol_fee_config(&env) { - let (creator_fee, protocol_fee) = - fee::checked_compute_fee_split(price, config.creator_bps, config.protocol_bps) - .ok_or(ContractError::Overflow)?; - credit_creator_fee_recipient_balance(&env, &creator, creator_fee)?; - credit_protocol_fee_recipient_balance(&env, protocol_fee)?; - } - - env.events().publish( - events::buy_event_topics(&creator, &buyer), - (profile.supply, payment), - ); - - Ok(profile.supply) - } + // Persist profile before event publication so indexers reading contract state + // after this tx observe the same registration payload that was emitted. + env.storage().persistent().set(&key, &profile); + env.events().publish( + events::register_event_topics(&profile.creator), + events::CreatorRegisteredEvent { + creator: profile.creator.clone(), + handle: profile.handle.clone(), + supply: profile.supply, + holder_count: profile.holder_count, + creator_bps: fee_config.creator_bps, + protocol_bps: fee_config.protocol_bps, + }, + ); - pub fn sell_key( - env: Env, - creator: Address, - seller: Address, - min_proceeds: Option, - ) -> Result { - seller.require_auth(); - assert_not_paused(&env)?; - - let mut profile: CreatorProfile = read_registered_creator_profile(&env, &creator)?; - - let balance_key = constants::storage::key_balance(&creator, &seller); - // Missing balance entries are interpreted as zero and rejected consistently. - let current_balance: u32 = env.storage().persistent().get(&balance_key).unwrap_or(0); - if current_balance == 0 { - return Err(ContractError::InsufficientBalance); - } - - assert_sell_proceeds_slippage(&env, min_proceeds)?; - - let new_balance = current_balance - .checked_sub(1) - .ok_or(ContractError::SellUnderflow)?; - profile.supply = profile - .supply - .checked_sub(1) - .ok_or(ContractError::SellUnderflow)?; - - if new_balance == 0 { - profile.holder_count = profile - .holder_count - .checked_sub(1) - .ok_or(ContractError::SellUnderflow)?; - let mut holders = read_holders_list(&env, &creator); - if let Some(pos) = holders.iter().position(|h| h == &seller) { - holders.remove(pos); - write_holders_list(&env, &creator, &holders); + Ok(()) + } + + pub fn buy_key( + env: Env, + creator: Address, + buyer: Address, + payment: i128, + max_price: Option, + ) -> Result { + buyer.require_auth(); + assert_not_paused(&env)?; + + if payment <= 0 { + return Err(ContractError::NotPositiveAmount); } - } - let key = constants::storage::creator(&creator); - // Profile and holder balance are updated in the same call to preserve - // supply/holder_count invariants for subsequent reads. - env.storage().persistent().set(&key, &profile); - env.storage().persistent().set(&balance_key, &new_balance); - accrue_sell_protocol_fee(&env)?; + let price: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; - env.events() - .publish((events::SELL_EVENT_NAME, creator, seller), profile.supply); + assert_buy_price_slippage(price, max_price)?; - Ok(profile.supply) + if payment < price { + return Err(ContractError::InsufficientPayment); } - /// Halts all state-changing operations (buy, sell, register_creator). - /// - /// Only the protocol admin may call this. Emits a `ProtocolPaused` event. - /// Read-only view functions are unaffected and continue to work while paused. - pub fn pause(env: Env, admin: Address) -> Result<(), ContractError> { - admin.require_auth(); - assert_is_admin(&env, &admin)?; - env.storage() - .persistent() - .set(&constants::storage::PAUSED, &true); - env.events().publish((events::PAUSE_EVENT_NAME, admin), ()); - Ok(()) - } + let mut profile: CreatorProfile = + read_registered_creator_profile(&env, &creator)?; - /// Resumes all state-changing operations after an emergency pause. - /// - /// Only the protocol admin may call this. Emits a `ProtocolUnpaused` event. - pub fn unpause(env: Env, admin: Address) -> Result<(), ContractError> { - admin.require_auth(); - assert_is_admin(&env, &admin)?; - env.storage() - .persistent() - .set(&constants::storage::PAUSED, &false); - env.events() - .publish((events::UNPAUSE_EVENT_NAME, admin), ()); - Ok(()) - } + let balance_key = constants::storage::key_balance(&creator, &buyer); + // Missing balance entries are treated as zero to keep storage sparse. + let current_balance: u32 = + env.storage().persistent().get(&balance_key).unwrap_or(0); - /// Read-only view: returns whether the protocol is currently paused. - pub fn get_is_paused(env: Env) -> bool { - is_paused(&env) + if current_balance == 0 { + profile.holder_count = profile + .holder_count + .checked_add(1) + .ok_or(ContractError::Overflow)?; } - - pub fn get_key_balance(env: Env, creator: Address, wallet: Address) -> u32 { - let key = constants::storage::key_balance(&creator, &wallet); - // Read-only callers get `0` for unseen balances to avoid sparse-map lookups failing. - env.storage().persistent().get(&key).unwrap_or(0) + let mut holders = read_holders_list(&env, &creator); + if !holders.iter().any(|h| h == &buyer) { + holders.push_back(buyer.clone()); + write_holders_list(&env, &creator, &holders); } - /// Read-only view: returns a stable view of a holder's key count for a creator. - /// - /// Returns a [`HolderKeyCountView`] regardless of creator registration status. - /// When the creator is not registered, `creator_exists` is `false` and `key_count` is `0`. - /// When the creator exists but the holder has no keys, `key_count` is `0`. - /// This method is designed for indexer-friendly consumption and avoids panics. - pub fn get_holder_key_count(env: Env, creator: Address, holder: Address) -> HolderKeyCountView { - let creator_exists = read_creator_profile(&env, &creator).is_some(); - let key_count = if creator_exists { - let key = constants::storage::key_balance(&creator, &holder); - env.storage().persistent().get(&key).unwrap_or(0) - } else { - 0 - }; - - HolderKeyCountView { - creator, - holder, - key_count, - creator_exists, - } - } + profile.supply = profile + .supply + .checked_add(1) + .ok_or(ContractError::Overflow)?; + + let key = constants::storage::creator(&creator); + // Supply and holder_count must always move together with buyer balance writes. + env.storage().persistent().set(&key, &profile); + + let new_balance = current_balance + .checked_add(1) + .ok_or(ContractError::Overflow)?; + // Balance key is scoped by (creator, holder) so creator positions cannot collide. + env.storage().persistent().set(&balance_key, &new_balance); - pub fn get_creator(env: Env, creator: Address) -> Result { - read_registered_creator_profile(&env, &creator) + if let Some(config) = read_protocol_fee_config(&env) { + let (creator_fee, protocol_fee) = fee::checked_compute_fee_split( + price, + config.creator_bps, + config.protocol_bps, + ) + .ok_or(ContractError::Overflow)?; + credit_creator_fee_recipient_balance(&env, &creator, creator_fee)?; + credit_protocol_fee_recipient_balance(&env, protocol_fee)?; } - /// Read-only view: returns stable creator details. - /// - /// Returns a [`CreatorDetailsView`] regardless of registration status. - /// When the creator is not registered, `is_registered` is `false` and - /// default values are provided for other fields, including `registered_at: 0`. - pub fn get_creator_details(env: Env, creator: Address) -> CreatorDetailsView { - let key = constants::storage::creator(&creator); - match env - .storage() - .persistent() - .get::(&key) - { - Some(profile) => CreatorDetailsView { - creator: profile.creator, - handle: profile.handle, - supply: profile.supply, - is_registered: true, - registered_at: profile.registered_at, - }, - None => CreatorDetailsView { - creator, - handle: read_none_string(&env), - supply: 0, - is_registered: false, - registered_at: 0, - }, - } + env.events().publish( + events::buy_event_topics(&creator, &buyer), + (profile.supply, payment), + ); + + Ok(profile.supply) + } + + pub fn sell_key( + env: Env, + creator: Address, + seller: Address, + min_proceeds: Option, + ) -> Result { + seller.require_auth(); + assert_not_paused(&env)?; + + let mut profile: CreatorProfile = + read_registered_creator_profile(&env, &creator)?; + + let balance_key = constants::storage::key_balance(&creator, &seller); + // Missing balance entries are interpreted as zero and rejected consistently. + let current_balance: u32 = + env.storage().persistent().get(&balance_key).unwrap_or(0); + if current_balance == 0 { + return Err(ContractError::InsufficientBalance); + } + + assert_sell_proceeds_slippage(&env, min_proceeds)?; + + let new_balance = current_balance + .checked_sub(1) + .ok_or(ContractError::SellUnderflow)?; + profile.supply = profile + .supply + .checked_sub(1) + .ok_or(ContractError::SellUnderflow)?; + + if new_balance == 0 { + profile.holder_count = profile + .holder_count + .checked_sub(1) + .ok_or(ContractError::SellUnderflow)?; + let mut holders = read_holders_list(&env, &creator); + if let Some(pos) = holders.iter().position(|h| h == &seller) { + holders.remove(pos); + write_holders_list(&env, &creator, &holders); + } + } + + let key = constants::storage::creator(&creator); + // Profile and holder balance are updated in the same call to preserve + // supply/holder_count invariants for subsequent reads. + env.storage().persistent().set(&key, &profile); + env.storage().persistent().set(&balance_key, &new_balance); + accrue_sell_protocol_fee(&env)?; + + env + .events() + .publish((events::SELL_EVENT_NAME, creator, seller), profile.supply); + + Ok(profile.supply) + } + + /// Halts all state-changing operations (buy, sell, register_creator). + /// + /// Only the protocol admin may call this. Emits a `ProtocolPaused` event. + /// Read-only view functions are unaffected and continue to work while paused. + pub fn pause(env: Env, admin: Address) -> Result<(), ContractError> { + admin.require_auth(); + assert_is_admin(&env, &admin)?; + env + .storage() + .persistent() + .set(&constants::storage::PAUSED, &true); + env.events().publish((events::PAUSE_EVENT_NAME, admin), ()); + Ok(()) + } + + /// Resumes all state-changing operations after an emergency pause. + /// + /// Only the protocol admin may call this. Emits a `ProtocolUnpaused` event. + pub fn unpause(env: Env, admin: Address) -> Result<(), ContractError> { + admin.require_auth(); + assert_is_admin(&env, &admin)?; + env + .storage() + .persistent() + .set(&constants::storage::PAUSED, &false); + env + .events() + .publish((events::UNPAUSE_EVENT_NAME, admin), ()); + Ok(()) + } + + /// Read-only view: returns whether the protocol is currently paused. + pub fn get_is_paused(env: Env) -> bool { + is_paused(&env) + } + + pub fn get_key_balance(env: Env, creator: Address, wallet: Address) -> u32 { + let key = constants::storage::key_balance(&creator, &wallet); + // Read-only callers get `0` for unseen balances to avoid sparse-map lookups failing. + env.storage().persistent().get(&key).unwrap_or(0) + } + + /// Read-only view: returns a stable view of a holder's key count for a creator. + /// + /// Returns a [`HolderKeyCountView`] regardless of creator registration status. + /// When the creator is not registered, `creator_exists` is `false` and `key_count` is `0`. + /// When the creator exists but the holder has no keys, `key_count` is `0`. + /// This method is designed for indexer-friendly consumption and avoids panics. + pub fn get_holder_key_count( + env: Env, + creator: Address, + holder: Address, + ) -> HolderKeyCountView { + let creator_exists = read_creator_profile(&env, &creator).is_some(); + let key_count = if creator_exists { + let key = constants::storage::key_balance(&creator, &holder); + env.storage().persistent().get(&key).unwrap_or(0) + } else { + 0 + }; + + HolderKeyCountView { + creator, + holder, + key_count, + creator_exists, } + } - pub fn distribute_dividend( + pub fn get_creator( + env: Env, + creator: Address, + ) -> Result { + read_registered_creator_profile(&env, &creator) + } + + /// Read-only view: returns stable creator details. + /// + /// Returns a [`CreatorDetailsView`] regardless of registration status. + /// When the creator is not registered, `is_registered` is `false` and + /// default values are provided for other fields, including `registered_at: 0`. + pub fn get_creator_details(env: Env, creator: Address) -> CreatorDetailsView { + let key = constants::storage::creator(&creator); + match env + .storage() + .persistent() + .get::(&key) + { + Some(profile) => CreatorDetailsView { + creator: profile.creator, + handle: profile.handle, + supply: profile.supply, + is_registered: true, + registered_at: profile.registered_at, + }, + None => CreatorDetailsView { + creator, + handle: read_none_string(&env), + supply: 0, + is_registered: false, + registered_at: 0, + }, + } + } + + pub fn distribute_dividend( env: Env, creator: Address, amount: i128, -) -> Result<(), ContractError> { + ) -> Result<(), ContractError> { let caller = env.invoker(); // Transfer XLM from caller to contract @@ -999,18 +1061,18 @@ impl CreatorKeysContract { let profile = read_registered_creator_profile(&env, &creator)?; let total_supply = profile.supply as i128; if total_supply == 0 { - return Err(ContractError::NoHolders); + return Err(ContractError::NoHolders); } // Load fee config and compute protocol fee let config = read_required_protocol_fee_config(&env)?; let protocol_fee = fee::apply_percentage_fee(amount, config.protocol_bps) - .ok_or(ContractError::Overflow)?; + .ok_or(ContractError::Overflow)?; let distribute_amount = amount - .checked_sub(protocol_fee) - .ok_or(ContractError::Overflow)?; + .checked_sub(protocol_fee) + .ok_or(ContractError::Overflow)?; if distribute_amount <= 0 { - return Err(ContractError::DividendAmountZero); + return Err(ContractError::DividendAmountZero); } // Credit protocol fee to the protocol fee recipient balance @@ -1021,47 +1083,51 @@ impl CreatorKeysContract { let ledger = env.ledger().sequence(); for holder in holders.iter() { - let balance = Self::get_key_balance(env.clone(), creator.clone(), holder.clone()) as i128; - if balance == 0 { - continue; - } - let share = (balance * distribute_amount) / total_supply; - if share > 0 { - let current = read_claimable_dividend(&env, &creator, &holder); - let new_claim = current - .checked_add(share) - .ok_or(ContractError::Overflow)?; - write_claimable_dividend(&env, &creator, &holder, new_claim); - } + let balance = + Self::get_key_balance(env.clone(), creator.clone(), holder.clone()) + as i128; + if balance == 0 { + continue; + } + let share = (balance * distribute_amount) / total_supply; + if share > 0 { + let current = read_claimable_dividend(&env, &creator, &holder); + let new_claim = + current.checked_add(share).ok_or(ContractError::Overflow)?; + write_claimable_dividend(&env, &creator, &holder, new_claim); + } } // Emit event env.events().publish( - events::dividend_distributed_topics(&creator), - events::DividendDistributedEvent { - creator_id: creator, - total_amount: amount, - snapshot_supply: total_supply, - ledger, - protocol_fee, - distributed_amount: distribute_amount, - }, + events::dividend_distributed_topics(&creator), + events::DividendDistributedEvent { + creator_id: creator, + total_amount: amount, + snapshot_supply: total_supply, + ledger, + protocol_fee, + distributed_amount: distribute_amount, + }, ); Ok(()) -} - -/// Claims accrued dividends for the caller. -/// -/// Transfers the claimable amount from the contract to the caller's wallet. -/// Resets the claimable balance to zero. -/// Emits a `DividendClaimed` event. -pub fn claim_dividend(env: Env, creator: Address) -> Result<(), ContractError> { + } + + /// Claims accrued dividends for the caller. + /// + /// Transfers the claimable amount from the contract to the caller's wallet. + /// Resets the claimable balance to zero. + /// Emits a `DividendClaimed` event. + pub fn claim_dividend( + env: Env, + creator: Address, + ) -> Result<(), ContractError> { let caller = env.invoker(); let claimable = read_claimable_dividend(&env, &creator, &caller); if claimable == 0 { - return Err(ContractError::ZeroClaimable); + return Err(ContractError::ZeroClaimable); } // Reset claimable to zero before transfer (reentrancy-safe) @@ -1073,920 +1139,988 @@ pub fn claim_dividend(env: Env, creator: Address) -> Result<(), ContractError> { // Emit event env.events().publish( - events::dividend_claimed_topics(&creator, &caller), - events::DividendClaimedEvent { - creator_id: creator, - claimant: caller, - amount: claimable, - }, + events::dividend_claimed_topics(&creator, &caller), + events::DividendClaimedEvent { + creator_id: creator, + claimant: caller, + amount: claimable, + }, ); Ok(()) -} + } -/// Returns the unclaimed dividend balance for a specific (creator, holder) pair. -pub fn get_claimable_dividend(env: Env, creator: Address, wallet: Address) -> i128 { + /// Returns the unclaimed dividend balance for a specific (creator, holder) pair. + pub fn get_claimable_dividend( + env: Env, + creator: Address, + wallet: Address, + ) -> i128 { read_claimable_dividend(&env, &creator, &wallet) -} - - /// Read-only batch view: returns [`CreatorDetailsView`] for each address in `creators`. - /// - /// Iterates the provided addresses in order and fetches each creator's profile - /// from persistent storage. The output `Vec` is the same length as the input and - /// preserves input order, so clients can zip the two slices without an extra sort. - /// - /// Unregistered addresses never cause the call to fail: they produce a default - /// [`CreatorDetailsView`] with `is_registered: false` and `registered_at: 0`, - /// matching the single-address behaviour of [`get_creator_details`]. - /// - /// # Usage - /// - /// ```text - /// let views = client.get_creators_batch(&vec![alice, bob, unknown]); - /// // views[0] → alice's details (is_registered: true) - /// // views[1] → bob's details (is_registered: true) - /// // views[2] → default view (is_registered: false, registered_at: 0) - /// ``` - pub fn get_creators_batch( - env: Env, - creators: soroban_sdk::Vec
, - ) -> soroban_sdk::Vec { - let mut results = soroban_sdk::Vec::new(&env); - for creator in creators.iter() { - let key = constants::storage::creator(&creator); - let view = match env - .storage() - .persistent() - .get::(&key) - { - Some(profile) => CreatorDetailsView { - creator: profile.creator, - handle: profile.handle, - supply: profile.supply, - is_registered: true, - registered_at: profile.registered_at, - }, - None => CreatorDetailsView { - creator, - handle: read_none_string(&env), - supply: 0, - is_registered: false, - registered_at: 0, - }, - }; - results.push_back(view); - } - results - } - /// Read-only view: returns the protocol state version. - /// - /// Returns a stable scalar value for clients and indexers to detect - /// protocol-state schema/semantics revisions. The version is stored in - /// storage and increments on config updates. - pub fn get_protocol_state_version(env: Env) -> u32 { - env.storage() - .persistent() - .get(&constants::storage::PROTOCOL_STATE_VERSION) - .unwrap_or(PROTOCOL_STATE_VERSION_INITIAL) - } - - /// Read-only view: returns the decimal precision used by creator key values. - /// - /// Returns the fixed [`KEY_DECIMALS`] constant. Does not read or mutate contract state. - pub fn get_key_decimals(_env: Env) -> u32 { - KEY_DECIMALS - } - - /// Read-only view: returns the display name for a creator's key. - /// - /// Returns the creator's handle for registered creators. Fails with - /// [`ContractError::NotRegistered`] if the creator is not registered. - pub fn get_key_name(env: Env, creator: Address) -> Result { - let profile = read_registered_creator_profile(&env, &creator)?; - Ok(profile.handle) - } - - /// Read-only view: returns the ticker symbol for a creator's key. - /// - /// Returns the creator's handle for registered creators. Fails with - /// [`ContractError::NotRegistered`] if the creator is not registered. - pub fn get_key_symbol(env: Env, creator: Address) -> Result { - let profile = read_registered_creator_profile(&env, &creator)?; - Ok(profile.handle) - } - - /// Read-only view: returns the total key supply for a creator. - /// - /// Returns `0` if the creator is not registered, avoiding panics for - /// invalid lookups. Delegates to the shared [`read_key_balance`] helper. - pub fn get_total_key_supply(env: Env, creator: Address) -> u32 { - read_key_balance(&env, &creator) - } - - /// Read-only view: returns the current supply for a registered creator. - /// - /// Fails with [`ContractError::NotRegistered`] if the creator does not exist. - pub fn get_creator_supply(env: Env, creator: Address) -> Result { - let profile = read_registered_creator_profile(&env, &creator)?; - Ok(profile.supply) - } - - /// Read-only view: returns the number of unique holders for a creator. - /// - /// Returns `0` if the creator is not registered, avoiding panics for - /// invalid lookups. Uses the stored creator profile holder count. - pub fn get_creator_holder_count(env: Env, creator: Address) -> u32 { - read_creator_profile(&env, &creator) - .map(|profile| profile.holder_count) - .unwrap_or(0) - } - - /// Read-only view: returns whether a creator is registered in the contract. - /// - /// Returns `true` if a [`CreatorProfile`] exists for the given address, - /// `false` otherwise. Does not mutate state. - pub fn is_creator_registered(env: Env, creator: Address) -> bool { - read_creator_profile(&env, &creator).is_some() - } - - /// Read-only view: returns the creator fee recipient address. - /// - /// Fails with [`ContractError::NotRegistered`] if the creator is not registered. - /// Reuses current creator storage access patterns. - pub fn get_creator_fee_recipient(env: Env, creator: Address) -> Result { - let profile = read_registered_creator_profile(&env, &creator)?; - Ok(profile.fee_recipient) - } - - /// Read-only view: returns accrued creator fee balance for the creator's fee recipient. - /// - /// Fails with [`ContractError::NotRegistered`] if the creator is not registered. - /// Returns `0` when no buy has accrued fees yet. - pub fn get_creator_fee_balance(env: Env, creator: Address) -> Result { - read_registered_creator_profile(&env, &creator)?; - Ok(read_creator_fee_recipient_balance(&env, &creator)) - } - - /// Read-only view: returns the configured creator fee rate in basis points. - /// - /// The returned value is the creator-facing share stored in the current protocol - /// fee configuration, scoped to a registered creator lookup. - pub fn get_creator_fee_bps(env: Env, creator: Address) -> Result { - let _profile = read_registered_creator_profile(&env, &creator)?; - let config = read_required_protocol_fee_config(&env)?; - Ok(config.creator_bps) - } - - /// Read-only view: returns the creator treasury share for a registered creator. - /// - /// Access Layer currently stores creator treasury share as the creator-facing - /// basis-point share in protocol fee configuration. This method provides a - /// creator-scoped accessor without mutating state. - pub fn get_creator_treasury_share(env: Env, creator: Address) -> Result { - Self::get_creator_fee_bps(env, creator) - } - - /// Read-only view: returns the configured protocol treasury share in basis points. - /// - /// This value is sourced from the current protocol fee configuration and is - /// expressed in stable basis-point units. - pub fn get_protocol_treasury_share_bps(env: Env) -> Result { - let config = read_required_protocol_fee_config(&env)?; - Ok(config.protocol_bps) - } - - /// Sets the global protocol/creator fee split. Contract initialization - /// entrypoint. - /// - /// Parameter validation (via [`fee::assert_valid_fee_bps`]): - /// - `admin`: must authorize the call (`require_auth`). - /// - `creator_bps` + `protocol_bps`: must sum to exactly `BPS_MAX` (10_000), - /// otherwise [`ContractError::InvalidFeeConfig`]. - /// - `protocol_bps`: must not exceed `PROTOCOL_BPS_MAX`, otherwise - /// [`ContractError::ProtocolFeeExceedsCap`]. - pub fn set_fee_config( - env: Env, - admin: Address, - creator_bps: u32, - protocol_bps: u32, - ) -> Result<(), ContractError> { - admin.require_auth(); - fee::assert_valid_fee_bps(creator_bps, protocol_bps)?; - - let config = fee::FeeConfig { - creator_bps, - protocol_bps, - }; - if env - .storage() - .persistent() - .get::(&constants::storage::FEE_CONFIG) - .as_ref() - == Some(&config) - { - return Ok(()); - } - env.storage() - .persistent() - .set(&constants::storage::FEE_CONFIG, &config); - - // Increment protocol state version on config update - let current_version = env - .storage() - .persistent() - .get(&constants::storage::PROTOCOL_STATE_VERSION) - .unwrap_or(PROTOCOL_STATE_VERSION_INITIAL); - let new_version = current_version - .checked_add(1) - .ok_or(ContractError::Overflow)?; - env.storage() - .persistent() - .set(&constants::storage::PROTOCOL_STATE_VERSION, &new_version); - - Ok(()) - } - - /// Sets the per-key price. Contract initialization entrypoint. - /// - /// Parameter validation: - /// - `admin`: must authorize the call (`require_auth`). - /// - `price`: must be strictly positive; zero or negative returns - /// [`ContractError::NotPositiveAmount`]. - pub fn set_key_price(env: Env, admin: Address, price: i128) -> Result<(), ContractError> { - admin.require_auth(); - if price <= 0 { - return Err(ContractError::NotPositiveAmount); - } - if env - .storage() - .persistent() - .get::(&constants::storage::KEY_PRICE) - .as_ref() - == Some(&price) - { - return Ok(()); - } - env.storage() - .persistent() - .set(&constants::storage::KEY_PRICE, &price); - Ok(()) - } - - pub fn get_fee_config(env: Env) -> Option { - read_protocol_fee_config(&env) - } - - /// Sets the protocol treasury address. - /// - /// Only callable by an authorized admin. Stores the treasury address used - /// for protocol fee routing. - pub fn set_treasury_address(env: Env, admin: Address, treasury: Address) { - admin.require_auth(); - if env - .storage() - .persistent() - .get::(&constants::storage::TREASURY_ADDRESS) - .as_ref() - == Some(&treasury) - { - return; - } - env.storage() - .persistent() - .set(&constants::storage::TREASURY_ADDRESS, &treasury); - } - - /// Read-only view: returns the current protocol treasury address. - /// - /// Returns `None` if no treasury address has been configured. - /// Use this method for indexers and read-only callers that need the current - /// treasury routing target. - pub fn get_treasury_address(env: Env) -> Option
{ - env.storage() - .persistent() - .get(&constants::storage::TREASURY_ADDRESS) - } - - /// Sets the protocol admin address. - /// - /// Only callable by an authorized admin. Stores the admin address used - /// for protocol administration. - pub fn set_protocol_admin(env: Env, admin: Address, new_admin: Address) { - admin.require_auth(); - if env - .storage() - .persistent() - .get::(&constants::storage::ADMIN_ADDRESS) - .as_ref() - == Some(&new_admin) - { - return; - } - env.storage() - .persistent() - .set(&constants::storage::ADMIN_ADDRESS, &new_admin); - } - - /// Read-only view: returns the current protocol admin address. - /// - /// Returns `None` if no admin address has been configured. - /// Use this method for indexers and read-only callers that need the current - /// protocol admin address. - pub fn get_protocol_admin(env: Env) -> Option
{ - env.storage() - .persistent() - .get(&constants::storage::ADMIN_ADDRESS) - } - - /// Read-only view: returns the current protocol fee recipient address. - /// - /// Returns `None` if no protocol fee recipient address has been configured. - /// Use this method for indexers and read-only callers that need the current - /// protocol fee recipient address. - pub fn get_protocol_fee_recipient(env: Env) -> Option
{ - env.storage() - .persistent() - .get(&constants::storage::PROTOCOL_FEE_RECIPIENT) - } - - /// Read-only view: returns the accrued protocol fee balance for the configured recipient. - /// - /// Returns `0` when no protocol fees have been accrued from sell execution. - pub fn get_protocol_recipient_balance(env: Env) -> i128 { - read_protocol_fee_recipient_balance(&env) - } - - /// Sets the protocol fee recipient address. - /// - /// Only callable by an authorized admin. Rejects the Stellar zero address - /// to prevent silent fee burning. - /// - /// Parameter validation: - /// - `admin`: must authorize the call (`require_auth`). - /// - `recipient`: must not be the Stellar zero address, otherwise - /// [`ContractError::ZeroAddress`]. - pub fn set_protocol_fee_recipient( - env: Env, - admin: Address, - recipient: Address, - ) -> Result<(), ContractError> { - admin.require_auth(); - validate_non_zero_address(&env, &recipient)?; - - if env - .storage() - .persistent() - .get::(&constants::storage::PROTOCOL_FEE_RECIPIENT) - .as_ref() - == Some(&recipient) - { - return Ok(()); - } - env.storage() - .persistent() - .set(&constants::storage::PROTOCOL_FEE_RECIPIENT, &recipient); - Ok(()) - } - - /// Read-only view: returns whether protocol configuration has been initialized. - /// - /// Returns `true` once a protocol fee configuration has been stored and `false` - /// otherwise. Does not mutate contract state. - pub fn is_protocol_config_initialized(env: Env) -> bool { - read_protocol_fee_config(&env).is_some() - } + } + + /// Read-only batch view: returns [`CreatorDetailsView`] for each address in `creators`. + /// + /// Iterates the provided addresses in order and fetches each creator's profile + /// from persistent storage. The output `Vec` is the same length as the input and + /// preserves input order, so clients can zip the two slices without an extra sort. + /// + /// Unregistered addresses never cause the call to fail: they produce a default + /// [`CreatorDetailsView`] with `is_registered: false` and `registered_at: 0`, + /// matching the single-address behaviour of [`get_creator_details`]. + /// + /// # Usage + /// + /// ```text + /// let views = client.get_creators_batch(&vec![alice, bob, unknown]); + /// // views[0] → alice's details (is_registered: true) + /// // views[1] → bob's details (is_registered: true) + /// // views[2] → default view (is_registered: false, registered_at: 0) + /// ``` + pub fn get_creators_batch( + env: Env, + creators: soroban_sdk::Vec
, + ) -> soroban_sdk::Vec { + let mut results = soroban_sdk::Vec::new(&env); + for creator in creators.iter() { + let key = constants::storage::creator(&creator); + let view = match env + .storage() + .persistent() + .get::(&key) + { + Some(profile) => CreatorDetailsView { + creator: profile.creator, + handle: profile.handle, + supply: profile.supply, + is_registered: true, + registered_at: profile.registered_at, + }, + None => CreatorDetailsView { + creator, + handle: read_none_string(&env), + supply: 0, + is_registered: false, + registered_at: 0, + }, + }; + results.push_back(view); + } + results + } + /// Read-only view: returns the protocol state version. + /// + /// Returns a stable scalar value for clients and indexers to detect + /// protocol-state schema/semantics revisions. The version is stored in + /// storage and increments on config updates. + pub fn get_protocol_state_version(env: Env) -> u32 { + env + .storage() + .persistent() + .get(&constants::storage::PROTOCOL_STATE_VERSION) + .unwrap_or(PROTOCOL_STATE_VERSION_INITIAL) + } + + /// Read-only view: returns the decimal precision used by creator key values. + /// + /// Returns the fixed [`KEY_DECIMALS`] constant. Does not read or mutate contract state. + pub fn get_key_decimals(_env: Env) -> u32 { + KEY_DECIMALS + } + + /// Read-only view: returns the display name for a creator's key. + /// + /// Returns the creator's handle for registered creators. Fails with + /// [`ContractError::NotRegistered`] if the creator is not registered. + pub fn get_key_name( + env: Env, + creator: Address, + ) -> Result { + let profile = read_registered_creator_profile(&env, &creator)?; + Ok(profile.handle) + } + + /// Read-only view: returns the ticker symbol for a creator's key. + /// + /// Returns the creator's handle for registered creators. Fails with + /// [`ContractError::NotRegistered`] if the creator is not registered. + pub fn get_key_symbol( + env: Env, + creator: Address, + ) -> Result { + let profile = read_registered_creator_profile(&env, &creator)?; + Ok(profile.handle) + } + + /// Read-only view: returns the total key supply for a creator. + /// + /// Returns `0` if the creator is not registered, avoiding panics for + /// invalid lookups. Delegates to the shared [`read_key_balance`] helper. + pub fn get_total_key_supply(env: Env, creator: Address) -> u32 { + read_key_balance(&env, &creator) + } + + /// Read-only view: returns the current supply for a registered creator. + /// + /// Fails with [`ContractError::NotRegistered`] if the creator does not exist. + pub fn get_creator_supply( + env: Env, + creator: Address, + ) -> Result { + let profile = read_registered_creator_profile(&env, &creator)?; + Ok(profile.supply) + } + + /// Read-only view: returns the number of unique holders for a creator. + /// + /// Returns `0` if the creator is not registered, avoiding panics for + /// invalid lookups. Uses the stored creator profile holder count. + pub fn get_creator_holder_count(env: Env, creator: Address) -> u32 { + read_creator_profile(&env, &creator) + .map(|profile| profile.holder_count) + .unwrap_or(0) + } + + /// Read-only view: returns whether a creator is registered in the contract. + /// + /// Returns `true` if a [`CreatorProfile`] exists for the given address, + /// `false` otherwise. Does not mutate state. + pub fn is_creator_registered(env: Env, creator: Address) -> bool { + read_creator_profile(&env, &creator).is_some() + } + + /// Read-only view: returns the creator fee recipient address. + /// + /// Fails with [`ContractError::NotRegistered`] if the creator is not registered. + /// Reuses current creator storage access patterns. + pub fn get_creator_fee_recipient( + env: Env, + creator: Address, + ) -> Result { + let profile = read_registered_creator_profile(&env, &creator)?; + Ok(profile.fee_recipient) + } + + /// Read-only view: returns accrued creator fee balance for the creator's fee recipient. + /// + /// Fails with [`ContractError::NotRegistered`] if the creator is not registered. + /// Returns `0` when no buy has accrued fees yet. + pub fn get_creator_fee_balance( + env: Env, + creator: Address, + ) -> Result { + read_registered_creator_profile(&env, &creator)?; + Ok(read_creator_fee_recipient_balance(&env, &creator)) + } + + /// Read-only view: returns the configured creator fee rate in basis points. + /// + /// The returned value is the creator-facing share stored in the current protocol + /// fee configuration, scoped to a registered creator lookup. + pub fn get_creator_fee_bps( + env: Env, + creator: Address, + ) -> Result { + let _profile = read_registered_creator_profile(&env, &creator)?; + let config = read_required_protocol_fee_config(&env)?; + Ok(config.creator_bps) + } + + /// Read-only view: returns the creator treasury share for a registered creator. + /// + /// Access Layer currently stores creator treasury share as the creator-facing + /// basis-point share in protocol fee configuration. This method provides a + /// creator-scoped accessor without mutating state. + pub fn get_creator_treasury_share( + env: Env, + creator: Address, + ) -> Result { + Self::get_creator_fee_bps(env, creator) + } + + /// Read-only view: returns the configured protocol treasury share in basis points. + /// + /// This value is sourced from the current protocol fee configuration and is + /// expressed in stable basis-point units. + pub fn get_protocol_treasury_share_bps( + env: Env, + ) -> Result { + let config = read_required_protocol_fee_config(&env)?; + Ok(config.protocol_bps) + } + + /// Sets the global protocol/creator fee split. Contract initialization + /// entrypoint. + /// + /// Parameter validation (via [`fee::assert_valid_fee_bps`]): + /// - `admin`: must authorize the call (`require_auth`). + /// - `creator_bps` + `protocol_bps`: must sum to exactly `BPS_MAX` (10_000), + /// otherwise [`ContractError::InvalidFeeConfig`]. + /// - `protocol_bps`: must not exceed `PROTOCOL_BPS_MAX`, otherwise + /// [`ContractError::ProtocolFeeExceedsCap`]. + pub fn set_fee_config( + env: Env, + admin: Address, + creator_bps: u32, + protocol_bps: u32, + ) -> Result<(), ContractError> { + admin.require_auth(); + fee::assert_valid_fee_bps(creator_bps, protocol_bps)?; + + let config = fee::FeeConfig { + creator_bps, + protocol_bps, + }; + if env + .storage() + .persistent() + .get::(&constants::storage::FEE_CONFIG) + .as_ref() + == Some(&config) + { + return Ok(()); + } + env + .storage() + .persistent() + .set(&constants::storage::FEE_CONFIG, &config); + + // Increment protocol state version on config update + let current_version = env + .storage() + .persistent() + .get(&constants::storage::PROTOCOL_STATE_VERSION) + .unwrap_or(PROTOCOL_STATE_VERSION_INITIAL); + let new_version = current_version + .checked_add(1) + .ok_or(ContractError::Overflow)?; + env + .storage() + .persistent() + .set(&constants::storage::PROTOCOL_STATE_VERSION, &new_version); - /// Read-only view: returns the current protocol fee configuration. - /// - /// Returns a stable [`ProtocolFeeView`] regardless of whether a fee config has been set. - /// When no config is stored, `is_configured` is `false` and both bps fields are `0`. - /// Use this method for indexers and read-only callers that need a non-optional result. - pub fn get_protocol_fee_view(env: Env) -> ProtocolFeeView { - match read_protocol_fee_config(&env) { - Some(config) => ProtocolFeeView { - creator_bps: config.creator_bps, - protocol_bps: config.protocol_bps, - is_configured: true, - }, - None => ProtocolFeeView { - creator_bps: 0, - protocol_bps: 0, - is_configured: false, - }, - } + Ok(()) + } + + /// Sets the per-key price. Contract initialization entrypoint. + /// + /// Parameter validation: + /// - `admin`: must authorize the call (`require_auth`). + /// - `price`: must be strictly positive; zero or negative returns + /// [`ContractError::NotPositiveAmount`]. + pub fn set_key_price( + env: Env, + admin: Address, + price: i128, + ) -> Result<(), ContractError> { + admin.require_auth(); + if price <= 0 { + return Err(ContractError::NotPositiveAmount); } - - pub fn compute_fees_for_payment(env: Env, total: i128) -> Result<(i128, i128), ContractError> { - let config = read_required_protocol_fee_config(&env)?; - fee::checked_compute_fee_split(total, config.creator_bps, config.protocol_bps) - .ok_or(ContractError::Overflow) + if env + .storage() + .persistent() + .get::(&constants::storage::KEY_PRICE) + .as_ref() + == Some(&price) + { + return Ok(()); } + env + .storage() + .persistent() + .set(&constants::storage::KEY_PRICE, &price); + Ok(()) + } + + pub fn get_fee_config(env: Env) -> Option { + read_protocol_fee_config(&env) + } + + /// Sets the protocol treasury address. + /// + /// Only callable by an authorized admin. Stores the treasury address used + /// for protocol fee routing. + pub fn set_treasury_address(env: Env, admin: Address, treasury: Address) { + admin.require_auth(); + if env + .storage() + .persistent() + .get::(&constants::storage::TREASURY_ADDRESS) + .as_ref() + == Some(&treasury) + { + return; + } + env + .storage() + .persistent() + .set(&constants::storage::TREASURY_ADDRESS, &treasury); + } + + /// Read-only view: returns the current protocol treasury address. + /// + /// Returns `None` if no treasury address has been configured. + /// Use this method for indexers and read-only callers that need the current + /// treasury routing target. + pub fn get_treasury_address(env: Env) -> Option
{ + env + .storage() + .persistent() + .get(&constants::storage::TREASURY_ADDRESS) + } + + /// Sets the protocol admin address. + /// + /// Only callable by an authorized admin. Stores the admin address used + /// for protocol administration. + pub fn set_protocol_admin(env: Env, admin: Address, new_admin: Address) { + admin.require_auth(); + if env + .storage() + .persistent() + .get::(&constants::storage::ADMIN_ADDRESS) + .as_ref() + == Some(&new_admin) + { + return; + } + env + .storage() + .persistent() + .set(&constants::storage::ADMIN_ADDRESS, &new_admin); + } + + /// Read-only view: returns the current protocol admin address. + /// + /// Returns `None` if no admin address has been configured. + /// Use this method for indexers and read-only callers that need the current + /// protocol admin address. + pub fn get_protocol_admin(env: Env) -> Option
{ + env + .storage() + .persistent() + .get(&constants::storage::ADMIN_ADDRESS) + } + + /// Read-only view: returns the current protocol fee recipient address. + /// + /// Returns `None` if no protocol fee recipient address has been configured. + /// Use this method for indexers and read-only callers that need the current + /// protocol fee recipient address. + pub fn get_protocol_fee_recipient(env: Env) -> Option
{ + env + .storage() + .persistent() + .get(&constants::storage::PROTOCOL_FEE_RECIPIENT) + } + + /// Read-only view: returns the accrued protocol fee balance for the configured recipient. + /// + /// Returns `0` when no protocol fees have been accrued from sell execution. + pub fn get_protocol_recipient_balance(env: Env) -> i128 { + read_protocol_fee_recipient_balance(&env) + } + + /// Sets the protocol fee recipient address. + /// + /// Only callable by an authorized admin. Rejects the Stellar zero address + /// to prevent silent fee burning. + /// + /// Parameter validation: + /// - `admin`: must authorize the call (`require_auth`). + /// - `recipient`: must not be the Stellar zero address, otherwise + /// [`ContractError::ZeroAddress`]. + pub fn set_protocol_fee_recipient( + env: Env, + admin: Address, + recipient: Address, + ) -> Result<(), ContractError> { + admin.require_auth(); + validate_non_zero_address(&env, &recipient)?; - /// Read-only view: returns the fee configuration for a specific creator. - /// - /// Returns a stable [`CreatorFeeView`] regardless of whether the creator is registered - /// or a fee config has been set. When `is_registered` is `false`, the creator does not - /// exist and both bps fields are `0`. When `is_configured` is `false`, no global fee - /// config has been set. Use this method for indexers and read-only callers that need - /// a non-optional result. - pub fn get_creator_fee_config(env: Env, creator: Address) -> CreatorFeeView { - let is_registered = read_registered_creator_profile(&env, &creator).is_ok(); - - if !is_registered { - return CreatorFeeView { - creator_bps: 0, - protocol_bps: 0, - is_registered: false, - is_configured: false, - }; - } - - match env - .storage() - .persistent() - .get::(&constants::storage::FEE_CONFIG) - { - Some(config) => CreatorFeeView { - creator_bps: config.creator_bps, - protocol_bps: config.protocol_bps, - is_registered: true, - is_configured: true, - }, - None => CreatorFeeView { - creator_bps: 0, - protocol_bps: 0, - is_registered: true, - is_configured: false, - }, - } + if env + .storage() + .persistent() + .get::(&constants::storage::PROTOCOL_FEE_RECIPIENT) + .as_ref() + == Some(&recipient) + { + return Ok(()); } + env + .storage() + .persistent() + .set(&constants::storage::PROTOCOL_FEE_RECIPIENT, &recipient); + Ok(()) + } + + /// Read-only view: returns whether protocol configuration has been initialized. + /// + /// Returns `true` once a protocol fee configuration has been stored and `false` + /// otherwise. Does not mutate contract state. + pub fn is_protocol_config_initialized(env: Env) -> bool { + read_protocol_fee_config(&env).is_some() + } + + /// Read-only view: returns the current protocol fee configuration. + /// + /// Returns a stable [`ProtocolFeeView`] regardless of whether a fee config has been set. + /// When no config is stored, `is_configured` is `false` and both bps fields are `0`. + /// Use this method for indexers and read-only callers that need a non-optional result. + pub fn get_protocol_fee_view(env: Env) -> ProtocolFeeView { + match read_protocol_fee_config(&env) { + Some(config) => ProtocolFeeView { + creator_bps: config.creator_bps, + protocol_bps: config.protocol_bps, + is_configured: true, + }, + None => ProtocolFeeView { + creator_bps: 0, + protocol_bps: 0, + is_configured: false, + }, + } + } + + pub fn compute_fees_for_payment( + env: Env, + total: i128, + ) -> Result<(i128, i128), ContractError> { + let config = read_required_protocol_fee_config(&env)?; + fee::checked_compute_fee_split( + total, + config.creator_bps, + config.protocol_bps, + ) + .ok_or(ContractError::Overflow) + } + + /// Read-only view: returns the fee configuration for a specific creator. + /// + /// Returns a stable [`CreatorFeeView`] regardless of whether the creator is registered + /// or a fee config has been set. When `is_registered` is `false`, the creator does not + /// exist and both bps fields are `0`. When `is_configured` is `false`, no global fee + /// config has been set. Use this method for indexers and read-only callers that need + /// a non-optional result. + pub fn get_creator_fee_config(env: Env, creator: Address) -> CreatorFeeView { + let is_registered = read_registered_creator_profile(&env, &creator).is_ok(); + + if !is_registered { + return CreatorFeeView { + creator_bps: 0, + protocol_bps: 0, + is_registered: false, + is_configured: false, + }; + } + + match env + .storage() + .persistent() + .get::(&constants::storage::FEE_CONFIG) + { + Some(config) => CreatorFeeView { + creator_bps: config.creator_bps, + protocol_bps: config.protocol_bps, + is_registered: true, + is_configured: true, + }, + None => CreatorFeeView { + creator_bps: 0, + protocol_bps: 0, + is_registered: true, + is_configured: false, + }, + } + } + + /// Read-only view: returns a quote for buying a key. + /// + /// Returns a [`QuoteResponse`] containing the current price and fee breakdown. + /// Fees are calculated based on the fixed key price. + pub fn get_buy_quote( + env: Env, + creator: Address, + ) -> Result { + let Some(price) = resolve_quote_inputs(&env, &creator)? else { + return Ok(zero_quote_response()); + }; + let (creator_fee, protocol_fee) = + Self::compute_fees_for_payment(env.clone(), price)?; + checked_format_quote_response(price, creator_fee, protocol_fee, true) + } + + /// Read-only view: returns a quote for selling a key. + /// + /// Returns a [`QuoteResponse`] containing the current price and fee breakdown. + /// Fees are calculated based on the fixed key price. + /// Rejects with [`ContractError::InsufficientBalance`] if the holder has no keys. + pub fn get_sell_quote( + env: Env, + creator: Address, + holder: Address, + ) -> Result { + let Some(price) = resolve_quote_inputs(&env, &creator)? else { + return Ok(zero_quote_response()); + }; - /// Read-only view: returns a quote for buying a key. - /// - /// Returns a [`QuoteResponse`] containing the current price and fee breakdown. - /// Fees are calculated based on the fixed key price. - pub fn get_buy_quote(env: Env, creator: Address) -> Result { - let Some(price) = resolve_quote_inputs(&env, &creator)? else { - return Ok(zero_quote_response()); - }; - let (creator_fee, protocol_fee) = Self::compute_fees_for_payment(env.clone(), price)?; - checked_format_quote_response(price, creator_fee, protocol_fee, true) + let balance = Self::get_key_balance(env.clone(), creator, holder); + if balance == 0 { + return Err(ContractError::InsufficientBalance); } - /// Read-only view: returns a quote for selling a key. - /// - /// Returns a [`QuoteResponse`] containing the current price and fee breakdown. - /// Fees are calculated based on the fixed key price. - /// Rejects with [`ContractError::InsufficientBalance`] if the holder has no keys. - pub fn get_sell_quote( - env: Env, - creator: Address, - holder: Address, - ) -> Result { - let Some(price) = resolve_quote_inputs(&env, &creator)? else { - return Ok(zero_quote_response()); - }; - - let balance = Self::get_key_balance(env.clone(), creator, holder); - if balance == 0 { - return Err(ContractError::InsufficientBalance); - } - - let (creator_fee, protocol_fee) = Self::compute_fees_for_payment(env.clone(), price)?; - checked_format_quote_response(price, creator_fee, protocol_fee, false) - } - fn read_holders_list(env: &Env, creator: &Address) -> Vec
{ - let key = DataKey::HoldersList(creator.clone()); - env.storage().persistent().get(&key).unwrap_or(Vec::new(env)) + let (creator_fee, protocol_fee) = + Self::compute_fees_for_payment(env.clone(), price)?; + checked_format_quote_response(price, creator_fee, protocol_fee, false) + } } -fn write_holders_list(env: &Env, creator: &Address, holders: &Vec
) { +fn read_holders_list(env: &Env, creator: &Address) -> Vec
{ + let key = DataKey::HoldersList(creator.clone()); + env + .storage() + .persistent() + .get(&key) + .unwrap_or(Vec::new(env)); + + fn write_holders_list(env: &Env, creator: &Address, holders: &Vec
) { let key = DataKey::HoldersList(creator.clone()); env.storage().persistent().set(&key, holders); -} + } -fn read_claimable_dividend(env: &Env, creator: &Address, holder: &Address) -> i128 { + fn read_claimable_dividend( + env: &Env, + creator: &Address, + holder: &Address, + ) -> i128 { let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); env.storage().persistent().get(&key).unwrap_or(0) -} + } -fn write_claimable_dividend(env: &Env, creator: &Address, holder: &Address, amount: i128) { + fn write_claimable_dividend( + env: &Env, + creator: &Address, + holder: &Address, + amount: i128, + ) { let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); env.storage().persistent().set(&key, &amount); -} + } } #[cfg(test)] mod tests { - use super::fee; - - #[test] - fn test_fee_split_90_10_1000() { - let (creator, protocol) = fee::compute_fee_split(1000, 9000, 1000); - assert_eq!(creator, 900); - assert_eq!(protocol, 100); - assert_eq!(creator + protocol, 1000); - } - - #[test] - fn test_fee_split_100_creator() { - let (creator, protocol) = fee::compute_fee_split(1000, 10000, 0); - assert_eq!(creator, 1000); - assert_eq!(protocol, 0); - assert_eq!(creator + protocol, 1000); - } - - #[test] - fn test_fee_split_100_protocol() { - let (creator, protocol) = fee::compute_fee_split(1000, 0, 10000); - assert_eq!(creator, 0); - assert_eq!(protocol, 1000); - assert_eq!(creator + protocol, 1000); - } - - #[test] - fn test_fee_split_remainder_to_creator() { - // 999 * 1000 / 10000 = 99 (protocol floor), creator gets remainder - let (creator, protocol) = fee::compute_fee_split(999, 9000, 1000); - assert_eq!(creator, 900); - assert_eq!(protocol, 99); - assert_eq!(creator + protocol, 999); - } - - #[test] - fn test_fee_split_zero_total() { - let (creator, protocol) = fee::compute_fee_split(0, 9000, 1000); - assert_eq!(creator, 0); - assert_eq!(protocol, 0); - } - - #[test] - fn test_fee_split_dust_total_one() { - // 1 * 1000 / 10000 = 0 protocol, creator gets full amount - let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); - assert_eq!(creator, 1); - assert_eq!(protocol, 0); - assert_eq!(creator + protocol, 1); - } - - #[test] - fn test_fee_split_balance_conservation() { - for total in [100_i128, 1, 999, 10000, 1234567] { - let (creator, protocol) = fee::compute_fee_split(total, 9000, 1000); - assert_eq!(creator + protocol, total, "total={}", total); - } - } - - #[test] - fn test_checked_mul_i128_success() { - assert_eq!(fee::checked_mul_i128(100, 10), Some(1000)); - } - - #[test] - fn test_checked_mul_i128_rejects_overflow() { - assert_eq!(fee::checked_mul_i128(i128::MAX, 2), None); - assert_eq!(fee::checked_mul_i128(i128::MIN, 2), None); - } - - #[test] - fn test_checked_div_i128_success() { - assert_eq!(fee::checked_div_i128(100, 10), Some(10)); - } - - #[test] - fn test_checked_div_i128_rejects_zero_divisor() { - assert_eq!(fee::checked_div_i128(100, 0), None); - } - - #[test] - fn test_checked_sub_i128_success() { - assert_eq!(fee::checked_sub_i128(100, 10), Some(90)); - } - - #[test] - fn test_checked_sub_i128_underflow() { - assert_eq!(fee::checked_sub_i128(i128::MIN, 1), None); - } - - #[test] - fn test_checked_add_i128_success() { - assert_eq!(fee::checked_add_i128(100, 10), Some(110)); - } - - #[test] - fn test_checked_add_i128_overflow() { - assert_eq!(fee::checked_add_i128(i128::MAX, 1), None); - } - - #[test] - fn test_checked_add_i128_zero() { - assert_eq!(fee::checked_add_i128(0, 0), Some(0)); - assert_eq!(fee::checked_add_i128(100, 0), Some(100)); - assert_eq!(fee::checked_add_i128(0, 100), Some(100)); - } - - #[test] - fn test_checked_add_i128_negative_values() { - assert_eq!(fee::checked_add_i128(-10, 20), Some(10)); - assert_eq!(fee::checked_add_i128(10, -20), Some(-10)); - assert_eq!(fee::checked_add_i128(-10, -10), Some(-20)); - } - - #[test] - fn test_checked_add_i128_boundary_values() { - assert_eq!(fee::checked_add_i128(i128::MAX, 0), Some(i128::MAX)); - assert_eq!(fee::checked_add_i128(i128::MIN, 0), Some(i128::MIN)); - assert_eq!(fee::checked_add_i128(0, i128::MAX), Some(i128::MAX)); - assert_eq!(fee::checked_add_i128(0, i128::MIN), Some(i128::MIN)); - } - - #[test] - fn test_checked_add_i128_deterministic_error() { - // Verify that overflow always returns None, never panics - assert_eq!(fee::checked_add_i128(i128::MAX, i128::MAX), None); - assert_eq!(fee::checked_add_i128(i128::MIN, i128::MIN), None); - } - - #[test] - fn test_checked_div_i128_rejects_overflow() { - assert_eq!(fee::checked_div_i128(i128::MIN, -1), None); - } - - /// Both operands at `i128::MAX / 2 + 1` must overflow. - /// - /// `(i128::MAX / 2 + 1) + (i128::MAX / 2 + 1) == i128::MAX + 1`, which - /// exceeds i128 capacity, so the helper must return `None` rather than wrap. - #[test] - fn test_checked_add_i128_both_at_half_max_plus_one_overflows() { - let half_plus_one = i128::MAX / 2 + 1; - assert_eq!(fee::checked_add_i128(half_plus_one, half_plus_one), None); - } - - /// Both operands at `i128::MAX / 2` must not overflow. - /// - /// `(i128::MAX / 2) + (i128::MAX / 2) == i128::MAX - 1`, which fits in i128, - /// so the helper must return the correct sum just below the overflow boundary. - #[test] - fn test_checked_add_i128_both_at_half_max_succeeds() { - let half = i128::MAX / 2; - assert_eq!(fee::checked_add_i128(half, half), Some(half + half)); - } - - #[test] - fn test_normalize_quote_amount_preserves_positive_amount() { - assert_eq!(super::normalize_quote_amount(100), Ok(Some(100))); - } - - #[test] - fn test_normalize_quote_amount_maps_zero_to_noop() { - assert_eq!(super::normalize_quote_amount(0), Ok(None)); - } - - #[test] - fn test_normalize_quote_amount_rejects_negative_amount() { - assert_eq!( - super::normalize_quote_amount(-1), - Err(super::ContractError::NotPositiveAmount) - ); - } - - #[test] - fn test_normalize_quote_amount_rejects_large_amount() { - let large = super::fee::MAX_SAFE_AMOUNT + 1; - assert_eq!( - super::normalize_quote_amount(large), - Err(super::ContractError::Overflow) - ); - } - - #[test] - fn test_checked_format_quote_response_buy_success() { - let res = super::checked_format_quote_response(1000, 90, 10, true).unwrap(); - assert_eq!(res.price, 1000); - assert_eq!(res.creator_fee, 90); - assert_eq!(res.protocol_fee, 10); - assert_eq!(res.total_amount, 1100); - } - - #[test] - fn test_checked_format_quote_response_sell_success() { - let res = super::checked_format_quote_response(1000, 90, 10, false).unwrap(); - assert_eq!(res.price, 1000); - assert_eq!(res.creator_fee, 90); - assert_eq!(res.protocol_fee, 10); - assert_eq!(res.total_amount, 900); - } - - #[test] - fn test_checked_format_quote_response_buy_overflow_fees() { - let res = super::checked_format_quote_response(1000, i128::MAX, 1, true); - assert_eq!(res, Err(super::ContractError::Overflow)); - } - - #[test] - fn test_checked_format_quote_response_buy_overflow_total() { - let res = super::checked_format_quote_response(i128::MAX, 1, 0, true); - assert_eq!(res, Err(super::ContractError::Overflow)); - } - - #[test] - fn test_checked_format_quote_response_sell_underflow_total() { - let res = super::checked_format_quote_response(i128::MIN, 1, 0, false); - assert_eq!(res, Err(super::ContractError::SellUnderflow)); - } - - #[test] - fn test_apply_percentage_fee_success() { - assert_eq!(fee::apply_percentage_fee(1000, 1000), Some(100)); - assert_eq!(fee::apply_percentage_fee(1000, 0), Some(0)); - assert_eq!(fee::apply_percentage_fee(1000, 10000), Some(1000)); - } - - #[test] - fn test_apply_percentage_fee_zero_amount() { - assert_eq!(fee::apply_percentage_fee(0, 1000), Some(0)); - } - - #[test] - fn test_apply_percentage_fee_negative_amount() { - assert_eq!(fee::apply_percentage_fee(-100, 1000), Some(0)); - } - - #[test] - fn test_apply_percentage_fee_rounding() { - // 999 * 1000 / 10000 = 99.9 -> 99 - assert_eq!(fee::apply_percentage_fee(999, 1000), Some(99)); - } - - #[test] - fn test_apply_percentage_fee_overflow() { - // Multiplication overflows before division - assert_eq!(fee::apply_percentage_fee(i128::MAX, 2), None); - } - - #[test] - fn test_assert_valid_fee_bps() { - // Valid scenarios - assert_eq!(fee::assert_valid_fee_bps(10000, 0), Ok(())); - assert_eq!(fee::assert_valid_fee_bps(5000, 5000), Ok(())); - assert_eq!(fee::assert_valid_fee_bps(9000, 1000), Ok(())); - - // Invalid Sum - assert_eq!( - fee::assert_valid_fee_bps(9000, 2000), - Err(super::ContractError::InvalidFeeConfig) - ); - assert_eq!( - fee::assert_valid_fee_bps(0, 0), - Err(super::ContractError::InvalidFeeConfig) - ); - - // Protocol Cap Exceeded (PROTOCOL_BPS_MAX = 5000) - assert_eq!( - fee::assert_valid_fee_bps(4999, 5001), - Err(super::ContractError::ProtocolFeeExceedsCap) - ); - assert_eq!( - fee::assert_valid_fee_bps(0, 10000), - Err(super::ContractError::ProtocolFeeExceedsCap) - ); - - // Overflow - assert_eq!( - fee::assert_valid_fee_bps(u32::MAX, 1), - Err(super::ContractError::InvalidFeeConfig) - ); - } - - #[test] - fn test_validate_fee_bps() { - // Valid - assert!(fee::validate_fee_bps(10000, 0)); - assert!(fee::validate_fee_bps(5000, 5000)); - assert!(fee::validate_fee_bps(9000, 1000)); - - // Invalid Sum - assert!(!fee::validate_fee_bps(9000, 2000)); - assert!(!fee::validate_fee_bps(0, 0)); - - // Protocol Cap Exceeded - assert!(!fee::validate_fee_bps(4999, 5001)); - - // Overflow - assert!(!fee::validate_fee_bps(u32::MAX, 1)); - } - - // --- checked_fee_sum unit tests --- - - /// Verifies that `checked_fee_sum` returns the correct sum for two ordinary - /// positive fee components. - #[test] - fn test_checked_fee_sum_success() { - assert_eq!(fee::checked_fee_sum(900, 100), Some(1000)); - assert_eq!(fee::checked_fee_sum(0, 0), Some(0)); - assert_eq!(fee::checked_fee_sum(500, 500), Some(1000)); - } - - /// Verifies that `checked_fee_sum` returns `None` when the addition would - /// overflow `i128`, preventing silent wrapping in fee total calculations. - #[test] - fn test_checked_fee_sum_overflow_returns_none() { - assert_eq!(fee::checked_fee_sum(i128::MAX, 1), None); - assert_eq!(fee::checked_fee_sum(i128::MAX, i128::MAX), None); - } - - /// Edge case: verifies `checked_fee_sum` at the boundary where one component - /// is exactly `i128::MAX` and the other is zero — the only non-overflowing - /// case at that boundary. - #[test] - fn test_checked_fee_sum_boundary_max_plus_zero() { - assert_eq!(fee::checked_fee_sum(i128::MAX, 0), Some(i128::MAX)); - assert_eq!(fee::checked_fee_sum(0, i128::MAX), Some(i128::MAX)); - // One above the boundary must overflow - assert_eq!(fee::checked_fee_sum(i128::MAX, 1), None); - } - - // --- BPS truncation on small amounts --- - - /// Bps calculation on very small amounts produces zero due to integer division - /// truncation. These tests document the behavior at the lower precision boundary. - /// - /// Formula: `amount * bps / 10_000` (floor division). - /// When the product `amount * bps < 10_000`, the result truncates to zero. - #[test] - fn test_apply_percentage_fee_truncation_1_stroop() { - // 1 * 1000 / 10_000 = 0.1 → truncated to 0 - // At 1 stroop with 10% bps, the fee is zero — value is silently lost. - let result = fee::apply_percentage_fee(1, 1000); - assert_eq!(result, Some(0), "1 stroop at 1000 bps truncates to 0"); - } - - #[test] - fn test_apply_percentage_fee_truncation_10_stroops() { - // 10 * 1000 / 10_000 = 1.0 → exactly 1 - // At 10 stroops with 10% bps, the fee is exactly 1. - let result = fee::apply_percentage_fee(10, 1000); - assert_eq!(result, Some(1), "10 stroops at 1000 bps yields 1"); - } - - #[test] - fn test_apply_percentage_fee_truncation_100_stroops() { - // 100 * 1000 / 10_000 = 10.0 → exactly 10 - let result = fee::apply_percentage_fee(100, 1000); - assert_eq!(result, Some(10), "100 stroops at 1000 bps yields 10"); - } - - #[test] - fn test_fee_split_truncation_1_stroop() { - // 1 * 1000 / 10_000 = 0 protocol, 1 creator (remainder to creator) - // Truncation causes the full amount to go to creator. - let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); - assert_eq!(protocol, 0, "1 stroop: protocol fee truncated to 0"); - assert_eq!(creator, 1, "1 stroop: creator gets full amount"); - assert_eq!(creator + protocol, 1, "conservation holds"); - } - - #[test] - fn test_fee_split_truncation_10_stroops() { - // 10 * 1000 / 10_000 = 1 protocol, 9 creator - let (creator, protocol) = fee::compute_fee_split(10, 9000, 1000); - assert_eq!(protocol, 1, "10 stroops: protocol fee is 1"); - assert_eq!(creator, 9, "10 stroops: creator gets 9"); - assert_eq!(creator + protocol, 10, "conservation holds"); - } - - #[test] - fn test_fee_split_truncation_100_stroops() { - // 100 * 1000 / 10_000 = 10 protocol, 90 creator - let (creator, protocol) = fee::compute_fee_split(100, 9000, 1000); - assert_eq!(protocol, 10, "100 stroops: protocol fee is 10"); - assert_eq!(creator, 90, "100 stroops: creator gets 90"); - assert_eq!(creator + protocol, 100, "conservation holds"); - } + use super::fee; + + #[test] + fn test_fee_split_90_10_1000() { + let (creator, protocol) = fee::compute_fee_split(1000, 9000, 1000); + assert_eq!(creator, 900); + assert_eq!(protocol, 100); + assert_eq!(creator + protocol, 1000); + } + + #[test] + fn test_fee_split_100_creator() { + let (creator, protocol) = fee::compute_fee_split(1000, 10000, 0); + assert_eq!(creator, 1000); + assert_eq!(protocol, 0); + assert_eq!(creator + protocol, 1000); + } + + #[test] + fn test_fee_split_100_protocol() { + let (creator, protocol) = fee::compute_fee_split(1000, 0, 10000); + assert_eq!(creator, 0); + assert_eq!(protocol, 1000); + assert_eq!(creator + protocol, 1000); + } + + #[test] + fn test_fee_split_remainder_to_creator() { + // 999 * 1000 / 10000 = 99 (protocol floor), creator gets remainder + let (creator, protocol) = fee::compute_fee_split(999, 9000, 1000); + assert_eq!(creator, 900); + assert_eq!(protocol, 99); + assert_eq!(creator + protocol, 999); + } + + #[test] + fn test_fee_split_zero_total() { + let (creator, protocol) = fee::compute_fee_split(0, 9000, 1000); + assert_eq!(creator, 0); + assert_eq!(protocol, 0); + } + + #[test] + fn test_fee_split_dust_total_one() { + // 1 * 1000 / 10000 = 0 protocol, creator gets full amount + let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); + assert_eq!(creator, 1); + assert_eq!(protocol, 0); + assert_eq!(creator + protocol, 1); + } + + #[test] + fn test_fee_split_balance_conservation() { + for total in [100_i128, 1, 999, 10000, 1234567] { + let (creator, protocol) = fee::compute_fee_split(total, 9000, 1000); + assert_eq!(creator + protocol, total, "total={}", total); + } + } + + #[test] + fn test_checked_mul_i128_success() { + assert_eq!(fee::checked_mul_i128(100, 10), Some(1000)); + } + + #[test] + fn test_checked_mul_i128_rejects_overflow() { + assert_eq!(fee::checked_mul_i128(i128::MAX, 2), None); + assert_eq!(fee::checked_mul_i128(i128::MIN, 2), None); + } + + #[test] + fn test_checked_div_i128_success() { + assert_eq!(fee::checked_div_i128(100, 10), Some(10)); + } + + #[test] + fn test_checked_div_i128_rejects_zero_divisor() { + assert_eq!(fee::checked_div_i128(100, 0), None); + } + + #[test] + fn test_checked_sub_i128_success() { + assert_eq!(fee::checked_sub_i128(100, 10), Some(90)); + } + + #[test] + fn test_checked_sub_i128_underflow() { + assert_eq!(fee::checked_sub_i128(i128::MIN, 1), None); + } + + #[test] + fn test_checked_add_i128_success() { + assert_eq!(fee::checked_add_i128(100, 10), Some(110)); + } + + #[test] + fn test_checked_add_i128_overflow() { + assert_eq!(fee::checked_add_i128(i128::MAX, 1), None); + } + + #[test] + fn test_checked_add_i128_zero() { + assert_eq!(fee::checked_add_i128(0, 0), Some(0)); + assert_eq!(fee::checked_add_i128(100, 0), Some(100)); + assert_eq!(fee::checked_add_i128(0, 100), Some(100)); + } + + #[test] + fn test_checked_add_i128_negative_values() { + assert_eq!(fee::checked_add_i128(-10, 20), Some(10)); + assert_eq!(fee::checked_add_i128(10, -20), Some(-10)); + assert_eq!(fee::checked_add_i128(-10, -10), Some(-20)); + } + + #[test] + fn test_checked_add_i128_boundary_values() { + assert_eq!(fee::checked_add_i128(i128::MAX, 0), Some(i128::MAX)); + assert_eq!(fee::checked_add_i128(i128::MIN, 0), Some(i128::MIN)); + assert_eq!(fee::checked_add_i128(0, i128::MAX), Some(i128::MAX)); + assert_eq!(fee::checked_add_i128(0, i128::MIN), Some(i128::MIN)); + } + + #[test] + fn test_checked_add_i128_deterministic_error() { + // Verify that overflow always returns None, never panics + assert_eq!(fee::checked_add_i128(i128::MAX, i128::MAX), None); + assert_eq!(fee::checked_add_i128(i128::MIN, i128::MIN), None); + } + + #[test] + fn test_checked_div_i128_rejects_overflow() { + assert_eq!(fee::checked_div_i128(i128::MIN, -1), None); + } + + /// Both operands at `i128::MAX / 2 + 1` must overflow. + /// + /// `(i128::MAX / 2 + 1) + (i128::MAX / 2 + 1) == i128::MAX + 1`, which + /// exceeds i128 capacity, so the helper must return `None` rather than wrap. + #[test] + fn test_checked_add_i128_both_at_half_max_plus_one_overflows() { + let half_plus_one = i128::MAX / 2 + 1; + assert_eq!(fee::checked_add_i128(half_plus_one, half_plus_one), None); + } + + /// Both operands at `i128::MAX / 2` must not overflow. + /// + /// `(i128::MAX / 2) + (i128::MAX / 2) == i128::MAX - 1`, which fits in i128, + /// so the helper must return the correct sum just below the overflow boundary. + #[test] + fn test_checked_add_i128_both_at_half_max_succeeds() { + let half = i128::MAX / 2; + assert_eq!(fee::checked_add_i128(half, half), Some(half + half)); + } + + #[test] + fn test_normalize_quote_amount_preserves_positive_amount() { + assert_eq!(super::normalize_quote_amount(100), Ok(Some(100))); + } + + #[test] + fn test_normalize_quote_amount_maps_zero_to_noop() { + assert_eq!(super::normalize_quote_amount(0), Ok(None)); + } + + #[test] + fn test_normalize_quote_amount_rejects_negative_amount() { + assert_eq!( + super::normalize_quote_amount(-1), + Err(super::ContractError::NotPositiveAmount) + ); + } + + #[test] + fn test_normalize_quote_amount_rejects_large_amount() { + let large = super::fee::MAX_SAFE_AMOUNT + 1; + assert_eq!( + super::normalize_quote_amount(large), + Err(super::ContractError::Overflow) + ); + } + + #[test] + fn test_checked_format_quote_response_buy_success() { + let res = super::checked_format_quote_response(1000, 90, 10, true).unwrap(); + assert_eq!(res.price, 1000); + assert_eq!(res.creator_fee, 90); + assert_eq!(res.protocol_fee, 10); + assert_eq!(res.total_amount, 1100); + } + + #[test] + fn test_checked_format_quote_response_sell_success() { + let res = + super::checked_format_quote_response(1000, 90, 10, false).unwrap(); + assert_eq!(res.price, 1000); + assert_eq!(res.creator_fee, 90); + assert_eq!(res.protocol_fee, 10); + assert_eq!(res.total_amount, 900); + } + + #[test] + fn test_checked_format_quote_response_buy_overflow_fees() { + let res = super::checked_format_quote_response(1000, i128::MAX, 1, true); + assert_eq!(res, Err(super::ContractError::Overflow)); + } + + #[test] + fn test_checked_format_quote_response_buy_overflow_total() { + let res = super::checked_format_quote_response(i128::MAX, 1, 0, true); + assert_eq!(res, Err(super::ContractError::Overflow)); + } + + #[test] + fn test_checked_format_quote_response_sell_underflow_total() { + let res = super::checked_format_quote_response(i128::MIN, 1, 0, false); + assert_eq!(res, Err(super::ContractError::SellUnderflow)); + } + + #[test] + fn test_apply_percentage_fee_success() { + assert_eq!(fee::apply_percentage_fee(1000, 1000), Some(100)); + assert_eq!(fee::apply_percentage_fee(1000, 0), Some(0)); + assert_eq!(fee::apply_percentage_fee(1000, 10000), Some(1000)); + } + + #[test] + fn test_apply_percentage_fee_zero_amount() { + assert_eq!(fee::apply_percentage_fee(0, 1000), Some(0)); + } + + #[test] + fn test_apply_percentage_fee_negative_amount() { + assert_eq!(fee::apply_percentage_fee(-100, 1000), Some(0)); + } + + #[test] + fn test_apply_percentage_fee_rounding() { + // 999 * 1000 / 10000 = 99.9 -> 99 + assert_eq!(fee::apply_percentage_fee(999, 1000), Some(99)); + } + + #[test] + fn test_apply_percentage_fee_overflow() { + // Multiplication overflows before division + assert_eq!(fee::apply_percentage_fee(i128::MAX, 2), None); + } + + #[test] + fn test_assert_valid_fee_bps() { + // Valid scenarios + assert_eq!(fee::assert_valid_fee_bps(10000, 0), Ok(())); + assert_eq!(fee::assert_valid_fee_bps(5000, 5000), Ok(())); + assert_eq!(fee::assert_valid_fee_bps(9000, 1000), Ok(())); + + // Invalid Sum + assert_eq!( + fee::assert_valid_fee_bps(9000, 2000), + Err(super::ContractError::InvalidFeeConfig) + ); + assert_eq!( + fee::assert_valid_fee_bps(0, 0), + Err(super::ContractError::InvalidFeeConfig) + ); - // --- Zero address validation --- - - #[test] - fn test_validate_non_zero_address_rejects_zero() { - use soroban_sdk::{Address, Env, String}; - let env = Env::default(); - let zero_str = String::from_str( - &env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ); - let zero_addr = Address::from_string(&zero_str); - let result = super::validate_non_zero_address(&env, &zero_addr); - assert_eq!(result, Err(super::ContractError::ZeroAddress)); - } + // Protocol Cap Exceeded (PROTOCOL_BPS_MAX = 5000) + assert_eq!( + fee::assert_valid_fee_bps(4999, 5001), + Err(super::ContractError::ProtocolFeeExceedsCap) + ); + assert_eq!( + fee::assert_valid_fee_bps(0, 10000), + Err(super::ContractError::ProtocolFeeExceedsCap) + ); - #[test] - fn test_validate_non_zero_address_accepts_valid() { - use soroban_sdk::{testutils::Address as _, Address, Env}; - let env = Env::default(); - let valid = Address::generate(&env); - let result = super::validate_non_zero_address(&env, &valid); - assert_eq!(result, Ok(())); - } + // Overflow + assert_eq!( + fee::assert_valid_fee_bps(u32::MAX, 1), + Err(super::ContractError::InvalidFeeConfig) + ); + } + + #[test] + fn test_validate_fee_bps() { + // Valid + assert!(fee::validate_fee_bps(10000, 0)); + assert!(fee::validate_fee_bps(5000, 5000)); + assert!(fee::validate_fee_bps(9000, 1000)); + + // Invalid Sum + assert!(!fee::validate_fee_bps(9000, 2000)); + assert!(!fee::validate_fee_bps(0, 0)); + + // Protocol Cap Exceeded + assert!(!fee::validate_fee_bps(4999, 5001)); + + // Overflow + assert!(!fee::validate_fee_bps(u32::MAX, 1)); + } + + // --- checked_fee_sum unit tests --- + + /// Verifies that `checked_fee_sum` returns the correct sum for two ordinary + /// positive fee components. + #[test] + fn test_checked_fee_sum_success() { + assert_eq!(fee::checked_fee_sum(900, 100), Some(1000)); + assert_eq!(fee::checked_fee_sum(0, 0), Some(0)); + assert_eq!(fee::checked_fee_sum(500, 500), Some(1000)); + } + + /// Verifies that `checked_fee_sum` returns `None` when the addition would + /// overflow `i128`, preventing silent wrapping in fee total calculations. + #[test] + fn test_checked_fee_sum_overflow_returns_none() { + assert_eq!(fee::checked_fee_sum(i128::MAX, 1), None); + assert_eq!(fee::checked_fee_sum(i128::MAX, i128::MAX), None); + } + + /// Edge case: verifies `checked_fee_sum` at the boundary where one component + /// is exactly `i128::MAX` and the other is zero — the only non-overflowing + /// case at that boundary. + #[test] + fn test_checked_fee_sum_boundary_max_plus_zero() { + assert_eq!(fee::checked_fee_sum(i128::MAX, 0), Some(i128::MAX)); + assert_eq!(fee::checked_fee_sum(0, i128::MAX), Some(i128::MAX)); + // One above the boundary must overflow + assert_eq!(fee::checked_fee_sum(i128::MAX, 1), None); + } + + // --- BPS truncation on small amounts --- + + /// Bps calculation on very small amounts produces zero due to integer division + /// truncation. These tests document the behavior at the lower precision boundary. + /// + /// Formula: `amount * bps / 10_000` (floor division). + /// When the product `amount * bps < 10_000`, the result truncates to zero. + #[test] + fn test_apply_percentage_fee_truncation_1_stroop() { + // 1 * 1000 / 10_000 = 0.1 → truncated to 0 + // At 1 stroop with 10% bps, the fee is zero — value is silently lost. + let result = fee::apply_percentage_fee(1, 1000); + assert_eq!(result, Some(0), "1 stroop at 1000 bps truncates to 0"); + } + + #[test] + fn test_apply_percentage_fee_truncation_10_stroops() { + // 10 * 1000 / 10_000 = 1.0 → exactly 1 + // At 10 stroops with 10% bps, the fee is exactly 1. + let result = fee::apply_percentage_fee(10, 1000); + assert_eq!(result, Some(1), "10 stroops at 1000 bps yields 1"); + } + + #[test] + fn test_apply_percentage_fee_truncation_100_stroops() { + // 100 * 1000 / 10_000 = 10.0 → exactly 10 + let result = fee::apply_percentage_fee(100, 1000); + assert_eq!(result, Some(10), "100 stroops at 1000 bps yields 10"); + } + + #[test] + fn test_fee_split_truncation_1_stroop() { + // 1 * 1000 / 10_000 = 0 protocol, 1 creator (remainder to creator) + // Truncation causes the full amount to go to creator. + let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); + assert_eq!(protocol, 0, "1 stroop: protocol fee truncated to 0"); + assert_eq!(creator, 1, "1 stroop: creator gets full amount"); + assert_eq!(creator + protocol, 1, "conservation holds"); + } + + #[test] + fn test_fee_split_truncation_10_stroops() { + // 10 * 1000 / 10_000 = 1 protocol, 9 creator + let (creator, protocol) = fee::compute_fee_split(10, 9000, 1000); + assert_eq!(protocol, 1, "10 stroops: protocol fee is 1"); + assert_eq!(creator, 9, "10 stroops: creator gets 9"); + assert_eq!(creator + protocol, 10, "conservation holds"); + } + + #[test] + fn test_fee_split_truncation_100_stroops() { + // 100 * 1000 / 10_000 = 10 protocol, 90 creator + let (creator, protocol) = fee::compute_fee_split(100, 9000, 1000); + assert_eq!(protocol, 10, "100 stroops: protocol fee is 10"); + assert_eq!(creator, 90, "100 stroops: creator gets 90"); + assert_eq!(creator + protocol, 100, "conservation holds"); + } + + // --- Zero address validation --- + + #[test] + fn test_validate_non_zero_address_rejects_zero() { + use soroban_sdk::{Address, Env, String}; + let env = Env::default(); + let zero_str = String::from_str( + &env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ); + let zero_addr = Address::from_string(&zero_str); + let result = super::validate_non_zero_address(&env, &zero_addr); + assert_eq!(result, Err(super::ContractError::ZeroAddress)); + } + + #[test] + fn test_validate_non_zero_address_accepts_valid() { + use soroban_sdk::{testutils::Address as _, Address, Env}; + let env = Env::default(); + let valid = Address::generate(&env); + let result = super::validate_non_zero_address(&env, &valid); + assert_eq!(result, Ok(())); + } } #[cfg(test)] From 7a547659351b79f64b3f29d7f4f1275699b442c2 Mon Sep 17 00:00:00 2001 From: Syed Ghufran Hassan Date: Wed, 24 Jun 2026 16:16:59 +0500 Subject: [PATCH 03/11] Update lib.rs --- creator-keys/src/lib.rs | 42 ++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index c6b9816..523c591 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -1683,30 +1683,30 @@ fn read_holders_list(env: &Env, creator: &Address) -> Vec
{ .persistent() .get(&key) .unwrap_or(Vec::new(env)); +} - fn write_holders_list(env: &Env, creator: &Address, holders: &Vec
) { - let key = DataKey::HoldersList(creator.clone()); - env.storage().persistent().set(&key, holders); - } +fn write_holders_list(env: &Env, creator: &Address, holders: &Vec
) { + let key = DataKey::HoldersList(creator.clone()); + env.storage().persistent().set(&key, holders); +} - fn read_claimable_dividend( - env: &Env, - creator: &Address, - holder: &Address, - ) -> i128 { - let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); - env.storage().persistent().get(&key).unwrap_or(0) - } +fn read_claimable_dividend( + env: &Env, + creator: &Address, + holder: &Address, +) -> i128 { + let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); + env.storage().persistent().get(&key).unwrap_or(0) +} - fn write_claimable_dividend( - env: &Env, - creator: &Address, - holder: &Address, - amount: i128, - ) { - let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); - env.storage().persistent().set(&key, &amount); - } +fn write_claimable_dividend( + env: &Env, + creator: &Address, + holder: &Address, + amount: i128, +) { + let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); + env.storage().persistent().set(&key, &amount); } #[cfg(test)] From 83a9d6b2fb4feea9aa8a9b45b919a31b02318c21 Mon Sep 17 00:00:00 2001 From: Syed Ghufran Hassan Date: Wed, 24 Jun 2026 16:25:21 +0500 Subject: [PATCH 04/11] Update lib.rs --- creator-keys/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 523c591..7d3be08 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -1682,7 +1682,7 @@ fn read_holders_list(env: &Env, creator: &Address) -> Vec
{ .storage() .persistent() .get(&key) - .unwrap_or(Vec::new(env)); + .unwrap_or(Vec::new(env)) } fn write_holders_list(env: &Env, creator: &Address, holders: &Vec
) { From a600d7835635d88de71187a4b8017faed3404ba1 Mon Sep 17 00:00:00 2001 From: Syed Ghufran Hassan Date: Wed, 24 Jun 2026 16:31:35 +0500 Subject: [PATCH 05/11] Update lib.rs --- creator-keys/src/lib.rs | 3477 +++++++++++++++++++-------------------- 1 file changed, 1673 insertions(+), 1804 deletions(-) diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 7d3be08..7b39bc2 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -1,10 +1,7 @@ #![no_std] pub mod quote_view_errors; -use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, Address, Env, String, - Vec, -}; +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, String, Vec}; pub mod events; @@ -50,226 +47,215 @@ pub mod events; /// } /// ``` pub enum ContractError { - AlreadyRegistered = 1, - NotRegistered = 2, - Overflow = 3, - InsufficientPayment = 4, - KeyPriceNotSet = 5, - NotPositiveAmount = 6, - FeeConfigNotSet = 7, - InvalidFeeConfig = 8, - InsufficientBalance = 9, - SellUnderflow = 10, - ProtocolFeeExceedsCap = 11, - HandleTooShort = 12, - HandleTooLong = 13, - InvalidHandleCharacter = 14, - ZeroAddress = 15, - SlippageExceeded = 16, - ProtocolPaused = 17, - Unauthorized = 18, - ZeroClaimable = 19, - NoHolders = 20, - DividendAmountZero = 21, + AlreadyRegistered = 1, + NotRegistered = 2, + Overflow = 3, + InsufficientPayment = 4, + KeyPriceNotSet = 5, + NotPositiveAmount = 6, + FeeConfigNotSet = 7, + InvalidFeeConfig = 8, + InsufficientBalance = 9, + SellUnderflow = 10, + ProtocolFeeExceedsCap = 11, + HandleTooShort = 12, + HandleTooLong = 13, + InvalidHandleCharacter = 14, + ZeroAddress = 15, + SlippageExceeded = 16, + ProtocolPaused = 17, + Unauthorized = 18, + ZeroClaimable = 19, + NoHolders = 20, + DividendAmountZero = 21, } pub mod fee { - use crate::ContractError; + use crate::ContractError; - use soroban_sdk::contracttype; + use soroban_sdk::contracttype; - /// Basis points per 100% (10000 = 100%). - pub const BPS_MAX: u32 = 10_000; + /// Basis points per 100% (10000 = 100%). + pub const BPS_MAX: u32 = 10_000; - /// Maximum safe amount to prevent overflow in fee calculations. - pub const MAX_SAFE_AMOUNT: i128 = i128::MAX / BPS_MAX as i128; + /// Maximum safe amount to prevent overflow in fee calculations. + pub const MAX_SAFE_AMOUNT: i128 = i128::MAX / BPS_MAX as i128; - /// Maximum protocol share when configuring fees via [`assert_valid_fee_bps`]. - /// - /// Caps the on-chain configured protocol take at 50% so fee settings stay within - /// expected economic bounds before they affect market logic. - pub const PROTOCOL_BPS_MAX: u32 = 5_000; + /// Maximum protocol share when configuring fees via [`assert_valid_fee_bps`]. + /// + /// Caps the on-chain configured protocol take at 50% so fee settings stay within + /// expected economic bounds before they affect market logic. + pub const PROTOCOL_BPS_MAX: u32 = 5_000; - #[derive(Clone, Eq, PartialEq)] - #[contracttype] - pub struct FeeConfig { - pub creator_bps: u32, - pub protocol_bps: u32, - } + #[derive(Clone, Eq, PartialEq)] + #[contracttype] + pub struct FeeConfig { + pub creator_bps: u32, + pub protocol_bps: u32, + } - /// Validates creator and protocol basis points for storage and fee-setting entrypoints. - pub fn validate_fee_bps(creator_bps: u32, protocol_bps: u32) -> bool { - let Some(sum) = creator_bps.checked_add(protocol_bps) else { - return false; - }; - if sum != BPS_MAX { - return false; - } - if protocol_bps > PROTOCOL_BPS_MAX { - return false; - } - true - } - - /// Shared guard for fee config updates that need structured contract errors. - pub fn assert_valid_fee_bps( - creator_bps: u32, - protocol_bps: u32, - ) -> Result<(), ContractError> { - let Some(sum) = creator_bps.checked_add(protocol_bps) else { - return Err(ContractError::InvalidFeeConfig); - }; - if sum != BPS_MAX { - return Err(ContractError::InvalidFeeConfig); + /// Validates creator and protocol basis points for storage and fee-setting entrypoints. + pub fn validate_fee_bps(creator_bps: u32, protocol_bps: u32) -> bool { + let Some(sum) = creator_bps.checked_add(protocol_bps) else { + return false; + }; + if sum != BPS_MAX { + return false; + } + if protocol_bps > PROTOCOL_BPS_MAX { + return false; + } + true } - if protocol_bps > PROTOCOL_BPS_MAX { - return Err(ContractError::ProtocolFeeExceedsCap); + + /// Shared guard for fee config updates that need structured contract errors. + pub fn assert_valid_fee_bps(creator_bps: u32, protocol_bps: u32) -> Result<(), ContractError> { + let Some(sum) = creator_bps.checked_add(protocol_bps) else { + return Err(ContractError::InvalidFeeConfig); + }; + if sum != BPS_MAX { + return Err(ContractError::InvalidFeeConfig); + } + if protocol_bps > PROTOCOL_BPS_MAX { + return Err(ContractError::ProtocolFeeExceedsCap); + } + Ok(()) + } + + /// Computes the fee split for a given total amount. + /// + /// Returns `(creator_amount, protocol_amount)`. Remainder from integer division + /// is assigned to the creator. Ensures creator_amount + protocol_amount == total. + pub fn compute_fee_split(total: i128, _creator_bps: u32, protocol_bps: u32) -> (i128, i128) { + if total <= 0 { + return (0, 0); + } + let protocol_amount = (total * protocol_bps as i128) / BPS_MAX as i128; + let creator_amount = total - protocol_amount; + (creator_amount, protocol_amount) + } + + /// Safely applies a percentage-based fee to an amount. + /// + /// Returns `None` if the multiplication overflows. Rounding is performed via + /// floor division towards zero. + pub fn apply_percentage_fee(amount: i128, bps: u32) -> Option { + if amount <= 0 { + return Some(0); + } + checked_div_i128(amount.checked_mul(bps as i128)?, BPS_MAX as i128) + } + + /// Computes the fee split safely, returning `None` if multiplication or subtraction overflows. + pub fn checked_compute_fee_split( + total: i128, + _creator_bps: u32, + protocol_bps: u32, + ) -> Option<(i128, i128)> { + if total <= 0 { + return Some((0, 0)); + } + let protocol_amount = apply_percentage_fee(total, protocol_bps)?; + let creator_amount = checked_sub_i128(total, protocol_amount)?; + Some((creator_amount, protocol_amount)) + } + + /// Performs checked integer multiplication for quote math helpers. + pub fn checked_mul_i128(a: i128, b: i128) -> Option { + a.checked_mul(b) + } + + /// Performs checked integer division for quote math helpers. + pub fn checked_div_i128(dividend: i128, divisor: i128) -> Option { + if divisor == 0 { + return None; + } + dividend.checked_div(divisor) + } + + /// Performs checked integer subtraction for quote math helpers. + pub fn checked_sub_i128(left: i128, right: i128) -> Option { + left.checked_sub(right) + } + + /// Performs checked integer addition for quote math helpers. + pub fn checked_add_i128(left: i128, right: i128) -> Option { + left.checked_add(right) + } + + /// Computes the checked sum of creator and protocol fee components. + /// + /// Returns `None` if the addition would overflow. Use this helper wherever + /// fee components are combined before being compared against a price or total, + /// to keep the overflow guard consistent across buy and sell quote paths. + /// + /// # Naming convention + /// + /// Quote helpers in this module follow a `checked_*` prefix convention: + /// - `checked_*` functions return `Option` and propagate `None` on overflow. + /// - `compute_*` functions return the result directly (may panic on overflow in + /// debug builds; use only where inputs are already validated). + /// - `apply_*` functions apply a rate or percentage to a single amount. + /// + /// `checked_fee_sum` belongs to the `checked_*` family: it is the canonical + /// helper for summing two fee components before they are used in total-amount + /// arithmetic, replacing ad-hoc inline `checked_add` calls at each call site. + pub fn checked_fee_sum(creator_fee: i128, protocol_fee: i128) -> Option { + creator_fee.checked_add(protocol_fee) } - Ok(()) - } - - /// Computes the fee split for a given total amount. - /// - /// Returns `(creator_amount, protocol_amount)`. Remainder from integer division - /// is assigned to the creator. Ensures creator_amount + protocol_amount == total. - pub fn compute_fee_split( - total: i128, - _creator_bps: u32, - protocol_bps: u32, - ) -> (i128, i128) { - if total <= 0 { - return (0, 0); - } - let protocol_amount = (total * protocol_bps as i128) / BPS_MAX as i128; - let creator_amount = total - protocol_amount; - (creator_amount, protocol_amount) - } - - /// Safely applies a percentage-based fee to an amount. - /// - /// Returns `None` if the multiplication overflows. Rounding is performed via - /// floor division towards zero. - pub fn apply_percentage_fee(amount: i128, bps: u32) -> Option { - if amount <= 0 { - return Some(0); - } - checked_div_i128(amount.checked_mul(bps as i128)?, BPS_MAX as i128) - } - - /// Computes the fee split safely, returning `None` if multiplication or subtraction overflows. - pub fn checked_compute_fee_split( - total: i128, - _creator_bps: u32, - protocol_bps: u32, - ) -> Option<(i128, i128)> { - if total <= 0 { - return Some((0, 0)); - } - let protocol_amount = apply_percentage_fee(total, protocol_bps)?; - let creator_amount = checked_sub_i128(total, protocol_amount)?; - Some((creator_amount, protocol_amount)) - } - - /// Performs checked integer multiplication for quote math helpers. - pub fn checked_mul_i128(a: i128, b: i128) -> Option { - a.checked_mul(b) - } - - /// Performs checked integer division for quote math helpers. - pub fn checked_div_i128(dividend: i128, divisor: i128) -> Option { - if divisor == 0 { - return None; - } - dividend.checked_div(divisor) - } - - /// Performs checked integer subtraction for quote math helpers. - pub fn checked_sub_i128(left: i128, right: i128) -> Option { - left.checked_sub(right) - } - - /// Performs checked integer addition for quote math helpers. - pub fn checked_add_i128(left: i128, right: i128) -> Option { - left.checked_add(right) - } - - /// Computes the checked sum of creator and protocol fee components. - /// - /// Returns `None` if the addition would overflow. Use this helper wherever - /// fee components are combined before being compared against a price or total, - /// to keep the overflow guard consistent across buy and sell quote paths. - /// - /// # Naming convention - /// - /// Quote helpers in this module follow a `checked_*` prefix convention: - /// - `checked_*` functions return `Option` and propagate `None` on overflow. - /// - `compute_*` functions return the result directly (may panic on overflow in - /// debug builds; use only where inputs are already validated). - /// - `apply_*` functions apply a rate or percentage to a single amount. - /// - /// `checked_fee_sum` belongs to the `checked_*` family: it is the canonical - /// helper for summing two fee components before they are used in total-amount - /// arithmetic, replacing ad-hoc inline `checked_add` calls at each call site. - pub fn checked_fee_sum( - creator_fee: i128, - protocol_fee: i128, - ) -> Option { - creator_fee.checked_add(protocol_fee) - } } pub mod constants { - use super::DataKey; - use soroban_sdk::Address; - - pub mod storage { - use super::{creator_key, key_balance_key, DataKey}; + use super::DataKey; use soroban_sdk::Address; - pub const FEE_CONFIG: DataKey = DataKey::FeeConfig; - pub const KEY_PRICE: DataKey = DataKey::KeyPrice; - pub const TREASURY_ADDRESS: DataKey = DataKey::TreasuryAddress; - pub const ADMIN_ADDRESS: DataKey = DataKey::AdminAddress; - pub const PROTOCOL_FEE_RECIPIENT: DataKey = DataKey::ProtocolFeeRecipient; - pub const PROTOCOL_FEE_RECIPIENT_BALANCE: DataKey = - DataKey::ProtocolFeeRecipientBalance; - pub const PROTOCOL_STATE_VERSION: DataKey = DataKey::ProtocolStateVersion; - pub const PAUSED: DataKey = DataKey::Paused; - - pub fn creator_fee_balance(creator: &Address) -> DataKey { - DataKey::CreatorFeeBalance(creator.clone()) - } - - pub fn creator(creator: &Address) -> DataKey { - creator_key(creator) - } - - pub fn key_balance(creator: &Address, holder: &Address) -> DataKey { - key_balance_key(creator, holder) - } - } - - fn creator_key(creator: &Address) -> DataKey { - DataKey::Creator(creator.clone()) - } - - fn key_balance_key(creator: &Address, holder: &Address) -> DataKey { - DataKey::KeyBalance(creator.clone(), holder.clone()) - } - - pub mod creator_reads { - pub const DETAILS: &str = "get_creator_details"; - pub const FEE_BPS: &str = "get_creator_fee_bps"; - pub const FEE_CONFIG: &str = "get_creator_fee_config"; - pub const FEE_RECIPIENT: &str = "get_creator_fee_recipient"; - pub const FEE_RECIPIENT_BALANCE: &str = "get_creator_fee_balance"; - pub const HOLDER_KEY_COUNT: &str = "get_holder_key_count"; - pub const PROFILE: &str = "get_creator"; - pub const SUPPLY: &str = "get_creator_supply"; - pub const TREASURY_SHARE: &str = "get_creator_treasury_share"; - pub const NAME: &str = "get_key_name"; - pub const SYMBOL: &str = "get_key_symbol"; - } + pub mod storage { + use super::{creator_key, key_balance_key, DataKey}; + use soroban_sdk::Address; + + pub const FEE_CONFIG: DataKey = DataKey::FeeConfig; + pub const KEY_PRICE: DataKey = DataKey::KeyPrice; + pub const TREASURY_ADDRESS: DataKey = DataKey::TreasuryAddress; + pub const ADMIN_ADDRESS: DataKey = DataKey::AdminAddress; + pub const PROTOCOL_FEE_RECIPIENT: DataKey = DataKey::ProtocolFeeRecipient; + pub const PROTOCOL_FEE_RECIPIENT_BALANCE: DataKey = DataKey::ProtocolFeeRecipientBalance; + pub const PROTOCOL_STATE_VERSION: DataKey = DataKey::ProtocolStateVersion; + pub const PAUSED: DataKey = DataKey::Paused; + + pub fn creator_fee_balance(creator: &Address) -> DataKey { + DataKey::CreatorFeeBalance(creator.clone()) + } + + pub fn creator(creator: &Address) -> DataKey { + creator_key(creator) + } + + pub fn key_balance(creator: &Address, holder: &Address) -> DataKey { + key_balance_key(creator, holder) + } + } + + fn creator_key(creator: &Address) -> DataKey { + DataKey::Creator(creator.clone()) + } + + fn key_balance_key(creator: &Address, holder: &Address) -> DataKey { + DataKey::KeyBalance(creator.clone(), holder.clone()) + } + + pub mod creator_reads { + pub const DETAILS: &str = "get_creator_details"; + pub const FEE_BPS: &str = "get_creator_fee_bps"; + pub const FEE_CONFIG: &str = "get_creator_fee_config"; + pub const FEE_RECIPIENT: &str = "get_creator_fee_recipient"; + pub const FEE_RECIPIENT_BALANCE: &str = "get_creator_fee_balance"; + pub const HOLDER_KEY_COUNT: &str = "get_holder_key_count"; + pub const PROFILE: &str = "get_creator"; + pub const SUPPLY: &str = "get_creator_supply"; + pub const TREASURY_SHARE: &str = "get_creator_treasury_share"; + pub const NAME: &str = "get_key_name"; + pub const SYMBOL: &str = "get_key_symbol"; + } } /// Stable, non-optional view of the protocol fee configuration. @@ -279,9 +265,9 @@ pub mod constants { #[derive(Clone)] #[contracttype] pub struct ProtocolFeeView { - pub creator_bps: u32, - pub protocol_bps: u32, - pub is_configured: bool, + pub creator_bps: u32, + pub protocol_bps: u32, + pub is_configured: bool, } /// Stable, non-optional view of creator details. @@ -299,17 +285,17 @@ pub struct ProtocolFeeView { #[derive(Clone)] #[contracttype] pub struct CreatorDetailsView { - pub creator: Address, - pub handle: String, - pub supply: u32, - pub is_registered: bool, - /// Ledger sequence number at the time the creator registered. - /// - /// Set to `env.ledger().sequence()` inside [`CreatorKeysContract::register_creator`]. - /// Returns `0` for unregistered addresses so callers never receive an `Option`. - /// Clients can use this field to sort a marketplace grid chronologically without - /// maintaining a separate off-chain index. - pub registered_at: u32, + pub creator: Address, + pub handle: String, + pub supply: u32, + pub is_registered: bool, + /// Ledger sequence number at the time the creator registered. + /// + /// Set to `env.ledger().sequence()` inside [`CreatorKeysContract::register_creator`]. + /// Returns `0` for unregistered addresses so callers never receive an `Option`. + /// Clients can use this field to sort a marketplace grid chronologically without + /// maintaining a separate off-chain index. + pub registered_at: u32, } /// Stable, non-optional view of a creator's fee configuration. /// @@ -319,10 +305,10 @@ pub struct CreatorDetailsView { #[derive(Clone)] #[contracttype] pub struct CreatorFeeView { - pub creator_bps: u32, - pub protocol_bps: u32, - pub is_registered: bool, - pub is_configured: bool, + pub creator_bps: u32, + pub protocol_bps: u32, + pub is_registered: bool, + pub is_configured: bool, } /// Stable, non-optional view of a holder's key count for a creator. @@ -333,10 +319,10 @@ pub struct CreatorFeeView { #[derive(Clone)] #[contracttype] pub struct HolderKeyCountView { - pub creator: Address, - pub holder: Address, - pub key_count: u32, - pub creator_exists: bool, + pub creator: Address, + pub holder: Address, + pub key_count: u32, + pub creator_exists: bool, } /// Stable, non-optional view of a buy or sell quote. @@ -345,10 +331,10 @@ pub struct HolderKeyCountView { #[derive(Clone, Debug, PartialEq)] #[contracttype] pub struct QuoteResponse { - pub price: i128, - pub creator_fee: i128, - pub protocol_fee: i128, - pub total_amount: i128, + pub price: i128, + pub creator_fee: i128, + pub protocol_fee: i128, + pub total_amount: i128, } /// Shared result type for read-only quote methods. @@ -374,50 +360,46 @@ pub const HANDLE_LEN_MAX: u32 = 32; #[derive(Clone)] #[contracttype] pub enum DataKey { - Creator(Address), - FeeConfig, - KeyPrice, - KeyBalance(Address, Address), - TreasuryAddress, - AdminAddress, - ProtocolFeeRecipient, - ProtocolFeeRecipientBalance, - CreatorFeeBalance(Address), - ProtocolStateVersion, - Paused, - HoldersList(Address), - DividendClaimable(Address, Address), + Creator(Address), + FeeConfig, + KeyPrice, + KeyBalance(Address, Address), + TreasuryAddress, + AdminAddress, + ProtocolFeeRecipient, + ProtocolFeeRecipientBalance, + CreatorFeeBalance(Address), + ProtocolStateVersion, + Paused, + HoldersList(Address), + DividendClaimable(Address, Address), } #[derive(Clone, Debug, PartialEq)] #[contracttype] pub struct CreatorProfile { - pub creator: Address, - pub handle: String, - pub supply: u32, - pub holder_count: u32, - pub fee_recipient: Address, - /// Ledger sequence number captured at registration time via `env.ledger().sequence()`. - /// - /// Stored as the last field so existing serialised profiles written before this - /// field was added deserialise correctly — the Soroban persistent storage layer - /// reads structs by field index, so appending is the only safe extension pattern. - pub registered_at: u32, + pub creator: Address, + pub handle: String, + pub supply: u32, + pub holder_count: u32, + pub fee_recipient: Address, + /// Ledger sequence number captured at registration time via `env.ledger().sequence()`. + /// + /// Stored as the last field so existing serialised profiles written before this + /// field was added deserialise correctly — the Soroban persistent storage layer + /// reads structs by field index, so appending is the only safe extension pattern. + pub registered_at: u32, } /// Reads a creator profile from storage, returning `None` for unregistered creators. /// /// Use this helper wherever repeated creator read logic is needed to keep /// missing-creator behavior consistent across the contract. -pub fn read_creator_profile( - env: &Env, - creator: &Address, -) -> Option { - let key = constants::storage::creator(creator); - env - .storage() - .persistent() - .get::(&key) +pub fn read_creator_profile(env: &Env, creator: &Address) -> Option { + let key = constants::storage::creator(creator); + env.storage() + .persistent() + .get::(&key) } /// Reads a registered creator profile, returning an error when the creator is missing. @@ -425,10 +407,10 @@ pub fn read_creator_profile( /// Use this helper for methods that require an existing creator and should return /// a structured contract error instead of a default value. pub fn read_registered_creator_profile( - env: &Env, - creator: &Address, + env: &Env, + creator: &Address, ) -> Result { - read_creator_profile(env, creator).ok_or(ContractError::NotRegistered) + read_creator_profile(env, creator).ok_or(ContractError::NotRegistered) } /// Reads the key balance (supply) for a creator, returning `0` for unregistered creators. @@ -436,9 +418,9 @@ pub fn read_registered_creator_profile( /// Use this helper wherever repeated key balance read logic is needed to keep /// missing-balance behavior consistent across the contract. pub fn read_key_balance(env: &Env, creator: &Address) -> u32 { - read_creator_profile(env, creator) - .map(|p| p.supply) - .unwrap_or(0) + read_creator_profile(env, creator) + .map(|p| p.supply) + .unwrap_or(0) } /// Reads an empty string for use as a default in read-only view methods. @@ -446,7 +428,7 @@ pub fn read_key_balance(env: &Env, creator: &Address) -> u32 { /// Use this helper wherever an empty string is needed to maintain consistency /// and reduce duplication of string allocation logic. pub fn read_none_string(env: &Env) -> String { - String::from_str(env, "") + String::from_str(env, "") } /// Reads the handle for a creator, returning an empty string for unregistered creators. @@ -454,93 +436,88 @@ pub fn read_none_string(env: &Env) -> String { /// Use this helper wherever repeated handle read logic is needed to maintain /// missing-handle behavior consistency across the contract. pub fn read_creator_handle(env: &Env, creator: &Address) -> String { - read_creator_profile(env, creator) - .map(|p| p.handle) - .unwrap_or_else(|| read_none_string(env)) + read_creator_profile(env, creator) + .map(|p| p.handle) + .unwrap_or_else(|| read_none_string(env)) } /// Reads accrued creator fee balance for a creator, returning `0` when none is stored. -pub fn read_creator_fee_recipient_balance( - env: &Env, - creator: &Address, -) -> i128 { - let key = constants::storage::creator_fee_balance(creator); - env.storage().persistent().get(&key).unwrap_or(0) +pub fn read_creator_fee_recipient_balance(env: &Env, creator: &Address) -> i128 { + let key = constants::storage::creator_fee_balance(creator); + env.storage().persistent().get(&key).unwrap_or(0) } /// Credits `amount` to the creator fee recipient balance for `creator`. fn credit_creator_fee_recipient_balance( - env: &Env, - creator: &Address, - amount: i128, + env: &Env, + creator: &Address, + amount: i128, ) -> Result<(), ContractError> { - if amount <= 0 { - return Ok(()); - } - let key = constants::storage::creator_fee_balance(creator); - let current = read_creator_fee_recipient_balance(env, creator); - let updated = current.checked_add(amount).ok_or(ContractError::Overflow)?; - env.storage().persistent().set(&key, &updated); - Ok(()) + if amount <= 0 { + return Ok(()); + } + let key = constants::storage::creator_fee_balance(creator); + let current = read_creator_fee_recipient_balance(env, creator); + let updated = current.checked_add(amount).ok_or(ContractError::Overflow)?; + env.storage().persistent().set(&key, &updated); + Ok(()) } fn is_valid_handle_byte(byte: u8) -> bool { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' } fn validate_creator_handle(handle: &String) -> Result<(), ContractError> { - let len = handle.len(); - if len < HANDLE_LEN_MIN { - return Err(ContractError::HandleTooShort); - } - if len > HANDLE_LEN_MAX { - return Err(ContractError::HandleTooLong); - } - - let mut bytes = [0u8; HANDLE_LEN_MAX as usize]; - handle.copy_into_slice(&mut bytes[..len as usize]); - if bytes[..len as usize] - .iter() - .any(|byte| !is_valid_handle_byte(*byte)) - { - return Err(ContractError::InvalidHandleCharacter); - } - - Ok(()) + let len = handle.len(); + if len < HANDLE_LEN_MIN { + return Err(ContractError::HandleTooShort); + } + if len > HANDLE_LEN_MAX { + return Err(ContractError::HandleTooLong); + } + + let mut bytes = [0u8; HANDLE_LEN_MAX as usize]; + handle.copy_into_slice(&mut bytes[..len as usize]); + if bytes[..len as usize] + .iter() + .any(|byte| !is_valid_handle_byte(*byte)) + { + return Err(ContractError::InvalidHandleCharacter); + } + + Ok(()) } fn is_paused(env: &Env) -> bool { - env - .storage() - .persistent() - .get::(&constants::storage::PAUSED) - .unwrap_or(false) + env.storage() + .persistent() + .get::(&constants::storage::PAUSED) + .unwrap_or(false) } fn assert_not_paused(env: &Env) -> Result<(), ContractError> { - if is_paused(env) { - return Err(ContractError::ProtocolPaused); - } - Ok(()) + if is_paused(env) { + return Err(ContractError::ProtocolPaused); + } + Ok(()) } fn assert_is_admin(env: &Env, caller: &Address) -> Result<(), ContractError> { - let admin: Address = env - .storage() - .persistent() - .get(&constants::storage::ADMIN_ADDRESS) - .ok_or(ContractError::Unauthorized)?; - if *caller != admin { - return Err(ContractError::Unauthorized); - } - Ok(()) + let admin: Address = env + .storage() + .persistent() + .get(&constants::storage::ADMIN_ADDRESS) + .ok_or(ContractError::Unauthorized)?; + if *caller != admin { + return Err(ContractError::Unauthorized); + } + Ok(()) } fn read_protocol_fee_config(env: &Env) -> Option { - env - .storage() - .persistent() - .get(&constants::storage::FEE_CONFIG) + env.storage() + .persistent() + .get(&constants::storage::FEE_CONFIG) } /// Validates that an address is not the Stellar zero address. @@ -548,139 +525,119 @@ fn read_protocol_fee_config(env: &Env) -> Option { /// The zero address (`GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF`) /// is the all-zero public key. Setting it as a fee recipient would silently /// burn all protocol fees. This helper rejects it at the point of assignment. -fn validate_non_zero_address( - env: &Env, - addr: &Address, -) -> Result<(), ContractError> { - let zero_str = String::from_str( - env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ); - let zero_addr = Address::from_string(&zero_str); - if *addr == zero_addr { - return Err(ContractError::ZeroAddress); - } - Ok(()) +fn validate_non_zero_address(env: &Env, addr: &Address) -> Result<(), ContractError> { + let zero_str = String::from_str( + env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ); + let zero_addr = Address::from_string(&zero_str); + if *addr == zero_addr { + return Err(ContractError::ZeroAddress); + } + Ok(()) } -fn read_required_protocol_fee_config( - env: &Env, -) -> Result { - read_protocol_fee_config(env).ok_or(ContractError::FeeConfigNotSet) +fn read_required_protocol_fee_config(env: &Env) -> Result { + read_protocol_fee_config(env).ok_or(ContractError::FeeConfigNotSet) } fn read_protocol_fee_recipient_balance(env: &Env) -> i128 { - env - .storage() - .persistent() - .get(&constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE) - .unwrap_or(0) + env.storage() + .persistent() + .get(&constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE) + .unwrap_or(0) } -fn credit_protocol_fee_recipient_balance( - env: &Env, - amount: i128, -) -> Result<(), ContractError> { - if amount <= 0 { - return Ok(()); - } - let updated = read_protocol_fee_recipient_balance(env) - .checked_add(amount) - .ok_or(ContractError::Overflow)?; - env.storage().persistent().set( - &constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE, - &updated, - ); - Ok(()) +fn credit_protocol_fee_recipient_balance(env: &Env, amount: i128) -> Result<(), ContractError> { + if amount <= 0 { + return Ok(()); + } + let updated = read_protocol_fee_recipient_balance(env) + .checked_add(amount) + .ok_or(ContractError::Overflow)?; + env.storage().persistent().set( + &constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE, + &updated, + ); + Ok(()) } -fn assert_buy_price_slippage( - price: i128, - max_price: Option, -) -> Result<(), ContractError> { - if let Some(max) = max_price { - if price > max { - return Err(ContractError::SlippageExceeded); +fn assert_buy_price_slippage(price: i128, max_price: Option) -> Result<(), ContractError> { + if let Some(max) = max_price { + if price > max { + return Err(ContractError::SlippageExceeded); + } } - } - Ok(()) + Ok(()) } -fn compute_sell_proceeds( - env: &Env, - price: i128, -) -> Result { - let (creator_fee, protocol_fee) = - CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?; - let fees = fee::checked_fee_sum(creator_fee, protocol_fee) - .ok_or(ContractError::Overflow)?; - fee::checked_sub_i128(price, fees).ok_or(ContractError::SellUnderflow) +fn compute_sell_proceeds(env: &Env, price: i128) -> Result { + let (creator_fee, protocol_fee) = + CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?; + let fees = fee::checked_fee_sum(creator_fee, protocol_fee).ok_or(ContractError::Overflow)?; + fee::checked_sub_i128(price, fees).ok_or(ContractError::SellUnderflow) } fn assert_sell_proceeds_slippage( - env: &Env, - min_proceeds: Option, + env: &Env, + min_proceeds: Option, ) -> Result<(), ContractError> { - if let Some(min) = min_proceeds { - let price: i128 = env - .storage() - .persistent() - .get(&constants::storage::KEY_PRICE) - .ok_or(ContractError::KeyPriceNotSet)?; - let proceeds = compute_sell_proceeds(env, price)?; - if proceeds < min { - return Err(ContractError::SlippageExceeded); - } - } - Ok(()) + if let Some(min) = min_proceeds { + let price: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; + let proceeds = compute_sell_proceeds(env, price)?; + if proceeds < min { + return Err(ContractError::SlippageExceeded); + } + } + Ok(()) } fn accrue_sell_protocol_fee(env: &Env) -> Result<(), ContractError> { - if env - .storage() - .persistent() - .get::(&constants::storage::PROTOCOL_FEE_RECIPIENT) - .is_none() - { - return Ok(()); - } - - let Some(price) = env - .storage() - .persistent() - .get(&constants::storage::KEY_PRICE) - else { - return Ok(()); - }; - - if read_protocol_fee_config(env).is_none() { - return Ok(()); - } - - let (_, protocol_fee) = - CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?; - credit_protocol_fee_recipient_balance(env, protocol_fee) + if env + .storage() + .persistent() + .get::(&constants::storage::PROTOCOL_FEE_RECIPIENT) + .is_none() + { + return Ok(()); + } + + let Some(price) = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + else { + return Ok(()); + }; + + if read_protocol_fee_config(env).is_none() { + return Ok(()); + } + + let (_, protocol_fee) = CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?; + credit_protocol_fee_recipient_balance(env, protocol_fee) } /// Resolves and validates the shared inputs required by read-only quote methods. /// /// Reads the key price from storage and confirms the creator is registered. /// Returns `(price)` on success, or the appropriate [`ContractError`] on failure. -fn resolve_quote_inputs( - env: &Env, - creator: &Address, -) -> Result, ContractError> { - let price: i128 = env - .storage() - .persistent() - .get(&constants::storage::KEY_PRICE) - .ok_or(ContractError::KeyPriceNotSet)?; - - if read_creator_profile(env, creator).is_none() { - return Err(ContractError::NotRegistered); - } - - normalize_quote_amount(price) +fn resolve_quote_inputs(env: &Env, creator: &Address) -> Result, ContractError> { + let price: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; + + if read_creator_profile(env, creator).is_none() { + return Err(ContractError::NotRegistered); + } + + normalize_quote_amount(price) } /// Normalizes quote amounts before fee math is applied. @@ -689,54 +646,53 @@ fn resolve_quote_inputs( /// Negative quote amounts are rejected consistently across buy and sell paths. /// Amounts exceeding MAX_SAFE_AMOUNT are rejected to prevent overflow in fee calculations. fn normalize_quote_amount(amount: i128) -> Result, ContractError> { - if amount < 0 { - return Err(ContractError::NotPositiveAmount); - } + if amount < 0 { + return Err(ContractError::NotPositiveAmount); + } - if amount == 0 { - return Ok(None); - } + if amount == 0 { + return Ok(None); + } - if amount > fee::MAX_SAFE_AMOUNT { - return Err(ContractError::Overflow); - } + if amount > fee::MAX_SAFE_AMOUNT { + return Err(ContractError::Overflow); + } - Ok(Some(amount)) + Ok(Some(amount)) } fn zero_quote_response() -> QuoteResponse { - QuoteResponse { - price: 0, - creator_fee: 0, - protocol_fee: 0, - total_amount: 0, - } + QuoteResponse { + price: 0, + creator_fee: 0, + protocol_fee: 0, + total_amount: 0, + } } /// Formats a quote response with overflow-safe total amount calculation. /// /// Returns `Err(ContractError::Overflow)` if any addition or subtraction would overflow. fn checked_format_quote_response( - price: i128, - creator_fee: i128, - protocol_fee: i128, - is_buy: bool, + price: i128, + creator_fee: i128, + protocol_fee: i128, + is_buy: bool, ) -> QuoteViewResult { - let fees = fee::checked_fee_sum(creator_fee, protocol_fee) - .ok_or(ContractError::Overflow)?; - - let total_amount = if is_buy { - price.checked_add(fees).ok_or(ContractError::Overflow)? - } else { - fee::checked_sub_i128(price, fees).ok_or(ContractError::SellUnderflow)? - }; - - Ok(QuoteResponse { - price, - creator_fee, - protocol_fee, - total_amount, - }) + let fees = fee::checked_fee_sum(creator_fee, protocol_fee).ok_or(ContractError::Overflow)?; + + let total_amount = if is_buy { + price.checked_add(fees).ok_or(ContractError::Overflow)? + } else { + fee::checked_sub_i128(price, fees).ok_or(ContractError::SellUnderflow)? + }; + + Ok(QuoteResponse { + price, + creator_fee, + protocol_fee, + total_amount, + }) } #[contract] @@ -744,1383 +700,1296 @@ pub struct CreatorKeysContract; #[contractimpl] impl CreatorKeysContract { - /// Registers a new creator profile. This is a contract initialization - /// entrypoint; the contract has no single `initialize` call, so the - /// init-time parameter validation lives on the individual setters. - /// - /// Parameter validation: - /// - `creator`: must authorize the call (`require_auth`). A profile must not - /// already exist for this address, otherwise - /// [`ContractError::AlreadyRegistered`]. - /// - `handle`: validated by [`validate_creator_handle`] — below the minimum - /// length returns [`ContractError::HandleTooShort`], above the maximum - /// returns [`ContractError::HandleTooLong`], and any disallowed byte - /// returns [`ContractError::InvalidHandleCharacter`]. - pub fn register_creator( - env: Env, - creator: Address, - handle: String, - ) -> Result<(), ContractError> { - creator.require_auth(); - assert_not_paused(&env)?; - - validate_creator_handle(&handle)?; - - let key = constants::storage::creator(&creator); - // Creator profile storage is a single source of truth keyed by creator address. - // Once written, this key's existence is the registration invariant. - if env.storage().persistent().has(&key) { - return Err(ContractError::AlreadyRegistered); - } - - let profile = CreatorProfile { - creator: creator.clone(), - handle, - supply: 0, - holder_count: 0, - fee_recipient: creator.clone(), - registered_at: env.ledger().sequence(), - }; + /// Registers a new creator profile. This is a contract initialization + /// entrypoint; the contract has no single `initialize` call, so the + /// init-time parameter validation lives on the individual setters. + /// + /// Parameter validation: + /// - `creator`: must authorize the call (`require_auth`). A profile must not + /// already exist for this address, otherwise + /// [`ContractError::AlreadyRegistered`]. + /// - `handle`: validated by [`validate_creator_handle`] — below the minimum + /// length returns [`ContractError::HandleTooShort`], above the maximum + /// returns [`ContractError::HandleTooLong`], and any disallowed byte + /// returns [`ContractError::InvalidHandleCharacter`]. + pub fn register_creator( + env: Env, + creator: Address, + handle: String, + ) -> Result<(), ContractError> { + creator.require_auth(); + assert_not_paused(&env)?; + + validate_creator_handle(&handle)?; + + let key = constants::storage::creator(&creator); + // Creator profile storage is a single source of truth keyed by creator address. + // Once written, this key's existence is the registration invariant. + if env.storage().persistent().has(&key) { + return Err(ContractError::AlreadyRegistered); + } + + let profile = CreatorProfile { + creator: creator.clone(), + handle, + supply: 0, + holder_count: 0, + fee_recipient: creator.clone(), + registered_at: env.ledger().sequence(), + }; + + let fee_config = read_protocol_fee_config(&env).unwrap_or(fee::FeeConfig { + creator_bps: 0, + protocol_bps: 0, + }); + + // Persist profile before event publication so indexers reading contract state + // after this tx observe the same registration payload that was emitted. + env.storage().persistent().set(&key, &profile); + env.events().publish( + events::register_event_topics(&profile.creator), + events::CreatorRegisteredEvent { + creator: profile.creator.clone(), + handle: profile.handle.clone(), + supply: profile.supply, + holder_count: profile.holder_count, + creator_bps: fee_config.creator_bps, + protocol_bps: fee_config.protocol_bps, + }, + ); + + Ok(()) + } - let fee_config = read_protocol_fee_config(&env).unwrap_or(fee::FeeConfig { - creator_bps: 0, - protocol_bps: 0, - }); - - // Persist profile before event publication so indexers reading contract state - // after this tx observe the same registration payload that was emitted. - env.storage().persistent().set(&key, &profile); - env.events().publish( - events::register_event_topics(&profile.creator), - events::CreatorRegisteredEvent { - creator: profile.creator.clone(), - handle: profile.handle.clone(), - supply: profile.supply, - holder_count: profile.holder_count, - creator_bps: fee_config.creator_bps, - protocol_bps: fee_config.protocol_bps, - }, - ); + pub fn buy_key( + env: Env, + creator: Address, + buyer: Address, + payment: i128, + max_price: Option, + ) -> Result { + buyer.require_auth(); + assert_not_paused(&env)?; + + if payment <= 0 { + return Err(ContractError::NotPositiveAmount); + } + + let price: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; + + assert_buy_price_slippage(price, max_price)?; + + if payment < price { + return Err(ContractError::InsufficientPayment); + } + + let mut profile: CreatorProfile = read_registered_creator_profile(&env, &creator)?; + + let balance_key = constants::storage::key_balance(&creator, &buyer); + // Missing balance entries are treated as zero to keep storage sparse. + let current_balance: u32 = env.storage().persistent().get(&balance_key).unwrap_or(0); + + if current_balance == 0 { + profile.holder_count = profile + .holder_count + .checked_add(1) + .ok_or(ContractError::Overflow)?; + } + let mut holders = read_holders_list(&env, &creator); + if !holders.iter().any(|h| h == &buyer) { + holders.push_back(buyer.clone()); + write_holders_list(&env, &creator, &holders); + } + + profile.supply = profile + .supply + .checked_add(1) + .ok_or(ContractError::Overflow)?; + + let key = constants::storage::creator(&creator); + // Supply and holder_count must always move together with buyer balance writes. + env.storage().persistent().set(&key, &profile); + + let new_balance = current_balance + .checked_add(1) + .ok_or(ContractError::Overflow)?; + // Balance key is scoped by (creator, holder) so creator positions cannot collide. + env.storage().persistent().set(&balance_key, &new_balance); + + if let Some(config) = read_protocol_fee_config(&env) { + let (creator_fee, protocol_fee) = + fee::checked_compute_fee_split(price, config.creator_bps, config.protocol_bps) + .ok_or(ContractError::Overflow)?; + credit_creator_fee_recipient_balance(&env, &creator, creator_fee)?; + credit_protocol_fee_recipient_balance(&env, protocol_fee)?; + } + + env.events().publish( + events::buy_event_topics(&creator, &buyer), + (profile.supply, payment), + ); + + Ok(profile.supply) + } - Ok(()) - } + pub fn sell_key( + env: Env, + creator: Address, + seller: Address, + min_proceeds: Option, + ) -> Result { + seller.require_auth(); + assert_not_paused(&env)?; + + let mut profile: CreatorProfile = read_registered_creator_profile(&env, &creator)?; + + let balance_key = constants::storage::key_balance(&creator, &seller); + // Missing balance entries are interpreted as zero and rejected consistently. + let current_balance: u32 = env.storage().persistent().get(&balance_key).unwrap_or(0); + if current_balance == 0 { + return Err(ContractError::InsufficientBalance); + } + + assert_sell_proceeds_slippage(&env, min_proceeds)?; + + let new_balance = current_balance + .checked_sub(1) + .ok_or(ContractError::SellUnderflow)?; + profile.supply = profile + .supply + .checked_sub(1) + .ok_or(ContractError::SellUnderflow)?; + + if new_balance == 0 { + profile.holder_count = profile + .holder_count + .checked_sub(1) + .ok_or(ContractError::SellUnderflow)?; + let mut holders = read_holders_list(&env, &creator); + if let Some(pos) = holders.iter().position(|h| h == &seller) { + holders.remove(pos); + write_holders_list(&env, &creator, &holders); + } + } + + let key = constants::storage::creator(&creator); + // Profile and holder balance are updated in the same call to preserve + // supply/holder_count invariants for subsequent reads. + env.storage().persistent().set(&key, &profile); + env.storage().persistent().set(&balance_key, &new_balance); + accrue_sell_protocol_fee(&env)?; + + env.events() + .publish((events::SELL_EVENT_NAME, creator, seller), profile.supply); + + Ok(profile.supply) + } - pub fn buy_key( - env: Env, - creator: Address, - buyer: Address, - payment: i128, - max_price: Option, - ) -> Result { - buyer.require_auth(); - assert_not_paused(&env)?; + /// Halts all state-changing operations (buy, sell, register_creator). + /// + /// Only the protocol admin may call this. Emits a `ProtocolPaused` event. + /// Read-only view functions are unaffected and continue to work while paused. + pub fn pause(env: Env, admin: Address) -> Result<(), ContractError> { + admin.require_auth(); + assert_is_admin(&env, &admin)?; + env.storage() + .persistent() + .set(&constants::storage::PAUSED, &true); + env.events().publish((events::PAUSE_EVENT_NAME, admin), ()); + Ok(()) + } - if payment <= 0 { - return Err(ContractError::NotPositiveAmount); + /// Resumes all state-changing operations after an emergency pause. + /// + /// Only the protocol admin may call this. Emits a `ProtocolUnpaused` event. + pub fn unpause(env: Env, admin: Address) -> Result<(), ContractError> { + admin.require_auth(); + assert_is_admin(&env, &admin)?; + env.storage() + .persistent() + .set(&constants::storage::PAUSED, &false); + env.events() + .publish((events::UNPAUSE_EVENT_NAME, admin), ()); + Ok(()) } - let price: i128 = env - .storage() - .persistent() - .get(&constants::storage::KEY_PRICE) - .ok_or(ContractError::KeyPriceNotSet)?; + /// Read-only view: returns whether the protocol is currently paused. + pub fn get_is_paused(env: Env) -> bool { + is_paused(&env) + } - assert_buy_price_slippage(price, max_price)?; + pub fn get_key_balance(env: Env, creator: Address, wallet: Address) -> u32 { + let key = constants::storage::key_balance(&creator, &wallet); + // Read-only callers get `0` for unseen balances to avoid sparse-map lookups failing. + env.storage().persistent().get(&key).unwrap_or(0) + } - if payment < price { - return Err(ContractError::InsufficientPayment); + /// Read-only view: returns a stable view of a holder's key count for a creator. + /// + /// Returns a [`HolderKeyCountView`] regardless of creator registration status. + /// When the creator is not registered, `creator_exists` is `false` and `key_count` is `0`. + /// When the creator exists but the holder has no keys, `key_count` is `0`. + /// This method is designed for indexer-friendly consumption and avoids panics. + pub fn get_holder_key_count(env: Env, creator: Address, holder: Address) -> HolderKeyCountView { + let creator_exists = read_creator_profile(&env, &creator).is_some(); + let key_count = if creator_exists { + let key = constants::storage::key_balance(&creator, &holder); + env.storage().persistent().get(&key).unwrap_or(0) + } else { + 0 + }; + + HolderKeyCountView { + creator, + holder, + key_count, + creator_exists, + } } - let mut profile: CreatorProfile = - read_registered_creator_profile(&env, &creator)?; + pub fn get_creator(env: Env, creator: Address) -> Result { + read_registered_creator_profile(&env, &creator) + } - let balance_key = constants::storage::key_balance(&creator, &buyer); - // Missing balance entries are treated as zero to keep storage sparse. - let current_balance: u32 = - env.storage().persistent().get(&balance_key).unwrap_or(0); + /// Read-only view: returns stable creator details. + /// + /// Returns a [`CreatorDetailsView`] regardless of registration status. + /// When the creator is not registered, `is_registered` is `false` and + /// default values are provided for other fields, including `registered_at: 0`. + pub fn get_creator_details(env: Env, creator: Address) -> CreatorDetailsView { + let key = constants::storage::creator(&creator); + match env + .storage() + .persistent() + .get::(&key) + { + Some(profile) => CreatorDetailsView { + creator: profile.creator, + handle: profile.handle, + supply: profile.supply, + is_registered: true, + registered_at: profile.registered_at, + }, + None => CreatorDetailsView { + creator, + handle: read_none_string(&env), + supply: 0, + is_registered: false, + registered_at: 0, + }, + } + } - if current_balance == 0 { - profile.holder_count = profile - .holder_count - .checked_add(1) - .ok_or(ContractError::Overflow)?; + pub fn distribute_dividend( + env: Env, + creator: Address, + amount: i128, + ) -> Result<(), ContractError> { + let caller = env.invoker(); + + // Transfer XLM from caller to contract + let native = env.native_token(); + native.transfer(&caller, &env.current_contract_address(), &amount); + + // Load creator profile and validate + let profile = read_registered_creator_profile(&env, &creator)?; + let total_supply = profile.supply as i128; + if total_supply == 0 { + return Err(ContractError::NoHolders); + } + + // Load fee config and compute protocol fee + let config = read_required_protocol_fee_config(&env)?; + let protocol_fee = fee::apply_percentage_fee(amount, config.protocol_bps) + .ok_or(ContractError::Overflow)?; + let distribute_amount = amount + .checked_sub(protocol_fee) + .ok_or(ContractError::Overflow)?; + if distribute_amount <= 0 { + return Err(ContractError::DividendAmountZero); + } + + // Credit protocol fee to the protocol fee recipient balance + credit_protocol_fee_recipient_balance(&env, protocol_fee)?; + + // Iterate over all holders and distribute proportionally + let holders = read_holders_list(&env, &creator); + let ledger = env.ledger().sequence(); + + for holder in holders.iter() { + let balance = + Self::get_key_balance(env.clone(), creator.clone(), holder.clone()) as i128; + if balance == 0 { + continue; + } + let share = (balance * distribute_amount) / total_supply; + if share > 0 { + let current = read_claimable_dividend(&env, &creator, &holder); + let new_claim = current.checked_add(share).ok_or(ContractError::Overflow)?; + write_claimable_dividend(&env, &creator, &holder, new_claim); + } + } + + // Emit event + env.events().publish( + events::dividend_distributed_topics(&creator), + events::DividendDistributedEvent { + creator_id: creator, + total_amount: amount, + snapshot_supply: total_supply, + ledger, + protocol_fee, + distributed_amount: distribute_amount, + }, + ); + + Ok(()) } - let mut holders = read_holders_list(&env, &creator); - if !holders.iter().any(|h| h == &buyer) { - holders.push_back(buyer.clone()); - write_holders_list(&env, &creator, &holders); + + /// Claims accrued dividends for the caller. + /// + /// Transfers the claimable amount from the contract to the caller's wallet. + /// Resets the claimable balance to zero. + /// Emits a `DividendClaimed` event. + pub fn claim_dividend(env: Env, creator: Address) -> Result<(), ContractError> { + let caller = env.invoker(); + + let claimable = read_claimable_dividend(&env, &creator, &caller); + if claimable == 0 { + return Err(ContractError::ZeroClaimable); + } + + // Reset claimable to zero before transfer (reentrancy-safe) + write_claimable_dividend(&env, &creator, &caller, 0); + + // Transfer XLM from contract to caller + let native = env.native_token(); + native.transfer(&env.current_contract_address(), &caller, &claimable); + + // Emit event + env.events().publish( + events::dividend_claimed_topics(&creator, &caller), + events::DividendClaimedEvent { + creator_id: creator, + claimant: caller, + amount: claimable, + }, + ); + + Ok(()) } - profile.supply = profile - .supply - .checked_add(1) - .ok_or(ContractError::Overflow)?; + /// Returns the unclaimed dividend balance for a specific (creator, holder) pair. + pub fn get_claimable_dividend(env: Env, creator: Address, wallet: Address) -> i128 { + read_claimable_dividend(&env, &creator, &wallet) + } - let key = constants::storage::creator(&creator); - // Supply and holder_count must always move together with buyer balance writes. - env.storage().persistent().set(&key, &profile); + /// Read-only batch view: returns [`CreatorDetailsView`] for each address in `creators`. + /// + /// Iterates the provided addresses in order and fetches each creator's profile + /// from persistent storage. The output `Vec` is the same length as the input and + /// preserves input order, so clients can zip the two slices without an extra sort. + /// + /// Unregistered addresses never cause the call to fail: they produce a default + /// [`CreatorDetailsView`] with `is_registered: false` and `registered_at: 0`, + /// matching the single-address behaviour of [`get_creator_details`]. + /// + /// # Usage + /// + /// ```text + /// let views = client.get_creators_batch(&vec![alice, bob, unknown]); + /// // views[0] → alice's details (is_registered: true) + /// // views[1] → bob's details (is_registered: true) + /// // views[2] → default view (is_registered: false, registered_at: 0) + /// ``` + pub fn get_creators_batch( + env: Env, + creators: soroban_sdk::Vec
, + ) -> soroban_sdk::Vec { + let mut results = soroban_sdk::Vec::new(&env); + for creator in creators.iter() { + let key = constants::storage::creator(&creator); + let view = match env + .storage() + .persistent() + .get::(&key) + { + Some(profile) => CreatorDetailsView { + creator: profile.creator, + handle: profile.handle, + supply: profile.supply, + is_registered: true, + registered_at: profile.registered_at, + }, + None => CreatorDetailsView { + creator, + handle: read_none_string(&env), + supply: 0, + is_registered: false, + registered_at: 0, + }, + }; + results.push_back(view); + } + results + } + /// Read-only view: returns the protocol state version. + /// + /// Returns a stable scalar value for clients and indexers to detect + /// protocol-state schema/semantics revisions. The version is stored in + /// storage and increments on config updates. + pub fn get_protocol_state_version(env: Env) -> u32 { + env.storage() + .persistent() + .get(&constants::storage::PROTOCOL_STATE_VERSION) + .unwrap_or(PROTOCOL_STATE_VERSION_INITIAL) + } - let new_balance = current_balance - .checked_add(1) - .ok_or(ContractError::Overflow)?; - // Balance key is scoped by (creator, holder) so creator positions cannot collide. - env.storage().persistent().set(&balance_key, &new_balance); + /// Read-only view: returns the decimal precision used by creator key values. + /// + /// Returns the fixed [`KEY_DECIMALS`] constant. Does not read or mutate contract state. + pub fn get_key_decimals(_env: Env) -> u32 { + KEY_DECIMALS + } - if let Some(config) = read_protocol_fee_config(&env) { - let (creator_fee, protocol_fee) = fee::checked_compute_fee_split( - price, - config.creator_bps, - config.protocol_bps, - ) - .ok_or(ContractError::Overflow)?; - credit_creator_fee_recipient_balance(&env, &creator, creator_fee)?; - credit_protocol_fee_recipient_balance(&env, protocol_fee)?; + /// Read-only view: returns the display name for a creator's key. + /// + /// Returns the creator's handle for registered creators. Fails with + /// [`ContractError::NotRegistered`] if the creator is not registered. + pub fn get_key_name(env: Env, creator: Address) -> Result { + let profile = read_registered_creator_profile(&env, &creator)?; + Ok(profile.handle) } - env.events().publish( - events::buy_event_topics(&creator, &buyer), - (profile.supply, payment), - ); + /// Read-only view: returns the ticker symbol for a creator's key. + /// + /// Returns the creator's handle for registered creators. Fails with + /// [`ContractError::NotRegistered`] if the creator is not registered. + pub fn get_key_symbol(env: Env, creator: Address) -> Result { + let profile = read_registered_creator_profile(&env, &creator)?; + Ok(profile.handle) + } - Ok(profile.supply) - } + /// Read-only view: returns the total key supply for a creator. + /// + /// Returns `0` if the creator is not registered, avoiding panics for + /// invalid lookups. Delegates to the shared [`read_key_balance`] helper. + pub fn get_total_key_supply(env: Env, creator: Address) -> u32 { + read_key_balance(&env, &creator) + } - pub fn sell_key( - env: Env, - creator: Address, - seller: Address, - min_proceeds: Option, - ) -> Result { - seller.require_auth(); - assert_not_paused(&env)?; - - let mut profile: CreatorProfile = - read_registered_creator_profile(&env, &creator)?; - - let balance_key = constants::storage::key_balance(&creator, &seller); - // Missing balance entries are interpreted as zero and rejected consistently. - let current_balance: u32 = - env.storage().persistent().get(&balance_key).unwrap_or(0); - if current_balance == 0 { - return Err(ContractError::InsufficientBalance); - } - - assert_sell_proceeds_slippage(&env, min_proceeds)?; - - let new_balance = current_balance - .checked_sub(1) - .ok_or(ContractError::SellUnderflow)?; - profile.supply = profile - .supply - .checked_sub(1) - .ok_or(ContractError::SellUnderflow)?; - - if new_balance == 0 { - profile.holder_count = profile - .holder_count - .checked_sub(1) - .ok_or(ContractError::SellUnderflow)?; - let mut holders = read_holders_list(&env, &creator); - if let Some(pos) = holders.iter().position(|h| h == &seller) { - holders.remove(pos); - write_holders_list(&env, &creator, &holders); - } - } - - let key = constants::storage::creator(&creator); - // Profile and holder balance are updated in the same call to preserve - // supply/holder_count invariants for subsequent reads. - env.storage().persistent().set(&key, &profile); - env.storage().persistent().set(&balance_key, &new_balance); - accrue_sell_protocol_fee(&env)?; - - env - .events() - .publish((events::SELL_EVENT_NAME, creator, seller), profile.supply); - - Ok(profile.supply) - } - - /// Halts all state-changing operations (buy, sell, register_creator). - /// - /// Only the protocol admin may call this. Emits a `ProtocolPaused` event. - /// Read-only view functions are unaffected and continue to work while paused. - pub fn pause(env: Env, admin: Address) -> Result<(), ContractError> { - admin.require_auth(); - assert_is_admin(&env, &admin)?; - env - .storage() - .persistent() - .set(&constants::storage::PAUSED, &true); - env.events().publish((events::PAUSE_EVENT_NAME, admin), ()); - Ok(()) - } - - /// Resumes all state-changing operations after an emergency pause. - /// - /// Only the protocol admin may call this. Emits a `ProtocolUnpaused` event. - pub fn unpause(env: Env, admin: Address) -> Result<(), ContractError> { - admin.require_auth(); - assert_is_admin(&env, &admin)?; - env - .storage() - .persistent() - .set(&constants::storage::PAUSED, &false); - env - .events() - .publish((events::UNPAUSE_EVENT_NAME, admin), ()); - Ok(()) - } + /// Read-only view: returns the current supply for a registered creator. + /// + /// Fails with [`ContractError::NotRegistered`] if the creator does not exist. + pub fn get_creator_supply(env: Env, creator: Address) -> Result { + let profile = read_registered_creator_profile(&env, &creator)?; + Ok(profile.supply) + } - /// Read-only view: returns whether the protocol is currently paused. - pub fn get_is_paused(env: Env) -> bool { - is_paused(&env) - } + /// Read-only view: returns the number of unique holders for a creator. + /// + /// Returns `0` if the creator is not registered, avoiding panics for + /// invalid lookups. Uses the stored creator profile holder count. + pub fn get_creator_holder_count(env: Env, creator: Address) -> u32 { + read_creator_profile(&env, &creator) + .map(|profile| profile.holder_count) + .unwrap_or(0) + } - pub fn get_key_balance(env: Env, creator: Address, wallet: Address) -> u32 { - let key = constants::storage::key_balance(&creator, &wallet); - // Read-only callers get `0` for unseen balances to avoid sparse-map lookups failing. - env.storage().persistent().get(&key).unwrap_or(0) - } - - /// Read-only view: returns a stable view of a holder's key count for a creator. - /// - /// Returns a [`HolderKeyCountView`] regardless of creator registration status. - /// When the creator is not registered, `creator_exists` is `false` and `key_count` is `0`. - /// When the creator exists but the holder has no keys, `key_count` is `0`. - /// This method is designed for indexer-friendly consumption and avoids panics. - pub fn get_holder_key_count( - env: Env, - creator: Address, - holder: Address, - ) -> HolderKeyCountView { - let creator_exists = read_creator_profile(&env, &creator).is_some(); - let key_count = if creator_exists { - let key = constants::storage::key_balance(&creator, &holder); - env.storage().persistent().get(&key).unwrap_or(0) - } else { - 0 - }; + /// Read-only view: returns whether a creator is registered in the contract. + /// + /// Returns `true` if a [`CreatorProfile`] exists for the given address, + /// `false` otherwise. Does not mutate state. + pub fn is_creator_registered(env: Env, creator: Address) -> bool { + read_creator_profile(&env, &creator).is_some() + } - HolderKeyCountView { - creator, - holder, - key_count, - creator_exists, - } - } - - pub fn get_creator( - env: Env, - creator: Address, - ) -> Result { - read_registered_creator_profile(&env, &creator) - } - - /// Read-only view: returns stable creator details. - /// - /// Returns a [`CreatorDetailsView`] regardless of registration status. - /// When the creator is not registered, `is_registered` is `false` and - /// default values are provided for other fields, including `registered_at: 0`. - pub fn get_creator_details(env: Env, creator: Address) -> CreatorDetailsView { - let key = constants::storage::creator(&creator); - match env - .storage() - .persistent() - .get::(&key) - { - Some(profile) => CreatorDetailsView { - creator: profile.creator, - handle: profile.handle, - supply: profile.supply, - is_registered: true, - registered_at: profile.registered_at, - }, - None => CreatorDetailsView { - creator, - handle: read_none_string(&env), - supply: 0, - is_registered: false, - registered_at: 0, - }, - } - } - - pub fn distribute_dividend( - env: Env, - creator: Address, - amount: i128, - ) -> Result<(), ContractError> { - let caller = env.invoker(); - - // Transfer XLM from caller to contract - let native = env.native_token(); - native.transfer(&caller, &env.current_contract_address(), &amount); - - // Load creator profile and validate - let profile = read_registered_creator_profile(&env, &creator)?; - let total_supply = profile.supply as i128; - if total_supply == 0 { - return Err(ContractError::NoHolders); - } - - // Load fee config and compute protocol fee - let config = read_required_protocol_fee_config(&env)?; - let protocol_fee = fee::apply_percentage_fee(amount, config.protocol_bps) - .ok_or(ContractError::Overflow)?; - let distribute_amount = amount - .checked_sub(protocol_fee) - .ok_or(ContractError::Overflow)?; - if distribute_amount <= 0 { - return Err(ContractError::DividendAmountZero); - } - - // Credit protocol fee to the protocol fee recipient balance - credit_protocol_fee_recipient_balance(&env, protocol_fee)?; - - // Iterate over all holders and distribute proportionally - let holders = read_holders_list(&env, &creator); - let ledger = env.ledger().sequence(); - - for holder in holders.iter() { - let balance = - Self::get_key_balance(env.clone(), creator.clone(), holder.clone()) - as i128; - if balance == 0 { - continue; - } - let share = (balance * distribute_amount) / total_supply; - if share > 0 { - let current = read_claimable_dividend(&env, &creator, &holder); - let new_claim = - current.checked_add(share).ok_or(ContractError::Overflow)?; - write_claimable_dividend(&env, &creator, &holder, new_claim); - } - } - - // Emit event - env.events().publish( - events::dividend_distributed_topics(&creator), - events::DividendDistributedEvent { - creator_id: creator, - total_amount: amount, - snapshot_supply: total_supply, - ledger, - protocol_fee, - distributed_amount: distribute_amount, - }, - ); + /// Read-only view: returns the creator fee recipient address. + /// + /// Fails with [`ContractError::NotRegistered`] if the creator is not registered. + /// Reuses current creator storage access patterns. + pub fn get_creator_fee_recipient(env: Env, creator: Address) -> Result { + let profile = read_registered_creator_profile(&env, &creator)?; + Ok(profile.fee_recipient) + } - Ok(()) - } - - /// Claims accrued dividends for the caller. - /// - /// Transfers the claimable amount from the contract to the caller's wallet. - /// Resets the claimable balance to zero. - /// Emits a `DividendClaimed` event. - pub fn claim_dividend( - env: Env, - creator: Address, - ) -> Result<(), ContractError> { - let caller = env.invoker(); - - let claimable = read_claimable_dividend(&env, &creator, &caller); - if claimable == 0 { - return Err(ContractError::ZeroClaimable); - } - - // Reset claimable to zero before transfer (reentrancy-safe) - write_claimable_dividend(&env, &creator, &caller, 0); - - // Transfer XLM from contract to caller - let native = env.native_token(); - native.transfer(&env.current_contract_address(), &caller, &claimable); - - // Emit event - env.events().publish( - events::dividend_claimed_topics(&creator, &caller), - events::DividendClaimedEvent { - creator_id: creator, - claimant: caller, - amount: claimable, - }, - ); + /// Read-only view: returns accrued creator fee balance for the creator's fee recipient. + /// + /// Fails with [`ContractError::NotRegistered`] if the creator is not registered. + /// Returns `0` when no buy has accrued fees yet. + pub fn get_creator_fee_balance(env: Env, creator: Address) -> Result { + read_registered_creator_profile(&env, &creator)?; + Ok(read_creator_fee_recipient_balance(&env, &creator)) + } - Ok(()) - } - - /// Returns the unclaimed dividend balance for a specific (creator, holder) pair. - pub fn get_claimable_dividend( - env: Env, - creator: Address, - wallet: Address, - ) -> i128 { - read_claimable_dividend(&env, &creator, &wallet) - } - - /// Read-only batch view: returns [`CreatorDetailsView`] for each address in `creators`. - /// - /// Iterates the provided addresses in order and fetches each creator's profile - /// from persistent storage. The output `Vec` is the same length as the input and - /// preserves input order, so clients can zip the two slices without an extra sort. - /// - /// Unregistered addresses never cause the call to fail: they produce a default - /// [`CreatorDetailsView`] with `is_registered: false` and `registered_at: 0`, - /// matching the single-address behaviour of [`get_creator_details`]. - /// - /// # Usage - /// - /// ```text - /// let views = client.get_creators_batch(&vec![alice, bob, unknown]); - /// // views[0] → alice's details (is_registered: true) - /// // views[1] → bob's details (is_registered: true) - /// // views[2] → default view (is_registered: false, registered_at: 0) - /// ``` - pub fn get_creators_batch( - env: Env, - creators: soroban_sdk::Vec
, - ) -> soroban_sdk::Vec { - let mut results = soroban_sdk::Vec::new(&env); - for creator in creators.iter() { - let key = constants::storage::creator(&creator); - let view = match env - .storage() - .persistent() - .get::(&key) - { - Some(profile) => CreatorDetailsView { - creator: profile.creator, - handle: profile.handle, - supply: profile.supply, - is_registered: true, - registered_at: profile.registered_at, - }, - None => CreatorDetailsView { - creator, - handle: read_none_string(&env), - supply: 0, - is_registered: false, - registered_at: 0, - }, - }; - results.push_back(view); - } - results - } - /// Read-only view: returns the protocol state version. - /// - /// Returns a stable scalar value for clients and indexers to detect - /// protocol-state schema/semantics revisions. The version is stored in - /// storage and increments on config updates. - pub fn get_protocol_state_version(env: Env) -> u32 { - env - .storage() - .persistent() - .get(&constants::storage::PROTOCOL_STATE_VERSION) - .unwrap_or(PROTOCOL_STATE_VERSION_INITIAL) - } - - /// Read-only view: returns the decimal precision used by creator key values. - /// - /// Returns the fixed [`KEY_DECIMALS`] constant. Does not read or mutate contract state. - pub fn get_key_decimals(_env: Env) -> u32 { - KEY_DECIMALS - } - - /// Read-only view: returns the display name for a creator's key. - /// - /// Returns the creator's handle for registered creators. Fails with - /// [`ContractError::NotRegistered`] if the creator is not registered. - pub fn get_key_name( - env: Env, - creator: Address, - ) -> Result { - let profile = read_registered_creator_profile(&env, &creator)?; - Ok(profile.handle) - } - - /// Read-only view: returns the ticker symbol for a creator's key. - /// - /// Returns the creator's handle for registered creators. Fails with - /// [`ContractError::NotRegistered`] if the creator is not registered. - pub fn get_key_symbol( - env: Env, - creator: Address, - ) -> Result { - let profile = read_registered_creator_profile(&env, &creator)?; - Ok(profile.handle) - } - - /// Read-only view: returns the total key supply for a creator. - /// - /// Returns `0` if the creator is not registered, avoiding panics for - /// invalid lookups. Delegates to the shared [`read_key_balance`] helper. - pub fn get_total_key_supply(env: Env, creator: Address) -> u32 { - read_key_balance(&env, &creator) - } - - /// Read-only view: returns the current supply for a registered creator. - /// - /// Fails with [`ContractError::NotRegistered`] if the creator does not exist. - pub fn get_creator_supply( - env: Env, - creator: Address, - ) -> Result { - let profile = read_registered_creator_profile(&env, &creator)?; - Ok(profile.supply) - } - - /// Read-only view: returns the number of unique holders for a creator. - /// - /// Returns `0` if the creator is not registered, avoiding panics for - /// invalid lookups. Uses the stored creator profile holder count. - pub fn get_creator_holder_count(env: Env, creator: Address) -> u32 { - read_creator_profile(&env, &creator) - .map(|profile| profile.holder_count) - .unwrap_or(0) - } - - /// Read-only view: returns whether a creator is registered in the contract. - /// - /// Returns `true` if a [`CreatorProfile`] exists for the given address, - /// `false` otherwise. Does not mutate state. - pub fn is_creator_registered(env: Env, creator: Address) -> bool { - read_creator_profile(&env, &creator).is_some() - } - - /// Read-only view: returns the creator fee recipient address. - /// - /// Fails with [`ContractError::NotRegistered`] if the creator is not registered. - /// Reuses current creator storage access patterns. - pub fn get_creator_fee_recipient( - env: Env, - creator: Address, - ) -> Result { - let profile = read_registered_creator_profile(&env, &creator)?; - Ok(profile.fee_recipient) - } - - /// Read-only view: returns accrued creator fee balance for the creator's fee recipient. - /// - /// Fails with [`ContractError::NotRegistered`] if the creator is not registered. - /// Returns `0` when no buy has accrued fees yet. - pub fn get_creator_fee_balance( - env: Env, - creator: Address, - ) -> Result { - read_registered_creator_profile(&env, &creator)?; - Ok(read_creator_fee_recipient_balance(&env, &creator)) - } - - /// Read-only view: returns the configured creator fee rate in basis points. - /// - /// The returned value is the creator-facing share stored in the current protocol - /// fee configuration, scoped to a registered creator lookup. - pub fn get_creator_fee_bps( - env: Env, - creator: Address, - ) -> Result { - let _profile = read_registered_creator_profile(&env, &creator)?; - let config = read_required_protocol_fee_config(&env)?; - Ok(config.creator_bps) - } - - /// Read-only view: returns the creator treasury share for a registered creator. - /// - /// Access Layer currently stores creator treasury share as the creator-facing - /// basis-point share in protocol fee configuration. This method provides a - /// creator-scoped accessor without mutating state. - pub fn get_creator_treasury_share( - env: Env, - creator: Address, - ) -> Result { - Self::get_creator_fee_bps(env, creator) - } - - /// Read-only view: returns the configured protocol treasury share in basis points. - /// - /// This value is sourced from the current protocol fee configuration and is - /// expressed in stable basis-point units. - pub fn get_protocol_treasury_share_bps( - env: Env, - ) -> Result { - let config = read_required_protocol_fee_config(&env)?; - Ok(config.protocol_bps) - } - - /// Sets the global protocol/creator fee split. Contract initialization - /// entrypoint. - /// - /// Parameter validation (via [`fee::assert_valid_fee_bps`]): - /// - `admin`: must authorize the call (`require_auth`). - /// - `creator_bps` + `protocol_bps`: must sum to exactly `BPS_MAX` (10_000), - /// otherwise [`ContractError::InvalidFeeConfig`]. - /// - `protocol_bps`: must not exceed `PROTOCOL_BPS_MAX`, otherwise - /// [`ContractError::ProtocolFeeExceedsCap`]. - pub fn set_fee_config( - env: Env, - admin: Address, - creator_bps: u32, - protocol_bps: u32, - ) -> Result<(), ContractError> { - admin.require_auth(); - fee::assert_valid_fee_bps(creator_bps, protocol_bps)?; - - let config = fee::FeeConfig { - creator_bps, - protocol_bps, - }; - if env - .storage() - .persistent() - .get::(&constants::storage::FEE_CONFIG) - .as_ref() - == Some(&config) - { - return Ok(()); - } - env - .storage() - .persistent() - .set(&constants::storage::FEE_CONFIG, &config); - - // Increment protocol state version on config update - let current_version = env - .storage() - .persistent() - .get(&constants::storage::PROTOCOL_STATE_VERSION) - .unwrap_or(PROTOCOL_STATE_VERSION_INITIAL); - let new_version = current_version - .checked_add(1) - .ok_or(ContractError::Overflow)?; - env - .storage() - .persistent() - .set(&constants::storage::PROTOCOL_STATE_VERSION, &new_version); + /// Read-only view: returns the configured creator fee rate in basis points. + /// + /// The returned value is the creator-facing share stored in the current protocol + /// fee configuration, scoped to a registered creator lookup. + pub fn get_creator_fee_bps(env: Env, creator: Address) -> Result { + let _profile = read_registered_creator_profile(&env, &creator)?; + let config = read_required_protocol_fee_config(&env)?; + Ok(config.creator_bps) + } - Ok(()) - } - - /// Sets the per-key price. Contract initialization entrypoint. - /// - /// Parameter validation: - /// - `admin`: must authorize the call (`require_auth`). - /// - `price`: must be strictly positive; zero or negative returns - /// [`ContractError::NotPositiveAmount`]. - pub fn set_key_price( - env: Env, - admin: Address, - price: i128, - ) -> Result<(), ContractError> { - admin.require_auth(); - if price <= 0 { - return Err(ContractError::NotPositiveAmount); + /// Read-only view: returns the creator treasury share for a registered creator. + /// + /// Access Layer currently stores creator treasury share as the creator-facing + /// basis-point share in protocol fee configuration. This method provides a + /// creator-scoped accessor without mutating state. + pub fn get_creator_treasury_share(env: Env, creator: Address) -> Result { + Self::get_creator_fee_bps(env, creator) } - if env - .storage() - .persistent() - .get::(&constants::storage::KEY_PRICE) - .as_ref() - == Some(&price) - { - return Ok(()); + + /// Read-only view: returns the configured protocol treasury share in basis points. + /// + /// This value is sourced from the current protocol fee configuration and is + /// expressed in stable basis-point units. + pub fn get_protocol_treasury_share_bps(env: Env) -> Result { + let config = read_required_protocol_fee_config(&env)?; + Ok(config.protocol_bps) } - env - .storage() - .persistent() - .set(&constants::storage::KEY_PRICE, &price); - Ok(()) - } - - pub fn get_fee_config(env: Env) -> Option { - read_protocol_fee_config(&env) - } - - /// Sets the protocol treasury address. - /// - /// Only callable by an authorized admin. Stores the treasury address used - /// for protocol fee routing. - pub fn set_treasury_address(env: Env, admin: Address, treasury: Address) { - admin.require_auth(); - if env - .storage() - .persistent() - .get::(&constants::storage::TREASURY_ADDRESS) - .as_ref() - == Some(&treasury) - { - return; - } - env - .storage() - .persistent() - .set(&constants::storage::TREASURY_ADDRESS, &treasury); - } - - /// Read-only view: returns the current protocol treasury address. - /// - /// Returns `None` if no treasury address has been configured. - /// Use this method for indexers and read-only callers that need the current - /// treasury routing target. - pub fn get_treasury_address(env: Env) -> Option
{ - env - .storage() - .persistent() - .get(&constants::storage::TREASURY_ADDRESS) - } - - /// Sets the protocol admin address. - /// - /// Only callable by an authorized admin. Stores the admin address used - /// for protocol administration. - pub fn set_protocol_admin(env: Env, admin: Address, new_admin: Address) { - admin.require_auth(); - if env - .storage() - .persistent() - .get::(&constants::storage::ADMIN_ADDRESS) - .as_ref() - == Some(&new_admin) - { - return; - } - env - .storage() - .persistent() - .set(&constants::storage::ADMIN_ADDRESS, &new_admin); - } - - /// Read-only view: returns the current protocol admin address. - /// - /// Returns `None` if no admin address has been configured. - /// Use this method for indexers and read-only callers that need the current - /// protocol admin address. - pub fn get_protocol_admin(env: Env) -> Option
{ - env - .storage() - .persistent() - .get(&constants::storage::ADMIN_ADDRESS) - } - - /// Read-only view: returns the current protocol fee recipient address. - /// - /// Returns `None` if no protocol fee recipient address has been configured. - /// Use this method for indexers and read-only callers that need the current - /// protocol fee recipient address. - pub fn get_protocol_fee_recipient(env: Env) -> Option
{ - env - .storage() - .persistent() - .get(&constants::storage::PROTOCOL_FEE_RECIPIENT) - } - - /// Read-only view: returns the accrued protocol fee balance for the configured recipient. - /// - /// Returns `0` when no protocol fees have been accrued from sell execution. - pub fn get_protocol_recipient_balance(env: Env) -> i128 { - read_protocol_fee_recipient_balance(&env) - } - - /// Sets the protocol fee recipient address. - /// - /// Only callable by an authorized admin. Rejects the Stellar zero address - /// to prevent silent fee burning. - /// - /// Parameter validation: - /// - `admin`: must authorize the call (`require_auth`). - /// - `recipient`: must not be the Stellar zero address, otherwise - /// [`ContractError::ZeroAddress`]. - pub fn set_protocol_fee_recipient( - env: Env, - admin: Address, - recipient: Address, - ) -> Result<(), ContractError> { - admin.require_auth(); - validate_non_zero_address(&env, &recipient)?; - if env - .storage() - .persistent() - .get::(&constants::storage::PROTOCOL_FEE_RECIPIENT) - .as_ref() - == Some(&recipient) - { - return Ok(()); + /// Sets the global protocol/creator fee split. Contract initialization + /// entrypoint. + /// + /// Parameter validation (via [`fee::assert_valid_fee_bps`]): + /// - `admin`: must authorize the call (`require_auth`). + /// - `creator_bps` + `protocol_bps`: must sum to exactly `BPS_MAX` (10_000), + /// otherwise [`ContractError::InvalidFeeConfig`]. + /// - `protocol_bps`: must not exceed `PROTOCOL_BPS_MAX`, otherwise + /// [`ContractError::ProtocolFeeExceedsCap`]. + pub fn set_fee_config( + env: Env, + admin: Address, + creator_bps: u32, + protocol_bps: u32, + ) -> Result<(), ContractError> { + admin.require_auth(); + fee::assert_valid_fee_bps(creator_bps, protocol_bps)?; + + let config = fee::FeeConfig { + creator_bps, + protocol_bps, + }; + if env + .storage() + .persistent() + .get::(&constants::storage::FEE_CONFIG) + .as_ref() + == Some(&config) + { + return Ok(()); + } + env.storage() + .persistent() + .set(&constants::storage::FEE_CONFIG, &config); + + // Increment protocol state version on config update + let current_version = env + .storage() + .persistent() + .get(&constants::storage::PROTOCOL_STATE_VERSION) + .unwrap_or(PROTOCOL_STATE_VERSION_INITIAL); + let new_version = current_version + .checked_add(1) + .ok_or(ContractError::Overflow)?; + env.storage() + .persistent() + .set(&constants::storage::PROTOCOL_STATE_VERSION, &new_version); + + Ok(()) } - env - .storage() - .persistent() - .set(&constants::storage::PROTOCOL_FEE_RECIPIENT, &recipient); - Ok(()) - } - - /// Read-only view: returns whether protocol configuration has been initialized. - /// - /// Returns `true` once a protocol fee configuration has been stored and `false` - /// otherwise. Does not mutate contract state. - pub fn is_protocol_config_initialized(env: Env) -> bool { - read_protocol_fee_config(&env).is_some() - } - - /// Read-only view: returns the current protocol fee configuration. - /// - /// Returns a stable [`ProtocolFeeView`] regardless of whether a fee config has been set. - /// When no config is stored, `is_configured` is `false` and both bps fields are `0`. - /// Use this method for indexers and read-only callers that need a non-optional result. - pub fn get_protocol_fee_view(env: Env) -> ProtocolFeeView { - match read_protocol_fee_config(&env) { - Some(config) => ProtocolFeeView { - creator_bps: config.creator_bps, - protocol_bps: config.protocol_bps, - is_configured: true, - }, - None => ProtocolFeeView { - creator_bps: 0, - protocol_bps: 0, - is_configured: false, - }, - } - } - - pub fn compute_fees_for_payment( - env: Env, - total: i128, - ) -> Result<(i128, i128), ContractError> { - let config = read_required_protocol_fee_config(&env)?; - fee::checked_compute_fee_split( - total, - config.creator_bps, - config.protocol_bps, - ) - .ok_or(ContractError::Overflow) - } - - /// Read-only view: returns the fee configuration for a specific creator. - /// - /// Returns a stable [`CreatorFeeView`] regardless of whether the creator is registered - /// or a fee config has been set. When `is_registered` is `false`, the creator does not - /// exist and both bps fields are `0`. When `is_configured` is `false`, no global fee - /// config has been set. Use this method for indexers and read-only callers that need - /// a non-optional result. - pub fn get_creator_fee_config(env: Env, creator: Address) -> CreatorFeeView { - let is_registered = read_registered_creator_profile(&env, &creator).is_ok(); - - if !is_registered { - return CreatorFeeView { - creator_bps: 0, - protocol_bps: 0, - is_registered: false, - is_configured: false, - }; - } - - match env - .storage() - .persistent() - .get::(&constants::storage::FEE_CONFIG) - { - Some(config) => CreatorFeeView { - creator_bps: config.creator_bps, - protocol_bps: config.protocol_bps, - is_registered: true, - is_configured: true, - }, - None => CreatorFeeView { - creator_bps: 0, - protocol_bps: 0, - is_registered: true, - is_configured: false, - }, - } - } - - /// Read-only view: returns a quote for buying a key. - /// - /// Returns a [`QuoteResponse`] containing the current price and fee breakdown. - /// Fees are calculated based on the fixed key price. - pub fn get_buy_quote( - env: Env, - creator: Address, - ) -> Result { - let Some(price) = resolve_quote_inputs(&env, &creator)? else { - return Ok(zero_quote_response()); - }; - let (creator_fee, protocol_fee) = - Self::compute_fees_for_payment(env.clone(), price)?; - checked_format_quote_response(price, creator_fee, protocol_fee, true) - } - - /// Read-only view: returns a quote for selling a key. - /// - /// Returns a [`QuoteResponse`] containing the current price and fee breakdown. - /// Fees are calculated based on the fixed key price. - /// Rejects with [`ContractError::InsufficientBalance`] if the holder has no keys. - pub fn get_sell_quote( - env: Env, - creator: Address, - holder: Address, - ) -> Result { - let Some(price) = resolve_quote_inputs(&env, &creator)? else { - return Ok(zero_quote_response()); - }; - let balance = Self::get_key_balance(env.clone(), creator, holder); - if balance == 0 { - return Err(ContractError::InsufficientBalance); + /// Sets the per-key price. Contract initialization entrypoint. + /// + /// Parameter validation: + /// - `admin`: must authorize the call (`require_auth`). + /// - `price`: must be strictly positive; zero or negative returns + /// [`ContractError::NotPositiveAmount`]. + pub fn set_key_price(env: Env, admin: Address, price: i128) -> Result<(), ContractError> { + admin.require_auth(); + if price <= 0 { + return Err(ContractError::NotPositiveAmount); + } + if env + .storage() + .persistent() + .get::(&constants::storage::KEY_PRICE) + .as_ref() + == Some(&price) + { + return Ok(()); + } + env.storage() + .persistent() + .set(&constants::storage::KEY_PRICE, &price); + Ok(()) } - let (creator_fee, protocol_fee) = - Self::compute_fees_for_payment(env.clone(), price)?; - checked_format_quote_response(price, creator_fee, protocol_fee, false) - } + pub fn get_fee_config(env: Env) -> Option { + read_protocol_fee_config(&env) + } + + /// Sets the protocol treasury address. + /// + /// Only callable by an authorized admin. Stores the treasury address used + /// for protocol fee routing. + pub fn set_treasury_address(env: Env, admin: Address, treasury: Address) { + admin.require_auth(); + if env + .storage() + .persistent() + .get::(&constants::storage::TREASURY_ADDRESS) + .as_ref() + == Some(&treasury) + { + return; + } + env.storage() + .persistent() + .set(&constants::storage::TREASURY_ADDRESS, &treasury); + } + + /// Read-only view: returns the current protocol treasury address. + /// + /// Returns `None` if no treasury address has been configured. + /// Use this method for indexers and read-only callers that need the current + /// treasury routing target. + pub fn get_treasury_address(env: Env) -> Option
{ + env.storage() + .persistent() + .get(&constants::storage::TREASURY_ADDRESS) + } + + /// Sets the protocol admin address. + /// + /// Only callable by an authorized admin. Stores the admin address used + /// for protocol administration. + pub fn set_protocol_admin(env: Env, admin: Address, new_admin: Address) { + admin.require_auth(); + if env + .storage() + .persistent() + .get::(&constants::storage::ADMIN_ADDRESS) + .as_ref() + == Some(&new_admin) + { + return; + } + env.storage() + .persistent() + .set(&constants::storage::ADMIN_ADDRESS, &new_admin); + } + + /// Read-only view: returns the current protocol admin address. + /// + /// Returns `None` if no admin address has been configured. + /// Use this method for indexers and read-only callers that need the current + /// protocol admin address. + pub fn get_protocol_admin(env: Env) -> Option
{ + env.storage() + .persistent() + .get(&constants::storage::ADMIN_ADDRESS) + } + + /// Read-only view: returns the current protocol fee recipient address. + /// + /// Returns `None` if no protocol fee recipient address has been configured. + /// Use this method for indexers and read-only callers that need the current + /// protocol fee recipient address. + pub fn get_protocol_fee_recipient(env: Env) -> Option
{ + env.storage() + .persistent() + .get(&constants::storage::PROTOCOL_FEE_RECIPIENT) + } + + /// Read-only view: returns the accrued protocol fee balance for the configured recipient. + /// + /// Returns `0` when no protocol fees have been accrued from sell execution. + pub fn get_protocol_recipient_balance(env: Env) -> i128 { + read_protocol_fee_recipient_balance(&env) + } + + /// Sets the protocol fee recipient address. + /// + /// Only callable by an authorized admin. Rejects the Stellar zero address + /// to prevent silent fee burning. + /// + /// Parameter validation: + /// - `admin`: must authorize the call (`require_auth`). + /// - `recipient`: must not be the Stellar zero address, otherwise + /// [`ContractError::ZeroAddress`]. + pub fn set_protocol_fee_recipient( + env: Env, + admin: Address, + recipient: Address, + ) -> Result<(), ContractError> { + admin.require_auth(); + validate_non_zero_address(&env, &recipient)?; + + if env + .storage() + .persistent() + .get::(&constants::storage::PROTOCOL_FEE_RECIPIENT) + .as_ref() + == Some(&recipient) + { + return Ok(()); + } + env.storage() + .persistent() + .set(&constants::storage::PROTOCOL_FEE_RECIPIENT, &recipient); + Ok(()) + } + + /// Read-only view: returns whether protocol configuration has been initialized. + /// + /// Returns `true` once a protocol fee configuration has been stored and `false` + /// otherwise. Does not mutate contract state. + pub fn is_protocol_config_initialized(env: Env) -> bool { + read_protocol_fee_config(&env).is_some() + } + + /// Read-only view: returns the current protocol fee configuration. + /// + /// Returns a stable [`ProtocolFeeView`] regardless of whether a fee config has been set. + /// When no config is stored, `is_configured` is `false` and both bps fields are `0`. + /// Use this method for indexers and read-only callers that need a non-optional result. + pub fn get_protocol_fee_view(env: Env) -> ProtocolFeeView { + match read_protocol_fee_config(&env) { + Some(config) => ProtocolFeeView { + creator_bps: config.creator_bps, + protocol_bps: config.protocol_bps, + is_configured: true, + }, + None => ProtocolFeeView { + creator_bps: 0, + protocol_bps: 0, + is_configured: false, + }, + } + } + + pub fn compute_fees_for_payment(env: Env, total: i128) -> Result<(i128, i128), ContractError> { + let config = read_required_protocol_fee_config(&env)?; + fee::checked_compute_fee_split(total, config.creator_bps, config.protocol_bps) + .ok_or(ContractError::Overflow) + } + + /// Read-only view: returns the fee configuration for a specific creator. + /// + /// Returns a stable [`CreatorFeeView`] regardless of whether the creator is registered + /// or a fee config has been set. When `is_registered` is `false`, the creator does not + /// exist and both bps fields are `0`. When `is_configured` is `false`, no global fee + /// config has been set. Use this method for indexers and read-only callers that need + /// a non-optional result. + pub fn get_creator_fee_config(env: Env, creator: Address) -> CreatorFeeView { + let is_registered = read_registered_creator_profile(&env, &creator).is_ok(); + + if !is_registered { + return CreatorFeeView { + creator_bps: 0, + protocol_bps: 0, + is_registered: false, + is_configured: false, + }; + } + + match env + .storage() + .persistent() + .get::(&constants::storage::FEE_CONFIG) + { + Some(config) => CreatorFeeView { + creator_bps: config.creator_bps, + protocol_bps: config.protocol_bps, + is_registered: true, + is_configured: true, + }, + None => CreatorFeeView { + creator_bps: 0, + protocol_bps: 0, + is_registered: true, + is_configured: false, + }, + } + } + + /// Read-only view: returns a quote for buying a key. + /// + /// Returns a [`QuoteResponse`] containing the current price and fee breakdown. + /// Fees are calculated based on the fixed key price. + pub fn get_buy_quote(env: Env, creator: Address) -> Result { + let Some(price) = resolve_quote_inputs(&env, &creator)? else { + return Ok(zero_quote_response()); + }; + let (creator_fee, protocol_fee) = Self::compute_fees_for_payment(env.clone(), price)?; + checked_format_quote_response(price, creator_fee, protocol_fee, true) + } + + /// Read-only view: returns a quote for selling a key. + /// + /// Returns a [`QuoteResponse`] containing the current price and fee breakdown. + /// Fees are calculated based on the fixed key price. + /// Rejects with [`ContractError::InsufficientBalance`] if the holder has no keys. + pub fn get_sell_quote( + env: Env, + creator: Address, + holder: Address, + ) -> Result { + let Some(price) = resolve_quote_inputs(&env, &creator)? else { + return Ok(zero_quote_response()); + }; + + let balance = Self::get_key_balance(env.clone(), creator, holder); + if balance == 0 { + return Err(ContractError::InsufficientBalance); + } + + let (creator_fee, protocol_fee) = Self::compute_fees_for_payment(env.clone(), price)?; + checked_format_quote_response(price, creator_fee, protocol_fee, false) + } } fn read_holders_list(env: &Env, creator: &Address) -> Vec
{ - let key = DataKey::HoldersList(creator.clone()); - env - .storage() - .persistent() - .get(&key) - .unwrap_or(Vec::new(env)) + let key = DataKey::HoldersList(creator.clone()); + env.storage() + .persistent() + .get(&key) + .unwrap_or(Vec::new(env)) } fn write_holders_list(env: &Env, creator: &Address, holders: &Vec
) { - let key = DataKey::HoldersList(creator.clone()); - env.storage().persistent().set(&key, holders); + let key = DataKey::HoldersList(creator.clone()); + env.storage().persistent().set(&key, holders); } -fn read_claimable_dividend( - env: &Env, - creator: &Address, - holder: &Address, -) -> i128 { - let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); - env.storage().persistent().get(&key).unwrap_or(0) +fn read_claimable_dividend(env: &Env, creator: &Address, holder: &Address) -> i128 { + let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); + env.storage().persistent().get(&key).unwrap_or(0) } -fn write_claimable_dividend( - env: &Env, - creator: &Address, - holder: &Address, - amount: i128, -) { - let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); - env.storage().persistent().set(&key, &amount); +fn write_claimable_dividend(env: &Env, creator: &Address, holder: &Address, amount: i128) { + let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); + env.storage().persistent().set(&key, &amount); } #[cfg(test)] mod tests { - use super::fee; - - #[test] - fn test_fee_split_90_10_1000() { - let (creator, protocol) = fee::compute_fee_split(1000, 9000, 1000); - assert_eq!(creator, 900); - assert_eq!(protocol, 100); - assert_eq!(creator + protocol, 1000); - } - - #[test] - fn test_fee_split_100_creator() { - let (creator, protocol) = fee::compute_fee_split(1000, 10000, 0); - assert_eq!(creator, 1000); - assert_eq!(protocol, 0); - assert_eq!(creator + protocol, 1000); - } - - #[test] - fn test_fee_split_100_protocol() { - let (creator, protocol) = fee::compute_fee_split(1000, 0, 10000); - assert_eq!(creator, 0); - assert_eq!(protocol, 1000); - assert_eq!(creator + protocol, 1000); - } - - #[test] - fn test_fee_split_remainder_to_creator() { - // 999 * 1000 / 10000 = 99 (protocol floor), creator gets remainder - let (creator, protocol) = fee::compute_fee_split(999, 9000, 1000); - assert_eq!(creator, 900); - assert_eq!(protocol, 99); - assert_eq!(creator + protocol, 999); - } - - #[test] - fn test_fee_split_zero_total() { - let (creator, protocol) = fee::compute_fee_split(0, 9000, 1000); - assert_eq!(creator, 0); - assert_eq!(protocol, 0); - } - - #[test] - fn test_fee_split_dust_total_one() { - // 1 * 1000 / 10000 = 0 protocol, creator gets full amount - let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); - assert_eq!(creator, 1); - assert_eq!(protocol, 0); - assert_eq!(creator + protocol, 1); - } - - #[test] - fn test_fee_split_balance_conservation() { - for total in [100_i128, 1, 999, 10000, 1234567] { - let (creator, protocol) = fee::compute_fee_split(total, 9000, 1000); - assert_eq!(creator + protocol, total, "total={}", total); - } - } - - #[test] - fn test_checked_mul_i128_success() { - assert_eq!(fee::checked_mul_i128(100, 10), Some(1000)); - } - - #[test] - fn test_checked_mul_i128_rejects_overflow() { - assert_eq!(fee::checked_mul_i128(i128::MAX, 2), None); - assert_eq!(fee::checked_mul_i128(i128::MIN, 2), None); - } - - #[test] - fn test_checked_div_i128_success() { - assert_eq!(fee::checked_div_i128(100, 10), Some(10)); - } - - #[test] - fn test_checked_div_i128_rejects_zero_divisor() { - assert_eq!(fee::checked_div_i128(100, 0), None); - } - - #[test] - fn test_checked_sub_i128_success() { - assert_eq!(fee::checked_sub_i128(100, 10), Some(90)); - } - - #[test] - fn test_checked_sub_i128_underflow() { - assert_eq!(fee::checked_sub_i128(i128::MIN, 1), None); - } - - #[test] - fn test_checked_add_i128_success() { - assert_eq!(fee::checked_add_i128(100, 10), Some(110)); - } - - #[test] - fn test_checked_add_i128_overflow() { - assert_eq!(fee::checked_add_i128(i128::MAX, 1), None); - } - - #[test] - fn test_checked_add_i128_zero() { - assert_eq!(fee::checked_add_i128(0, 0), Some(0)); - assert_eq!(fee::checked_add_i128(100, 0), Some(100)); - assert_eq!(fee::checked_add_i128(0, 100), Some(100)); - } - - #[test] - fn test_checked_add_i128_negative_values() { - assert_eq!(fee::checked_add_i128(-10, 20), Some(10)); - assert_eq!(fee::checked_add_i128(10, -20), Some(-10)); - assert_eq!(fee::checked_add_i128(-10, -10), Some(-20)); - } - - #[test] - fn test_checked_add_i128_boundary_values() { - assert_eq!(fee::checked_add_i128(i128::MAX, 0), Some(i128::MAX)); - assert_eq!(fee::checked_add_i128(i128::MIN, 0), Some(i128::MIN)); - assert_eq!(fee::checked_add_i128(0, i128::MAX), Some(i128::MAX)); - assert_eq!(fee::checked_add_i128(0, i128::MIN), Some(i128::MIN)); - } - - #[test] - fn test_checked_add_i128_deterministic_error() { - // Verify that overflow always returns None, never panics - assert_eq!(fee::checked_add_i128(i128::MAX, i128::MAX), None); - assert_eq!(fee::checked_add_i128(i128::MIN, i128::MIN), None); - } - - #[test] - fn test_checked_div_i128_rejects_overflow() { - assert_eq!(fee::checked_div_i128(i128::MIN, -1), None); - } - - /// Both operands at `i128::MAX / 2 + 1` must overflow. - /// - /// `(i128::MAX / 2 + 1) + (i128::MAX / 2 + 1) == i128::MAX + 1`, which - /// exceeds i128 capacity, so the helper must return `None` rather than wrap. - #[test] - fn test_checked_add_i128_both_at_half_max_plus_one_overflows() { - let half_plus_one = i128::MAX / 2 + 1; - assert_eq!(fee::checked_add_i128(half_plus_one, half_plus_one), None); - } - - /// Both operands at `i128::MAX / 2` must not overflow. - /// - /// `(i128::MAX / 2) + (i128::MAX / 2) == i128::MAX - 1`, which fits in i128, - /// so the helper must return the correct sum just below the overflow boundary. - #[test] - fn test_checked_add_i128_both_at_half_max_succeeds() { - let half = i128::MAX / 2; - assert_eq!(fee::checked_add_i128(half, half), Some(half + half)); - } - - #[test] - fn test_normalize_quote_amount_preserves_positive_amount() { - assert_eq!(super::normalize_quote_amount(100), Ok(Some(100))); - } - - #[test] - fn test_normalize_quote_amount_maps_zero_to_noop() { - assert_eq!(super::normalize_quote_amount(0), Ok(None)); - } - - #[test] - fn test_normalize_quote_amount_rejects_negative_amount() { - assert_eq!( - super::normalize_quote_amount(-1), - Err(super::ContractError::NotPositiveAmount) - ); - } - - #[test] - fn test_normalize_quote_amount_rejects_large_amount() { - let large = super::fee::MAX_SAFE_AMOUNT + 1; - assert_eq!( - super::normalize_quote_amount(large), - Err(super::ContractError::Overflow) - ); - } - - #[test] - fn test_checked_format_quote_response_buy_success() { - let res = super::checked_format_quote_response(1000, 90, 10, true).unwrap(); - assert_eq!(res.price, 1000); - assert_eq!(res.creator_fee, 90); - assert_eq!(res.protocol_fee, 10); - assert_eq!(res.total_amount, 1100); - } - - #[test] - fn test_checked_format_quote_response_sell_success() { - let res = - super::checked_format_quote_response(1000, 90, 10, false).unwrap(); - assert_eq!(res.price, 1000); - assert_eq!(res.creator_fee, 90); - assert_eq!(res.protocol_fee, 10); - assert_eq!(res.total_amount, 900); - } - - #[test] - fn test_checked_format_quote_response_buy_overflow_fees() { - let res = super::checked_format_quote_response(1000, i128::MAX, 1, true); - assert_eq!(res, Err(super::ContractError::Overflow)); - } - - #[test] - fn test_checked_format_quote_response_buy_overflow_total() { - let res = super::checked_format_quote_response(i128::MAX, 1, 0, true); - assert_eq!(res, Err(super::ContractError::Overflow)); - } - - #[test] - fn test_checked_format_quote_response_sell_underflow_total() { - let res = super::checked_format_quote_response(i128::MIN, 1, 0, false); - assert_eq!(res, Err(super::ContractError::SellUnderflow)); - } - - #[test] - fn test_apply_percentage_fee_success() { - assert_eq!(fee::apply_percentage_fee(1000, 1000), Some(100)); - assert_eq!(fee::apply_percentage_fee(1000, 0), Some(0)); - assert_eq!(fee::apply_percentage_fee(1000, 10000), Some(1000)); - } - - #[test] - fn test_apply_percentage_fee_zero_amount() { - assert_eq!(fee::apply_percentage_fee(0, 1000), Some(0)); - } - - #[test] - fn test_apply_percentage_fee_negative_amount() { - assert_eq!(fee::apply_percentage_fee(-100, 1000), Some(0)); - } - - #[test] - fn test_apply_percentage_fee_rounding() { - // 999 * 1000 / 10000 = 99.9 -> 99 - assert_eq!(fee::apply_percentage_fee(999, 1000), Some(99)); - } - - #[test] - fn test_apply_percentage_fee_overflow() { - // Multiplication overflows before division - assert_eq!(fee::apply_percentage_fee(i128::MAX, 2), None); - } - - #[test] - fn test_assert_valid_fee_bps() { - // Valid scenarios - assert_eq!(fee::assert_valid_fee_bps(10000, 0), Ok(())); - assert_eq!(fee::assert_valid_fee_bps(5000, 5000), Ok(())); - assert_eq!(fee::assert_valid_fee_bps(9000, 1000), Ok(())); - - // Invalid Sum - assert_eq!( - fee::assert_valid_fee_bps(9000, 2000), - Err(super::ContractError::InvalidFeeConfig) - ); - assert_eq!( - fee::assert_valid_fee_bps(0, 0), - Err(super::ContractError::InvalidFeeConfig) - ); + use super::fee; + + #[test] + fn test_fee_split_90_10_1000() { + let (creator, protocol) = fee::compute_fee_split(1000, 9000, 1000); + assert_eq!(creator, 900); + assert_eq!(protocol, 100); + assert_eq!(creator + protocol, 1000); + } - // Protocol Cap Exceeded (PROTOCOL_BPS_MAX = 5000) - assert_eq!( - fee::assert_valid_fee_bps(4999, 5001), - Err(super::ContractError::ProtocolFeeExceedsCap) - ); - assert_eq!( - fee::assert_valid_fee_bps(0, 10000), - Err(super::ContractError::ProtocolFeeExceedsCap) - ); + #[test] + fn test_fee_split_100_creator() { + let (creator, protocol) = fee::compute_fee_split(1000, 10000, 0); + assert_eq!(creator, 1000); + assert_eq!(protocol, 0); + assert_eq!(creator + protocol, 1000); + } - // Overflow - assert_eq!( - fee::assert_valid_fee_bps(u32::MAX, 1), - Err(super::ContractError::InvalidFeeConfig) - ); - } - - #[test] - fn test_validate_fee_bps() { - // Valid - assert!(fee::validate_fee_bps(10000, 0)); - assert!(fee::validate_fee_bps(5000, 5000)); - assert!(fee::validate_fee_bps(9000, 1000)); - - // Invalid Sum - assert!(!fee::validate_fee_bps(9000, 2000)); - assert!(!fee::validate_fee_bps(0, 0)); - - // Protocol Cap Exceeded - assert!(!fee::validate_fee_bps(4999, 5001)); - - // Overflow - assert!(!fee::validate_fee_bps(u32::MAX, 1)); - } - - // --- checked_fee_sum unit tests --- - - /// Verifies that `checked_fee_sum` returns the correct sum for two ordinary - /// positive fee components. - #[test] - fn test_checked_fee_sum_success() { - assert_eq!(fee::checked_fee_sum(900, 100), Some(1000)); - assert_eq!(fee::checked_fee_sum(0, 0), Some(0)); - assert_eq!(fee::checked_fee_sum(500, 500), Some(1000)); - } - - /// Verifies that `checked_fee_sum` returns `None` when the addition would - /// overflow `i128`, preventing silent wrapping in fee total calculations. - #[test] - fn test_checked_fee_sum_overflow_returns_none() { - assert_eq!(fee::checked_fee_sum(i128::MAX, 1), None); - assert_eq!(fee::checked_fee_sum(i128::MAX, i128::MAX), None); - } - - /// Edge case: verifies `checked_fee_sum` at the boundary where one component - /// is exactly `i128::MAX` and the other is zero — the only non-overflowing - /// case at that boundary. - #[test] - fn test_checked_fee_sum_boundary_max_plus_zero() { - assert_eq!(fee::checked_fee_sum(i128::MAX, 0), Some(i128::MAX)); - assert_eq!(fee::checked_fee_sum(0, i128::MAX), Some(i128::MAX)); - // One above the boundary must overflow - assert_eq!(fee::checked_fee_sum(i128::MAX, 1), None); - } - - // --- BPS truncation on small amounts --- - - /// Bps calculation on very small amounts produces zero due to integer division - /// truncation. These tests document the behavior at the lower precision boundary. - /// - /// Formula: `amount * bps / 10_000` (floor division). - /// When the product `amount * bps < 10_000`, the result truncates to zero. - #[test] - fn test_apply_percentage_fee_truncation_1_stroop() { - // 1 * 1000 / 10_000 = 0.1 → truncated to 0 - // At 1 stroop with 10% bps, the fee is zero — value is silently lost. - let result = fee::apply_percentage_fee(1, 1000); - assert_eq!(result, Some(0), "1 stroop at 1000 bps truncates to 0"); - } - - #[test] - fn test_apply_percentage_fee_truncation_10_stroops() { - // 10 * 1000 / 10_000 = 1.0 → exactly 1 - // At 10 stroops with 10% bps, the fee is exactly 1. - let result = fee::apply_percentage_fee(10, 1000); - assert_eq!(result, Some(1), "10 stroops at 1000 bps yields 1"); - } - - #[test] - fn test_apply_percentage_fee_truncation_100_stroops() { - // 100 * 1000 / 10_000 = 10.0 → exactly 10 - let result = fee::apply_percentage_fee(100, 1000); - assert_eq!(result, Some(10), "100 stroops at 1000 bps yields 10"); - } - - #[test] - fn test_fee_split_truncation_1_stroop() { - // 1 * 1000 / 10_000 = 0 protocol, 1 creator (remainder to creator) - // Truncation causes the full amount to go to creator. - let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); - assert_eq!(protocol, 0, "1 stroop: protocol fee truncated to 0"); - assert_eq!(creator, 1, "1 stroop: creator gets full amount"); - assert_eq!(creator + protocol, 1, "conservation holds"); - } - - #[test] - fn test_fee_split_truncation_10_stroops() { - // 10 * 1000 / 10_000 = 1 protocol, 9 creator - let (creator, protocol) = fee::compute_fee_split(10, 9000, 1000); - assert_eq!(protocol, 1, "10 stroops: protocol fee is 1"); - assert_eq!(creator, 9, "10 stroops: creator gets 9"); - assert_eq!(creator + protocol, 10, "conservation holds"); - } - - #[test] - fn test_fee_split_truncation_100_stroops() { - // 100 * 1000 / 10_000 = 10 protocol, 90 creator - let (creator, protocol) = fee::compute_fee_split(100, 9000, 1000); - assert_eq!(protocol, 10, "100 stroops: protocol fee is 10"); - assert_eq!(creator, 90, "100 stroops: creator gets 90"); - assert_eq!(creator + protocol, 100, "conservation holds"); - } - - // --- Zero address validation --- - - #[test] - fn test_validate_non_zero_address_rejects_zero() { - use soroban_sdk::{Address, Env, String}; - let env = Env::default(); - let zero_str = String::from_str( - &env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ); - let zero_addr = Address::from_string(&zero_str); - let result = super::validate_non_zero_address(&env, &zero_addr); - assert_eq!(result, Err(super::ContractError::ZeroAddress)); - } - - #[test] - fn test_validate_non_zero_address_accepts_valid() { - use soroban_sdk::{testutils::Address as _, Address, Env}; - let env = Env::default(); - let valid = Address::generate(&env); - let result = super::validate_non_zero_address(&env, &valid); - assert_eq!(result, Ok(())); - } + #[test] + fn test_fee_split_100_protocol() { + let (creator, protocol) = fee::compute_fee_split(1000, 0, 10000); + assert_eq!(creator, 0); + assert_eq!(protocol, 1000); + assert_eq!(creator + protocol, 1000); + } + + #[test] + fn test_fee_split_remainder_to_creator() { + // 999 * 1000 / 10000 = 99 (protocol floor), creator gets remainder + let (creator, protocol) = fee::compute_fee_split(999, 9000, 1000); + assert_eq!(creator, 900); + assert_eq!(protocol, 99); + assert_eq!(creator + protocol, 999); + } + + #[test] + fn test_fee_split_zero_total() { + let (creator, protocol) = fee::compute_fee_split(0, 9000, 1000); + assert_eq!(creator, 0); + assert_eq!(protocol, 0); + } + + #[test] + fn test_fee_split_dust_total_one() { + // 1 * 1000 / 10000 = 0 protocol, creator gets full amount + let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); + assert_eq!(creator, 1); + assert_eq!(protocol, 0); + assert_eq!(creator + protocol, 1); + } + + #[test] + fn test_fee_split_balance_conservation() { + for total in [100_i128, 1, 999, 10000, 1234567] { + let (creator, protocol) = fee::compute_fee_split(total, 9000, 1000); + assert_eq!(creator + protocol, total, "total={}", total); + } + } + + #[test] + fn test_checked_mul_i128_success() { + assert_eq!(fee::checked_mul_i128(100, 10), Some(1000)); + } + + #[test] + fn test_checked_mul_i128_rejects_overflow() { + assert_eq!(fee::checked_mul_i128(i128::MAX, 2), None); + assert_eq!(fee::checked_mul_i128(i128::MIN, 2), None); + } + + #[test] + fn test_checked_div_i128_success() { + assert_eq!(fee::checked_div_i128(100, 10), Some(10)); + } + + #[test] + fn test_checked_div_i128_rejects_zero_divisor() { + assert_eq!(fee::checked_div_i128(100, 0), None); + } + + #[test] + fn test_checked_sub_i128_success() { + assert_eq!(fee::checked_sub_i128(100, 10), Some(90)); + } + + #[test] + fn test_checked_sub_i128_underflow() { + assert_eq!(fee::checked_sub_i128(i128::MIN, 1), None); + } + + #[test] + fn test_checked_add_i128_success() { + assert_eq!(fee::checked_add_i128(100, 10), Some(110)); + } + + #[test] + fn test_checked_add_i128_overflow() { + assert_eq!(fee::checked_add_i128(i128::MAX, 1), None); + } + + #[test] + fn test_checked_add_i128_zero() { + assert_eq!(fee::checked_add_i128(0, 0), Some(0)); + assert_eq!(fee::checked_add_i128(100, 0), Some(100)); + assert_eq!(fee::checked_add_i128(0, 100), Some(100)); + } + + #[test] + fn test_checked_add_i128_negative_values() { + assert_eq!(fee::checked_add_i128(-10, 20), Some(10)); + assert_eq!(fee::checked_add_i128(10, -20), Some(-10)); + assert_eq!(fee::checked_add_i128(-10, -10), Some(-20)); + } + + #[test] + fn test_checked_add_i128_boundary_values() { + assert_eq!(fee::checked_add_i128(i128::MAX, 0), Some(i128::MAX)); + assert_eq!(fee::checked_add_i128(i128::MIN, 0), Some(i128::MIN)); + assert_eq!(fee::checked_add_i128(0, i128::MAX), Some(i128::MAX)); + assert_eq!(fee::checked_add_i128(0, i128::MIN), Some(i128::MIN)); + } + + #[test] + fn test_checked_add_i128_deterministic_error() { + // Verify that overflow always returns None, never panics + assert_eq!(fee::checked_add_i128(i128::MAX, i128::MAX), None); + assert_eq!(fee::checked_add_i128(i128::MIN, i128::MIN), None); + } + + #[test] + fn test_checked_div_i128_rejects_overflow() { + assert_eq!(fee::checked_div_i128(i128::MIN, -1), None); + } + + /// Both operands at `i128::MAX / 2 + 1` must overflow. + /// + /// `(i128::MAX / 2 + 1) + (i128::MAX / 2 + 1) == i128::MAX + 1`, which + /// exceeds i128 capacity, so the helper must return `None` rather than wrap. + #[test] + fn test_checked_add_i128_both_at_half_max_plus_one_overflows() { + let half_plus_one = i128::MAX / 2 + 1; + assert_eq!(fee::checked_add_i128(half_plus_one, half_plus_one), None); + } + + /// Both operands at `i128::MAX / 2` must not overflow. + /// + /// `(i128::MAX / 2) + (i128::MAX / 2) == i128::MAX - 1`, which fits in i128, + /// so the helper must return the correct sum just below the overflow boundary. + #[test] + fn test_checked_add_i128_both_at_half_max_succeeds() { + let half = i128::MAX / 2; + assert_eq!(fee::checked_add_i128(half, half), Some(half + half)); + } + + #[test] + fn test_normalize_quote_amount_preserves_positive_amount() { + assert_eq!(super::normalize_quote_amount(100), Ok(Some(100))); + } + + #[test] + fn test_normalize_quote_amount_maps_zero_to_noop() { + assert_eq!(super::normalize_quote_amount(0), Ok(None)); + } + + #[test] + fn test_normalize_quote_amount_rejects_negative_amount() { + assert_eq!( + super::normalize_quote_amount(-1), + Err(super::ContractError::NotPositiveAmount) + ); + } + + #[test] + fn test_normalize_quote_amount_rejects_large_amount() { + let large = super::fee::MAX_SAFE_AMOUNT + 1; + assert_eq!( + super::normalize_quote_amount(large), + Err(super::ContractError::Overflow) + ); + } + + #[test] + fn test_checked_format_quote_response_buy_success() { + let res = super::checked_format_quote_response(1000, 90, 10, true).unwrap(); + assert_eq!(res.price, 1000); + assert_eq!(res.creator_fee, 90); + assert_eq!(res.protocol_fee, 10); + assert_eq!(res.total_amount, 1100); + } + + #[test] + fn test_checked_format_quote_response_sell_success() { + let res = super::checked_format_quote_response(1000, 90, 10, false).unwrap(); + assert_eq!(res.price, 1000); + assert_eq!(res.creator_fee, 90); + assert_eq!(res.protocol_fee, 10); + assert_eq!(res.total_amount, 900); + } + + #[test] + fn test_checked_format_quote_response_buy_overflow_fees() { + let res = super::checked_format_quote_response(1000, i128::MAX, 1, true); + assert_eq!(res, Err(super::ContractError::Overflow)); + } + + #[test] + fn test_checked_format_quote_response_buy_overflow_total() { + let res = super::checked_format_quote_response(i128::MAX, 1, 0, true); + assert_eq!(res, Err(super::ContractError::Overflow)); + } + + #[test] + fn test_checked_format_quote_response_sell_underflow_total() { + let res = super::checked_format_quote_response(i128::MIN, 1, 0, false); + assert_eq!(res, Err(super::ContractError::SellUnderflow)); + } + + #[test] + fn test_apply_percentage_fee_success() { + assert_eq!(fee::apply_percentage_fee(1000, 1000), Some(100)); + assert_eq!(fee::apply_percentage_fee(1000, 0), Some(0)); + assert_eq!(fee::apply_percentage_fee(1000, 10000), Some(1000)); + } + + #[test] + fn test_apply_percentage_fee_zero_amount() { + assert_eq!(fee::apply_percentage_fee(0, 1000), Some(0)); + } + + #[test] + fn test_apply_percentage_fee_negative_amount() { + assert_eq!(fee::apply_percentage_fee(-100, 1000), Some(0)); + } + + #[test] + fn test_apply_percentage_fee_rounding() { + // 999 * 1000 / 10000 = 99.9 -> 99 + assert_eq!(fee::apply_percentage_fee(999, 1000), Some(99)); + } + + #[test] + fn test_apply_percentage_fee_overflow() { + // Multiplication overflows before division + assert_eq!(fee::apply_percentage_fee(i128::MAX, 2), None); + } + + #[test] + fn test_assert_valid_fee_bps() { + // Valid scenarios + assert_eq!(fee::assert_valid_fee_bps(10000, 0), Ok(())); + assert_eq!(fee::assert_valid_fee_bps(5000, 5000), Ok(())); + assert_eq!(fee::assert_valid_fee_bps(9000, 1000), Ok(())); + + // Invalid Sum + assert_eq!( + fee::assert_valid_fee_bps(9000, 2000), + Err(super::ContractError::InvalidFeeConfig) + ); + assert_eq!( + fee::assert_valid_fee_bps(0, 0), + Err(super::ContractError::InvalidFeeConfig) + ); + + // Protocol Cap Exceeded (PROTOCOL_BPS_MAX = 5000) + assert_eq!( + fee::assert_valid_fee_bps(4999, 5001), + Err(super::ContractError::ProtocolFeeExceedsCap) + ); + assert_eq!( + fee::assert_valid_fee_bps(0, 10000), + Err(super::ContractError::ProtocolFeeExceedsCap) + ); + + // Overflow + assert_eq!( + fee::assert_valid_fee_bps(u32::MAX, 1), + Err(super::ContractError::InvalidFeeConfig) + ); + } + + #[test] + fn test_validate_fee_bps() { + // Valid + assert!(fee::validate_fee_bps(10000, 0)); + assert!(fee::validate_fee_bps(5000, 5000)); + assert!(fee::validate_fee_bps(9000, 1000)); + + // Invalid Sum + assert!(!fee::validate_fee_bps(9000, 2000)); + assert!(!fee::validate_fee_bps(0, 0)); + + // Protocol Cap Exceeded + assert!(!fee::validate_fee_bps(4999, 5001)); + + // Overflow + assert!(!fee::validate_fee_bps(u32::MAX, 1)); + } + + // --- checked_fee_sum unit tests --- + + /// Verifies that `checked_fee_sum` returns the correct sum for two ordinary + /// positive fee components. + #[test] + fn test_checked_fee_sum_success() { + assert_eq!(fee::checked_fee_sum(900, 100), Some(1000)); + assert_eq!(fee::checked_fee_sum(0, 0), Some(0)); + assert_eq!(fee::checked_fee_sum(500, 500), Some(1000)); + } + + /// Verifies that `checked_fee_sum` returns `None` when the addition would + /// overflow `i128`, preventing silent wrapping in fee total calculations. + #[test] + fn test_checked_fee_sum_overflow_returns_none() { + assert_eq!(fee::checked_fee_sum(i128::MAX, 1), None); + assert_eq!(fee::checked_fee_sum(i128::MAX, i128::MAX), None); + } + + /// Edge case: verifies `checked_fee_sum` at the boundary where one component + /// is exactly `i128::MAX` and the other is zero — the only non-overflowing + /// case at that boundary. + #[test] + fn test_checked_fee_sum_boundary_max_plus_zero() { + assert_eq!(fee::checked_fee_sum(i128::MAX, 0), Some(i128::MAX)); + assert_eq!(fee::checked_fee_sum(0, i128::MAX), Some(i128::MAX)); + // One above the boundary must overflow + assert_eq!(fee::checked_fee_sum(i128::MAX, 1), None); + } + + // --- BPS truncation on small amounts --- + + /// Bps calculation on very small amounts produces zero due to integer division + /// truncation. These tests document the behavior at the lower precision boundary. + /// + /// Formula: `amount * bps / 10_000` (floor division). + /// When the product `amount * bps < 10_000`, the result truncates to zero. + #[test] + fn test_apply_percentage_fee_truncation_1_stroop() { + // 1 * 1000 / 10_000 = 0.1 → truncated to 0 + // At 1 stroop with 10% bps, the fee is zero — value is silently lost. + let result = fee::apply_percentage_fee(1, 1000); + assert_eq!(result, Some(0), "1 stroop at 1000 bps truncates to 0"); + } + + #[test] + fn test_apply_percentage_fee_truncation_10_stroops() { + // 10 * 1000 / 10_000 = 1.0 → exactly 1 + // At 10 stroops with 10% bps, the fee is exactly 1. + let result = fee::apply_percentage_fee(10, 1000); + assert_eq!(result, Some(1), "10 stroops at 1000 bps yields 1"); + } + + #[test] + fn test_apply_percentage_fee_truncation_100_stroops() { + // 100 * 1000 / 10_000 = 10.0 → exactly 10 + let result = fee::apply_percentage_fee(100, 1000); + assert_eq!(result, Some(10), "100 stroops at 1000 bps yields 10"); + } + + #[test] + fn test_fee_split_truncation_1_stroop() { + // 1 * 1000 / 10_000 = 0 protocol, 1 creator (remainder to creator) + // Truncation causes the full amount to go to creator. + let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); + assert_eq!(protocol, 0, "1 stroop: protocol fee truncated to 0"); + assert_eq!(creator, 1, "1 stroop: creator gets full amount"); + assert_eq!(creator + protocol, 1, "conservation holds"); + } + + #[test] + fn test_fee_split_truncation_10_stroops() { + // 10 * 1000 / 10_000 = 1 protocol, 9 creator + let (creator, protocol) = fee::compute_fee_split(10, 9000, 1000); + assert_eq!(protocol, 1, "10 stroops: protocol fee is 1"); + assert_eq!(creator, 9, "10 stroops: creator gets 9"); + assert_eq!(creator + protocol, 10, "conservation holds"); + } + + #[test] + fn test_fee_split_truncation_100_stroops() { + // 100 * 1000 / 10_000 = 10 protocol, 90 creator + let (creator, protocol) = fee::compute_fee_split(100, 9000, 1000); + assert_eq!(protocol, 10, "100 stroops: protocol fee is 10"); + assert_eq!(creator, 90, "100 stroops: creator gets 90"); + assert_eq!(creator + protocol, 100, "conservation holds"); + } + + // --- Zero address validation --- + + #[test] + fn test_validate_non_zero_address_rejects_zero() { + use soroban_sdk::{Address, Env, String}; + let env = Env::default(); + let zero_str = String::from_str( + &env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ); + let zero_addr = Address::from_string(&zero_str); + let result = super::validate_non_zero_address(&env, &zero_addr); + assert_eq!(result, Err(super::ContractError::ZeroAddress)); + } + + #[test] + fn test_validate_non_zero_address_accepts_valid() { + use soroban_sdk::{testutils::Address as _, Address, Env}; + let env = Env::default(); + let valid = Address::generate(&env); + let result = super::validate_non_zero_address(&env, &valid); + assert_eq!(result, Ok(())); + } } #[cfg(test)] From db9bef30246cff3438ded9ed6ab131ad521219b5 Mon Sep 17 00:00:00 2001 From: Syed Ghufran Hassan Date: Wed, 24 Jun 2026 16:54:31 +0500 Subject: [PATCH 06/11] Update lib.rs Updated --- creator-keys/src/lib.rs | 3479 ++++++++++++++++++++------------------- 1 file changed, 1806 insertions(+), 1673 deletions(-) diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 7b39bc2..c8367d9 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -1,7 +1,10 @@ #![no_std] pub mod quote_view_errors; -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, String, Vec}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, token, Address, Env, + String, Vec, +}; pub mod events; @@ -47,215 +50,226 @@ pub mod events; /// } /// ``` pub enum ContractError { - AlreadyRegistered = 1, - NotRegistered = 2, - Overflow = 3, - InsufficientPayment = 4, - KeyPriceNotSet = 5, - NotPositiveAmount = 6, - FeeConfigNotSet = 7, - InvalidFeeConfig = 8, - InsufficientBalance = 9, - SellUnderflow = 10, - ProtocolFeeExceedsCap = 11, - HandleTooShort = 12, - HandleTooLong = 13, - InvalidHandleCharacter = 14, - ZeroAddress = 15, - SlippageExceeded = 16, - ProtocolPaused = 17, - Unauthorized = 18, - ZeroClaimable = 19, - NoHolders = 20, - DividendAmountZero = 21, + AlreadyRegistered = 1, + NotRegistered = 2, + Overflow = 3, + InsufficientPayment = 4, + KeyPriceNotSet = 5, + NotPositiveAmount = 6, + FeeConfigNotSet = 7, + InvalidFeeConfig = 8, + InsufficientBalance = 9, + SellUnderflow = 10, + ProtocolFeeExceedsCap = 11, + HandleTooShort = 12, + HandleTooLong = 13, + InvalidHandleCharacter = 14, + ZeroAddress = 15, + SlippageExceeded = 16, + ProtocolPaused = 17, + Unauthorized = 18, + ZeroClaimable = 19, + NoHolders = 20, + DividendAmountZero = 21, } pub mod fee { - use crate::ContractError; + use crate::ContractError; - use soroban_sdk::contracttype; + use soroban_sdk::contracttype; - /// Basis points per 100% (10000 = 100%). - pub const BPS_MAX: u32 = 10_000; + /// Basis points per 100% (10000 = 100%). + pub const BPS_MAX: u32 = 10_000; - /// Maximum safe amount to prevent overflow in fee calculations. - pub const MAX_SAFE_AMOUNT: i128 = i128::MAX / BPS_MAX as i128; + /// Maximum safe amount to prevent overflow in fee calculations. + pub const MAX_SAFE_AMOUNT: i128 = i128::MAX / BPS_MAX as i128; - /// Maximum protocol share when configuring fees via [`assert_valid_fee_bps`]. - /// - /// Caps the on-chain configured protocol take at 50% so fee settings stay within - /// expected economic bounds before they affect market logic. - pub const PROTOCOL_BPS_MAX: u32 = 5_000; + /// Maximum protocol share when configuring fees via [`assert_valid_fee_bps`]. + /// + /// Caps the on-chain configured protocol take at 50% so fee settings stay within + /// expected economic bounds before they affect market logic. + pub const PROTOCOL_BPS_MAX: u32 = 5_000; - #[derive(Clone, Eq, PartialEq)] - #[contracttype] - pub struct FeeConfig { - pub creator_bps: u32, - pub protocol_bps: u32, - } - - /// Validates creator and protocol basis points for storage and fee-setting entrypoints. - pub fn validate_fee_bps(creator_bps: u32, protocol_bps: u32) -> bool { - let Some(sum) = creator_bps.checked_add(protocol_bps) else { - return false; - }; - if sum != BPS_MAX { - return false; - } - if protocol_bps > PROTOCOL_BPS_MAX { - return false; - } - true - } - - /// Shared guard for fee config updates that need structured contract errors. - pub fn assert_valid_fee_bps(creator_bps: u32, protocol_bps: u32) -> Result<(), ContractError> { - let Some(sum) = creator_bps.checked_add(protocol_bps) else { - return Err(ContractError::InvalidFeeConfig); - }; - if sum != BPS_MAX { - return Err(ContractError::InvalidFeeConfig); - } - if protocol_bps > PROTOCOL_BPS_MAX { - return Err(ContractError::ProtocolFeeExceedsCap); - } - Ok(()) - } - - /// Computes the fee split for a given total amount. - /// - /// Returns `(creator_amount, protocol_amount)`. Remainder from integer division - /// is assigned to the creator. Ensures creator_amount + protocol_amount == total. - pub fn compute_fee_split(total: i128, _creator_bps: u32, protocol_bps: u32) -> (i128, i128) { - if total <= 0 { - return (0, 0); - } - let protocol_amount = (total * protocol_bps as i128) / BPS_MAX as i128; - let creator_amount = total - protocol_amount; - (creator_amount, protocol_amount) - } - - /// Safely applies a percentage-based fee to an amount. - /// - /// Returns `None` if the multiplication overflows. Rounding is performed via - /// floor division towards zero. - pub fn apply_percentage_fee(amount: i128, bps: u32) -> Option { - if amount <= 0 { - return Some(0); - } - checked_div_i128(amount.checked_mul(bps as i128)?, BPS_MAX as i128) - } - - /// Computes the fee split safely, returning `None` if multiplication or subtraction overflows. - pub fn checked_compute_fee_split( - total: i128, - _creator_bps: u32, - protocol_bps: u32, - ) -> Option<(i128, i128)> { - if total <= 0 { - return Some((0, 0)); - } - let protocol_amount = apply_percentage_fee(total, protocol_bps)?; - let creator_amount = checked_sub_i128(total, protocol_amount)?; - Some((creator_amount, protocol_amount)) - } - - /// Performs checked integer multiplication for quote math helpers. - pub fn checked_mul_i128(a: i128, b: i128) -> Option { - a.checked_mul(b) - } - - /// Performs checked integer division for quote math helpers. - pub fn checked_div_i128(dividend: i128, divisor: i128) -> Option { - if divisor == 0 { - return None; - } - dividend.checked_div(divisor) - } - - /// Performs checked integer subtraction for quote math helpers. - pub fn checked_sub_i128(left: i128, right: i128) -> Option { - left.checked_sub(right) - } + #[derive(Clone, Eq, PartialEq)] + #[contracttype] + pub struct FeeConfig { + pub creator_bps: u32, + pub protocol_bps: u32, + } - /// Performs checked integer addition for quote math helpers. - pub fn checked_add_i128(left: i128, right: i128) -> Option { - left.checked_add(right) + /// Validates creator and protocol basis points for storage and fee-setting entrypoints. + pub fn validate_fee_bps(creator_bps: u32, protocol_bps: u32) -> bool { + let Some(sum) = creator_bps.checked_add(protocol_bps) else { + return false; + }; + if sum != BPS_MAX { + return false; + } + if protocol_bps > PROTOCOL_BPS_MAX { + return false; + } + true + } + + /// Shared guard for fee config updates that need structured contract errors. + pub fn assert_valid_fee_bps( + creator_bps: u32, + protocol_bps: u32, + ) -> Result<(), ContractError> { + let Some(sum) = creator_bps.checked_add(protocol_bps) else { + return Err(ContractError::InvalidFeeConfig); + }; + if sum != BPS_MAX { + return Err(ContractError::InvalidFeeConfig); } - - /// Computes the checked sum of creator and protocol fee components. - /// - /// Returns `None` if the addition would overflow. Use this helper wherever - /// fee components are combined before being compared against a price or total, - /// to keep the overflow guard consistent across buy and sell quote paths. - /// - /// # Naming convention - /// - /// Quote helpers in this module follow a `checked_*` prefix convention: - /// - `checked_*` functions return `Option` and propagate `None` on overflow. - /// - `compute_*` functions return the result directly (may panic on overflow in - /// debug builds; use only where inputs are already validated). - /// - `apply_*` functions apply a rate or percentage to a single amount. - /// - /// `checked_fee_sum` belongs to the `checked_*` family: it is the canonical - /// helper for summing two fee components before they are used in total-amount - /// arithmetic, replacing ad-hoc inline `checked_add` calls at each call site. - pub fn checked_fee_sum(creator_fee: i128, protocol_fee: i128) -> Option { - creator_fee.checked_add(protocol_fee) + if protocol_bps > PROTOCOL_BPS_MAX { + return Err(ContractError::ProtocolFeeExceedsCap); } + Ok(()) + } + + /// Computes the fee split for a given total amount. + /// + /// Returns `(creator_amount, protocol_amount)`. Remainder from integer division + /// is assigned to the creator. Ensures creator_amount + protocol_amount == total. + pub fn compute_fee_split( + total: i128, + _creator_bps: u32, + protocol_bps: u32, + ) -> (i128, i128) { + if total <= 0 { + return (0, 0); + } + let protocol_amount = (total * protocol_bps as i128) / BPS_MAX as i128; + let creator_amount = total - protocol_amount; + (creator_amount, protocol_amount) + } + + /// Safely applies a percentage-based fee to an amount. + /// + /// Returns `None` if the multiplication overflows. Rounding is performed via + /// floor division towards zero. + pub fn apply_percentage_fee(amount: i128, bps: u32) -> Option { + if amount <= 0 { + return Some(0); + } + checked_div_i128(amount.checked_mul(bps as i128)?, BPS_MAX as i128) + } + + /// Computes the fee split safely, returning `None` if multiplication or subtraction overflows. + pub fn checked_compute_fee_split( + total: i128, + _creator_bps: u32, + protocol_bps: u32, + ) -> Option<(i128, i128)> { + if total <= 0 { + return Some((0, 0)); + } + let protocol_amount = apply_percentage_fee(total, protocol_bps)?; + let creator_amount = checked_sub_i128(total, protocol_amount)?; + Some((creator_amount, protocol_amount)) + } + + /// Performs checked integer multiplication for quote math helpers. + pub fn checked_mul_i128(a: i128, b: i128) -> Option { + a.checked_mul(b) + } + + /// Performs checked integer division for quote math helpers. + pub fn checked_div_i128(dividend: i128, divisor: i128) -> Option { + if divisor == 0 { + return None; + } + dividend.checked_div(divisor) + } + + /// Performs checked integer subtraction for quote math helpers. + pub fn checked_sub_i128(left: i128, right: i128) -> Option { + left.checked_sub(right) + } + + /// Performs checked integer addition for quote math helpers. + pub fn checked_add_i128(left: i128, right: i128) -> Option { + left.checked_add(right) + } + + /// Computes the checked sum of creator and protocol fee components. + /// + /// Returns `None` if the addition would overflow. Use this helper wherever + /// fee components are combined before being compared against a price or total, + /// to keep the overflow guard consistent across buy and sell quote paths. + /// + /// # Naming convention + /// + /// Quote helpers in this module follow a `checked_*` prefix convention: + /// - `checked_*` functions return `Option` and propagate `None` on overflow. + /// - `compute_*` functions return the result directly (may panic on overflow in + /// debug builds; use only where inputs are already validated). + /// - `apply_*` functions apply a rate or percentage to a single amount. + /// + /// `checked_fee_sum` belongs to the `checked_*` family: it is the canonical + /// helper for summing two fee components before they are used in total-amount + /// arithmetic, replacing ad-hoc inline `checked_add` calls at each call site. + pub fn checked_fee_sum( + creator_fee: i128, + protocol_fee: i128, + ) -> Option { + creator_fee.checked_add(protocol_fee) + } } pub mod constants { - use super::DataKey; - use soroban_sdk::Address; - - pub mod storage { - use super::{creator_key, key_balance_key, DataKey}; - use soroban_sdk::Address; - - pub const FEE_CONFIG: DataKey = DataKey::FeeConfig; - pub const KEY_PRICE: DataKey = DataKey::KeyPrice; - pub const TREASURY_ADDRESS: DataKey = DataKey::TreasuryAddress; - pub const ADMIN_ADDRESS: DataKey = DataKey::AdminAddress; - pub const PROTOCOL_FEE_RECIPIENT: DataKey = DataKey::ProtocolFeeRecipient; - pub const PROTOCOL_FEE_RECIPIENT_BALANCE: DataKey = DataKey::ProtocolFeeRecipientBalance; - pub const PROTOCOL_STATE_VERSION: DataKey = DataKey::ProtocolStateVersion; - pub const PAUSED: DataKey = DataKey::Paused; - - pub fn creator_fee_balance(creator: &Address) -> DataKey { - DataKey::CreatorFeeBalance(creator.clone()) - } - - pub fn creator(creator: &Address) -> DataKey { - creator_key(creator) - } - - pub fn key_balance(creator: &Address, holder: &Address) -> DataKey { - key_balance_key(creator, holder) - } - } - - fn creator_key(creator: &Address) -> DataKey { - DataKey::Creator(creator.clone()) - } + use super::DataKey; + use soroban_sdk::Address; - fn key_balance_key(creator: &Address, holder: &Address) -> DataKey { - DataKey::KeyBalance(creator.clone(), holder.clone()) - } + pub mod storage { + use super::{creator_key, key_balance_key, DataKey}; + use soroban_sdk::Address; - pub mod creator_reads { - pub const DETAILS: &str = "get_creator_details"; - pub const FEE_BPS: &str = "get_creator_fee_bps"; - pub const FEE_CONFIG: &str = "get_creator_fee_config"; - pub const FEE_RECIPIENT: &str = "get_creator_fee_recipient"; - pub const FEE_RECIPIENT_BALANCE: &str = "get_creator_fee_balance"; - pub const HOLDER_KEY_COUNT: &str = "get_holder_key_count"; - pub const PROFILE: &str = "get_creator"; - pub const SUPPLY: &str = "get_creator_supply"; - pub const TREASURY_SHARE: &str = "get_creator_treasury_share"; - pub const NAME: &str = "get_key_name"; - pub const SYMBOL: &str = "get_key_symbol"; - } + pub const FEE_CONFIG: DataKey = DataKey::FeeConfig; + pub const KEY_PRICE: DataKey = DataKey::KeyPrice; + pub const TREASURY_ADDRESS: DataKey = DataKey::TreasuryAddress; + pub const ADMIN_ADDRESS: DataKey = DataKey::AdminAddress; + pub const PROTOCOL_FEE_RECIPIENT: DataKey = DataKey::ProtocolFeeRecipient; + pub const PROTOCOL_FEE_RECIPIENT_BALANCE: DataKey = + DataKey::ProtocolFeeRecipientBalance; + pub const PROTOCOL_STATE_VERSION: DataKey = DataKey::ProtocolStateVersion; + pub const PAUSED: DataKey = DataKey::Paused; + + pub fn creator_fee_balance(creator: &Address) -> DataKey { + DataKey::CreatorFeeBalance(creator.clone()) + } + + pub fn creator(creator: &Address) -> DataKey { + creator_key(creator) + } + + pub fn key_balance(creator: &Address, holder: &Address) -> DataKey { + key_balance_key(creator, holder) + } + } + + fn creator_key(creator: &Address) -> DataKey { + DataKey::Creator(creator.clone()) + } + + fn key_balance_key(creator: &Address, holder: &Address) -> DataKey { + DataKey::KeyBalance(creator.clone(), holder.clone()) + } + + pub mod creator_reads { + pub const DETAILS: &str = "get_creator_details"; + pub const FEE_BPS: &str = "get_creator_fee_bps"; + pub const FEE_CONFIG: &str = "get_creator_fee_config"; + pub const FEE_RECIPIENT: &str = "get_creator_fee_recipient"; + pub const FEE_RECIPIENT_BALANCE: &str = "get_creator_fee_balance"; + pub const HOLDER_KEY_COUNT: &str = "get_holder_key_count"; + pub const PROFILE: &str = "get_creator"; + pub const SUPPLY: &str = "get_creator_supply"; + pub const TREASURY_SHARE: &str = "get_creator_treasury_share"; + pub const NAME: &str = "get_key_name"; + pub const SYMBOL: &str = "get_key_symbol"; + } } /// Stable, non-optional view of the protocol fee configuration. @@ -265,9 +279,9 @@ pub mod constants { #[derive(Clone)] #[contracttype] pub struct ProtocolFeeView { - pub creator_bps: u32, - pub protocol_bps: u32, - pub is_configured: bool, + pub creator_bps: u32, + pub protocol_bps: u32, + pub is_configured: bool, } /// Stable, non-optional view of creator details. @@ -285,17 +299,17 @@ pub struct ProtocolFeeView { #[derive(Clone)] #[contracttype] pub struct CreatorDetailsView { - pub creator: Address, - pub handle: String, - pub supply: u32, - pub is_registered: bool, - /// Ledger sequence number at the time the creator registered. - /// - /// Set to `env.ledger().sequence()` inside [`CreatorKeysContract::register_creator`]. - /// Returns `0` for unregistered addresses so callers never receive an `Option`. - /// Clients can use this field to sort a marketplace grid chronologically without - /// maintaining a separate off-chain index. - pub registered_at: u32, + pub creator: Address, + pub handle: String, + pub supply: u32, + pub is_registered: bool, + /// Ledger sequence number at the time the creator registered. + /// + /// Set to `env.ledger().sequence()` inside [`CreatorKeysContract::register_creator`]. + /// Returns `0` for unregistered addresses so callers never receive an `Option`. + /// Clients can use this field to sort a marketplace grid chronologically without + /// maintaining a separate off-chain index. + pub registered_at: u32, } /// Stable, non-optional view of a creator's fee configuration. /// @@ -305,10 +319,10 @@ pub struct CreatorDetailsView { #[derive(Clone)] #[contracttype] pub struct CreatorFeeView { - pub creator_bps: u32, - pub protocol_bps: u32, - pub is_registered: bool, - pub is_configured: bool, + pub creator_bps: u32, + pub protocol_bps: u32, + pub is_registered: bool, + pub is_configured: bool, } /// Stable, non-optional view of a holder's key count for a creator. @@ -319,10 +333,10 @@ pub struct CreatorFeeView { #[derive(Clone)] #[contracttype] pub struct HolderKeyCountView { - pub creator: Address, - pub holder: Address, - pub key_count: u32, - pub creator_exists: bool, + pub creator: Address, + pub holder: Address, + pub key_count: u32, + pub creator_exists: bool, } /// Stable, non-optional view of a buy or sell quote. @@ -331,10 +345,10 @@ pub struct HolderKeyCountView { #[derive(Clone, Debug, PartialEq)] #[contracttype] pub struct QuoteResponse { - pub price: i128, - pub creator_fee: i128, - pub protocol_fee: i128, - pub total_amount: i128, + pub price: i128, + pub creator_fee: i128, + pub protocol_fee: i128, + pub total_amount: i128, } /// Shared result type for read-only quote methods. @@ -360,46 +374,50 @@ pub const HANDLE_LEN_MAX: u32 = 32; #[derive(Clone)] #[contracttype] pub enum DataKey { - Creator(Address), - FeeConfig, - KeyPrice, - KeyBalance(Address, Address), - TreasuryAddress, - AdminAddress, - ProtocolFeeRecipient, - ProtocolFeeRecipientBalance, - CreatorFeeBalance(Address), - ProtocolStateVersion, - Paused, - HoldersList(Address), - DividendClaimable(Address, Address), + Creator(Address), + FeeConfig, + KeyPrice, + KeyBalance(Address, Address), + TreasuryAddress, + AdminAddress, + ProtocolFeeRecipient, + ProtocolFeeRecipientBalance, + CreatorFeeBalance(Address), + ProtocolStateVersion, + Paused, + HoldersList(Address), + DividendClaimable(Address, Address), } #[derive(Clone, Debug, PartialEq)] #[contracttype] pub struct CreatorProfile { - pub creator: Address, - pub handle: String, - pub supply: u32, - pub holder_count: u32, - pub fee_recipient: Address, - /// Ledger sequence number captured at registration time via `env.ledger().sequence()`. - /// - /// Stored as the last field so existing serialised profiles written before this - /// field was added deserialise correctly — the Soroban persistent storage layer - /// reads structs by field index, so appending is the only safe extension pattern. - pub registered_at: u32, + pub creator: Address, + pub handle: String, + pub supply: u32, + pub holder_count: u32, + pub fee_recipient: Address, + /// Ledger sequence number captured at registration time via `env.ledger().sequence()`. + /// + /// Stored as the last field so existing serialised profiles written before this + /// field was added deserialise correctly — the Soroban persistent storage layer + /// reads structs by field index, so appending is the only safe extension pattern. + pub registered_at: u32, } /// Reads a creator profile from storage, returning `None` for unregistered creators. /// /// Use this helper wherever repeated creator read logic is needed to keep /// missing-creator behavior consistent across the contract. -pub fn read_creator_profile(env: &Env, creator: &Address) -> Option { - let key = constants::storage::creator(creator); - env.storage() - .persistent() - .get::(&key) +pub fn read_creator_profile( + env: &Env, + creator: &Address, +) -> Option { + let key = constants::storage::creator(creator); + env + .storage() + .persistent() + .get::(&key) } /// Reads a registered creator profile, returning an error when the creator is missing. @@ -407,10 +425,10 @@ pub fn read_creator_profile(env: &Env, creator: &Address) -> Option Result { - read_creator_profile(env, creator).ok_or(ContractError::NotRegistered) + read_creator_profile(env, creator).ok_or(ContractError::NotRegistered) } /// Reads the key balance (supply) for a creator, returning `0` for unregistered creators. @@ -418,9 +436,9 @@ pub fn read_registered_creator_profile( /// Use this helper wherever repeated key balance read logic is needed to keep /// missing-balance behavior consistent across the contract. pub fn read_key_balance(env: &Env, creator: &Address) -> u32 { - read_creator_profile(env, creator) - .map(|p| p.supply) - .unwrap_or(0) + read_creator_profile(env, creator) + .map(|p| p.supply) + .unwrap_or(0) } /// Reads an empty string for use as a default in read-only view methods. @@ -428,7 +446,7 @@ pub fn read_key_balance(env: &Env, creator: &Address) -> u32 { /// Use this helper wherever an empty string is needed to maintain consistency /// and reduce duplication of string allocation logic. pub fn read_none_string(env: &Env) -> String { - String::from_str(env, "") + String::from_str(env, "") } /// Reads the handle for a creator, returning an empty string for unregistered creators. @@ -436,88 +454,93 @@ pub fn read_none_string(env: &Env) -> String { /// Use this helper wherever repeated handle read logic is needed to maintain /// missing-handle behavior consistency across the contract. pub fn read_creator_handle(env: &Env, creator: &Address) -> String { - read_creator_profile(env, creator) - .map(|p| p.handle) - .unwrap_or_else(|| read_none_string(env)) + read_creator_profile(env, creator) + .map(|p| p.handle) + .unwrap_or_else(|| read_none_string(env)) } /// Reads accrued creator fee balance for a creator, returning `0` when none is stored. -pub fn read_creator_fee_recipient_balance(env: &Env, creator: &Address) -> i128 { - let key = constants::storage::creator_fee_balance(creator); - env.storage().persistent().get(&key).unwrap_or(0) +pub fn read_creator_fee_recipient_balance( + env: &Env, + creator: &Address, +) -> i128 { + let key = constants::storage::creator_fee_balance(creator); + env.storage().persistent().get(&key).unwrap_or(0) } /// Credits `amount` to the creator fee recipient balance for `creator`. fn credit_creator_fee_recipient_balance( - env: &Env, - creator: &Address, - amount: i128, + env: &Env, + creator: &Address, + amount: i128, ) -> Result<(), ContractError> { - if amount <= 0 { - return Ok(()); - } - let key = constants::storage::creator_fee_balance(creator); - let current = read_creator_fee_recipient_balance(env, creator); - let updated = current.checked_add(amount).ok_or(ContractError::Overflow)?; - env.storage().persistent().set(&key, &updated); - Ok(()) + if amount <= 0 { + return Ok(()); + } + let key = constants::storage::creator_fee_balance(creator); + let current = read_creator_fee_recipient_balance(env, creator); + let updated = current.checked_add(amount).ok_or(ContractError::Overflow)?; + env.storage().persistent().set(&key, &updated); + Ok(()) } fn is_valid_handle_byte(byte: u8) -> bool { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' } fn validate_creator_handle(handle: &String) -> Result<(), ContractError> { - let len = handle.len(); - if len < HANDLE_LEN_MIN { - return Err(ContractError::HandleTooShort); - } - if len > HANDLE_LEN_MAX { - return Err(ContractError::HandleTooLong); - } - - let mut bytes = [0u8; HANDLE_LEN_MAX as usize]; - handle.copy_into_slice(&mut bytes[..len as usize]); - if bytes[..len as usize] - .iter() - .any(|byte| !is_valid_handle_byte(*byte)) - { - return Err(ContractError::InvalidHandleCharacter); - } - - Ok(()) + let len = handle.len(); + if len < HANDLE_LEN_MIN { + return Err(ContractError::HandleTooShort); + } + if len > HANDLE_LEN_MAX { + return Err(ContractError::HandleTooLong); + } + + let mut bytes = [0u8; HANDLE_LEN_MAX as usize]; + handle.copy_into_slice(&mut bytes[..len as usize]); + if bytes[..len as usize] + .iter() + .any(|byte| !is_valid_handle_byte(*byte)) + { + return Err(ContractError::InvalidHandleCharacter); + } + + Ok(()) } fn is_paused(env: &Env) -> bool { - env.storage() - .persistent() - .get::(&constants::storage::PAUSED) - .unwrap_or(false) + env + .storage() + .persistent() + .get::(&constants::storage::PAUSED) + .unwrap_or(false) } fn assert_not_paused(env: &Env) -> Result<(), ContractError> { - if is_paused(env) { - return Err(ContractError::ProtocolPaused); - } - Ok(()) + if is_paused(env) { + return Err(ContractError::ProtocolPaused); + } + Ok(()) } fn assert_is_admin(env: &Env, caller: &Address) -> Result<(), ContractError> { - let admin: Address = env - .storage() - .persistent() - .get(&constants::storage::ADMIN_ADDRESS) - .ok_or(ContractError::Unauthorized)?; - if *caller != admin { - return Err(ContractError::Unauthorized); - } - Ok(()) + let admin: Address = env + .storage() + .persistent() + .get(&constants::storage::ADMIN_ADDRESS) + .ok_or(ContractError::Unauthorized)?; + if *caller != admin { + return Err(ContractError::Unauthorized); + } + Ok(()) } fn read_protocol_fee_config(env: &Env) -> Option { - env.storage() - .persistent() - .get(&constants::storage::FEE_CONFIG) + env + .storage() + .persistent() + .get(&constants::storage::FEE_CONFIG) } /// Validates that an address is not the Stellar zero address. @@ -525,119 +548,139 @@ fn read_protocol_fee_config(env: &Env) -> Option { /// The zero address (`GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF`) /// is the all-zero public key. Setting it as a fee recipient would silently /// burn all protocol fees. This helper rejects it at the point of assignment. -fn validate_non_zero_address(env: &Env, addr: &Address) -> Result<(), ContractError> { - let zero_str = String::from_str( - env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ); - let zero_addr = Address::from_string(&zero_str); - if *addr == zero_addr { - return Err(ContractError::ZeroAddress); - } - Ok(()) +fn validate_non_zero_address( + env: &Env, + addr: &Address, +) -> Result<(), ContractError> { + let zero_str = String::from_str( + env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ); + let zero_addr = Address::from_string(&zero_str); + if *addr == zero_addr { + return Err(ContractError::ZeroAddress); + } + Ok(()) } -fn read_required_protocol_fee_config(env: &Env) -> Result { - read_protocol_fee_config(env).ok_or(ContractError::FeeConfigNotSet) +fn read_required_protocol_fee_config( + env: &Env, +) -> Result { + read_protocol_fee_config(env).ok_or(ContractError::FeeConfigNotSet) } fn read_protocol_fee_recipient_balance(env: &Env) -> i128 { - env.storage() - .persistent() - .get(&constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE) - .unwrap_or(0) + env + .storage() + .persistent() + .get(&constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE) + .unwrap_or(0) } -fn credit_protocol_fee_recipient_balance(env: &Env, amount: i128) -> Result<(), ContractError> { - if amount <= 0 { - return Ok(()); - } - let updated = read_protocol_fee_recipient_balance(env) - .checked_add(amount) - .ok_or(ContractError::Overflow)?; - env.storage().persistent().set( - &constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE, - &updated, - ); - Ok(()) +fn credit_protocol_fee_recipient_balance( + env: &Env, + amount: i128, +) -> Result<(), ContractError> { + if amount <= 0 { + return Ok(()); + } + let updated = read_protocol_fee_recipient_balance(env) + .checked_add(amount) + .ok_or(ContractError::Overflow)?; + env.storage().persistent().set( + &constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE, + &updated, + ); + Ok(()) } -fn assert_buy_price_slippage(price: i128, max_price: Option) -> Result<(), ContractError> { - if let Some(max) = max_price { - if price > max { - return Err(ContractError::SlippageExceeded); - } +fn assert_buy_price_slippage( + price: i128, + max_price: Option, +) -> Result<(), ContractError> { + if let Some(max) = max_price { + if price > max { + return Err(ContractError::SlippageExceeded); } - Ok(()) + } + Ok(()) } -fn compute_sell_proceeds(env: &Env, price: i128) -> Result { - let (creator_fee, protocol_fee) = - CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?; - let fees = fee::checked_fee_sum(creator_fee, protocol_fee).ok_or(ContractError::Overflow)?; - fee::checked_sub_i128(price, fees).ok_or(ContractError::SellUnderflow) +fn compute_sell_proceeds( + env: &Env, + price: i128, +) -> Result { + let (creator_fee, protocol_fee) = + CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?; + let fees = fee::checked_fee_sum(creator_fee, protocol_fee) + .ok_or(ContractError::Overflow)?; + fee::checked_sub_i128(price, fees).ok_or(ContractError::SellUnderflow) } fn assert_sell_proceeds_slippage( - env: &Env, - min_proceeds: Option, + env: &Env, + min_proceeds: Option, ) -> Result<(), ContractError> { - if let Some(min) = min_proceeds { - let price: i128 = env - .storage() - .persistent() - .get(&constants::storage::KEY_PRICE) - .ok_or(ContractError::KeyPriceNotSet)?; - let proceeds = compute_sell_proceeds(env, price)?; - if proceeds < min { - return Err(ContractError::SlippageExceeded); - } - } - Ok(()) + if let Some(min) = min_proceeds { + let price: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; + let proceeds = compute_sell_proceeds(env, price)?; + if proceeds < min { + return Err(ContractError::SlippageExceeded); + } + } + Ok(()) } fn accrue_sell_protocol_fee(env: &Env) -> Result<(), ContractError> { - if env - .storage() - .persistent() - .get::(&constants::storage::PROTOCOL_FEE_RECIPIENT) - .is_none() - { - return Ok(()); - } - - let Some(price) = env - .storage() - .persistent() - .get(&constants::storage::KEY_PRICE) - else { - return Ok(()); - }; - - if read_protocol_fee_config(env).is_none() { - return Ok(()); - } - - let (_, protocol_fee) = CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?; - credit_protocol_fee_recipient_balance(env, protocol_fee) + if env + .storage() + .persistent() + .get::(&constants::storage::PROTOCOL_FEE_RECIPIENT) + .is_none() + { + return Ok(()); + } + + let Some(price) = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + else { + return Ok(()); + }; + + if read_protocol_fee_config(env).is_none() { + return Ok(()); + } + + let (_, protocol_fee) = + CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?; + credit_protocol_fee_recipient_balance(env, protocol_fee) } /// Resolves and validates the shared inputs required by read-only quote methods. /// /// Reads the key price from storage and confirms the creator is registered. /// Returns `(price)` on success, or the appropriate [`ContractError`] on failure. -fn resolve_quote_inputs(env: &Env, creator: &Address) -> Result, ContractError> { - let price: i128 = env - .storage() - .persistent() - .get(&constants::storage::KEY_PRICE) - .ok_or(ContractError::KeyPriceNotSet)?; - - if read_creator_profile(env, creator).is_none() { - return Err(ContractError::NotRegistered); - } - - normalize_quote_amount(price) +fn resolve_quote_inputs( + env: &Env, + creator: &Address, +) -> Result, ContractError> { + let price: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; + + if read_creator_profile(env, creator).is_none() { + return Err(ContractError::NotRegistered); + } + + normalize_quote_amount(price) } /// Normalizes quote amounts before fee math is applied. @@ -646,53 +689,54 @@ fn resolve_quote_inputs(env: &Env, creator: &Address) -> Result, Co /// Negative quote amounts are rejected consistently across buy and sell paths. /// Amounts exceeding MAX_SAFE_AMOUNT are rejected to prevent overflow in fee calculations. fn normalize_quote_amount(amount: i128) -> Result, ContractError> { - if amount < 0 { - return Err(ContractError::NotPositiveAmount); - } + if amount < 0 { + return Err(ContractError::NotPositiveAmount); + } - if amount == 0 { - return Ok(None); - } + if amount == 0 { + return Ok(None); + } - if amount > fee::MAX_SAFE_AMOUNT { - return Err(ContractError::Overflow); - } + if amount > fee::MAX_SAFE_AMOUNT { + return Err(ContractError::Overflow); + } - Ok(Some(amount)) + Ok(Some(amount)) } fn zero_quote_response() -> QuoteResponse { - QuoteResponse { - price: 0, - creator_fee: 0, - protocol_fee: 0, - total_amount: 0, - } + QuoteResponse { + price: 0, + creator_fee: 0, + protocol_fee: 0, + total_amount: 0, + } } /// Formats a quote response with overflow-safe total amount calculation. /// /// Returns `Err(ContractError::Overflow)` if any addition or subtraction would overflow. fn checked_format_quote_response( - price: i128, - creator_fee: i128, - protocol_fee: i128, - is_buy: bool, + price: i128, + creator_fee: i128, + protocol_fee: i128, + is_buy: bool, ) -> QuoteViewResult { - let fees = fee::checked_fee_sum(creator_fee, protocol_fee).ok_or(ContractError::Overflow)?; - - let total_amount = if is_buy { - price.checked_add(fees).ok_or(ContractError::Overflow)? - } else { - fee::checked_sub_i128(price, fees).ok_or(ContractError::SellUnderflow)? - }; - - Ok(QuoteResponse { - price, - creator_fee, - protocol_fee, - total_amount, - }) + let fees = fee::checked_fee_sum(creator_fee, protocol_fee) + .ok_or(ContractError::Overflow)?; + + let total_amount = if is_buy { + price.checked_add(fees).ok_or(ContractError::Overflow)? + } else { + fee::checked_sub_i128(price, fees).ok_or(ContractError::SellUnderflow)? + }; + + Ok(QuoteResponse { + price, + creator_fee, + protocol_fee, + total_amount, + }) } #[contract] @@ -700,1296 +744,1385 @@ pub struct CreatorKeysContract; #[contractimpl] impl CreatorKeysContract { - /// Registers a new creator profile. This is a contract initialization - /// entrypoint; the contract has no single `initialize` call, so the - /// init-time parameter validation lives on the individual setters. - /// - /// Parameter validation: - /// - `creator`: must authorize the call (`require_auth`). A profile must not - /// already exist for this address, otherwise - /// [`ContractError::AlreadyRegistered`]. - /// - `handle`: validated by [`validate_creator_handle`] — below the minimum - /// length returns [`ContractError::HandleTooShort`], above the maximum - /// returns [`ContractError::HandleTooLong`], and any disallowed byte - /// returns [`ContractError::InvalidHandleCharacter`]. - pub fn register_creator( - env: Env, - creator: Address, - handle: String, - ) -> Result<(), ContractError> { - creator.require_auth(); - assert_not_paused(&env)?; - - validate_creator_handle(&handle)?; - - let key = constants::storage::creator(&creator); - // Creator profile storage is a single source of truth keyed by creator address. - // Once written, this key's existence is the registration invariant. - if env.storage().persistent().has(&key) { - return Err(ContractError::AlreadyRegistered); - } - - let profile = CreatorProfile { - creator: creator.clone(), - handle, - supply: 0, - holder_count: 0, - fee_recipient: creator.clone(), - registered_at: env.ledger().sequence(), - }; - - let fee_config = read_protocol_fee_config(&env).unwrap_or(fee::FeeConfig { - creator_bps: 0, - protocol_bps: 0, - }); - - // Persist profile before event publication so indexers reading contract state - // after this tx observe the same registration payload that was emitted. - env.storage().persistent().set(&key, &profile); - env.events().publish( - events::register_event_topics(&profile.creator), - events::CreatorRegisteredEvent { - creator: profile.creator.clone(), - handle: profile.handle.clone(), - supply: profile.supply, - holder_count: profile.holder_count, - creator_bps: fee_config.creator_bps, - protocol_bps: fee_config.protocol_bps, - }, - ); - - Ok(()) - } - - pub fn buy_key( - env: Env, - creator: Address, - buyer: Address, - payment: i128, - max_price: Option, - ) -> Result { - buyer.require_auth(); - assert_not_paused(&env)?; - - if payment <= 0 { - return Err(ContractError::NotPositiveAmount); - } - - let price: i128 = env - .storage() - .persistent() - .get(&constants::storage::KEY_PRICE) - .ok_or(ContractError::KeyPriceNotSet)?; - - assert_buy_price_slippage(price, max_price)?; - - if payment < price { - return Err(ContractError::InsufficientPayment); - } - - let mut profile: CreatorProfile = read_registered_creator_profile(&env, &creator)?; - - let balance_key = constants::storage::key_balance(&creator, &buyer); - // Missing balance entries are treated as zero to keep storage sparse. - let current_balance: u32 = env.storage().persistent().get(&balance_key).unwrap_or(0); - - if current_balance == 0 { - profile.holder_count = profile - .holder_count - .checked_add(1) - .ok_or(ContractError::Overflow)?; - } - let mut holders = read_holders_list(&env, &creator); - if !holders.iter().any(|h| h == &buyer) { - holders.push_back(buyer.clone()); - write_holders_list(&env, &creator, &holders); - } - - profile.supply = profile - .supply - .checked_add(1) - .ok_or(ContractError::Overflow)?; - - let key = constants::storage::creator(&creator); - // Supply and holder_count must always move together with buyer balance writes. - env.storage().persistent().set(&key, &profile); - - let new_balance = current_balance - .checked_add(1) - .ok_or(ContractError::Overflow)?; - // Balance key is scoped by (creator, holder) so creator positions cannot collide. - env.storage().persistent().set(&balance_key, &new_balance); - - if let Some(config) = read_protocol_fee_config(&env) { - let (creator_fee, protocol_fee) = - fee::checked_compute_fee_split(price, config.creator_bps, config.protocol_bps) - .ok_or(ContractError::Overflow)?; - credit_creator_fee_recipient_balance(&env, &creator, creator_fee)?; - credit_protocol_fee_recipient_balance(&env, protocol_fee)?; - } - - env.events().publish( - events::buy_event_topics(&creator, &buyer), - (profile.supply, payment), - ); - - Ok(profile.supply) - } - - pub fn sell_key( - env: Env, - creator: Address, - seller: Address, - min_proceeds: Option, - ) -> Result { - seller.require_auth(); - assert_not_paused(&env)?; - - let mut profile: CreatorProfile = read_registered_creator_profile(&env, &creator)?; - - let balance_key = constants::storage::key_balance(&creator, &seller); - // Missing balance entries are interpreted as zero and rejected consistently. - let current_balance: u32 = env.storage().persistent().get(&balance_key).unwrap_or(0); - if current_balance == 0 { - return Err(ContractError::InsufficientBalance); - } - - assert_sell_proceeds_slippage(&env, min_proceeds)?; - - let new_balance = current_balance - .checked_sub(1) - .ok_or(ContractError::SellUnderflow)?; - profile.supply = profile - .supply - .checked_sub(1) - .ok_or(ContractError::SellUnderflow)?; - - if new_balance == 0 { - profile.holder_count = profile - .holder_count - .checked_sub(1) - .ok_or(ContractError::SellUnderflow)?; - let mut holders = read_holders_list(&env, &creator); - if let Some(pos) = holders.iter().position(|h| h == &seller) { - holders.remove(pos); - write_holders_list(&env, &creator, &holders); - } - } - - let key = constants::storage::creator(&creator); - // Profile and holder balance are updated in the same call to preserve - // supply/holder_count invariants for subsequent reads. - env.storage().persistent().set(&key, &profile); - env.storage().persistent().set(&balance_key, &new_balance); - accrue_sell_protocol_fee(&env)?; - - env.events() - .publish((events::SELL_EVENT_NAME, creator, seller), profile.supply); - - Ok(profile.supply) - } - - /// Halts all state-changing operations (buy, sell, register_creator). - /// - /// Only the protocol admin may call this. Emits a `ProtocolPaused` event. - /// Read-only view functions are unaffected and continue to work while paused. - pub fn pause(env: Env, admin: Address) -> Result<(), ContractError> { - admin.require_auth(); - assert_is_admin(&env, &admin)?; - env.storage() - .persistent() - .set(&constants::storage::PAUSED, &true); - env.events().publish((events::PAUSE_EVENT_NAME, admin), ()); - Ok(()) - } - - /// Resumes all state-changing operations after an emergency pause. - /// - /// Only the protocol admin may call this. Emits a `ProtocolUnpaused` event. - pub fn unpause(env: Env, admin: Address) -> Result<(), ContractError> { - admin.require_auth(); - assert_is_admin(&env, &admin)?; - env.storage() - .persistent() - .set(&constants::storage::PAUSED, &false); - env.events() - .publish((events::UNPAUSE_EVENT_NAME, admin), ()); - Ok(()) - } - - /// Read-only view: returns whether the protocol is currently paused. - pub fn get_is_paused(env: Env) -> bool { - is_paused(&env) - } - - pub fn get_key_balance(env: Env, creator: Address, wallet: Address) -> u32 { - let key = constants::storage::key_balance(&creator, &wallet); - // Read-only callers get `0` for unseen balances to avoid sparse-map lookups failing. - env.storage().persistent().get(&key).unwrap_or(0) - } - - /// Read-only view: returns a stable view of a holder's key count for a creator. - /// - /// Returns a [`HolderKeyCountView`] regardless of creator registration status. - /// When the creator is not registered, `creator_exists` is `false` and `key_count` is `0`. - /// When the creator exists but the holder has no keys, `key_count` is `0`. - /// This method is designed for indexer-friendly consumption and avoids panics. - pub fn get_holder_key_count(env: Env, creator: Address, holder: Address) -> HolderKeyCountView { - let creator_exists = read_creator_profile(&env, &creator).is_some(); - let key_count = if creator_exists { - let key = constants::storage::key_balance(&creator, &holder); - env.storage().persistent().get(&key).unwrap_or(0) - } else { - 0 - }; - - HolderKeyCountView { - creator, - holder, - key_count, - creator_exists, - } - } - - pub fn get_creator(env: Env, creator: Address) -> Result { - read_registered_creator_profile(&env, &creator) - } - - /// Read-only view: returns stable creator details. - /// - /// Returns a [`CreatorDetailsView`] regardless of registration status. - /// When the creator is not registered, `is_registered` is `false` and - /// default values are provided for other fields, including `registered_at: 0`. - pub fn get_creator_details(env: Env, creator: Address) -> CreatorDetailsView { - let key = constants::storage::creator(&creator); - match env - .storage() - .persistent() - .get::(&key) - { - Some(profile) => CreatorDetailsView { - creator: profile.creator, - handle: profile.handle, - supply: profile.supply, - is_registered: true, - registered_at: profile.registered_at, - }, - None => CreatorDetailsView { - creator, - handle: read_none_string(&env), - supply: 0, - is_registered: false, - registered_at: 0, - }, - } - } - - pub fn distribute_dividend( - env: Env, - creator: Address, - amount: i128, - ) -> Result<(), ContractError> { - let caller = env.invoker(); - - // Transfer XLM from caller to contract - let native = env.native_token(); - native.transfer(&caller, &env.current_contract_address(), &amount); - - // Load creator profile and validate - let profile = read_registered_creator_profile(&env, &creator)?; - let total_supply = profile.supply as i128; - if total_supply == 0 { - return Err(ContractError::NoHolders); - } - - // Load fee config and compute protocol fee - let config = read_required_protocol_fee_config(&env)?; - let protocol_fee = fee::apply_percentage_fee(amount, config.protocol_bps) - .ok_or(ContractError::Overflow)?; - let distribute_amount = amount - .checked_sub(protocol_fee) - .ok_or(ContractError::Overflow)?; - if distribute_amount <= 0 { - return Err(ContractError::DividendAmountZero); - } - - // Credit protocol fee to the protocol fee recipient balance - credit_protocol_fee_recipient_balance(&env, protocol_fee)?; - - // Iterate over all holders and distribute proportionally - let holders = read_holders_list(&env, &creator); - let ledger = env.ledger().sequence(); - - for holder in holders.iter() { - let balance = - Self::get_key_balance(env.clone(), creator.clone(), holder.clone()) as i128; - if balance == 0 { - continue; - } - let share = (balance * distribute_amount) / total_supply; - if share > 0 { - let current = read_claimable_dividend(&env, &creator, &holder); - let new_claim = current.checked_add(share).ok_or(ContractError::Overflow)?; - write_claimable_dividend(&env, &creator, &holder, new_claim); - } - } - - // Emit event - env.events().publish( - events::dividend_distributed_topics(&creator), - events::DividendDistributedEvent { - creator_id: creator, - total_amount: amount, - snapshot_supply: total_supply, - ledger, - protocol_fee, - distributed_amount: distribute_amount, - }, - ); - - Ok(()) - } - - /// Claims accrued dividends for the caller. - /// - /// Transfers the claimable amount from the contract to the caller's wallet. - /// Resets the claimable balance to zero. - /// Emits a `DividendClaimed` event. - pub fn claim_dividend(env: Env, creator: Address) -> Result<(), ContractError> { - let caller = env.invoker(); - - let claimable = read_claimable_dividend(&env, &creator, &caller); - if claimable == 0 { - return Err(ContractError::ZeroClaimable); - } - - // Reset claimable to zero before transfer (reentrancy-safe) - write_claimable_dividend(&env, &creator, &caller, 0); - - // Transfer XLM from contract to caller - let native = env.native_token(); - native.transfer(&env.current_contract_address(), &caller, &claimable); - - // Emit event - env.events().publish( - events::dividend_claimed_topics(&creator, &caller), - events::DividendClaimedEvent { - creator_id: creator, - claimant: caller, - amount: claimable, - }, - ); - - Ok(()) - } - - /// Returns the unclaimed dividend balance for a specific (creator, holder) pair. - pub fn get_claimable_dividend(env: Env, creator: Address, wallet: Address) -> i128 { - read_claimable_dividend(&env, &creator, &wallet) - } - - /// Read-only batch view: returns [`CreatorDetailsView`] for each address in `creators`. - /// - /// Iterates the provided addresses in order and fetches each creator's profile - /// from persistent storage. The output `Vec` is the same length as the input and - /// preserves input order, so clients can zip the two slices without an extra sort. - /// - /// Unregistered addresses never cause the call to fail: they produce a default - /// [`CreatorDetailsView`] with `is_registered: false` and `registered_at: 0`, - /// matching the single-address behaviour of [`get_creator_details`]. - /// - /// # Usage - /// - /// ```text - /// let views = client.get_creators_batch(&vec![alice, bob, unknown]); - /// // views[0] → alice's details (is_registered: true) - /// // views[1] → bob's details (is_registered: true) - /// // views[2] → default view (is_registered: false, registered_at: 0) - /// ``` - pub fn get_creators_batch( - env: Env, - creators: soroban_sdk::Vec
, - ) -> soroban_sdk::Vec { - let mut results = soroban_sdk::Vec::new(&env); - for creator in creators.iter() { - let key = constants::storage::creator(&creator); - let view = match env - .storage() - .persistent() - .get::(&key) - { - Some(profile) => CreatorDetailsView { - creator: profile.creator, - handle: profile.handle, - supply: profile.supply, - is_registered: true, - registered_at: profile.registered_at, - }, - None => CreatorDetailsView { - creator, - handle: read_none_string(&env), - supply: 0, - is_registered: false, - registered_at: 0, - }, - }; - results.push_back(view); - } - results - } - /// Read-only view: returns the protocol state version. - /// - /// Returns a stable scalar value for clients and indexers to detect - /// protocol-state schema/semantics revisions. The version is stored in - /// storage and increments on config updates. - pub fn get_protocol_state_version(env: Env) -> u32 { - env.storage() - .persistent() - .get(&constants::storage::PROTOCOL_STATE_VERSION) - .unwrap_or(PROTOCOL_STATE_VERSION_INITIAL) - } + /// Registers a new creator profile. This is a contract initialization + /// entrypoint; the contract has no single `initialize` call, so the + /// init-time parameter validation lives on the individual setters. + /// + /// Parameter validation: + /// - `creator`: must authorize the call (`require_auth`). A profile must not + /// already exist for this address, otherwise + /// [`ContractError::AlreadyRegistered`]. + /// - `handle`: validated by [`validate_creator_handle`] — below the minimum + /// length returns [`ContractError::HandleTooShort`], above the maximum + /// returns [`ContractError::HandleTooLong`], and any disallowed byte + /// returns [`ContractError::InvalidHandleCharacter`]. + pub fn register_creator( + env: Env, + creator: Address, + handle: String, + ) -> Result<(), ContractError> { + creator.require_auth(); + assert_not_paused(&env)?; + + validate_creator_handle(&handle)?; + + let key = constants::storage::creator(&creator); + // Creator profile storage is a single source of truth keyed by creator address. + // Once written, this key's existence is the registration invariant. + if env.storage().persistent().has(&key) { + return Err(ContractError::AlreadyRegistered); + } + + let profile = CreatorProfile { + creator: creator.clone(), + handle, + supply: 0, + holder_count: 0, + fee_recipient: creator.clone(), + registered_at: env.ledger().sequence(), + }; - /// Read-only view: returns the decimal precision used by creator key values. - /// - /// Returns the fixed [`KEY_DECIMALS`] constant. Does not read or mutate contract state. - pub fn get_key_decimals(_env: Env) -> u32 { - KEY_DECIMALS - } + let fee_config = read_protocol_fee_config(&env).unwrap_or(fee::FeeConfig { + creator_bps: 0, + protocol_bps: 0, + }); + + // Persist profile before event publication so indexers reading contract state + // after this tx observe the same registration payload that was emitted. + env.storage().persistent().set(&key, &profile); + env.events().publish( + events::register_event_topics(&profile.creator), + events::CreatorRegisteredEvent { + creator: profile.creator.clone(), + handle: profile.handle.clone(), + supply: profile.supply, + holder_count: profile.holder_count, + creator_bps: fee_config.creator_bps, + protocol_bps: fee_config.protocol_bps, + }, + ); - /// Read-only view: returns the display name for a creator's key. - /// - /// Returns the creator's handle for registered creators. Fails with - /// [`ContractError::NotRegistered`] if the creator is not registered. - pub fn get_key_name(env: Env, creator: Address) -> Result { - let profile = read_registered_creator_profile(&env, &creator)?; - Ok(profile.handle) - } + Ok(()) + } - /// Read-only view: returns the ticker symbol for a creator's key. - /// - /// Returns the creator's handle for registered creators. Fails with - /// [`ContractError::NotRegistered`] if the creator is not registered. - pub fn get_key_symbol(env: Env, creator: Address) -> Result { - let profile = read_registered_creator_profile(&env, &creator)?; - Ok(profile.handle) - } + pub fn buy_key( + env: Env, + creator: Address, + buyer: Address, + payment: i128, + max_price: Option, + ) -> Result { + buyer.require_auth(); + assert_not_paused(&env)?; - /// Read-only view: returns the total key supply for a creator. - /// - /// Returns `0` if the creator is not registered, avoiding panics for - /// invalid lookups. Delegates to the shared [`read_key_balance`] helper. - pub fn get_total_key_supply(env: Env, creator: Address) -> u32 { - read_key_balance(&env, &creator) + if payment <= 0 { + return Err(ContractError::NotPositiveAmount); } - /// Read-only view: returns the current supply for a registered creator. - /// - /// Fails with [`ContractError::NotRegistered`] if the creator does not exist. - pub fn get_creator_supply(env: Env, creator: Address) -> Result { - let profile = read_registered_creator_profile(&env, &creator)?; - Ok(profile.supply) - } + let price: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; - /// Read-only view: returns the number of unique holders for a creator. - /// - /// Returns `0` if the creator is not registered, avoiding panics for - /// invalid lookups. Uses the stored creator profile holder count. - pub fn get_creator_holder_count(env: Env, creator: Address) -> u32 { - read_creator_profile(&env, &creator) - .map(|profile| profile.holder_count) - .unwrap_or(0) - } + assert_buy_price_slippage(price, max_price)?; - /// Read-only view: returns whether a creator is registered in the contract. - /// - /// Returns `true` if a [`CreatorProfile`] exists for the given address, - /// `false` otherwise. Does not mutate state. - pub fn is_creator_registered(env: Env, creator: Address) -> bool { - read_creator_profile(&env, &creator).is_some() + if payment < price { + return Err(ContractError::InsufficientPayment); } - /// Read-only view: returns the creator fee recipient address. - /// - /// Fails with [`ContractError::NotRegistered`] if the creator is not registered. - /// Reuses current creator storage access patterns. - pub fn get_creator_fee_recipient(env: Env, creator: Address) -> Result { - let profile = read_registered_creator_profile(&env, &creator)?; - Ok(profile.fee_recipient) - } + let mut profile: CreatorProfile = + read_registered_creator_profile(&env, &creator)?; - /// Read-only view: returns accrued creator fee balance for the creator's fee recipient. - /// - /// Fails with [`ContractError::NotRegistered`] if the creator is not registered. - /// Returns `0` when no buy has accrued fees yet. - pub fn get_creator_fee_balance(env: Env, creator: Address) -> Result { - read_registered_creator_profile(&env, &creator)?; - Ok(read_creator_fee_recipient_balance(&env, &creator)) - } + let balance_key = constants::storage::key_balance(&creator, &buyer); + // Missing balance entries are treated as zero to keep storage sparse. + let current_balance: u32 = + env.storage().persistent().get(&balance_key).unwrap_or(0); - /// Read-only view: returns the configured creator fee rate in basis points. - /// - /// The returned value is the creator-facing share stored in the current protocol - /// fee configuration, scoped to a registered creator lookup. - pub fn get_creator_fee_bps(env: Env, creator: Address) -> Result { - let _profile = read_registered_creator_profile(&env, &creator)?; - let config = read_required_protocol_fee_config(&env)?; - Ok(config.creator_bps) + if current_balance == 0 { + profile.holder_count = profile + .holder_count + .checked_add(1) + .ok_or(ContractError::Overflow)?; } - - /// Read-only view: returns the creator treasury share for a registered creator. - /// - /// Access Layer currently stores creator treasury share as the creator-facing - /// basis-point share in protocol fee configuration. This method provides a - /// creator-scoped accessor without mutating state. - pub fn get_creator_treasury_share(env: Env, creator: Address) -> Result { - Self::get_creator_fee_bps(env, creator) + let mut holders = read_holders_list(&env, &creator); + if !holders.iter().any(|h| h == buyer) { + holders.push_back(buyer.clone()); + write_holders_list(&env, &creator, &holders); } - /// Read-only view: returns the configured protocol treasury share in basis points. - /// - /// This value is sourced from the current protocol fee configuration and is - /// expressed in stable basis-point units. - pub fn get_protocol_treasury_share_bps(env: Env) -> Result { - let config = read_required_protocol_fee_config(&env)?; - Ok(config.protocol_bps) - } + profile.supply = profile + .supply + .checked_add(1) + .ok_or(ContractError::Overflow)?; - /// Sets the global protocol/creator fee split. Contract initialization - /// entrypoint. - /// - /// Parameter validation (via [`fee::assert_valid_fee_bps`]): - /// - `admin`: must authorize the call (`require_auth`). - /// - `creator_bps` + `protocol_bps`: must sum to exactly `BPS_MAX` (10_000), - /// otherwise [`ContractError::InvalidFeeConfig`]. - /// - `protocol_bps`: must not exceed `PROTOCOL_BPS_MAX`, otherwise - /// [`ContractError::ProtocolFeeExceedsCap`]. - pub fn set_fee_config( - env: Env, - admin: Address, - creator_bps: u32, - protocol_bps: u32, - ) -> Result<(), ContractError> { - admin.require_auth(); - fee::assert_valid_fee_bps(creator_bps, protocol_bps)?; - - let config = fee::FeeConfig { - creator_bps, - protocol_bps, - }; - if env - .storage() - .persistent() - .get::(&constants::storage::FEE_CONFIG) - .as_ref() - == Some(&config) - { - return Ok(()); - } - env.storage() - .persistent() - .set(&constants::storage::FEE_CONFIG, &config); - - // Increment protocol state version on config update - let current_version = env - .storage() - .persistent() - .get(&constants::storage::PROTOCOL_STATE_VERSION) - .unwrap_or(PROTOCOL_STATE_VERSION_INITIAL); - let new_version = current_version - .checked_add(1) - .ok_or(ContractError::Overflow)?; - env.storage() - .persistent() - .set(&constants::storage::PROTOCOL_STATE_VERSION, &new_version); - - Ok(()) - } + let key = constants::storage::creator(&creator); + // Supply and holder_count must always move together with buyer balance writes. + env.storage().persistent().set(&key, &profile); - /// Sets the per-key price. Contract initialization entrypoint. - /// - /// Parameter validation: - /// - `admin`: must authorize the call (`require_auth`). - /// - `price`: must be strictly positive; zero or negative returns - /// [`ContractError::NotPositiveAmount`]. - pub fn set_key_price(env: Env, admin: Address, price: i128) -> Result<(), ContractError> { - admin.require_auth(); - if price <= 0 { - return Err(ContractError::NotPositiveAmount); - } - if env - .storage() - .persistent() - .get::(&constants::storage::KEY_PRICE) - .as_ref() - == Some(&price) - { - return Ok(()); - } - env.storage() - .persistent() - .set(&constants::storage::KEY_PRICE, &price); - Ok(()) - } + let new_balance = current_balance + .checked_add(1) + .ok_or(ContractError::Overflow)?; + // Balance key is scoped by (creator, holder) so creator positions cannot collide. + env.storage().persistent().set(&balance_key, &new_balance); - pub fn get_fee_config(env: Env) -> Option { - read_protocol_fee_config(&env) + if let Some(config) = read_protocol_fee_config(&env) { + let (creator_fee, protocol_fee) = fee::checked_compute_fee_split( + price, + config.creator_bps, + config.protocol_bps, + ) + .ok_or(ContractError::Overflow)?; + credit_creator_fee_recipient_balance(&env, &creator, creator_fee)?; + credit_protocol_fee_recipient_balance(&env, protocol_fee)?; } - /// Sets the protocol treasury address. - /// - /// Only callable by an authorized admin. Stores the treasury address used - /// for protocol fee routing. - pub fn set_treasury_address(env: Env, admin: Address, treasury: Address) { - admin.require_auth(); - if env - .storage() - .persistent() - .get::(&constants::storage::TREASURY_ADDRESS) - .as_ref() - == Some(&treasury) - { - return; - } - env.storage() - .persistent() - .set(&constants::storage::TREASURY_ADDRESS, &treasury); - } + env.events().publish( + events::buy_event_topics(&creator, &buyer), + (profile.supply, payment), + ); - /// Read-only view: returns the current protocol treasury address. - /// - /// Returns `None` if no treasury address has been configured. - /// Use this method for indexers and read-only callers that need the current - /// treasury routing target. - pub fn get_treasury_address(env: Env) -> Option
{ - env.storage() - .persistent() - .get(&constants::storage::TREASURY_ADDRESS) - } + Ok(profile.supply) + } - /// Sets the protocol admin address. - /// - /// Only callable by an authorized admin. Stores the admin address used - /// for protocol administration. - pub fn set_protocol_admin(env: Env, admin: Address, new_admin: Address) { - admin.require_auth(); - if env - .storage() - .persistent() - .get::(&constants::storage::ADMIN_ADDRESS) - .as_ref() - == Some(&new_admin) - { - return; - } - env.storage() - .persistent() - .set(&constants::storage::ADMIN_ADDRESS, &new_admin); - } + pub fn sell_key( + env: Env, + creator: Address, + seller: Address, + min_proceeds: Option, + ) -> Result { + seller.require_auth(); + assert_not_paused(&env)?; + + let mut profile: CreatorProfile = + read_registered_creator_profile(&env, &creator)?; + + let balance_key = constants::storage::key_balance(&creator, &seller); + // Missing balance entries are interpreted as zero and rejected consistently. + let current_balance: u32 = + env.storage().persistent().get(&balance_key).unwrap_or(0); + if current_balance == 0 { + return Err(ContractError::InsufficientBalance); + } + + assert_sell_proceeds_slippage(&env, min_proceeds)?; + + let new_balance = current_balance + .checked_sub(1) + .ok_or(ContractError::SellUnderflow)?; + profile.supply = profile + .supply + .checked_sub(1) + .ok_or(ContractError::SellUnderflow)?; + + if new_balance == 0 { + profile.holder_count = profile + .holder_count + .checked_sub(1) + .ok_or(ContractError::SellUnderflow)?; + let mut holders = read_holders_list(&env, &creator); + if let Some(pos) = holders.iter().position(|h| h == seller) { + holders.remove(pos as u32); + write_holders_list(&env, &creator, &holders); + } + } + + let key = constants::storage::creator(&creator); + // Profile and holder balance are updated in the same call to preserve + // supply/holder_count invariants for subsequent reads. + env.storage().persistent().set(&key, &profile); + env.storage().persistent().set(&balance_key, &new_balance); + accrue_sell_protocol_fee(&env)?; + + env + .events() + .publish((events::SELL_EVENT_NAME, creator, seller), profile.supply); + + Ok(profile.supply) + } + + /// Halts all state-changing operations (buy, sell, register_creator). + /// + /// Only the protocol admin may call this. Emits a `ProtocolPaused` event. + /// Read-only view functions are unaffected and continue to work while paused. + pub fn pause(env: Env, admin: Address) -> Result<(), ContractError> { + admin.require_auth(); + assert_is_admin(&env, &admin)?; + env + .storage() + .persistent() + .set(&constants::storage::PAUSED, &true); + env.events().publish((events::PAUSE_EVENT_NAME, admin), ()); + Ok(()) + } + + /// Resumes all state-changing operations after an emergency pause. + /// + /// Only the protocol admin may call this. Emits a `ProtocolUnpaused` event. + pub fn unpause(env: Env, admin: Address) -> Result<(), ContractError> { + admin.require_auth(); + assert_is_admin(&env, &admin)?; + env + .storage() + .persistent() + .set(&constants::storage::PAUSED, &false); + env + .events() + .publish((events::UNPAUSE_EVENT_NAME, admin), ()); + Ok(()) + } - /// Read-only view: returns the current protocol admin address. - /// - /// Returns `None` if no admin address has been configured. - /// Use this method for indexers and read-only callers that need the current - /// protocol admin address. - pub fn get_protocol_admin(env: Env) -> Option
{ - env.storage() - .persistent() - .get(&constants::storage::ADMIN_ADDRESS) - } + /// Read-only view: returns whether the protocol is currently paused. + pub fn get_is_paused(env: Env) -> bool { + is_paused(&env) + } - /// Read-only view: returns the current protocol fee recipient address. - /// - /// Returns `None` if no protocol fee recipient address has been configured. - /// Use this method for indexers and read-only callers that need the current - /// protocol fee recipient address. - pub fn get_protocol_fee_recipient(env: Env) -> Option
{ - env.storage() - .persistent() - .get(&constants::storage::PROTOCOL_FEE_RECIPIENT) - } + pub fn get_key_balance(env: Env, creator: Address, wallet: Address) -> u32 { + let key = constants::storage::key_balance(&creator, &wallet); + // Read-only callers get `0` for unseen balances to avoid sparse-map lookups failing. + env.storage().persistent().get(&key).unwrap_or(0) + } + + /// Read-only view: returns a stable view of a holder's key count for a creator. + /// + /// Returns a [`HolderKeyCountView`] regardless of creator registration status. + /// When the creator is not registered, `creator_exists` is `false` and `key_count` is `0`. + /// When the creator exists but the holder has no keys, `key_count` is `0`. + /// This method is designed for indexer-friendly consumption and avoids panics. + pub fn get_holder_key_count( + env: Env, + creator: Address, + holder: Address, + ) -> HolderKeyCountView { + let creator_exists = read_creator_profile(&env, &creator).is_some(); + let key_count = if creator_exists { + let key = constants::storage::key_balance(&creator, &holder); + env.storage().persistent().get(&key).unwrap_or(0) + } else { + 0 + }; - /// Read-only view: returns the accrued protocol fee balance for the configured recipient. - /// - /// Returns `0` when no protocol fees have been accrued from sell execution. - pub fn get_protocol_recipient_balance(env: Env) -> i128 { - read_protocol_fee_recipient_balance(&env) - } + HolderKeyCountView { + creator, + holder, + key_count, + creator_exists, + } + } + + pub fn get_creator( + env: Env, + creator: Address, + ) -> Result { + read_registered_creator_profile(&env, &creator) + } + + /// Read-only view: returns stable creator details. + /// + /// Returns a [`CreatorDetailsView`] regardless of registration status. + /// When the creator is not registered, `is_registered` is `false` and + /// default values are provided for other fields, including `registered_at: 0`. + pub fn get_creator_details(env: Env, creator: Address) -> CreatorDetailsView { + let key = constants::storage::creator(&creator); + match env + .storage() + .persistent() + .get::(&key) + { + Some(profile) => CreatorDetailsView { + creator: profile.creator, + handle: profile.handle, + supply: profile.supply, + is_registered: true, + registered_at: profile.registered_at, + }, + None => CreatorDetailsView { + creator, + handle: read_none_string(&env), + supply: 0, + is_registered: false, + registered_at: 0, + }, + } + } + + pub fn distribute_dividend( + env: Env, + creator: Address, + amount: i128, + ) -> Result<(), ContractError> { + let caller = env.invoker(); + let native_address = env.ledger().get_native_token(); + let native_token = + soroban_sdk::token::TokenClient::new(&env, &native_address); + native_token.transfer(&caller, &env.current_contract_address(), &amount); + + // Load creator profile and validate + let profile = read_registered_creator_profile(&env, &creator)?; + let total_supply = profile.supply as i128; + if total_supply == 0 { + return Err(ContractError::NoHolders); + } + + // Load fee config and compute protocol fee + let config = read_required_protocol_fee_config(&env)?; + let protocol_fee = fee::apply_percentage_fee(amount, config.protocol_bps) + .ok_or(ContractError::Overflow)?; + let distribute_amount = amount + .checked_sub(protocol_fee) + .ok_or(ContractError::Overflow)?; + if distribute_amount <= 0 { + return Err(ContractError::DividendAmountZero); + } + + // Credit protocol fee to the protocol fee recipient balance + credit_protocol_fee_recipient_balance(&env, protocol_fee)?; + + // Iterate over all holders and distribute proportionally + let holders = read_holders_list(&env, &creator); + let ledger = env.ledger().sequence(); + + for holder in holders.iter() { + let balance = + Self::get_key_balance(env.clone(), creator.clone(), holder.clone()) + as i128; + if balance == 0 { + continue; + } + let share = (balance * distribute_amount) / total_supply; + if share > 0 { + let current = read_claimable_dividend(&env, &creator, &holder); + let new_claim = + current.checked_add(share).ok_or(ContractError::Overflow)?; + write_claimable_dividend(&env, &creator, &holder, new_claim); + } + } + + // Emit event + env.events().publish( + events::dividend_distributed_topics(&creator), + events::DividendDistributedEvent { + creator_id: creator, + total_amount: amount, + snapshot_supply: total_supply, + ledger, + protocol_fee, + distributed_amount: distribute_amount, + }, + ); - /// Sets the protocol fee recipient address. - /// - /// Only callable by an authorized admin. Rejects the Stellar zero address - /// to prevent silent fee burning. - /// - /// Parameter validation: - /// - `admin`: must authorize the call (`require_auth`). - /// - `recipient`: must not be the Stellar zero address, otherwise - /// [`ContractError::ZeroAddress`]. - pub fn set_protocol_fee_recipient( - env: Env, - admin: Address, - recipient: Address, - ) -> Result<(), ContractError> { - admin.require_auth(); - validate_non_zero_address(&env, &recipient)?; - - if env - .storage() - .persistent() - .get::(&constants::storage::PROTOCOL_FEE_RECIPIENT) - .as_ref() - == Some(&recipient) - { - return Ok(()); - } - env.storage() - .persistent() - .set(&constants::storage::PROTOCOL_FEE_RECIPIENT, &recipient); - Ok(()) - } + Ok(()) + } + + /// Claims accrued dividends for the caller. + /// + /// Transfers the claimable amount from the contract to the caller's wallet. + /// Resets the claimable balance to zero. + /// Emits a `DividendClaimed` event. + pub fn claim_dividend( + env: Env, + creator: Address, + ) -> Result<(), ContractError> { + let caller = env.invoker(); + + // 1. Read claimable balance + let claimable = read_claimable_dividend(&env, &creator, &caller); + if claimable == 0 { + return Err(ContractError::ZeroClaimable); + } + + // 2. Reset claimable to zero before transfer (reentrancy-safe) + write_claimable_dividend(&env, &creator, &caller, 0); + + // 3. Transfer XLM from contract to caller + let native_address = env.ledger().get_native_token(); + let native_token = + soroban_sdk::token::TokenClient::new(&env, &native_address); + native_token.transfer(&env.current_contract_address(), &caller, &claimable); + + // 4. Emit event + env.events().publish( + events::dividend_claimed_topics(&creator, &caller), + events::DividendClaimedEvent { + creator_id: creator, + claimant: caller, + amount: claimable, + }, + ); - /// Read-only view: returns whether protocol configuration has been initialized. - /// - /// Returns `true` once a protocol fee configuration has been stored and `false` - /// otherwise. Does not mutate contract state. - pub fn is_protocol_config_initialized(env: Env) -> bool { - read_protocol_fee_config(&env).is_some() - } + Ok(()) + } + /// Returns the unclaimed dividend balance for a specific (creator, holder) pair. + pub fn get_claimable_dividend( + env: Env, + creator: Address, + wallet: Address, + ) -> i128 { + read_claimable_dividend(&env, &creator, &wallet) + } + + /// Read-only batch view: returns [`CreatorDetailsView`] for each address in `creators`. + /// + /// Iterates the provided addresses in order and fetches each creator's profile + /// from persistent storage. The output `Vec` is the same length as the input and + /// preserves input order, so clients can zip the two slices without an extra sort. + /// + /// Unregistered addresses never cause the call to fail: they produce a default + /// [`CreatorDetailsView`] with `is_registered: false` and `registered_at: 0`, + /// matching the single-address behaviour of [`get_creator_details`]. + /// + /// # Usage + /// + /// ```text + /// let views = client.get_creators_batch(&vec![alice, bob, unknown]); + /// // views[0] → alice's details (is_registered: true) + /// // views[1] → bob's details (is_registered: true) + /// // views[2] → default view (is_registered: false, registered_at: 0) + /// ``` + pub fn get_creators_batch( + env: Env, + creators: soroban_sdk::Vec
, + ) -> soroban_sdk::Vec { + let mut results = soroban_sdk::Vec::new(&env); + for creator in creators.iter() { + let key = constants::storage::creator(&creator); + let view = match env + .storage() + .persistent() + .get::(&key) + { + Some(profile) => CreatorDetailsView { + creator: profile.creator, + handle: profile.handle, + supply: profile.supply, + is_registered: true, + registered_at: profile.registered_at, + }, + None => CreatorDetailsView { + creator, + handle: read_none_string(&env), + supply: 0, + is_registered: false, + registered_at: 0, + }, + }; + results.push_back(view); + } + results + } + /// Read-only view: returns the protocol state version. + /// + /// Returns a stable scalar value for clients and indexers to detect + /// protocol-state schema/semantics revisions. The version is stored in + /// storage and increments on config updates. + pub fn get_protocol_state_version(env: Env) -> u32 { + env + .storage() + .persistent() + .get(&constants::storage::PROTOCOL_STATE_VERSION) + .unwrap_or(PROTOCOL_STATE_VERSION_INITIAL) + } + + /// Read-only view: returns the decimal precision used by creator key values. + /// + /// Returns the fixed [`KEY_DECIMALS`] constant. Does not read or mutate contract state. + pub fn get_key_decimals(_env: Env) -> u32 { + KEY_DECIMALS + } + + /// Read-only view: returns the display name for a creator's key. + /// + /// Returns the creator's handle for registered creators. Fails with + /// [`ContractError::NotRegistered`] if the creator is not registered. + pub fn get_key_name( + env: Env, + creator: Address, + ) -> Result { + let profile = read_registered_creator_profile(&env, &creator)?; + Ok(profile.handle) + } + + /// Read-only view: returns the ticker symbol for a creator's key. + /// + /// Returns the creator's handle for registered creators. Fails with + /// [`ContractError::NotRegistered`] if the creator is not registered. + pub fn get_key_symbol( + env: Env, + creator: Address, + ) -> Result { + let profile = read_registered_creator_profile(&env, &creator)?; + Ok(profile.handle) + } + + /// Read-only view: returns the total key supply for a creator. + /// + /// Returns `0` if the creator is not registered, avoiding panics for + /// invalid lookups. Delegates to the shared [`read_key_balance`] helper. + pub fn get_total_key_supply(env: Env, creator: Address) -> u32 { + read_key_balance(&env, &creator) + } + + /// Read-only view: returns the current supply for a registered creator. + /// + /// Fails with [`ContractError::NotRegistered`] if the creator does not exist. + pub fn get_creator_supply( + env: Env, + creator: Address, + ) -> Result { + let profile = read_registered_creator_profile(&env, &creator)?; + Ok(profile.supply) + } + + /// Read-only view: returns the number of unique holders for a creator. + /// + /// Returns `0` if the creator is not registered, avoiding panics for + /// invalid lookups. Uses the stored creator profile holder count. + pub fn get_creator_holder_count(env: Env, creator: Address) -> u32 { + read_creator_profile(&env, &creator) + .map(|profile| profile.holder_count) + .unwrap_or(0) + } + + /// Read-only view: returns whether a creator is registered in the contract. + /// + /// Returns `true` if a [`CreatorProfile`] exists for the given address, + /// `false` otherwise. Does not mutate state. + pub fn is_creator_registered(env: Env, creator: Address) -> bool { + read_creator_profile(&env, &creator).is_some() + } + + /// Read-only view: returns the creator fee recipient address. + /// + /// Fails with [`ContractError::NotRegistered`] if the creator is not registered. + /// Reuses current creator storage access patterns. + pub fn get_creator_fee_recipient( + env: Env, + creator: Address, + ) -> Result { + let profile = read_registered_creator_profile(&env, &creator)?; + Ok(profile.fee_recipient) + } + + /// Read-only view: returns accrued creator fee balance for the creator's fee recipient. + /// + /// Fails with [`ContractError::NotRegistered`] if the creator is not registered. + /// Returns `0` when no buy has accrued fees yet. + pub fn get_creator_fee_balance( + env: Env, + creator: Address, + ) -> Result { + read_registered_creator_profile(&env, &creator)?; + Ok(read_creator_fee_recipient_balance(&env, &creator)) + } + + /// Read-only view: returns the configured creator fee rate in basis points. + /// + /// The returned value is the creator-facing share stored in the current protocol + /// fee configuration, scoped to a registered creator lookup. + pub fn get_creator_fee_bps( + env: Env, + creator: Address, + ) -> Result { + let _profile = read_registered_creator_profile(&env, &creator)?; + let config = read_required_protocol_fee_config(&env)?; + Ok(config.creator_bps) + } + + /// Read-only view: returns the creator treasury share for a registered creator. + /// + /// Access Layer currently stores creator treasury share as the creator-facing + /// basis-point share in protocol fee configuration. This method provides a + /// creator-scoped accessor without mutating state. + pub fn get_creator_treasury_share( + env: Env, + creator: Address, + ) -> Result { + Self::get_creator_fee_bps(env, creator) + } + + /// Read-only view: returns the configured protocol treasury share in basis points. + /// + /// This value is sourced from the current protocol fee configuration and is + /// expressed in stable basis-point units. + pub fn get_protocol_treasury_share_bps( + env: Env, + ) -> Result { + let config = read_required_protocol_fee_config(&env)?; + Ok(config.protocol_bps) + } + + /// Sets the global protocol/creator fee split. Contract initialization + /// entrypoint. + /// + /// Parameter validation (via [`fee::assert_valid_fee_bps`]): + /// - `admin`: must authorize the call (`require_auth`). + /// - `creator_bps` + `protocol_bps`: must sum to exactly `BPS_MAX` (10_000), + /// otherwise [`ContractError::InvalidFeeConfig`]. + /// - `protocol_bps`: must not exceed `PROTOCOL_BPS_MAX`, otherwise + /// [`ContractError::ProtocolFeeExceedsCap`]. + pub fn set_fee_config( + env: Env, + admin: Address, + creator_bps: u32, + protocol_bps: u32, + ) -> Result<(), ContractError> { + admin.require_auth(); + fee::assert_valid_fee_bps(creator_bps, protocol_bps)?; + + let config = fee::FeeConfig { + creator_bps, + protocol_bps, + }; + if env + .storage() + .persistent() + .get::(&constants::storage::FEE_CONFIG) + .as_ref() + == Some(&config) + { + return Ok(()); + } + env + .storage() + .persistent() + .set(&constants::storage::FEE_CONFIG, &config); + + // Increment protocol state version on config update + let current_version = env + .storage() + .persistent() + .get(&constants::storage::PROTOCOL_STATE_VERSION) + .unwrap_or(PROTOCOL_STATE_VERSION_INITIAL); + let new_version = current_version + .checked_add(1) + .ok_or(ContractError::Overflow)?; + env + .storage() + .persistent() + .set(&constants::storage::PROTOCOL_STATE_VERSION, &new_version); - /// Read-only view: returns the current protocol fee configuration. - /// - /// Returns a stable [`ProtocolFeeView`] regardless of whether a fee config has been set. - /// When no config is stored, `is_configured` is `false` and both bps fields are `0`. - /// Use this method for indexers and read-only callers that need a non-optional result. - pub fn get_protocol_fee_view(env: Env) -> ProtocolFeeView { - match read_protocol_fee_config(&env) { - Some(config) => ProtocolFeeView { - creator_bps: config.creator_bps, - protocol_bps: config.protocol_bps, - is_configured: true, - }, - None => ProtocolFeeView { - creator_bps: 0, - protocol_bps: 0, - is_configured: false, - }, - } + Ok(()) + } + + /// Sets the per-key price. Contract initialization entrypoint. + /// + /// Parameter validation: + /// - `admin`: must authorize the call (`require_auth`). + /// - `price`: must be strictly positive; zero or negative returns + /// [`ContractError::NotPositiveAmount`]. + pub fn set_key_price( + env: Env, + admin: Address, + price: i128, + ) -> Result<(), ContractError> { + admin.require_auth(); + if price <= 0 { + return Err(ContractError::NotPositiveAmount); } - - pub fn compute_fees_for_payment(env: Env, total: i128) -> Result<(i128, i128), ContractError> { - let config = read_required_protocol_fee_config(&env)?; - fee::checked_compute_fee_split(total, config.creator_bps, config.protocol_bps) - .ok_or(ContractError::Overflow) + if env + .storage() + .persistent() + .get::(&constants::storage::KEY_PRICE) + .as_ref() + == Some(&price) + { + return Ok(()); } + env + .storage() + .persistent() + .set(&constants::storage::KEY_PRICE, &price); + Ok(()) + } + + pub fn get_fee_config(env: Env) -> Option { + read_protocol_fee_config(&env) + } + + /// Sets the protocol treasury address. + /// + /// Only callable by an authorized admin. Stores the treasury address used + /// for protocol fee routing. + pub fn set_treasury_address(env: Env, admin: Address, treasury: Address) { + admin.require_auth(); + if env + .storage() + .persistent() + .get::(&constants::storage::TREASURY_ADDRESS) + .as_ref() + == Some(&treasury) + { + return; + } + env + .storage() + .persistent() + .set(&constants::storage::TREASURY_ADDRESS, &treasury); + } + + /// Read-only view: returns the current protocol treasury address. + /// + /// Returns `None` if no treasury address has been configured. + /// Use this method for indexers and read-only callers that need the current + /// treasury routing target. + pub fn get_treasury_address(env: Env) -> Option
{ + env + .storage() + .persistent() + .get(&constants::storage::TREASURY_ADDRESS) + } + + /// Sets the protocol admin address. + /// + /// Only callable by an authorized admin. Stores the admin address used + /// for protocol administration. + pub fn set_protocol_admin(env: Env, admin: Address, new_admin: Address) { + admin.require_auth(); + if env + .storage() + .persistent() + .get::(&constants::storage::ADMIN_ADDRESS) + .as_ref() + == Some(&new_admin) + { + return; + } + env + .storage() + .persistent() + .set(&constants::storage::ADMIN_ADDRESS, &new_admin); + } + + /// Read-only view: returns the current protocol admin address. + /// + /// Returns `None` if no admin address has been configured. + /// Use this method for indexers and read-only callers that need the current + /// protocol admin address. + pub fn get_protocol_admin(env: Env) -> Option
{ + env + .storage() + .persistent() + .get(&constants::storage::ADMIN_ADDRESS) + } + + /// Read-only view: returns the current protocol fee recipient address. + /// + /// Returns `None` if no protocol fee recipient address has been configured. + /// Use this method for indexers and read-only callers that need the current + /// protocol fee recipient address. + pub fn get_protocol_fee_recipient(env: Env) -> Option
{ + env + .storage() + .persistent() + .get(&constants::storage::PROTOCOL_FEE_RECIPIENT) + } + + /// Read-only view: returns the accrued protocol fee balance for the configured recipient. + /// + /// Returns `0` when no protocol fees have been accrued from sell execution. + pub fn get_protocol_recipient_balance(env: Env) -> i128 { + read_protocol_fee_recipient_balance(&env) + } + + /// Sets the protocol fee recipient address. + /// + /// Only callable by an authorized admin. Rejects the Stellar zero address + /// to prevent silent fee burning. + /// + /// Parameter validation: + /// - `admin`: must authorize the call (`require_auth`). + /// - `recipient`: must not be the Stellar zero address, otherwise + /// [`ContractError::ZeroAddress`]. + pub fn set_protocol_fee_recipient( + env: Env, + admin: Address, + recipient: Address, + ) -> Result<(), ContractError> { + admin.require_auth(); + validate_non_zero_address(&env, &recipient)?; - /// Read-only view: returns the fee configuration for a specific creator. - /// - /// Returns a stable [`CreatorFeeView`] regardless of whether the creator is registered - /// or a fee config has been set. When `is_registered` is `false`, the creator does not - /// exist and both bps fields are `0`. When `is_configured` is `false`, no global fee - /// config has been set. Use this method for indexers and read-only callers that need - /// a non-optional result. - pub fn get_creator_fee_config(env: Env, creator: Address) -> CreatorFeeView { - let is_registered = read_registered_creator_profile(&env, &creator).is_ok(); - - if !is_registered { - return CreatorFeeView { - creator_bps: 0, - protocol_bps: 0, - is_registered: false, - is_configured: false, - }; - } - - match env - .storage() - .persistent() - .get::(&constants::storage::FEE_CONFIG) - { - Some(config) => CreatorFeeView { - creator_bps: config.creator_bps, - protocol_bps: config.protocol_bps, - is_registered: true, - is_configured: true, - }, - None => CreatorFeeView { - creator_bps: 0, - protocol_bps: 0, - is_registered: true, - is_configured: false, - }, - } + if env + .storage() + .persistent() + .get::(&constants::storage::PROTOCOL_FEE_RECIPIENT) + .as_ref() + == Some(&recipient) + { + return Ok(()); } + env + .storage() + .persistent() + .set(&constants::storage::PROTOCOL_FEE_RECIPIENT, &recipient); + Ok(()) + } + + /// Read-only view: returns whether protocol configuration has been initialized. + /// + /// Returns `true` once a protocol fee configuration has been stored and `false` + /// otherwise. Does not mutate contract state. + pub fn is_protocol_config_initialized(env: Env) -> bool { + read_protocol_fee_config(&env).is_some() + } + + /// Read-only view: returns the current protocol fee configuration. + /// + /// Returns a stable [`ProtocolFeeView`] regardless of whether a fee config has been set. + /// When no config is stored, `is_configured` is `false` and both bps fields are `0`. + /// Use this method for indexers and read-only callers that need a non-optional result. + pub fn get_protocol_fee_view(env: Env) -> ProtocolFeeView { + match read_protocol_fee_config(&env) { + Some(config) => ProtocolFeeView { + creator_bps: config.creator_bps, + protocol_bps: config.protocol_bps, + is_configured: true, + }, + None => ProtocolFeeView { + creator_bps: 0, + protocol_bps: 0, + is_configured: false, + }, + } + } + + pub fn compute_fees_for_payment( + env: Env, + total: i128, + ) -> Result<(i128, i128), ContractError> { + let config = read_required_protocol_fee_config(&env)?; + fee::checked_compute_fee_split( + total, + config.creator_bps, + config.protocol_bps, + ) + .ok_or(ContractError::Overflow) + } + + /// Read-only view: returns the fee configuration for a specific creator. + /// + /// Returns a stable [`CreatorFeeView`] regardless of whether the creator is registered + /// or a fee config has been set. When `is_registered` is `false`, the creator does not + /// exist and both bps fields are `0`. When `is_configured` is `false`, no global fee + /// config has been set. Use this method for indexers and read-only callers that need + /// a non-optional result. + pub fn get_creator_fee_config(env: Env, creator: Address) -> CreatorFeeView { + let is_registered = read_registered_creator_profile(&env, &creator).is_ok(); + + if !is_registered { + return CreatorFeeView { + creator_bps: 0, + protocol_bps: 0, + is_registered: false, + is_configured: false, + }; + } + + match env + .storage() + .persistent() + .get::(&constants::storage::FEE_CONFIG) + { + Some(config) => CreatorFeeView { + creator_bps: config.creator_bps, + protocol_bps: config.protocol_bps, + is_registered: true, + is_configured: true, + }, + None => CreatorFeeView { + creator_bps: 0, + protocol_bps: 0, + is_registered: true, + is_configured: false, + }, + } + } + + /// Read-only view: returns a quote for buying a key. + /// + /// Returns a [`QuoteResponse`] containing the current price and fee breakdown. + /// Fees are calculated based on the fixed key price. + pub fn get_buy_quote( + env: Env, + creator: Address, + ) -> Result { + let Some(price) = resolve_quote_inputs(&env, &creator)? else { + return Ok(zero_quote_response()); + }; + let (creator_fee, protocol_fee) = + Self::compute_fees_for_payment(env.clone(), price)?; + checked_format_quote_response(price, creator_fee, protocol_fee, true) + } + + /// Read-only view: returns a quote for selling a key. + /// + /// Returns a [`QuoteResponse`] containing the current price and fee breakdown. + /// Fees are calculated based on the fixed key price. + /// Rejects with [`ContractError::InsufficientBalance`] if the holder has no keys. + pub fn get_sell_quote( + env: Env, + creator: Address, + holder: Address, + ) -> Result { + let Some(price) = resolve_quote_inputs(&env, &creator)? else { + return Ok(zero_quote_response()); + }; - /// Read-only view: returns a quote for buying a key. - /// - /// Returns a [`QuoteResponse`] containing the current price and fee breakdown. - /// Fees are calculated based on the fixed key price. - pub fn get_buy_quote(env: Env, creator: Address) -> Result { - let Some(price) = resolve_quote_inputs(&env, &creator)? else { - return Ok(zero_quote_response()); - }; - let (creator_fee, protocol_fee) = Self::compute_fees_for_payment(env.clone(), price)?; - checked_format_quote_response(price, creator_fee, protocol_fee, true) + let balance = Self::get_key_balance(env.clone(), creator, holder); + if balance == 0 { + return Err(ContractError::InsufficientBalance); } - /// Read-only view: returns a quote for selling a key. - /// - /// Returns a [`QuoteResponse`] containing the current price and fee breakdown. - /// Fees are calculated based on the fixed key price. - /// Rejects with [`ContractError::InsufficientBalance`] if the holder has no keys. - pub fn get_sell_quote( - env: Env, - creator: Address, - holder: Address, - ) -> Result { - let Some(price) = resolve_quote_inputs(&env, &creator)? else { - return Ok(zero_quote_response()); - }; - - let balance = Self::get_key_balance(env.clone(), creator, holder); - if balance == 0 { - return Err(ContractError::InsufficientBalance); - } - - let (creator_fee, protocol_fee) = Self::compute_fees_for_payment(env.clone(), price)?; - checked_format_quote_response(price, creator_fee, protocol_fee, false) - } + let (creator_fee, protocol_fee) = + Self::compute_fees_for_payment(env.clone(), price)?; + checked_format_quote_response(price, creator_fee, protocol_fee, false) + } } fn read_holders_list(env: &Env, creator: &Address) -> Vec
{ - let key = DataKey::HoldersList(creator.clone()); - env.storage() - .persistent() - .get(&key) - .unwrap_or(Vec::new(env)) + let key = DataKey::HoldersList(creator.clone()); + env + .storage() + .persistent() + .get(&key) + .unwrap_or(Vec::new(env)) } fn write_holders_list(env: &Env, creator: &Address, holders: &Vec
) { - let key = DataKey::HoldersList(creator.clone()); - env.storage().persistent().set(&key, holders); + let key = DataKey::HoldersList(creator.clone()); + env.storage().persistent().set(&key, holders); } -fn read_claimable_dividend(env: &Env, creator: &Address, holder: &Address) -> i128 { - let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); - env.storage().persistent().get(&key).unwrap_or(0) +fn read_claimable_dividend( + env: &Env, + creator: &Address, + holder: &Address, +) -> i128 { + let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); + env.storage().persistent().get(&key).unwrap_or(0) } -fn write_claimable_dividend(env: &Env, creator: &Address, holder: &Address, amount: i128) { - let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); - env.storage().persistent().set(&key, &amount); +fn write_claimable_dividend( + env: &Env, + creator: &Address, + holder: &Address, + amount: i128, +) { + let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); + env.storage().persistent().set(&key, &amount); } #[cfg(test)] mod tests { - use super::fee; - - #[test] - fn test_fee_split_90_10_1000() { - let (creator, protocol) = fee::compute_fee_split(1000, 9000, 1000); - assert_eq!(creator, 900); - assert_eq!(protocol, 100); - assert_eq!(creator + protocol, 1000); - } - - #[test] - fn test_fee_split_100_creator() { - let (creator, protocol) = fee::compute_fee_split(1000, 10000, 0); - assert_eq!(creator, 1000); - assert_eq!(protocol, 0); - assert_eq!(creator + protocol, 1000); - } - - #[test] - fn test_fee_split_100_protocol() { - let (creator, protocol) = fee::compute_fee_split(1000, 0, 10000); - assert_eq!(creator, 0); - assert_eq!(protocol, 1000); - assert_eq!(creator + protocol, 1000); - } - - #[test] - fn test_fee_split_remainder_to_creator() { - // 999 * 1000 / 10000 = 99 (protocol floor), creator gets remainder - let (creator, protocol) = fee::compute_fee_split(999, 9000, 1000); - assert_eq!(creator, 900); - assert_eq!(protocol, 99); - assert_eq!(creator + protocol, 999); - } - - #[test] - fn test_fee_split_zero_total() { - let (creator, protocol) = fee::compute_fee_split(0, 9000, 1000); - assert_eq!(creator, 0); - assert_eq!(protocol, 0); - } - - #[test] - fn test_fee_split_dust_total_one() { - // 1 * 1000 / 10000 = 0 protocol, creator gets full amount - let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); - assert_eq!(creator, 1); - assert_eq!(protocol, 0); - assert_eq!(creator + protocol, 1); - } - - #[test] - fn test_fee_split_balance_conservation() { - for total in [100_i128, 1, 999, 10000, 1234567] { - let (creator, protocol) = fee::compute_fee_split(total, 9000, 1000); - assert_eq!(creator + protocol, total, "total={}", total); - } - } - - #[test] - fn test_checked_mul_i128_success() { - assert_eq!(fee::checked_mul_i128(100, 10), Some(1000)); - } - - #[test] - fn test_checked_mul_i128_rejects_overflow() { - assert_eq!(fee::checked_mul_i128(i128::MAX, 2), None); - assert_eq!(fee::checked_mul_i128(i128::MIN, 2), None); - } - - #[test] - fn test_checked_div_i128_success() { - assert_eq!(fee::checked_div_i128(100, 10), Some(10)); - } - - #[test] - fn test_checked_div_i128_rejects_zero_divisor() { - assert_eq!(fee::checked_div_i128(100, 0), None); - } - - #[test] - fn test_checked_sub_i128_success() { - assert_eq!(fee::checked_sub_i128(100, 10), Some(90)); - } - - #[test] - fn test_checked_sub_i128_underflow() { - assert_eq!(fee::checked_sub_i128(i128::MIN, 1), None); - } - - #[test] - fn test_checked_add_i128_success() { - assert_eq!(fee::checked_add_i128(100, 10), Some(110)); - } - - #[test] - fn test_checked_add_i128_overflow() { - assert_eq!(fee::checked_add_i128(i128::MAX, 1), None); - } - - #[test] - fn test_checked_add_i128_zero() { - assert_eq!(fee::checked_add_i128(0, 0), Some(0)); - assert_eq!(fee::checked_add_i128(100, 0), Some(100)); - assert_eq!(fee::checked_add_i128(0, 100), Some(100)); - } - - #[test] - fn test_checked_add_i128_negative_values() { - assert_eq!(fee::checked_add_i128(-10, 20), Some(10)); - assert_eq!(fee::checked_add_i128(10, -20), Some(-10)); - assert_eq!(fee::checked_add_i128(-10, -10), Some(-20)); - } - - #[test] - fn test_checked_add_i128_boundary_values() { - assert_eq!(fee::checked_add_i128(i128::MAX, 0), Some(i128::MAX)); - assert_eq!(fee::checked_add_i128(i128::MIN, 0), Some(i128::MIN)); - assert_eq!(fee::checked_add_i128(0, i128::MAX), Some(i128::MAX)); - assert_eq!(fee::checked_add_i128(0, i128::MIN), Some(i128::MIN)); - } - - #[test] - fn test_checked_add_i128_deterministic_error() { - // Verify that overflow always returns None, never panics - assert_eq!(fee::checked_add_i128(i128::MAX, i128::MAX), None); - assert_eq!(fee::checked_add_i128(i128::MIN, i128::MIN), None); - } - - #[test] - fn test_checked_div_i128_rejects_overflow() { - assert_eq!(fee::checked_div_i128(i128::MIN, -1), None); - } - - /// Both operands at `i128::MAX / 2 + 1` must overflow. - /// - /// `(i128::MAX / 2 + 1) + (i128::MAX / 2 + 1) == i128::MAX + 1`, which - /// exceeds i128 capacity, so the helper must return `None` rather than wrap. - #[test] - fn test_checked_add_i128_both_at_half_max_plus_one_overflows() { - let half_plus_one = i128::MAX / 2 + 1; - assert_eq!(fee::checked_add_i128(half_plus_one, half_plus_one), None); - } - - /// Both operands at `i128::MAX / 2` must not overflow. - /// - /// `(i128::MAX / 2) + (i128::MAX / 2) == i128::MAX - 1`, which fits in i128, - /// so the helper must return the correct sum just below the overflow boundary. - #[test] - fn test_checked_add_i128_both_at_half_max_succeeds() { - let half = i128::MAX / 2; - assert_eq!(fee::checked_add_i128(half, half), Some(half + half)); - } - - #[test] - fn test_normalize_quote_amount_preserves_positive_amount() { - assert_eq!(super::normalize_quote_amount(100), Ok(Some(100))); - } - - #[test] - fn test_normalize_quote_amount_maps_zero_to_noop() { - assert_eq!(super::normalize_quote_amount(0), Ok(None)); - } - - #[test] - fn test_normalize_quote_amount_rejects_negative_amount() { - assert_eq!( - super::normalize_quote_amount(-1), - Err(super::ContractError::NotPositiveAmount) - ); - } - - #[test] - fn test_normalize_quote_amount_rejects_large_amount() { - let large = super::fee::MAX_SAFE_AMOUNT + 1; - assert_eq!( - super::normalize_quote_amount(large), - Err(super::ContractError::Overflow) - ); - } - - #[test] - fn test_checked_format_quote_response_buy_success() { - let res = super::checked_format_quote_response(1000, 90, 10, true).unwrap(); - assert_eq!(res.price, 1000); - assert_eq!(res.creator_fee, 90); - assert_eq!(res.protocol_fee, 10); - assert_eq!(res.total_amount, 1100); - } - - #[test] - fn test_checked_format_quote_response_sell_success() { - let res = super::checked_format_quote_response(1000, 90, 10, false).unwrap(); - assert_eq!(res.price, 1000); - assert_eq!(res.creator_fee, 90); - assert_eq!(res.protocol_fee, 10); - assert_eq!(res.total_amount, 900); - } - - #[test] - fn test_checked_format_quote_response_buy_overflow_fees() { - let res = super::checked_format_quote_response(1000, i128::MAX, 1, true); - assert_eq!(res, Err(super::ContractError::Overflow)); - } - - #[test] - fn test_checked_format_quote_response_buy_overflow_total() { - let res = super::checked_format_quote_response(i128::MAX, 1, 0, true); - assert_eq!(res, Err(super::ContractError::Overflow)); - } - - #[test] - fn test_checked_format_quote_response_sell_underflow_total() { - let res = super::checked_format_quote_response(i128::MIN, 1, 0, false); - assert_eq!(res, Err(super::ContractError::SellUnderflow)); - } - - #[test] - fn test_apply_percentage_fee_success() { - assert_eq!(fee::apply_percentage_fee(1000, 1000), Some(100)); - assert_eq!(fee::apply_percentage_fee(1000, 0), Some(0)); - assert_eq!(fee::apply_percentage_fee(1000, 10000), Some(1000)); - } - - #[test] - fn test_apply_percentage_fee_zero_amount() { - assert_eq!(fee::apply_percentage_fee(0, 1000), Some(0)); - } - - #[test] - fn test_apply_percentage_fee_negative_amount() { - assert_eq!(fee::apply_percentage_fee(-100, 1000), Some(0)); - } - - #[test] - fn test_apply_percentage_fee_rounding() { - // 999 * 1000 / 10000 = 99.9 -> 99 - assert_eq!(fee::apply_percentage_fee(999, 1000), Some(99)); - } - - #[test] - fn test_apply_percentage_fee_overflow() { - // Multiplication overflows before division - assert_eq!(fee::apply_percentage_fee(i128::MAX, 2), None); - } - - #[test] - fn test_assert_valid_fee_bps() { - // Valid scenarios - assert_eq!(fee::assert_valid_fee_bps(10000, 0), Ok(())); - assert_eq!(fee::assert_valid_fee_bps(5000, 5000), Ok(())); - assert_eq!(fee::assert_valid_fee_bps(9000, 1000), Ok(())); - - // Invalid Sum - assert_eq!( - fee::assert_valid_fee_bps(9000, 2000), - Err(super::ContractError::InvalidFeeConfig) - ); - assert_eq!( - fee::assert_valid_fee_bps(0, 0), - Err(super::ContractError::InvalidFeeConfig) - ); - - // Protocol Cap Exceeded (PROTOCOL_BPS_MAX = 5000) - assert_eq!( - fee::assert_valid_fee_bps(4999, 5001), - Err(super::ContractError::ProtocolFeeExceedsCap) - ); - assert_eq!( - fee::assert_valid_fee_bps(0, 10000), - Err(super::ContractError::ProtocolFeeExceedsCap) - ); - - // Overflow - assert_eq!( - fee::assert_valid_fee_bps(u32::MAX, 1), - Err(super::ContractError::InvalidFeeConfig) - ); - } - - #[test] - fn test_validate_fee_bps() { - // Valid - assert!(fee::validate_fee_bps(10000, 0)); - assert!(fee::validate_fee_bps(5000, 5000)); - assert!(fee::validate_fee_bps(9000, 1000)); - - // Invalid Sum - assert!(!fee::validate_fee_bps(9000, 2000)); - assert!(!fee::validate_fee_bps(0, 0)); - - // Protocol Cap Exceeded - assert!(!fee::validate_fee_bps(4999, 5001)); - - // Overflow - assert!(!fee::validate_fee_bps(u32::MAX, 1)); - } - - // --- checked_fee_sum unit tests --- - - /// Verifies that `checked_fee_sum` returns the correct sum for two ordinary - /// positive fee components. - #[test] - fn test_checked_fee_sum_success() { - assert_eq!(fee::checked_fee_sum(900, 100), Some(1000)); - assert_eq!(fee::checked_fee_sum(0, 0), Some(0)); - assert_eq!(fee::checked_fee_sum(500, 500), Some(1000)); - } - - /// Verifies that `checked_fee_sum` returns `None` when the addition would - /// overflow `i128`, preventing silent wrapping in fee total calculations. - #[test] - fn test_checked_fee_sum_overflow_returns_none() { - assert_eq!(fee::checked_fee_sum(i128::MAX, 1), None); - assert_eq!(fee::checked_fee_sum(i128::MAX, i128::MAX), None); - } - - /// Edge case: verifies `checked_fee_sum` at the boundary where one component - /// is exactly `i128::MAX` and the other is zero — the only non-overflowing - /// case at that boundary. - #[test] - fn test_checked_fee_sum_boundary_max_plus_zero() { - assert_eq!(fee::checked_fee_sum(i128::MAX, 0), Some(i128::MAX)); - assert_eq!(fee::checked_fee_sum(0, i128::MAX), Some(i128::MAX)); - // One above the boundary must overflow - assert_eq!(fee::checked_fee_sum(i128::MAX, 1), None); - } - - // --- BPS truncation on small amounts --- - - /// Bps calculation on very small amounts produces zero due to integer division - /// truncation. These tests document the behavior at the lower precision boundary. - /// - /// Formula: `amount * bps / 10_000` (floor division). - /// When the product `amount * bps < 10_000`, the result truncates to zero. - #[test] - fn test_apply_percentage_fee_truncation_1_stroop() { - // 1 * 1000 / 10_000 = 0.1 → truncated to 0 - // At 1 stroop with 10% bps, the fee is zero — value is silently lost. - let result = fee::apply_percentage_fee(1, 1000); - assert_eq!(result, Some(0), "1 stroop at 1000 bps truncates to 0"); - } - - #[test] - fn test_apply_percentage_fee_truncation_10_stroops() { - // 10 * 1000 / 10_000 = 1.0 → exactly 1 - // At 10 stroops with 10% bps, the fee is exactly 1. - let result = fee::apply_percentage_fee(10, 1000); - assert_eq!(result, Some(1), "10 stroops at 1000 bps yields 1"); - } - - #[test] - fn test_apply_percentage_fee_truncation_100_stroops() { - // 100 * 1000 / 10_000 = 10.0 → exactly 10 - let result = fee::apply_percentage_fee(100, 1000); - assert_eq!(result, Some(10), "100 stroops at 1000 bps yields 10"); - } - - #[test] - fn test_fee_split_truncation_1_stroop() { - // 1 * 1000 / 10_000 = 0 protocol, 1 creator (remainder to creator) - // Truncation causes the full amount to go to creator. - let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); - assert_eq!(protocol, 0, "1 stroop: protocol fee truncated to 0"); - assert_eq!(creator, 1, "1 stroop: creator gets full amount"); - assert_eq!(creator + protocol, 1, "conservation holds"); - } - - #[test] - fn test_fee_split_truncation_10_stroops() { - // 10 * 1000 / 10_000 = 1 protocol, 9 creator - let (creator, protocol) = fee::compute_fee_split(10, 9000, 1000); - assert_eq!(protocol, 1, "10 stroops: protocol fee is 1"); - assert_eq!(creator, 9, "10 stroops: creator gets 9"); - assert_eq!(creator + protocol, 10, "conservation holds"); - } - - #[test] - fn test_fee_split_truncation_100_stroops() { - // 100 * 1000 / 10_000 = 10 protocol, 90 creator - let (creator, protocol) = fee::compute_fee_split(100, 9000, 1000); - assert_eq!(protocol, 10, "100 stroops: protocol fee is 10"); - assert_eq!(creator, 90, "100 stroops: creator gets 90"); - assert_eq!(creator + protocol, 100, "conservation holds"); - } + use super::fee; + + #[test] + fn test_fee_split_90_10_1000() { + let (creator, protocol) = fee::compute_fee_split(1000, 9000, 1000); + assert_eq!(creator, 900); + assert_eq!(protocol, 100); + assert_eq!(creator + protocol, 1000); + } + + #[test] + fn test_fee_split_100_creator() { + let (creator, protocol) = fee::compute_fee_split(1000, 10000, 0); + assert_eq!(creator, 1000); + assert_eq!(protocol, 0); + assert_eq!(creator + protocol, 1000); + } + + #[test] + fn test_fee_split_100_protocol() { + let (creator, protocol) = fee::compute_fee_split(1000, 0, 10000); + assert_eq!(creator, 0); + assert_eq!(protocol, 1000); + assert_eq!(creator + protocol, 1000); + } + + #[test] + fn test_fee_split_remainder_to_creator() { + // 999 * 1000 / 10000 = 99 (protocol floor), creator gets remainder + let (creator, protocol) = fee::compute_fee_split(999, 9000, 1000); + assert_eq!(creator, 900); + assert_eq!(protocol, 99); + assert_eq!(creator + protocol, 999); + } + + #[test] + fn test_fee_split_zero_total() { + let (creator, protocol) = fee::compute_fee_split(0, 9000, 1000); + assert_eq!(creator, 0); + assert_eq!(protocol, 0); + } + + #[test] + fn test_fee_split_dust_total_one() { + // 1 * 1000 / 10000 = 0 protocol, creator gets full amount + let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); + assert_eq!(creator, 1); + assert_eq!(protocol, 0); + assert_eq!(creator + protocol, 1); + } + + #[test] + fn test_fee_split_balance_conservation() { + for total in [100_i128, 1, 999, 10000, 1234567] { + let (creator, protocol) = fee::compute_fee_split(total, 9000, 1000); + assert_eq!(creator + protocol, total, "total={}", total); + } + } + + #[test] + fn test_checked_mul_i128_success() { + assert_eq!(fee::checked_mul_i128(100, 10), Some(1000)); + } + + #[test] + fn test_checked_mul_i128_rejects_overflow() { + assert_eq!(fee::checked_mul_i128(i128::MAX, 2), None); + assert_eq!(fee::checked_mul_i128(i128::MIN, 2), None); + } + + #[test] + fn test_checked_div_i128_success() { + assert_eq!(fee::checked_div_i128(100, 10), Some(10)); + } + + #[test] + fn test_checked_div_i128_rejects_zero_divisor() { + assert_eq!(fee::checked_div_i128(100, 0), None); + } + + #[test] + fn test_checked_sub_i128_success() { + assert_eq!(fee::checked_sub_i128(100, 10), Some(90)); + } + + #[test] + fn test_checked_sub_i128_underflow() { + assert_eq!(fee::checked_sub_i128(i128::MIN, 1), None); + } + + #[test] + fn test_checked_add_i128_success() { + assert_eq!(fee::checked_add_i128(100, 10), Some(110)); + } + + #[test] + fn test_checked_add_i128_overflow() { + assert_eq!(fee::checked_add_i128(i128::MAX, 1), None); + } + + #[test] + fn test_checked_add_i128_zero() { + assert_eq!(fee::checked_add_i128(0, 0), Some(0)); + assert_eq!(fee::checked_add_i128(100, 0), Some(100)); + assert_eq!(fee::checked_add_i128(0, 100), Some(100)); + } + + #[test] + fn test_checked_add_i128_negative_values() { + assert_eq!(fee::checked_add_i128(-10, 20), Some(10)); + assert_eq!(fee::checked_add_i128(10, -20), Some(-10)); + assert_eq!(fee::checked_add_i128(-10, -10), Some(-20)); + } + + #[test] + fn test_checked_add_i128_boundary_values() { + assert_eq!(fee::checked_add_i128(i128::MAX, 0), Some(i128::MAX)); + assert_eq!(fee::checked_add_i128(i128::MIN, 0), Some(i128::MIN)); + assert_eq!(fee::checked_add_i128(0, i128::MAX), Some(i128::MAX)); + assert_eq!(fee::checked_add_i128(0, i128::MIN), Some(i128::MIN)); + } + + #[test] + fn test_checked_add_i128_deterministic_error() { + // Verify that overflow always returns None, never panics + assert_eq!(fee::checked_add_i128(i128::MAX, i128::MAX), None); + assert_eq!(fee::checked_add_i128(i128::MIN, i128::MIN), None); + } + + #[test] + fn test_checked_div_i128_rejects_overflow() { + assert_eq!(fee::checked_div_i128(i128::MIN, -1), None); + } + + /// Both operands at `i128::MAX / 2 + 1` must overflow. + /// + /// `(i128::MAX / 2 + 1) + (i128::MAX / 2 + 1) == i128::MAX + 1`, which + /// exceeds i128 capacity, so the helper must return `None` rather than wrap. + #[test] + fn test_checked_add_i128_both_at_half_max_plus_one_overflows() { + let half_plus_one = i128::MAX / 2 + 1; + assert_eq!(fee::checked_add_i128(half_plus_one, half_plus_one), None); + } + + /// Both operands at `i128::MAX / 2` must not overflow. + /// + /// `(i128::MAX / 2) + (i128::MAX / 2) == i128::MAX - 1`, which fits in i128, + /// so the helper must return the correct sum just below the overflow boundary. + #[test] + fn test_checked_add_i128_both_at_half_max_succeeds() { + let half = i128::MAX / 2; + assert_eq!(fee::checked_add_i128(half, half), Some(half + half)); + } + + #[test] + fn test_normalize_quote_amount_preserves_positive_amount() { + assert_eq!(super::normalize_quote_amount(100), Ok(Some(100))); + } + + #[test] + fn test_normalize_quote_amount_maps_zero_to_noop() { + assert_eq!(super::normalize_quote_amount(0), Ok(None)); + } + + #[test] + fn test_normalize_quote_amount_rejects_negative_amount() { + assert_eq!( + super::normalize_quote_amount(-1), + Err(super::ContractError::NotPositiveAmount) + ); + } + + #[test] + fn test_normalize_quote_amount_rejects_large_amount() { + let large = super::fee::MAX_SAFE_AMOUNT + 1; + assert_eq!( + super::normalize_quote_amount(large), + Err(super::ContractError::Overflow) + ); + } + + #[test] + fn test_checked_format_quote_response_buy_success() { + let res = super::checked_format_quote_response(1000, 90, 10, true).unwrap(); + assert_eq!(res.price, 1000); + assert_eq!(res.creator_fee, 90); + assert_eq!(res.protocol_fee, 10); + assert_eq!(res.total_amount, 1100); + } + + #[test] + fn test_checked_format_quote_response_sell_success() { + let res = + super::checked_format_quote_response(1000, 90, 10, false).unwrap(); + assert_eq!(res.price, 1000); + assert_eq!(res.creator_fee, 90); + assert_eq!(res.protocol_fee, 10); + assert_eq!(res.total_amount, 900); + } + + #[test] + fn test_checked_format_quote_response_buy_overflow_fees() { + let res = super::checked_format_quote_response(1000, i128::MAX, 1, true); + assert_eq!(res, Err(super::ContractError::Overflow)); + } + + #[test] + fn test_checked_format_quote_response_buy_overflow_total() { + let res = super::checked_format_quote_response(i128::MAX, 1, 0, true); + assert_eq!(res, Err(super::ContractError::Overflow)); + } + + #[test] + fn test_checked_format_quote_response_sell_underflow_total() { + let res = super::checked_format_quote_response(i128::MIN, 1, 0, false); + assert_eq!(res, Err(super::ContractError::SellUnderflow)); + } + + #[test] + fn test_apply_percentage_fee_success() { + assert_eq!(fee::apply_percentage_fee(1000, 1000), Some(100)); + assert_eq!(fee::apply_percentage_fee(1000, 0), Some(0)); + assert_eq!(fee::apply_percentage_fee(1000, 10000), Some(1000)); + } + + #[test] + fn test_apply_percentage_fee_zero_amount() { + assert_eq!(fee::apply_percentage_fee(0, 1000), Some(0)); + } + + #[test] + fn test_apply_percentage_fee_negative_amount() { + assert_eq!(fee::apply_percentage_fee(-100, 1000), Some(0)); + } + + #[test] + fn test_apply_percentage_fee_rounding() { + // 999 * 1000 / 10000 = 99.9 -> 99 + assert_eq!(fee::apply_percentage_fee(999, 1000), Some(99)); + } + + #[test] + fn test_apply_percentage_fee_overflow() { + // Multiplication overflows before division + assert_eq!(fee::apply_percentage_fee(i128::MAX, 2), None); + } + + #[test] + fn test_assert_valid_fee_bps() { + // Valid scenarios + assert_eq!(fee::assert_valid_fee_bps(10000, 0), Ok(())); + assert_eq!(fee::assert_valid_fee_bps(5000, 5000), Ok(())); + assert_eq!(fee::assert_valid_fee_bps(9000, 1000), Ok(())); + + // Invalid Sum + assert_eq!( + fee::assert_valid_fee_bps(9000, 2000), + Err(super::ContractError::InvalidFeeConfig) + ); + assert_eq!( + fee::assert_valid_fee_bps(0, 0), + Err(super::ContractError::InvalidFeeConfig) + ); - // --- Zero address validation --- - - #[test] - fn test_validate_non_zero_address_rejects_zero() { - use soroban_sdk::{Address, Env, String}; - let env = Env::default(); - let zero_str = String::from_str( - &env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ); - let zero_addr = Address::from_string(&zero_str); - let result = super::validate_non_zero_address(&env, &zero_addr); - assert_eq!(result, Err(super::ContractError::ZeroAddress)); - } + // Protocol Cap Exceeded (PROTOCOL_BPS_MAX = 5000) + assert_eq!( + fee::assert_valid_fee_bps(4999, 5001), + Err(super::ContractError::ProtocolFeeExceedsCap) + ); + assert_eq!( + fee::assert_valid_fee_bps(0, 10000), + Err(super::ContractError::ProtocolFeeExceedsCap) + ); - #[test] - fn test_validate_non_zero_address_accepts_valid() { - use soroban_sdk::{testutils::Address as _, Address, Env}; - let env = Env::default(); - let valid = Address::generate(&env); - let result = super::validate_non_zero_address(&env, &valid); - assert_eq!(result, Ok(())); - } + // Overflow + assert_eq!( + fee::assert_valid_fee_bps(u32::MAX, 1), + Err(super::ContractError::InvalidFeeConfig) + ); + } + + #[test] + fn test_validate_fee_bps() { + // Valid + assert!(fee::validate_fee_bps(10000, 0)); + assert!(fee::validate_fee_bps(5000, 5000)); + assert!(fee::validate_fee_bps(9000, 1000)); + + // Invalid Sum + assert!(!fee::validate_fee_bps(9000, 2000)); + assert!(!fee::validate_fee_bps(0, 0)); + + // Protocol Cap Exceeded + assert!(!fee::validate_fee_bps(4999, 5001)); + + // Overflow + assert!(!fee::validate_fee_bps(u32::MAX, 1)); + } + + // --- checked_fee_sum unit tests --- + + /// Verifies that `checked_fee_sum` returns the correct sum for two ordinary + /// positive fee components. + #[test] + fn test_checked_fee_sum_success() { + assert_eq!(fee::checked_fee_sum(900, 100), Some(1000)); + assert_eq!(fee::checked_fee_sum(0, 0), Some(0)); + assert_eq!(fee::checked_fee_sum(500, 500), Some(1000)); + } + + /// Verifies that `checked_fee_sum` returns `None` when the addition would + /// overflow `i128`, preventing silent wrapping in fee total calculations. + #[test] + fn test_checked_fee_sum_overflow_returns_none() { + assert_eq!(fee::checked_fee_sum(i128::MAX, 1), None); + assert_eq!(fee::checked_fee_sum(i128::MAX, i128::MAX), None); + } + + /// Edge case: verifies `checked_fee_sum` at the boundary where one component + /// is exactly `i128::MAX` and the other is zero — the only non-overflowing + /// case at that boundary. + #[test] + fn test_checked_fee_sum_boundary_max_plus_zero() { + assert_eq!(fee::checked_fee_sum(i128::MAX, 0), Some(i128::MAX)); + assert_eq!(fee::checked_fee_sum(0, i128::MAX), Some(i128::MAX)); + // One above the boundary must overflow + assert_eq!(fee::checked_fee_sum(i128::MAX, 1), None); + } + + // --- BPS truncation on small amounts --- + + /// Bps calculation on very small amounts produces zero due to integer division + /// truncation. These tests document the behavior at the lower precision boundary. + /// + /// Formula: `amount * bps / 10_000` (floor division). + /// When the product `amount * bps < 10_000`, the result truncates to zero. + #[test] + fn test_apply_percentage_fee_truncation_1_stroop() { + // 1 * 1000 / 10_000 = 0.1 → truncated to 0 + // At 1 stroop with 10% bps, the fee is zero — value is silently lost. + let result = fee::apply_percentage_fee(1, 1000); + assert_eq!(result, Some(0), "1 stroop at 1000 bps truncates to 0"); + } + + #[test] + fn test_apply_percentage_fee_truncation_10_stroops() { + // 10 * 1000 / 10_000 = 1.0 → exactly 1 + // At 10 stroops with 10% bps, the fee is exactly 1. + let result = fee::apply_percentage_fee(10, 1000); + assert_eq!(result, Some(1), "10 stroops at 1000 bps yields 1"); + } + + #[test] + fn test_apply_percentage_fee_truncation_100_stroops() { + // 100 * 1000 / 10_000 = 10.0 → exactly 10 + let result = fee::apply_percentage_fee(100, 1000); + assert_eq!(result, Some(10), "100 stroops at 1000 bps yields 10"); + } + + #[test] + fn test_fee_split_truncation_1_stroop() { + // 1 * 1000 / 10_000 = 0 protocol, 1 creator (remainder to creator) + // Truncation causes the full amount to go to creator. + let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); + assert_eq!(protocol, 0, "1 stroop: protocol fee truncated to 0"); + assert_eq!(creator, 1, "1 stroop: creator gets full amount"); + assert_eq!(creator + protocol, 1, "conservation holds"); + } + + #[test] + fn test_fee_split_truncation_10_stroops() { + // 10 * 1000 / 10_000 = 1 protocol, 9 creator + let (creator, protocol) = fee::compute_fee_split(10, 9000, 1000); + assert_eq!(protocol, 1, "10 stroops: protocol fee is 1"); + assert_eq!(creator, 9, "10 stroops: creator gets 9"); + assert_eq!(creator + protocol, 10, "conservation holds"); + } + + #[test] + fn test_fee_split_truncation_100_stroops() { + // 100 * 1000 / 10_000 = 10 protocol, 90 creator + let (creator, protocol) = fee::compute_fee_split(100, 9000, 1000); + assert_eq!(protocol, 10, "100 stroops: protocol fee is 10"); + assert_eq!(creator, 90, "100 stroops: creator gets 90"); + assert_eq!(creator + protocol, 100, "conservation holds"); + } + + // --- Zero address validation --- + + #[test] + fn test_validate_non_zero_address_rejects_zero() { + use soroban_sdk::{Address, Env, String}; + let env = Env::default(); + let zero_str = String::from_str( + &env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ); + let zero_addr = Address::from_string(&zero_str); + let result = super::validate_non_zero_address(&env, &zero_addr); + assert_eq!(result, Err(super::ContractError::ZeroAddress)); + } + + #[test] + fn test_validate_non_zero_address_accepts_valid() { + use soroban_sdk::{testutils::Address as _, Address, Env}; + let env = Env::default(); + let valid = Address::generate(&env); + let result = super::validate_non_zero_address(&env, &valid); + assert_eq!(result, Ok(())); + } } #[cfg(test)] From a5e9905af530b103bc282812de0bc4453919eb19 Mon Sep 17 00:00:00 2001 From: Syed Ghufran Hassan Date: Wed, 24 Jun 2026 16:59:15 +0500 Subject: [PATCH 07/11] Update lib.rs --- creator-keys/src/lib.rs | 3481 +++++++++++++++++++-------------------- 1 file changed, 1675 insertions(+), 1806 deletions(-) diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index c8367d9..bca1f78 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -1,10 +1,7 @@ #![no_std] pub mod quote_view_errors; -use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token, Address, Env, - String, Vec, -}; +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, token, Address, Env, String, Vec}; pub mod events; @@ -50,226 +47,215 @@ pub mod events; /// } /// ``` pub enum ContractError { - AlreadyRegistered = 1, - NotRegistered = 2, - Overflow = 3, - InsufficientPayment = 4, - KeyPriceNotSet = 5, - NotPositiveAmount = 6, - FeeConfigNotSet = 7, - InvalidFeeConfig = 8, - InsufficientBalance = 9, - SellUnderflow = 10, - ProtocolFeeExceedsCap = 11, - HandleTooShort = 12, - HandleTooLong = 13, - InvalidHandleCharacter = 14, - ZeroAddress = 15, - SlippageExceeded = 16, - ProtocolPaused = 17, - Unauthorized = 18, - ZeroClaimable = 19, - NoHolders = 20, - DividendAmountZero = 21, + AlreadyRegistered = 1, + NotRegistered = 2, + Overflow = 3, + InsufficientPayment = 4, + KeyPriceNotSet = 5, + NotPositiveAmount = 6, + FeeConfigNotSet = 7, + InvalidFeeConfig = 8, + InsufficientBalance = 9, + SellUnderflow = 10, + ProtocolFeeExceedsCap = 11, + HandleTooShort = 12, + HandleTooLong = 13, + InvalidHandleCharacter = 14, + ZeroAddress = 15, + SlippageExceeded = 16, + ProtocolPaused = 17, + Unauthorized = 18, + ZeroClaimable = 19, + NoHolders = 20, + DividendAmountZero = 21, } pub mod fee { - use crate::ContractError; + use crate::ContractError; - use soroban_sdk::contracttype; + use soroban_sdk::contracttype; - /// Basis points per 100% (10000 = 100%). - pub const BPS_MAX: u32 = 10_000; + /// Basis points per 100% (10000 = 100%). + pub const BPS_MAX: u32 = 10_000; - /// Maximum safe amount to prevent overflow in fee calculations. - pub const MAX_SAFE_AMOUNT: i128 = i128::MAX / BPS_MAX as i128; + /// Maximum safe amount to prevent overflow in fee calculations. + pub const MAX_SAFE_AMOUNT: i128 = i128::MAX / BPS_MAX as i128; - /// Maximum protocol share when configuring fees via [`assert_valid_fee_bps`]. - /// - /// Caps the on-chain configured protocol take at 50% so fee settings stay within - /// expected economic bounds before they affect market logic. - pub const PROTOCOL_BPS_MAX: u32 = 5_000; + /// Maximum protocol share when configuring fees via [`assert_valid_fee_bps`]. + /// + /// Caps the on-chain configured protocol take at 50% so fee settings stay within + /// expected economic bounds before they affect market logic. + pub const PROTOCOL_BPS_MAX: u32 = 5_000; - #[derive(Clone, Eq, PartialEq)] - #[contracttype] - pub struct FeeConfig { - pub creator_bps: u32, - pub protocol_bps: u32, - } + #[derive(Clone, Eq, PartialEq)] + #[contracttype] + pub struct FeeConfig { + pub creator_bps: u32, + pub protocol_bps: u32, + } - /// Validates creator and protocol basis points for storage and fee-setting entrypoints. - pub fn validate_fee_bps(creator_bps: u32, protocol_bps: u32) -> bool { - let Some(sum) = creator_bps.checked_add(protocol_bps) else { - return false; - }; - if sum != BPS_MAX { - return false; - } - if protocol_bps > PROTOCOL_BPS_MAX { - return false; - } - true - } - - /// Shared guard for fee config updates that need structured contract errors. - pub fn assert_valid_fee_bps( - creator_bps: u32, - protocol_bps: u32, - ) -> Result<(), ContractError> { - let Some(sum) = creator_bps.checked_add(protocol_bps) else { - return Err(ContractError::InvalidFeeConfig); - }; - if sum != BPS_MAX { - return Err(ContractError::InvalidFeeConfig); + /// Validates creator and protocol basis points for storage and fee-setting entrypoints. + pub fn validate_fee_bps(creator_bps: u32, protocol_bps: u32) -> bool { + let Some(sum) = creator_bps.checked_add(protocol_bps) else { + return false; + }; + if sum != BPS_MAX { + return false; + } + if protocol_bps > PROTOCOL_BPS_MAX { + return false; + } + true } - if protocol_bps > PROTOCOL_BPS_MAX { - return Err(ContractError::ProtocolFeeExceedsCap); + + /// Shared guard for fee config updates that need structured contract errors. + pub fn assert_valid_fee_bps(creator_bps: u32, protocol_bps: u32) -> Result<(), ContractError> { + let Some(sum) = creator_bps.checked_add(protocol_bps) else { + return Err(ContractError::InvalidFeeConfig); + }; + if sum != BPS_MAX { + return Err(ContractError::InvalidFeeConfig); + } + if protocol_bps > PROTOCOL_BPS_MAX { + return Err(ContractError::ProtocolFeeExceedsCap); + } + Ok(()) + } + + /// Computes the fee split for a given total amount. + /// + /// Returns `(creator_amount, protocol_amount)`. Remainder from integer division + /// is assigned to the creator. Ensures creator_amount + protocol_amount == total. + pub fn compute_fee_split(total: i128, _creator_bps: u32, protocol_bps: u32) -> (i128, i128) { + if total <= 0 { + return (0, 0); + } + let protocol_amount = (total * protocol_bps as i128) / BPS_MAX as i128; + let creator_amount = total - protocol_amount; + (creator_amount, protocol_amount) + } + + /// Safely applies a percentage-based fee to an amount. + /// + /// Returns `None` if the multiplication overflows. Rounding is performed via + /// floor division towards zero. + pub fn apply_percentage_fee(amount: i128, bps: u32) -> Option { + if amount <= 0 { + return Some(0); + } + checked_div_i128(amount.checked_mul(bps as i128)?, BPS_MAX as i128) + } + + /// Computes the fee split safely, returning `None` if multiplication or subtraction overflows. + pub fn checked_compute_fee_split( + total: i128, + _creator_bps: u32, + protocol_bps: u32, + ) -> Option<(i128, i128)> { + if total <= 0 { + return Some((0, 0)); + } + let protocol_amount = apply_percentage_fee(total, protocol_bps)?; + let creator_amount = checked_sub_i128(total, protocol_amount)?; + Some((creator_amount, protocol_amount)) + } + + /// Performs checked integer multiplication for quote math helpers. + pub fn checked_mul_i128(a: i128, b: i128) -> Option { + a.checked_mul(b) + } + + /// Performs checked integer division for quote math helpers. + pub fn checked_div_i128(dividend: i128, divisor: i128) -> Option { + if divisor == 0 { + return None; + } + dividend.checked_div(divisor) + } + + /// Performs checked integer subtraction for quote math helpers. + pub fn checked_sub_i128(left: i128, right: i128) -> Option { + left.checked_sub(right) + } + + /// Performs checked integer addition for quote math helpers. + pub fn checked_add_i128(left: i128, right: i128) -> Option { + left.checked_add(right) + } + + /// Computes the checked sum of creator and protocol fee components. + /// + /// Returns `None` if the addition would overflow. Use this helper wherever + /// fee components are combined before being compared against a price or total, + /// to keep the overflow guard consistent across buy and sell quote paths. + /// + /// # Naming convention + /// + /// Quote helpers in this module follow a `checked_*` prefix convention: + /// - `checked_*` functions return `Option` and propagate `None` on overflow. + /// - `compute_*` functions return the result directly (may panic on overflow in + /// debug builds; use only where inputs are already validated). + /// - `apply_*` functions apply a rate or percentage to a single amount. + /// + /// `checked_fee_sum` belongs to the `checked_*` family: it is the canonical + /// helper for summing two fee components before they are used in total-amount + /// arithmetic, replacing ad-hoc inline `checked_add` calls at each call site. + pub fn checked_fee_sum(creator_fee: i128, protocol_fee: i128) -> Option { + creator_fee.checked_add(protocol_fee) } - Ok(()) - } - - /// Computes the fee split for a given total amount. - /// - /// Returns `(creator_amount, protocol_amount)`. Remainder from integer division - /// is assigned to the creator. Ensures creator_amount + protocol_amount == total. - pub fn compute_fee_split( - total: i128, - _creator_bps: u32, - protocol_bps: u32, - ) -> (i128, i128) { - if total <= 0 { - return (0, 0); - } - let protocol_amount = (total * protocol_bps as i128) / BPS_MAX as i128; - let creator_amount = total - protocol_amount; - (creator_amount, protocol_amount) - } - - /// Safely applies a percentage-based fee to an amount. - /// - /// Returns `None` if the multiplication overflows. Rounding is performed via - /// floor division towards zero. - pub fn apply_percentage_fee(amount: i128, bps: u32) -> Option { - if amount <= 0 { - return Some(0); - } - checked_div_i128(amount.checked_mul(bps as i128)?, BPS_MAX as i128) - } - - /// Computes the fee split safely, returning `None` if multiplication or subtraction overflows. - pub fn checked_compute_fee_split( - total: i128, - _creator_bps: u32, - protocol_bps: u32, - ) -> Option<(i128, i128)> { - if total <= 0 { - return Some((0, 0)); - } - let protocol_amount = apply_percentage_fee(total, protocol_bps)?; - let creator_amount = checked_sub_i128(total, protocol_amount)?; - Some((creator_amount, protocol_amount)) - } - - /// Performs checked integer multiplication for quote math helpers. - pub fn checked_mul_i128(a: i128, b: i128) -> Option { - a.checked_mul(b) - } - - /// Performs checked integer division for quote math helpers. - pub fn checked_div_i128(dividend: i128, divisor: i128) -> Option { - if divisor == 0 { - return None; - } - dividend.checked_div(divisor) - } - - /// Performs checked integer subtraction for quote math helpers. - pub fn checked_sub_i128(left: i128, right: i128) -> Option { - left.checked_sub(right) - } - - /// Performs checked integer addition for quote math helpers. - pub fn checked_add_i128(left: i128, right: i128) -> Option { - left.checked_add(right) - } - - /// Computes the checked sum of creator and protocol fee components. - /// - /// Returns `None` if the addition would overflow. Use this helper wherever - /// fee components are combined before being compared against a price or total, - /// to keep the overflow guard consistent across buy and sell quote paths. - /// - /// # Naming convention - /// - /// Quote helpers in this module follow a `checked_*` prefix convention: - /// - `checked_*` functions return `Option` and propagate `None` on overflow. - /// - `compute_*` functions return the result directly (may panic on overflow in - /// debug builds; use only where inputs are already validated). - /// - `apply_*` functions apply a rate or percentage to a single amount. - /// - /// `checked_fee_sum` belongs to the `checked_*` family: it is the canonical - /// helper for summing two fee components before they are used in total-amount - /// arithmetic, replacing ad-hoc inline `checked_add` calls at each call site. - pub fn checked_fee_sum( - creator_fee: i128, - protocol_fee: i128, - ) -> Option { - creator_fee.checked_add(protocol_fee) - } } pub mod constants { - use super::DataKey; - use soroban_sdk::Address; - - pub mod storage { - use super::{creator_key, key_balance_key, DataKey}; + use super::DataKey; use soroban_sdk::Address; - pub const FEE_CONFIG: DataKey = DataKey::FeeConfig; - pub const KEY_PRICE: DataKey = DataKey::KeyPrice; - pub const TREASURY_ADDRESS: DataKey = DataKey::TreasuryAddress; - pub const ADMIN_ADDRESS: DataKey = DataKey::AdminAddress; - pub const PROTOCOL_FEE_RECIPIENT: DataKey = DataKey::ProtocolFeeRecipient; - pub const PROTOCOL_FEE_RECIPIENT_BALANCE: DataKey = - DataKey::ProtocolFeeRecipientBalance; - pub const PROTOCOL_STATE_VERSION: DataKey = DataKey::ProtocolStateVersion; - pub const PAUSED: DataKey = DataKey::Paused; - - pub fn creator_fee_balance(creator: &Address) -> DataKey { - DataKey::CreatorFeeBalance(creator.clone()) - } - - pub fn creator(creator: &Address) -> DataKey { - creator_key(creator) - } - - pub fn key_balance(creator: &Address, holder: &Address) -> DataKey { - key_balance_key(creator, holder) - } - } - - fn creator_key(creator: &Address) -> DataKey { - DataKey::Creator(creator.clone()) - } - - fn key_balance_key(creator: &Address, holder: &Address) -> DataKey { - DataKey::KeyBalance(creator.clone(), holder.clone()) - } - - pub mod creator_reads { - pub const DETAILS: &str = "get_creator_details"; - pub const FEE_BPS: &str = "get_creator_fee_bps"; - pub const FEE_CONFIG: &str = "get_creator_fee_config"; - pub const FEE_RECIPIENT: &str = "get_creator_fee_recipient"; - pub const FEE_RECIPIENT_BALANCE: &str = "get_creator_fee_balance"; - pub const HOLDER_KEY_COUNT: &str = "get_holder_key_count"; - pub const PROFILE: &str = "get_creator"; - pub const SUPPLY: &str = "get_creator_supply"; - pub const TREASURY_SHARE: &str = "get_creator_treasury_share"; - pub const NAME: &str = "get_key_name"; - pub const SYMBOL: &str = "get_key_symbol"; - } + pub mod storage { + use super::{creator_key, key_balance_key, DataKey}; + use soroban_sdk::Address; + + pub const FEE_CONFIG: DataKey = DataKey::FeeConfig; + pub const KEY_PRICE: DataKey = DataKey::KeyPrice; + pub const TREASURY_ADDRESS: DataKey = DataKey::TreasuryAddress; + pub const ADMIN_ADDRESS: DataKey = DataKey::AdminAddress; + pub const PROTOCOL_FEE_RECIPIENT: DataKey = DataKey::ProtocolFeeRecipient; + pub const PROTOCOL_FEE_RECIPIENT_BALANCE: DataKey = DataKey::ProtocolFeeRecipientBalance; + pub const PROTOCOL_STATE_VERSION: DataKey = DataKey::ProtocolStateVersion; + pub const PAUSED: DataKey = DataKey::Paused; + + pub fn creator_fee_balance(creator: &Address) -> DataKey { + DataKey::CreatorFeeBalance(creator.clone()) + } + + pub fn creator(creator: &Address) -> DataKey { + creator_key(creator) + } + + pub fn key_balance(creator: &Address, holder: &Address) -> DataKey { + key_balance_key(creator, holder) + } + } + + fn creator_key(creator: &Address) -> DataKey { + DataKey::Creator(creator.clone()) + } + + fn key_balance_key(creator: &Address, holder: &Address) -> DataKey { + DataKey::KeyBalance(creator.clone(), holder.clone()) + } + + pub mod creator_reads { + pub const DETAILS: &str = "get_creator_details"; + pub const FEE_BPS: &str = "get_creator_fee_bps"; + pub const FEE_CONFIG: &str = "get_creator_fee_config"; + pub const FEE_RECIPIENT: &str = "get_creator_fee_recipient"; + pub const FEE_RECIPIENT_BALANCE: &str = "get_creator_fee_balance"; + pub const HOLDER_KEY_COUNT: &str = "get_holder_key_count"; + pub const PROFILE: &str = "get_creator"; + pub const SUPPLY: &str = "get_creator_supply"; + pub const TREASURY_SHARE: &str = "get_creator_treasury_share"; + pub const NAME: &str = "get_key_name"; + pub const SYMBOL: &str = "get_key_symbol"; + } } /// Stable, non-optional view of the protocol fee configuration. @@ -279,9 +265,9 @@ pub mod constants { #[derive(Clone)] #[contracttype] pub struct ProtocolFeeView { - pub creator_bps: u32, - pub protocol_bps: u32, - pub is_configured: bool, + pub creator_bps: u32, + pub protocol_bps: u32, + pub is_configured: bool, } /// Stable, non-optional view of creator details. @@ -299,17 +285,17 @@ pub struct ProtocolFeeView { #[derive(Clone)] #[contracttype] pub struct CreatorDetailsView { - pub creator: Address, - pub handle: String, - pub supply: u32, - pub is_registered: bool, - /// Ledger sequence number at the time the creator registered. - /// - /// Set to `env.ledger().sequence()` inside [`CreatorKeysContract::register_creator`]. - /// Returns `0` for unregistered addresses so callers never receive an `Option`. - /// Clients can use this field to sort a marketplace grid chronologically without - /// maintaining a separate off-chain index. - pub registered_at: u32, + pub creator: Address, + pub handle: String, + pub supply: u32, + pub is_registered: bool, + /// Ledger sequence number at the time the creator registered. + /// + /// Set to `env.ledger().sequence()` inside [`CreatorKeysContract::register_creator`]. + /// Returns `0` for unregistered addresses so callers never receive an `Option`. + /// Clients can use this field to sort a marketplace grid chronologically without + /// maintaining a separate off-chain index. + pub registered_at: u32, } /// Stable, non-optional view of a creator's fee configuration. /// @@ -319,10 +305,10 @@ pub struct CreatorDetailsView { #[derive(Clone)] #[contracttype] pub struct CreatorFeeView { - pub creator_bps: u32, - pub protocol_bps: u32, - pub is_registered: bool, - pub is_configured: bool, + pub creator_bps: u32, + pub protocol_bps: u32, + pub is_registered: bool, + pub is_configured: bool, } /// Stable, non-optional view of a holder's key count for a creator. @@ -333,10 +319,10 @@ pub struct CreatorFeeView { #[derive(Clone)] #[contracttype] pub struct HolderKeyCountView { - pub creator: Address, - pub holder: Address, - pub key_count: u32, - pub creator_exists: bool, + pub creator: Address, + pub holder: Address, + pub key_count: u32, + pub creator_exists: bool, } /// Stable, non-optional view of a buy or sell quote. @@ -345,10 +331,10 @@ pub struct HolderKeyCountView { #[derive(Clone, Debug, PartialEq)] #[contracttype] pub struct QuoteResponse { - pub price: i128, - pub creator_fee: i128, - pub protocol_fee: i128, - pub total_amount: i128, + pub price: i128, + pub creator_fee: i128, + pub protocol_fee: i128, + pub total_amount: i128, } /// Shared result type for read-only quote methods. @@ -374,50 +360,46 @@ pub const HANDLE_LEN_MAX: u32 = 32; #[derive(Clone)] #[contracttype] pub enum DataKey { - Creator(Address), - FeeConfig, - KeyPrice, - KeyBalance(Address, Address), - TreasuryAddress, - AdminAddress, - ProtocolFeeRecipient, - ProtocolFeeRecipientBalance, - CreatorFeeBalance(Address), - ProtocolStateVersion, - Paused, - HoldersList(Address), - DividendClaimable(Address, Address), + Creator(Address), + FeeConfig, + KeyPrice, + KeyBalance(Address, Address), + TreasuryAddress, + AdminAddress, + ProtocolFeeRecipient, + ProtocolFeeRecipientBalance, + CreatorFeeBalance(Address), + ProtocolStateVersion, + Paused, + HoldersList(Address), + DividendClaimable(Address, Address), } #[derive(Clone, Debug, PartialEq)] #[contracttype] pub struct CreatorProfile { - pub creator: Address, - pub handle: String, - pub supply: u32, - pub holder_count: u32, - pub fee_recipient: Address, - /// Ledger sequence number captured at registration time via `env.ledger().sequence()`. - /// - /// Stored as the last field so existing serialised profiles written before this - /// field was added deserialise correctly — the Soroban persistent storage layer - /// reads structs by field index, so appending is the only safe extension pattern. - pub registered_at: u32, + pub creator: Address, + pub handle: String, + pub supply: u32, + pub holder_count: u32, + pub fee_recipient: Address, + /// Ledger sequence number captured at registration time via `env.ledger().sequence()`. + /// + /// Stored as the last field so existing serialised profiles written before this + /// field was added deserialise correctly — the Soroban persistent storage layer + /// reads structs by field index, so appending is the only safe extension pattern. + pub registered_at: u32, } /// Reads a creator profile from storage, returning `None` for unregistered creators. /// /// Use this helper wherever repeated creator read logic is needed to keep /// missing-creator behavior consistent across the contract. -pub fn read_creator_profile( - env: &Env, - creator: &Address, -) -> Option { - let key = constants::storage::creator(creator); - env - .storage() - .persistent() - .get::(&key) +pub fn read_creator_profile(env: &Env, creator: &Address) -> Option { + let key = constants::storage::creator(creator); + env.storage() + .persistent() + .get::(&key) } /// Reads a registered creator profile, returning an error when the creator is missing. @@ -425,10 +407,10 @@ pub fn read_creator_profile( /// Use this helper for methods that require an existing creator and should return /// a structured contract error instead of a default value. pub fn read_registered_creator_profile( - env: &Env, - creator: &Address, + env: &Env, + creator: &Address, ) -> Result { - read_creator_profile(env, creator).ok_or(ContractError::NotRegistered) + read_creator_profile(env, creator).ok_or(ContractError::NotRegistered) } /// Reads the key balance (supply) for a creator, returning `0` for unregistered creators. @@ -436,9 +418,9 @@ pub fn read_registered_creator_profile( /// Use this helper wherever repeated key balance read logic is needed to keep /// missing-balance behavior consistent across the contract. pub fn read_key_balance(env: &Env, creator: &Address) -> u32 { - read_creator_profile(env, creator) - .map(|p| p.supply) - .unwrap_or(0) + read_creator_profile(env, creator) + .map(|p| p.supply) + .unwrap_or(0) } /// Reads an empty string for use as a default in read-only view methods. @@ -446,7 +428,7 @@ pub fn read_key_balance(env: &Env, creator: &Address) -> u32 { /// Use this helper wherever an empty string is needed to maintain consistency /// and reduce duplication of string allocation logic. pub fn read_none_string(env: &Env) -> String { - String::from_str(env, "") + String::from_str(env, "") } /// Reads the handle for a creator, returning an empty string for unregistered creators. @@ -454,93 +436,88 @@ pub fn read_none_string(env: &Env) -> String { /// Use this helper wherever repeated handle read logic is needed to maintain /// missing-handle behavior consistency across the contract. pub fn read_creator_handle(env: &Env, creator: &Address) -> String { - read_creator_profile(env, creator) - .map(|p| p.handle) - .unwrap_or_else(|| read_none_string(env)) + read_creator_profile(env, creator) + .map(|p| p.handle) + .unwrap_or_else(|| read_none_string(env)) } /// Reads accrued creator fee balance for a creator, returning `0` when none is stored. -pub fn read_creator_fee_recipient_balance( - env: &Env, - creator: &Address, -) -> i128 { - let key = constants::storage::creator_fee_balance(creator); - env.storage().persistent().get(&key).unwrap_or(0) +pub fn read_creator_fee_recipient_balance(env: &Env, creator: &Address) -> i128 { + let key = constants::storage::creator_fee_balance(creator); + env.storage().persistent().get(&key).unwrap_or(0) } /// Credits `amount` to the creator fee recipient balance for `creator`. fn credit_creator_fee_recipient_balance( - env: &Env, - creator: &Address, - amount: i128, + env: &Env, + creator: &Address, + amount: i128, ) -> Result<(), ContractError> { - if amount <= 0 { - return Ok(()); - } - let key = constants::storage::creator_fee_balance(creator); - let current = read_creator_fee_recipient_balance(env, creator); - let updated = current.checked_add(amount).ok_or(ContractError::Overflow)?; - env.storage().persistent().set(&key, &updated); - Ok(()) + if amount <= 0 { + return Ok(()); + } + let key = constants::storage::creator_fee_balance(creator); + let current = read_creator_fee_recipient_balance(env, creator); + let updated = current.checked_add(amount).ok_or(ContractError::Overflow)?; + env.storage().persistent().set(&key, &updated); + Ok(()) } fn is_valid_handle_byte(byte: u8) -> bool { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' } fn validate_creator_handle(handle: &String) -> Result<(), ContractError> { - let len = handle.len(); - if len < HANDLE_LEN_MIN { - return Err(ContractError::HandleTooShort); - } - if len > HANDLE_LEN_MAX { - return Err(ContractError::HandleTooLong); - } - - let mut bytes = [0u8; HANDLE_LEN_MAX as usize]; - handle.copy_into_slice(&mut bytes[..len as usize]); - if bytes[..len as usize] - .iter() - .any(|byte| !is_valid_handle_byte(*byte)) - { - return Err(ContractError::InvalidHandleCharacter); - } - - Ok(()) + let len = handle.len(); + if len < HANDLE_LEN_MIN { + return Err(ContractError::HandleTooShort); + } + if len > HANDLE_LEN_MAX { + return Err(ContractError::HandleTooLong); + } + + let mut bytes = [0u8; HANDLE_LEN_MAX as usize]; + handle.copy_into_slice(&mut bytes[..len as usize]); + if bytes[..len as usize] + .iter() + .any(|byte| !is_valid_handle_byte(*byte)) + { + return Err(ContractError::InvalidHandleCharacter); + } + + Ok(()) } fn is_paused(env: &Env) -> bool { - env - .storage() - .persistent() - .get::(&constants::storage::PAUSED) - .unwrap_or(false) + env.storage() + .persistent() + .get::(&constants::storage::PAUSED) + .unwrap_or(false) } fn assert_not_paused(env: &Env) -> Result<(), ContractError> { - if is_paused(env) { - return Err(ContractError::ProtocolPaused); - } - Ok(()) + if is_paused(env) { + return Err(ContractError::ProtocolPaused); + } + Ok(()) } fn assert_is_admin(env: &Env, caller: &Address) -> Result<(), ContractError> { - let admin: Address = env - .storage() - .persistent() - .get(&constants::storage::ADMIN_ADDRESS) - .ok_or(ContractError::Unauthorized)?; - if *caller != admin { - return Err(ContractError::Unauthorized); - } - Ok(()) + let admin: Address = env + .storage() + .persistent() + .get(&constants::storage::ADMIN_ADDRESS) + .ok_or(ContractError::Unauthorized)?; + if *caller != admin { + return Err(ContractError::Unauthorized); + } + Ok(()) } fn read_protocol_fee_config(env: &Env) -> Option { - env - .storage() - .persistent() - .get(&constants::storage::FEE_CONFIG) + env.storage() + .persistent() + .get(&constants::storage::FEE_CONFIG) } /// Validates that an address is not the Stellar zero address. @@ -548,139 +525,119 @@ fn read_protocol_fee_config(env: &Env) -> Option { /// The zero address (`GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF`) /// is the all-zero public key. Setting it as a fee recipient would silently /// burn all protocol fees. This helper rejects it at the point of assignment. -fn validate_non_zero_address( - env: &Env, - addr: &Address, -) -> Result<(), ContractError> { - let zero_str = String::from_str( - env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ); - let zero_addr = Address::from_string(&zero_str); - if *addr == zero_addr { - return Err(ContractError::ZeroAddress); - } - Ok(()) +fn validate_non_zero_address(env: &Env, addr: &Address) -> Result<(), ContractError> { + let zero_str = String::from_str( + env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ); + let zero_addr = Address::from_string(&zero_str); + if *addr == zero_addr { + return Err(ContractError::ZeroAddress); + } + Ok(()) } -fn read_required_protocol_fee_config( - env: &Env, -) -> Result { - read_protocol_fee_config(env).ok_or(ContractError::FeeConfigNotSet) +fn read_required_protocol_fee_config(env: &Env) -> Result { + read_protocol_fee_config(env).ok_or(ContractError::FeeConfigNotSet) } fn read_protocol_fee_recipient_balance(env: &Env) -> i128 { - env - .storage() - .persistent() - .get(&constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE) - .unwrap_or(0) + env.storage() + .persistent() + .get(&constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE) + .unwrap_or(0) } -fn credit_protocol_fee_recipient_balance( - env: &Env, - amount: i128, -) -> Result<(), ContractError> { - if amount <= 0 { - return Ok(()); - } - let updated = read_protocol_fee_recipient_balance(env) - .checked_add(amount) - .ok_or(ContractError::Overflow)?; - env.storage().persistent().set( - &constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE, - &updated, - ); - Ok(()) +fn credit_protocol_fee_recipient_balance(env: &Env, amount: i128) -> Result<(), ContractError> { + if amount <= 0 { + return Ok(()); + } + let updated = read_protocol_fee_recipient_balance(env) + .checked_add(amount) + .ok_or(ContractError::Overflow)?; + env.storage().persistent().set( + &constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE, + &updated, + ); + Ok(()) } -fn assert_buy_price_slippage( - price: i128, - max_price: Option, -) -> Result<(), ContractError> { - if let Some(max) = max_price { - if price > max { - return Err(ContractError::SlippageExceeded); +fn assert_buy_price_slippage(price: i128, max_price: Option) -> Result<(), ContractError> { + if let Some(max) = max_price { + if price > max { + return Err(ContractError::SlippageExceeded); + } } - } - Ok(()) + Ok(()) } -fn compute_sell_proceeds( - env: &Env, - price: i128, -) -> Result { - let (creator_fee, protocol_fee) = - CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?; - let fees = fee::checked_fee_sum(creator_fee, protocol_fee) - .ok_or(ContractError::Overflow)?; - fee::checked_sub_i128(price, fees).ok_or(ContractError::SellUnderflow) +fn compute_sell_proceeds(env: &Env, price: i128) -> Result { + let (creator_fee, protocol_fee) = + CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?; + let fees = fee::checked_fee_sum(creator_fee, protocol_fee).ok_or(ContractError::Overflow)?; + fee::checked_sub_i128(price, fees).ok_or(ContractError::SellUnderflow) } fn assert_sell_proceeds_slippage( - env: &Env, - min_proceeds: Option, + env: &Env, + min_proceeds: Option, ) -> Result<(), ContractError> { - if let Some(min) = min_proceeds { - let price: i128 = env - .storage() - .persistent() - .get(&constants::storage::KEY_PRICE) - .ok_or(ContractError::KeyPriceNotSet)?; - let proceeds = compute_sell_proceeds(env, price)?; - if proceeds < min { - return Err(ContractError::SlippageExceeded); - } - } - Ok(()) + if let Some(min) = min_proceeds { + let price: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; + let proceeds = compute_sell_proceeds(env, price)?; + if proceeds < min { + return Err(ContractError::SlippageExceeded); + } + } + Ok(()) } fn accrue_sell_protocol_fee(env: &Env) -> Result<(), ContractError> { - if env - .storage() - .persistent() - .get::(&constants::storage::PROTOCOL_FEE_RECIPIENT) - .is_none() - { - return Ok(()); - } - - let Some(price) = env - .storage() - .persistent() - .get(&constants::storage::KEY_PRICE) - else { - return Ok(()); - }; - - if read_protocol_fee_config(env).is_none() { - return Ok(()); - } - - let (_, protocol_fee) = - CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?; - credit_protocol_fee_recipient_balance(env, protocol_fee) + if env + .storage() + .persistent() + .get::(&constants::storage::PROTOCOL_FEE_RECIPIENT) + .is_none() + { + return Ok(()); + } + + let Some(price) = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + else { + return Ok(()); + }; + + if read_protocol_fee_config(env).is_none() { + return Ok(()); + } + + let (_, protocol_fee) = CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?; + credit_protocol_fee_recipient_balance(env, protocol_fee) } /// Resolves and validates the shared inputs required by read-only quote methods. /// /// Reads the key price from storage and confirms the creator is registered. /// Returns `(price)` on success, or the appropriate [`ContractError`] on failure. -fn resolve_quote_inputs( - env: &Env, - creator: &Address, -) -> Result, ContractError> { - let price: i128 = env - .storage() - .persistent() - .get(&constants::storage::KEY_PRICE) - .ok_or(ContractError::KeyPriceNotSet)?; - - if read_creator_profile(env, creator).is_none() { - return Err(ContractError::NotRegistered); - } - - normalize_quote_amount(price) +fn resolve_quote_inputs(env: &Env, creator: &Address) -> Result, ContractError> { + let price: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; + + if read_creator_profile(env, creator).is_none() { + return Err(ContractError::NotRegistered); + } + + normalize_quote_amount(price) } /// Normalizes quote amounts before fee math is applied. @@ -689,54 +646,53 @@ fn resolve_quote_inputs( /// Negative quote amounts are rejected consistently across buy and sell paths. /// Amounts exceeding MAX_SAFE_AMOUNT are rejected to prevent overflow in fee calculations. fn normalize_quote_amount(amount: i128) -> Result, ContractError> { - if amount < 0 { - return Err(ContractError::NotPositiveAmount); - } + if amount < 0 { + return Err(ContractError::NotPositiveAmount); + } - if amount == 0 { - return Ok(None); - } + if amount == 0 { + return Ok(None); + } - if amount > fee::MAX_SAFE_AMOUNT { - return Err(ContractError::Overflow); - } + if amount > fee::MAX_SAFE_AMOUNT { + return Err(ContractError::Overflow); + } - Ok(Some(amount)) + Ok(Some(amount)) } fn zero_quote_response() -> QuoteResponse { - QuoteResponse { - price: 0, - creator_fee: 0, - protocol_fee: 0, - total_amount: 0, - } + QuoteResponse { + price: 0, + creator_fee: 0, + protocol_fee: 0, + total_amount: 0, + } } /// Formats a quote response with overflow-safe total amount calculation. /// /// Returns `Err(ContractError::Overflow)` if any addition or subtraction would overflow. fn checked_format_quote_response( - price: i128, - creator_fee: i128, - protocol_fee: i128, - is_buy: bool, + price: i128, + creator_fee: i128, + protocol_fee: i128, + is_buy: bool, ) -> QuoteViewResult { - let fees = fee::checked_fee_sum(creator_fee, protocol_fee) - .ok_or(ContractError::Overflow)?; - - let total_amount = if is_buy { - price.checked_add(fees).ok_or(ContractError::Overflow)? - } else { - fee::checked_sub_i128(price, fees).ok_or(ContractError::SellUnderflow)? - }; - - Ok(QuoteResponse { - price, - creator_fee, - protocol_fee, - total_amount, - }) + let fees = fee::checked_fee_sum(creator_fee, protocol_fee).ok_or(ContractError::Overflow)?; + + let total_amount = if is_buy { + price.checked_add(fees).ok_or(ContractError::Overflow)? + } else { + fee::checked_sub_i128(price, fees).ok_or(ContractError::SellUnderflow)? + }; + + Ok(QuoteResponse { + price, + creator_fee, + protocol_fee, + total_amount, + }) } #[contract] @@ -744,1385 +700,1298 @@ pub struct CreatorKeysContract; #[contractimpl] impl CreatorKeysContract { - /// Registers a new creator profile. This is a contract initialization - /// entrypoint; the contract has no single `initialize` call, so the - /// init-time parameter validation lives on the individual setters. - /// - /// Parameter validation: - /// - `creator`: must authorize the call (`require_auth`). A profile must not - /// already exist for this address, otherwise - /// [`ContractError::AlreadyRegistered`]. - /// - `handle`: validated by [`validate_creator_handle`] — below the minimum - /// length returns [`ContractError::HandleTooShort`], above the maximum - /// returns [`ContractError::HandleTooLong`], and any disallowed byte - /// returns [`ContractError::InvalidHandleCharacter`]. - pub fn register_creator( - env: Env, - creator: Address, - handle: String, - ) -> Result<(), ContractError> { - creator.require_auth(); - assert_not_paused(&env)?; - - validate_creator_handle(&handle)?; - - let key = constants::storage::creator(&creator); - // Creator profile storage is a single source of truth keyed by creator address. - // Once written, this key's existence is the registration invariant. - if env.storage().persistent().has(&key) { - return Err(ContractError::AlreadyRegistered); - } - - let profile = CreatorProfile { - creator: creator.clone(), - handle, - supply: 0, - holder_count: 0, - fee_recipient: creator.clone(), - registered_at: env.ledger().sequence(), - }; + /// Registers a new creator profile. This is a contract initialization + /// entrypoint; the contract has no single `initialize` call, so the + /// init-time parameter validation lives on the individual setters. + /// + /// Parameter validation: + /// - `creator`: must authorize the call (`require_auth`). A profile must not + /// already exist for this address, otherwise + /// [`ContractError::AlreadyRegistered`]. + /// - `handle`: validated by [`validate_creator_handle`] — below the minimum + /// length returns [`ContractError::HandleTooShort`], above the maximum + /// returns [`ContractError::HandleTooLong`], and any disallowed byte + /// returns [`ContractError::InvalidHandleCharacter`]. + pub fn register_creator( + env: Env, + creator: Address, + handle: String, + ) -> Result<(), ContractError> { + creator.require_auth(); + assert_not_paused(&env)?; + + validate_creator_handle(&handle)?; + + let key = constants::storage::creator(&creator); + // Creator profile storage is a single source of truth keyed by creator address. + // Once written, this key's existence is the registration invariant. + if env.storage().persistent().has(&key) { + return Err(ContractError::AlreadyRegistered); + } + + let profile = CreatorProfile { + creator: creator.clone(), + handle, + supply: 0, + holder_count: 0, + fee_recipient: creator.clone(), + registered_at: env.ledger().sequence(), + }; + + let fee_config = read_protocol_fee_config(&env).unwrap_or(fee::FeeConfig { + creator_bps: 0, + protocol_bps: 0, + }); + + // Persist profile before event publication so indexers reading contract state + // after this tx observe the same registration payload that was emitted. + env.storage().persistent().set(&key, &profile); + env.events().publish( + events::register_event_topics(&profile.creator), + events::CreatorRegisteredEvent { + creator: profile.creator.clone(), + handle: profile.handle.clone(), + supply: profile.supply, + holder_count: profile.holder_count, + creator_bps: fee_config.creator_bps, + protocol_bps: fee_config.protocol_bps, + }, + ); + + Ok(()) + } - let fee_config = read_protocol_fee_config(&env).unwrap_or(fee::FeeConfig { - creator_bps: 0, - protocol_bps: 0, - }); - - // Persist profile before event publication so indexers reading contract state - // after this tx observe the same registration payload that was emitted. - env.storage().persistent().set(&key, &profile); - env.events().publish( - events::register_event_topics(&profile.creator), - events::CreatorRegisteredEvent { - creator: profile.creator.clone(), - handle: profile.handle.clone(), - supply: profile.supply, - holder_count: profile.holder_count, - creator_bps: fee_config.creator_bps, - protocol_bps: fee_config.protocol_bps, - }, - ); + pub fn buy_key( + env: Env, + creator: Address, + buyer: Address, + payment: i128, + max_price: Option, + ) -> Result { + buyer.require_auth(); + assert_not_paused(&env)?; + + if payment <= 0 { + return Err(ContractError::NotPositiveAmount); + } + + let price: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; + + assert_buy_price_slippage(price, max_price)?; + + if payment < price { + return Err(ContractError::InsufficientPayment); + } + + let mut profile: CreatorProfile = read_registered_creator_profile(&env, &creator)?; + + let balance_key = constants::storage::key_balance(&creator, &buyer); + // Missing balance entries are treated as zero to keep storage sparse. + let current_balance: u32 = env.storage().persistent().get(&balance_key).unwrap_or(0); + + if current_balance == 0 { + profile.holder_count = profile + .holder_count + .checked_add(1) + .ok_or(ContractError::Overflow)?; + } + let mut holders = read_holders_list(&env, &creator); + if !holders.iter().any(|h| h == buyer) { + holders.push_back(buyer.clone()); + write_holders_list(&env, &creator, &holders); + } + + profile.supply = profile + .supply + .checked_add(1) + .ok_or(ContractError::Overflow)?; + + let key = constants::storage::creator(&creator); + // Supply and holder_count must always move together with buyer balance writes. + env.storage().persistent().set(&key, &profile); + + let new_balance = current_balance + .checked_add(1) + .ok_or(ContractError::Overflow)?; + // Balance key is scoped by (creator, holder) so creator positions cannot collide. + env.storage().persistent().set(&balance_key, &new_balance); + + if let Some(config) = read_protocol_fee_config(&env) { + let (creator_fee, protocol_fee) = + fee::checked_compute_fee_split(price, config.creator_bps, config.protocol_bps) + .ok_or(ContractError::Overflow)?; + credit_creator_fee_recipient_balance(&env, &creator, creator_fee)?; + credit_protocol_fee_recipient_balance(&env, protocol_fee)?; + } + + env.events().publish( + events::buy_event_topics(&creator, &buyer), + (profile.supply, payment), + ); + + Ok(profile.supply) + } - Ok(()) - } + pub fn sell_key( + env: Env, + creator: Address, + seller: Address, + min_proceeds: Option, + ) -> Result { + seller.require_auth(); + assert_not_paused(&env)?; + + let mut profile: CreatorProfile = read_registered_creator_profile(&env, &creator)?; + + let balance_key = constants::storage::key_balance(&creator, &seller); + // Missing balance entries are interpreted as zero and rejected consistently. + let current_balance: u32 = env.storage().persistent().get(&balance_key).unwrap_or(0); + if current_balance == 0 { + return Err(ContractError::InsufficientBalance); + } + + assert_sell_proceeds_slippage(&env, min_proceeds)?; + + let new_balance = current_balance + .checked_sub(1) + .ok_or(ContractError::SellUnderflow)?; + profile.supply = profile + .supply + .checked_sub(1) + .ok_or(ContractError::SellUnderflow)?; + + if new_balance == 0 { + profile.holder_count = profile + .holder_count + .checked_sub(1) + .ok_or(ContractError::SellUnderflow)?; + let mut holders = read_holders_list(&env, &creator); + if let Some(pos) = holders.iter().position(|h| h == seller) { + holders.remove(pos as u32); + write_holders_list(&env, &creator, &holders); + } + } + + let key = constants::storage::creator(&creator); + // Profile and holder balance are updated in the same call to preserve + // supply/holder_count invariants for subsequent reads. + env.storage().persistent().set(&key, &profile); + env.storage().persistent().set(&balance_key, &new_balance); + accrue_sell_protocol_fee(&env)?; + + env.events() + .publish((events::SELL_EVENT_NAME, creator, seller), profile.supply); + + Ok(profile.supply) + } - pub fn buy_key( - env: Env, - creator: Address, - buyer: Address, - payment: i128, - max_price: Option, - ) -> Result { - buyer.require_auth(); - assert_not_paused(&env)?; + /// Halts all state-changing operations (buy, sell, register_creator). + /// + /// Only the protocol admin may call this. Emits a `ProtocolPaused` event. + /// Read-only view functions are unaffected and continue to work while paused. + pub fn pause(env: Env, admin: Address) -> Result<(), ContractError> { + admin.require_auth(); + assert_is_admin(&env, &admin)?; + env.storage() + .persistent() + .set(&constants::storage::PAUSED, &true); + env.events().publish((events::PAUSE_EVENT_NAME, admin), ()); + Ok(()) + } - if payment <= 0 { - return Err(ContractError::NotPositiveAmount); + /// Resumes all state-changing operations after an emergency pause. + /// + /// Only the protocol admin may call this. Emits a `ProtocolUnpaused` event. + pub fn unpause(env: Env, admin: Address) -> Result<(), ContractError> { + admin.require_auth(); + assert_is_admin(&env, &admin)?; + env.storage() + .persistent() + .set(&constants::storage::PAUSED, &false); + env.events() + .publish((events::UNPAUSE_EVENT_NAME, admin), ()); + Ok(()) } - let price: i128 = env - .storage() - .persistent() - .get(&constants::storage::KEY_PRICE) - .ok_or(ContractError::KeyPriceNotSet)?; + /// Read-only view: returns whether the protocol is currently paused. + pub fn get_is_paused(env: Env) -> bool { + is_paused(&env) + } - assert_buy_price_slippage(price, max_price)?; + pub fn get_key_balance(env: Env, creator: Address, wallet: Address) -> u32 { + let key = constants::storage::key_balance(&creator, &wallet); + // Read-only callers get `0` for unseen balances to avoid sparse-map lookups failing. + env.storage().persistent().get(&key).unwrap_or(0) + } - if payment < price { - return Err(ContractError::InsufficientPayment); + /// Read-only view: returns a stable view of a holder's key count for a creator. + /// + /// Returns a [`HolderKeyCountView`] regardless of creator registration status. + /// When the creator is not registered, `creator_exists` is `false` and `key_count` is `0`. + /// When the creator exists but the holder has no keys, `key_count` is `0`. + /// This method is designed for indexer-friendly consumption and avoids panics. + pub fn get_holder_key_count(env: Env, creator: Address, holder: Address) -> HolderKeyCountView { + let creator_exists = read_creator_profile(&env, &creator).is_some(); + let key_count = if creator_exists { + let key = constants::storage::key_balance(&creator, &holder); + env.storage().persistent().get(&key).unwrap_or(0) + } else { + 0 + }; + + HolderKeyCountView { + creator, + holder, + key_count, + creator_exists, + } } - let mut profile: CreatorProfile = - read_registered_creator_profile(&env, &creator)?; + pub fn get_creator(env: Env, creator: Address) -> Result { + read_registered_creator_profile(&env, &creator) + } - let balance_key = constants::storage::key_balance(&creator, &buyer); - // Missing balance entries are treated as zero to keep storage sparse. - let current_balance: u32 = - env.storage().persistent().get(&balance_key).unwrap_or(0); + /// Read-only view: returns stable creator details. + /// + /// Returns a [`CreatorDetailsView`] regardless of registration status. + /// When the creator is not registered, `is_registered` is `false` and + /// default values are provided for other fields, including `registered_at: 0`. + pub fn get_creator_details(env: Env, creator: Address) -> CreatorDetailsView { + let key = constants::storage::creator(&creator); + match env + .storage() + .persistent() + .get::(&key) + { + Some(profile) => CreatorDetailsView { + creator: profile.creator, + handle: profile.handle, + supply: profile.supply, + is_registered: true, + registered_at: profile.registered_at, + }, + None => CreatorDetailsView { + creator, + handle: read_none_string(&env), + supply: 0, + is_registered: false, + registered_at: 0, + }, + } + } - if current_balance == 0 { - profile.holder_count = profile - .holder_count - .checked_add(1) - .ok_or(ContractError::Overflow)?; + pub fn distribute_dividend( + env: Env, + creator: Address, + amount: i128, + ) -> Result<(), ContractError> { + let caller = env.invoker(); + let native_address = env.ledger().get_native_token(); + let native_token = soroban_sdk::token::TokenClient::new(&env, &native_address); + native_token.transfer(&caller, &env.current_contract_address(), &amount); + + // Load creator profile and validate + let profile = read_registered_creator_profile(&env, &creator)?; + let total_supply = profile.supply as i128; + if total_supply == 0 { + return Err(ContractError::NoHolders); + } + + // Load fee config and compute protocol fee + let config = read_required_protocol_fee_config(&env)?; + let protocol_fee = fee::apply_percentage_fee(amount, config.protocol_bps) + .ok_or(ContractError::Overflow)?; + let distribute_amount = amount + .checked_sub(protocol_fee) + .ok_or(ContractError::Overflow)?; + if distribute_amount <= 0 { + return Err(ContractError::DividendAmountZero); + } + + // Credit protocol fee to the protocol fee recipient balance + credit_protocol_fee_recipient_balance(&env, protocol_fee)?; + + // Iterate over all holders and distribute proportionally + let holders = read_holders_list(&env, &creator); + let ledger = env.ledger().sequence(); + + for holder in holders.iter() { + let balance = + Self::get_key_balance(env.clone(), creator.clone(), holder.clone()) as i128; + if balance == 0 { + continue; + } + let share = (balance * distribute_amount) / total_supply; + if share > 0 { + let current = read_claimable_dividend(&env, &creator, &holder); + let new_claim = current.checked_add(share).ok_or(ContractError::Overflow)?; + write_claimable_dividend(&env, &creator, &holder, new_claim); + } + } + + // Emit event + env.events().publish( + events::dividend_distributed_topics(&creator), + events::DividendDistributedEvent { + creator_id: creator, + total_amount: amount, + snapshot_supply: total_supply, + ledger, + protocol_fee, + distributed_amount: distribute_amount, + }, + ); + + Ok(()) } - let mut holders = read_holders_list(&env, &creator); - if !holders.iter().any(|h| h == buyer) { - holders.push_back(buyer.clone()); - write_holders_list(&env, &creator, &holders); + + /// Claims accrued dividends for the caller. + /// + /// Transfers the claimable amount from the contract to the caller's wallet. + /// Resets the claimable balance to zero. + /// Emits a `DividendClaimed` event. + pub fn claim_dividend(env: Env, creator: Address) -> Result<(), ContractError> { + let caller = env.invoker(); + + // 1. Read claimable balance + let claimable = read_claimable_dividend(&env, &creator, &caller); + if claimable == 0 { + return Err(ContractError::ZeroClaimable); + } + + // 2. Reset claimable to zero before transfer (reentrancy-safe) + write_claimable_dividend(&env, &creator, &caller, 0); + + // 3. Transfer XLM from contract to caller + let native_address = env.ledger().get_native_token(); + let native_token = soroban_sdk::token::TokenClient::new(&env, &native_address); + native_token.transfer(&env.current_contract_address(), &caller, &claimable); + + // 4. Emit event + env.events().publish( + events::dividend_claimed_topics(&creator, &caller), + events::DividendClaimedEvent { + creator_id: creator, + claimant: caller, + amount: claimable, + }, + ); + + Ok(()) } - profile.supply = profile - .supply - .checked_add(1) - .ok_or(ContractError::Overflow)?; + /// Returns the unclaimed dividend balance for a specific (creator, holder) pair. + pub fn get_claimable_dividend(env: Env, creator: Address, wallet: Address) -> i128 { + read_claimable_dividend(&env, &creator, &wallet) + } - let key = constants::storage::creator(&creator); - // Supply and holder_count must always move together with buyer balance writes. - env.storage().persistent().set(&key, &profile); + /// Read-only batch view: returns [`CreatorDetailsView`] for each address in `creators`. + /// + /// Iterates the provided addresses in order and fetches each creator's profile + /// from persistent storage. The output `Vec` is the same length as the input and + /// preserves input order, so clients can zip the two slices without an extra sort. + /// + /// Unregistered addresses never cause the call to fail: they produce a default + /// [`CreatorDetailsView`] with `is_registered: false` and `registered_at: 0`, + /// matching the single-address behaviour of [`get_creator_details`]. + /// + /// # Usage + /// + /// ```text + /// let views = client.get_creators_batch(&vec![alice, bob, unknown]); + /// // views[0] → alice's details (is_registered: true) + /// // views[1] → bob's details (is_registered: true) + /// // views[2] → default view (is_registered: false, registered_at: 0) + /// ``` + pub fn get_creators_batch( + env: Env, + creators: soroban_sdk::Vec
, + ) -> soroban_sdk::Vec { + let mut results = soroban_sdk::Vec::new(&env); + for creator in creators.iter() { + let key = constants::storage::creator(&creator); + let view = match env + .storage() + .persistent() + .get::(&key) + { + Some(profile) => CreatorDetailsView { + creator: profile.creator, + handle: profile.handle, + supply: profile.supply, + is_registered: true, + registered_at: profile.registered_at, + }, + None => CreatorDetailsView { + creator, + handle: read_none_string(&env), + supply: 0, + is_registered: false, + registered_at: 0, + }, + }; + results.push_back(view); + } + results + } - let new_balance = current_balance - .checked_add(1) - .ok_or(ContractError::Overflow)?; - // Balance key is scoped by (creator, holder) so creator positions cannot collide. - env.storage().persistent().set(&balance_key, &new_balance); + /// Read-only view: returns the protocol state version. + /// + /// Returns a stable scalar value for clients and indexers to detect + /// protocol-state schema/semantics revisions. The version is stored in + /// storage and increments on config updates. + pub fn get_protocol_state_version(env: Env) -> u32 { + env.storage() + .persistent() + .get(&constants::storage::PROTOCOL_STATE_VERSION) + .unwrap_or(PROTOCOL_STATE_VERSION_INITIAL) + } - if let Some(config) = read_protocol_fee_config(&env) { - let (creator_fee, protocol_fee) = fee::checked_compute_fee_split( - price, - config.creator_bps, - config.protocol_bps, - ) - .ok_or(ContractError::Overflow)?; - credit_creator_fee_recipient_balance(&env, &creator, creator_fee)?; - credit_protocol_fee_recipient_balance(&env, protocol_fee)?; + /// Read-only view: returns the decimal precision used by creator key values. + /// + /// Returns the fixed [`KEY_DECIMALS`] constant. Does not read or mutate contract state. + pub fn get_key_decimals(_env: Env) -> u32 { + KEY_DECIMALS } - env.events().publish( - events::buy_event_topics(&creator, &buyer), - (profile.supply, payment), - ); + /// Read-only view: returns the display name for a creator's key. + /// + /// Returns the creator's handle for registered creators. Fails with + /// [`ContractError::NotRegistered`] if the creator is not registered. + pub fn get_key_name(env: Env, creator: Address) -> Result { + let profile = read_registered_creator_profile(&env, &creator)?; + Ok(profile.handle) + } - Ok(profile.supply) - } + /// Read-only view: returns the ticker symbol for a creator's key. + /// + /// Returns the creator's handle for registered creators. Fails with + /// [`ContractError::NotRegistered`] if the creator is not registered. + pub fn get_key_symbol(env: Env, creator: Address) -> Result { + let profile = read_registered_creator_profile(&env, &creator)?; + Ok(profile.handle) + } - pub fn sell_key( - env: Env, - creator: Address, - seller: Address, - min_proceeds: Option, - ) -> Result { - seller.require_auth(); - assert_not_paused(&env)?; - - let mut profile: CreatorProfile = - read_registered_creator_profile(&env, &creator)?; - - let balance_key = constants::storage::key_balance(&creator, &seller); - // Missing balance entries are interpreted as zero and rejected consistently. - let current_balance: u32 = - env.storage().persistent().get(&balance_key).unwrap_or(0); - if current_balance == 0 { - return Err(ContractError::InsufficientBalance); - } - - assert_sell_proceeds_slippage(&env, min_proceeds)?; - - let new_balance = current_balance - .checked_sub(1) - .ok_or(ContractError::SellUnderflow)?; - profile.supply = profile - .supply - .checked_sub(1) - .ok_or(ContractError::SellUnderflow)?; - - if new_balance == 0 { - profile.holder_count = profile - .holder_count - .checked_sub(1) - .ok_or(ContractError::SellUnderflow)?; - let mut holders = read_holders_list(&env, &creator); - if let Some(pos) = holders.iter().position(|h| h == seller) { - holders.remove(pos as u32); - write_holders_list(&env, &creator, &holders); - } - } - - let key = constants::storage::creator(&creator); - // Profile and holder balance are updated in the same call to preserve - // supply/holder_count invariants for subsequent reads. - env.storage().persistent().set(&key, &profile); - env.storage().persistent().set(&balance_key, &new_balance); - accrue_sell_protocol_fee(&env)?; - - env - .events() - .publish((events::SELL_EVENT_NAME, creator, seller), profile.supply); - - Ok(profile.supply) - } - - /// Halts all state-changing operations (buy, sell, register_creator). - /// - /// Only the protocol admin may call this. Emits a `ProtocolPaused` event. - /// Read-only view functions are unaffected and continue to work while paused. - pub fn pause(env: Env, admin: Address) -> Result<(), ContractError> { - admin.require_auth(); - assert_is_admin(&env, &admin)?; - env - .storage() - .persistent() - .set(&constants::storage::PAUSED, &true); - env.events().publish((events::PAUSE_EVENT_NAME, admin), ()); - Ok(()) - } - - /// Resumes all state-changing operations after an emergency pause. - /// - /// Only the protocol admin may call this. Emits a `ProtocolUnpaused` event. - pub fn unpause(env: Env, admin: Address) -> Result<(), ContractError> { - admin.require_auth(); - assert_is_admin(&env, &admin)?; - env - .storage() - .persistent() - .set(&constants::storage::PAUSED, &false); - env - .events() - .publish((events::UNPAUSE_EVENT_NAME, admin), ()); - Ok(()) - } + /// Read-only view: returns the total key supply for a creator. + /// + /// Returns `0` if the creator is not registered, avoiding panics for + /// invalid lookups. Delegates to the shared [`read_key_balance`] helper. + pub fn get_total_key_supply(env: Env, creator: Address) -> u32 { + read_key_balance(&env, &creator) + } - /// Read-only view: returns whether the protocol is currently paused. - pub fn get_is_paused(env: Env) -> bool { - is_paused(&env) - } + /// Read-only view: returns the current supply for a registered creator. + /// + /// Fails with [`ContractError::NotRegistered`] if the creator does not exist. + pub fn get_creator_supply(env: Env, creator: Address) -> Result { + let profile = read_registered_creator_profile(&env, &creator)?; + Ok(profile.supply) + } - pub fn get_key_balance(env: Env, creator: Address, wallet: Address) -> u32 { - let key = constants::storage::key_balance(&creator, &wallet); - // Read-only callers get `0` for unseen balances to avoid sparse-map lookups failing. - env.storage().persistent().get(&key).unwrap_or(0) - } - - /// Read-only view: returns a stable view of a holder's key count for a creator. - /// - /// Returns a [`HolderKeyCountView`] regardless of creator registration status. - /// When the creator is not registered, `creator_exists` is `false` and `key_count` is `0`. - /// When the creator exists but the holder has no keys, `key_count` is `0`. - /// This method is designed for indexer-friendly consumption and avoids panics. - pub fn get_holder_key_count( - env: Env, - creator: Address, - holder: Address, - ) -> HolderKeyCountView { - let creator_exists = read_creator_profile(&env, &creator).is_some(); - let key_count = if creator_exists { - let key = constants::storage::key_balance(&creator, &holder); - env.storage().persistent().get(&key).unwrap_or(0) - } else { - 0 - }; + /// Read-only view: returns the number of unique holders for a creator. + /// + /// Returns `0` if the creator is not registered, avoiding panics for + /// invalid lookups. Uses the stored creator profile holder count. + pub fn get_creator_holder_count(env: Env, creator: Address) -> u32 { + read_creator_profile(&env, &creator) + .map(|profile| profile.holder_count) + .unwrap_or(0) + } - HolderKeyCountView { - creator, - holder, - key_count, - creator_exists, - } - } - - pub fn get_creator( - env: Env, - creator: Address, - ) -> Result { - read_registered_creator_profile(&env, &creator) - } - - /// Read-only view: returns stable creator details. - /// - /// Returns a [`CreatorDetailsView`] regardless of registration status. - /// When the creator is not registered, `is_registered` is `false` and - /// default values are provided for other fields, including `registered_at: 0`. - pub fn get_creator_details(env: Env, creator: Address) -> CreatorDetailsView { - let key = constants::storage::creator(&creator); - match env - .storage() - .persistent() - .get::(&key) - { - Some(profile) => CreatorDetailsView { - creator: profile.creator, - handle: profile.handle, - supply: profile.supply, - is_registered: true, - registered_at: profile.registered_at, - }, - None => CreatorDetailsView { - creator, - handle: read_none_string(&env), - supply: 0, - is_registered: false, - registered_at: 0, - }, - } - } - - pub fn distribute_dividend( - env: Env, - creator: Address, - amount: i128, - ) -> Result<(), ContractError> { - let caller = env.invoker(); - let native_address = env.ledger().get_native_token(); - let native_token = - soroban_sdk::token::TokenClient::new(&env, &native_address); - native_token.transfer(&caller, &env.current_contract_address(), &amount); - - // Load creator profile and validate - let profile = read_registered_creator_profile(&env, &creator)?; - let total_supply = profile.supply as i128; - if total_supply == 0 { - return Err(ContractError::NoHolders); - } - - // Load fee config and compute protocol fee - let config = read_required_protocol_fee_config(&env)?; - let protocol_fee = fee::apply_percentage_fee(amount, config.protocol_bps) - .ok_or(ContractError::Overflow)?; - let distribute_amount = amount - .checked_sub(protocol_fee) - .ok_or(ContractError::Overflow)?; - if distribute_amount <= 0 { - return Err(ContractError::DividendAmountZero); - } - - // Credit protocol fee to the protocol fee recipient balance - credit_protocol_fee_recipient_balance(&env, protocol_fee)?; - - // Iterate over all holders and distribute proportionally - let holders = read_holders_list(&env, &creator); - let ledger = env.ledger().sequence(); - - for holder in holders.iter() { - let balance = - Self::get_key_balance(env.clone(), creator.clone(), holder.clone()) - as i128; - if balance == 0 { - continue; - } - let share = (balance * distribute_amount) / total_supply; - if share > 0 { - let current = read_claimable_dividend(&env, &creator, &holder); - let new_claim = - current.checked_add(share).ok_or(ContractError::Overflow)?; - write_claimable_dividend(&env, &creator, &holder, new_claim); - } - } - - // Emit event - env.events().publish( - events::dividend_distributed_topics(&creator), - events::DividendDistributedEvent { - creator_id: creator, - total_amount: amount, - snapshot_supply: total_supply, - ledger, - protocol_fee, - distributed_amount: distribute_amount, - }, - ); + /// Read-only view: returns whether a creator is registered in the contract. + /// + /// Returns `true` if a [`CreatorProfile`] exists for the given address, + /// `false` otherwise. Does not mutate state. + pub fn is_creator_registered(env: Env, creator: Address) -> bool { + read_creator_profile(&env, &creator).is_some() + } - Ok(()) - } - - /// Claims accrued dividends for the caller. - /// - /// Transfers the claimable amount from the contract to the caller's wallet. - /// Resets the claimable balance to zero. - /// Emits a `DividendClaimed` event. - pub fn claim_dividend( - env: Env, - creator: Address, - ) -> Result<(), ContractError> { - let caller = env.invoker(); - - // 1. Read claimable balance - let claimable = read_claimable_dividend(&env, &creator, &caller); - if claimable == 0 { - return Err(ContractError::ZeroClaimable); - } - - // 2. Reset claimable to zero before transfer (reentrancy-safe) - write_claimable_dividend(&env, &creator, &caller, 0); - - // 3. Transfer XLM from contract to caller - let native_address = env.ledger().get_native_token(); - let native_token = - soroban_sdk::token::TokenClient::new(&env, &native_address); - native_token.transfer(&env.current_contract_address(), &caller, &claimable); - - // 4. Emit event - env.events().publish( - events::dividend_claimed_topics(&creator, &caller), - events::DividendClaimedEvent { - creator_id: creator, - claimant: caller, - amount: claimable, - }, - ); + /// Read-only view: returns the creator fee recipient address. + /// + /// Fails with [`ContractError::NotRegistered`] if the creator is not registered. + /// Reuses current creator storage access patterns. + pub fn get_creator_fee_recipient(env: Env, creator: Address) -> Result { + let profile = read_registered_creator_profile(&env, &creator)?; + Ok(profile.fee_recipient) + } - Ok(()) - } - /// Returns the unclaimed dividend balance for a specific (creator, holder) pair. - pub fn get_claimable_dividend( - env: Env, - creator: Address, - wallet: Address, - ) -> i128 { - read_claimable_dividend(&env, &creator, &wallet) - } - - /// Read-only batch view: returns [`CreatorDetailsView`] for each address in `creators`. - /// - /// Iterates the provided addresses in order and fetches each creator's profile - /// from persistent storage. The output `Vec` is the same length as the input and - /// preserves input order, so clients can zip the two slices without an extra sort. - /// - /// Unregistered addresses never cause the call to fail: they produce a default - /// [`CreatorDetailsView`] with `is_registered: false` and `registered_at: 0`, - /// matching the single-address behaviour of [`get_creator_details`]. - /// - /// # Usage - /// - /// ```text - /// let views = client.get_creators_batch(&vec![alice, bob, unknown]); - /// // views[0] → alice's details (is_registered: true) - /// // views[1] → bob's details (is_registered: true) - /// // views[2] → default view (is_registered: false, registered_at: 0) - /// ``` - pub fn get_creators_batch( - env: Env, - creators: soroban_sdk::Vec
, - ) -> soroban_sdk::Vec { - let mut results = soroban_sdk::Vec::new(&env); - for creator in creators.iter() { - let key = constants::storage::creator(&creator); - let view = match env - .storage() - .persistent() - .get::(&key) - { - Some(profile) => CreatorDetailsView { - creator: profile.creator, - handle: profile.handle, - supply: profile.supply, - is_registered: true, - registered_at: profile.registered_at, - }, - None => CreatorDetailsView { - creator, - handle: read_none_string(&env), - supply: 0, - is_registered: false, - registered_at: 0, - }, - }; - results.push_back(view); - } - results - } - /// Read-only view: returns the protocol state version. - /// - /// Returns a stable scalar value for clients and indexers to detect - /// protocol-state schema/semantics revisions. The version is stored in - /// storage and increments on config updates. - pub fn get_protocol_state_version(env: Env) -> u32 { - env - .storage() - .persistent() - .get(&constants::storage::PROTOCOL_STATE_VERSION) - .unwrap_or(PROTOCOL_STATE_VERSION_INITIAL) - } - - /// Read-only view: returns the decimal precision used by creator key values. - /// - /// Returns the fixed [`KEY_DECIMALS`] constant. Does not read or mutate contract state. - pub fn get_key_decimals(_env: Env) -> u32 { - KEY_DECIMALS - } - - /// Read-only view: returns the display name for a creator's key. - /// - /// Returns the creator's handle for registered creators. Fails with - /// [`ContractError::NotRegistered`] if the creator is not registered. - pub fn get_key_name( - env: Env, - creator: Address, - ) -> Result { - let profile = read_registered_creator_profile(&env, &creator)?; - Ok(profile.handle) - } - - /// Read-only view: returns the ticker symbol for a creator's key. - /// - /// Returns the creator's handle for registered creators. Fails with - /// [`ContractError::NotRegistered`] if the creator is not registered. - pub fn get_key_symbol( - env: Env, - creator: Address, - ) -> Result { - let profile = read_registered_creator_profile(&env, &creator)?; - Ok(profile.handle) - } - - /// Read-only view: returns the total key supply for a creator. - /// - /// Returns `0` if the creator is not registered, avoiding panics for - /// invalid lookups. Delegates to the shared [`read_key_balance`] helper. - pub fn get_total_key_supply(env: Env, creator: Address) -> u32 { - read_key_balance(&env, &creator) - } - - /// Read-only view: returns the current supply for a registered creator. - /// - /// Fails with [`ContractError::NotRegistered`] if the creator does not exist. - pub fn get_creator_supply( - env: Env, - creator: Address, - ) -> Result { - let profile = read_registered_creator_profile(&env, &creator)?; - Ok(profile.supply) - } - - /// Read-only view: returns the number of unique holders for a creator. - /// - /// Returns `0` if the creator is not registered, avoiding panics for - /// invalid lookups. Uses the stored creator profile holder count. - pub fn get_creator_holder_count(env: Env, creator: Address) -> u32 { - read_creator_profile(&env, &creator) - .map(|profile| profile.holder_count) - .unwrap_or(0) - } - - /// Read-only view: returns whether a creator is registered in the contract. - /// - /// Returns `true` if a [`CreatorProfile`] exists for the given address, - /// `false` otherwise. Does not mutate state. - pub fn is_creator_registered(env: Env, creator: Address) -> bool { - read_creator_profile(&env, &creator).is_some() - } - - /// Read-only view: returns the creator fee recipient address. - /// - /// Fails with [`ContractError::NotRegistered`] if the creator is not registered. - /// Reuses current creator storage access patterns. - pub fn get_creator_fee_recipient( - env: Env, - creator: Address, - ) -> Result { - let profile = read_registered_creator_profile(&env, &creator)?; - Ok(profile.fee_recipient) - } - - /// Read-only view: returns accrued creator fee balance for the creator's fee recipient. - /// - /// Fails with [`ContractError::NotRegistered`] if the creator is not registered. - /// Returns `0` when no buy has accrued fees yet. - pub fn get_creator_fee_balance( - env: Env, - creator: Address, - ) -> Result { - read_registered_creator_profile(&env, &creator)?; - Ok(read_creator_fee_recipient_balance(&env, &creator)) - } - - /// Read-only view: returns the configured creator fee rate in basis points. - /// - /// The returned value is the creator-facing share stored in the current protocol - /// fee configuration, scoped to a registered creator lookup. - pub fn get_creator_fee_bps( - env: Env, - creator: Address, - ) -> Result { - let _profile = read_registered_creator_profile(&env, &creator)?; - let config = read_required_protocol_fee_config(&env)?; - Ok(config.creator_bps) - } - - /// Read-only view: returns the creator treasury share for a registered creator. - /// - /// Access Layer currently stores creator treasury share as the creator-facing - /// basis-point share in protocol fee configuration. This method provides a - /// creator-scoped accessor without mutating state. - pub fn get_creator_treasury_share( - env: Env, - creator: Address, - ) -> Result { - Self::get_creator_fee_bps(env, creator) - } - - /// Read-only view: returns the configured protocol treasury share in basis points. - /// - /// This value is sourced from the current protocol fee configuration and is - /// expressed in stable basis-point units. - pub fn get_protocol_treasury_share_bps( - env: Env, - ) -> Result { - let config = read_required_protocol_fee_config(&env)?; - Ok(config.protocol_bps) - } - - /// Sets the global protocol/creator fee split. Contract initialization - /// entrypoint. - /// - /// Parameter validation (via [`fee::assert_valid_fee_bps`]): - /// - `admin`: must authorize the call (`require_auth`). - /// - `creator_bps` + `protocol_bps`: must sum to exactly `BPS_MAX` (10_000), - /// otherwise [`ContractError::InvalidFeeConfig`]. - /// - `protocol_bps`: must not exceed `PROTOCOL_BPS_MAX`, otherwise - /// [`ContractError::ProtocolFeeExceedsCap`]. - pub fn set_fee_config( - env: Env, - admin: Address, - creator_bps: u32, - protocol_bps: u32, - ) -> Result<(), ContractError> { - admin.require_auth(); - fee::assert_valid_fee_bps(creator_bps, protocol_bps)?; - - let config = fee::FeeConfig { - creator_bps, - protocol_bps, - }; - if env - .storage() - .persistent() - .get::(&constants::storage::FEE_CONFIG) - .as_ref() - == Some(&config) - { - return Ok(()); - } - env - .storage() - .persistent() - .set(&constants::storage::FEE_CONFIG, &config); - - // Increment protocol state version on config update - let current_version = env - .storage() - .persistent() - .get(&constants::storage::PROTOCOL_STATE_VERSION) - .unwrap_or(PROTOCOL_STATE_VERSION_INITIAL); - let new_version = current_version - .checked_add(1) - .ok_or(ContractError::Overflow)?; - env - .storage() - .persistent() - .set(&constants::storage::PROTOCOL_STATE_VERSION, &new_version); + /// Read-only view: returns accrued creator fee balance for the creator's fee recipient. + /// + /// Fails with [`ContractError::NotRegistered`] if the creator is not registered. + /// Returns `0` when no buy has accrued fees yet. + pub fn get_creator_fee_balance(env: Env, creator: Address) -> Result { + read_registered_creator_profile(&env, &creator)?; + Ok(read_creator_fee_recipient_balance(&env, &creator)) + } - Ok(()) - } - - /// Sets the per-key price. Contract initialization entrypoint. - /// - /// Parameter validation: - /// - `admin`: must authorize the call (`require_auth`). - /// - `price`: must be strictly positive; zero or negative returns - /// [`ContractError::NotPositiveAmount`]. - pub fn set_key_price( - env: Env, - admin: Address, - price: i128, - ) -> Result<(), ContractError> { - admin.require_auth(); - if price <= 0 { - return Err(ContractError::NotPositiveAmount); + /// Read-only view: returns the configured creator fee rate in basis points. + /// + /// The returned value is the creator-facing share stored in the current protocol + /// fee configuration, scoped to a registered creator lookup. + pub fn get_creator_fee_bps(env: Env, creator: Address) -> Result { + let _profile = read_registered_creator_profile(&env, &creator)?; + let config = read_required_protocol_fee_config(&env)?; + Ok(config.creator_bps) } - if env - .storage() - .persistent() - .get::(&constants::storage::KEY_PRICE) - .as_ref() - == Some(&price) - { - return Ok(()); + + /// Read-only view: returns the creator treasury share for a registered creator. + /// + /// Access Layer currently stores creator treasury share as the creator-facing + /// basis-point share in protocol fee configuration. This method provides a + /// creator-scoped accessor without mutating state. + pub fn get_creator_treasury_share(env: Env, creator: Address) -> Result { + Self::get_creator_fee_bps(env, creator) } - env - .storage() - .persistent() - .set(&constants::storage::KEY_PRICE, &price); - Ok(()) - } - - pub fn get_fee_config(env: Env) -> Option { - read_protocol_fee_config(&env) - } - - /// Sets the protocol treasury address. - /// - /// Only callable by an authorized admin. Stores the treasury address used - /// for protocol fee routing. - pub fn set_treasury_address(env: Env, admin: Address, treasury: Address) { - admin.require_auth(); - if env - .storage() - .persistent() - .get::(&constants::storage::TREASURY_ADDRESS) - .as_ref() - == Some(&treasury) - { - return; - } - env - .storage() - .persistent() - .set(&constants::storage::TREASURY_ADDRESS, &treasury); - } - - /// Read-only view: returns the current protocol treasury address. - /// - /// Returns `None` if no treasury address has been configured. - /// Use this method for indexers and read-only callers that need the current - /// treasury routing target. - pub fn get_treasury_address(env: Env) -> Option
{ - env - .storage() - .persistent() - .get(&constants::storage::TREASURY_ADDRESS) - } - - /// Sets the protocol admin address. - /// - /// Only callable by an authorized admin. Stores the admin address used - /// for protocol administration. - pub fn set_protocol_admin(env: Env, admin: Address, new_admin: Address) { - admin.require_auth(); - if env - .storage() - .persistent() - .get::(&constants::storage::ADMIN_ADDRESS) - .as_ref() - == Some(&new_admin) - { - return; - } - env - .storage() - .persistent() - .set(&constants::storage::ADMIN_ADDRESS, &new_admin); - } - - /// Read-only view: returns the current protocol admin address. - /// - /// Returns `None` if no admin address has been configured. - /// Use this method for indexers and read-only callers that need the current - /// protocol admin address. - pub fn get_protocol_admin(env: Env) -> Option
{ - env - .storage() - .persistent() - .get(&constants::storage::ADMIN_ADDRESS) - } - - /// Read-only view: returns the current protocol fee recipient address. - /// - /// Returns `None` if no protocol fee recipient address has been configured. - /// Use this method for indexers and read-only callers that need the current - /// protocol fee recipient address. - pub fn get_protocol_fee_recipient(env: Env) -> Option
{ - env - .storage() - .persistent() - .get(&constants::storage::PROTOCOL_FEE_RECIPIENT) - } - - /// Read-only view: returns the accrued protocol fee balance for the configured recipient. - /// - /// Returns `0` when no protocol fees have been accrued from sell execution. - pub fn get_protocol_recipient_balance(env: Env) -> i128 { - read_protocol_fee_recipient_balance(&env) - } - - /// Sets the protocol fee recipient address. - /// - /// Only callable by an authorized admin. Rejects the Stellar zero address - /// to prevent silent fee burning. - /// - /// Parameter validation: - /// - `admin`: must authorize the call (`require_auth`). - /// - `recipient`: must not be the Stellar zero address, otherwise - /// [`ContractError::ZeroAddress`]. - pub fn set_protocol_fee_recipient( - env: Env, - admin: Address, - recipient: Address, - ) -> Result<(), ContractError> { - admin.require_auth(); - validate_non_zero_address(&env, &recipient)?; - if env - .storage() - .persistent() - .get::(&constants::storage::PROTOCOL_FEE_RECIPIENT) - .as_ref() - == Some(&recipient) - { - return Ok(()); + /// Read-only view: returns the configured protocol treasury share in basis points. + /// + /// This value is sourced from the current protocol fee configuration and is + /// expressed in stable basis-point units. + pub fn get_protocol_treasury_share_bps(env: Env) -> Result { + let config = read_required_protocol_fee_config(&env)?; + Ok(config.protocol_bps) } - env - .storage() - .persistent() - .set(&constants::storage::PROTOCOL_FEE_RECIPIENT, &recipient); - Ok(()) - } - - /// Read-only view: returns whether protocol configuration has been initialized. - /// - /// Returns `true` once a protocol fee configuration has been stored and `false` - /// otherwise. Does not mutate contract state. - pub fn is_protocol_config_initialized(env: Env) -> bool { - read_protocol_fee_config(&env).is_some() - } - - /// Read-only view: returns the current protocol fee configuration. - /// - /// Returns a stable [`ProtocolFeeView`] regardless of whether a fee config has been set. - /// When no config is stored, `is_configured` is `false` and both bps fields are `0`. - /// Use this method for indexers and read-only callers that need a non-optional result. - pub fn get_protocol_fee_view(env: Env) -> ProtocolFeeView { - match read_protocol_fee_config(&env) { - Some(config) => ProtocolFeeView { - creator_bps: config.creator_bps, - protocol_bps: config.protocol_bps, - is_configured: true, - }, - None => ProtocolFeeView { - creator_bps: 0, - protocol_bps: 0, - is_configured: false, - }, - } - } - - pub fn compute_fees_for_payment( - env: Env, - total: i128, - ) -> Result<(i128, i128), ContractError> { - let config = read_required_protocol_fee_config(&env)?; - fee::checked_compute_fee_split( - total, - config.creator_bps, - config.protocol_bps, - ) - .ok_or(ContractError::Overflow) - } - - /// Read-only view: returns the fee configuration for a specific creator. - /// - /// Returns a stable [`CreatorFeeView`] regardless of whether the creator is registered - /// or a fee config has been set. When `is_registered` is `false`, the creator does not - /// exist and both bps fields are `0`. When `is_configured` is `false`, no global fee - /// config has been set. Use this method for indexers and read-only callers that need - /// a non-optional result. - pub fn get_creator_fee_config(env: Env, creator: Address) -> CreatorFeeView { - let is_registered = read_registered_creator_profile(&env, &creator).is_ok(); - - if !is_registered { - return CreatorFeeView { - creator_bps: 0, - protocol_bps: 0, - is_registered: false, - is_configured: false, - }; - } - - match env - .storage() - .persistent() - .get::(&constants::storage::FEE_CONFIG) - { - Some(config) => CreatorFeeView { - creator_bps: config.creator_bps, - protocol_bps: config.protocol_bps, - is_registered: true, - is_configured: true, - }, - None => CreatorFeeView { - creator_bps: 0, - protocol_bps: 0, - is_registered: true, - is_configured: false, - }, - } - } - - /// Read-only view: returns a quote for buying a key. - /// - /// Returns a [`QuoteResponse`] containing the current price and fee breakdown. - /// Fees are calculated based on the fixed key price. - pub fn get_buy_quote( - env: Env, - creator: Address, - ) -> Result { - let Some(price) = resolve_quote_inputs(&env, &creator)? else { - return Ok(zero_quote_response()); - }; - let (creator_fee, protocol_fee) = - Self::compute_fees_for_payment(env.clone(), price)?; - checked_format_quote_response(price, creator_fee, protocol_fee, true) - } - - /// Read-only view: returns a quote for selling a key. - /// - /// Returns a [`QuoteResponse`] containing the current price and fee breakdown. - /// Fees are calculated based on the fixed key price. - /// Rejects with [`ContractError::InsufficientBalance`] if the holder has no keys. - pub fn get_sell_quote( - env: Env, - creator: Address, - holder: Address, - ) -> Result { - let Some(price) = resolve_quote_inputs(&env, &creator)? else { - return Ok(zero_quote_response()); - }; - let balance = Self::get_key_balance(env.clone(), creator, holder); - if balance == 0 { - return Err(ContractError::InsufficientBalance); + /// Sets the global protocol/creator fee split. Contract initialization + /// entrypoint. + /// + /// Parameter validation (via [`fee::assert_valid_fee_bps`]): + /// - `admin`: must authorize the call (`require_auth`). + /// - `creator_bps` + `protocol_bps`: must sum to exactly `BPS_MAX` (10_000), + /// otherwise [`ContractError::InvalidFeeConfig`]. + /// - `protocol_bps`: must not exceed `PROTOCOL_BPS_MAX`, otherwise + /// [`ContractError::ProtocolFeeExceedsCap`]. + pub fn set_fee_config( + env: Env, + admin: Address, + creator_bps: u32, + protocol_bps: u32, + ) -> Result<(), ContractError> { + admin.require_auth(); + fee::assert_valid_fee_bps(creator_bps, protocol_bps)?; + + let config = fee::FeeConfig { + creator_bps, + protocol_bps, + }; + if env + .storage() + .persistent() + .get::(&constants::storage::FEE_CONFIG) + .as_ref() + == Some(&config) + { + return Ok(()); + } + env.storage() + .persistent() + .set(&constants::storage::FEE_CONFIG, &config); + + // Increment protocol state version on config update + let current_version = env + .storage() + .persistent() + .get(&constants::storage::PROTOCOL_STATE_VERSION) + .unwrap_or(PROTOCOL_STATE_VERSION_INITIAL); + let new_version = current_version + .checked_add(1) + .ok_or(ContractError::Overflow)?; + env.storage() + .persistent() + .set(&constants::storage::PROTOCOL_STATE_VERSION, &new_version); + + Ok(()) } - let (creator_fee, protocol_fee) = - Self::compute_fees_for_payment(env.clone(), price)?; - checked_format_quote_response(price, creator_fee, protocol_fee, false) - } + /// Sets the per-key price. Contract initialization entrypoint. + /// + /// Parameter validation: + /// - `admin`: must authorize the call (`require_auth`). + /// - `price`: must be strictly positive; zero or negative returns + /// [`ContractError::NotPositiveAmount`]. + pub fn set_key_price(env: Env, admin: Address, price: i128) -> Result<(), ContractError> { + admin.require_auth(); + if price <= 0 { + return Err(ContractError::NotPositiveAmount); + } + if env + .storage() + .persistent() + .get::(&constants::storage::KEY_PRICE) + .as_ref() + == Some(&price) + { + return Ok(()); + } + env.storage() + .persistent() + .set(&constants::storage::KEY_PRICE, &price); + Ok(()) + } + + pub fn get_fee_config(env: Env) -> Option { + read_protocol_fee_config(&env) + } + + /// Sets the protocol treasury address. + /// + /// Only callable by an authorized admin. Stores the treasury address used + /// for protocol fee routing. + pub fn set_treasury_address(env: Env, admin: Address, treasury: Address) { + admin.require_auth(); + if env + .storage() + .persistent() + .get::(&constants::storage::TREASURY_ADDRESS) + .as_ref() + == Some(&treasury) + { + return; + } + env.storage() + .persistent() + .set(&constants::storage::TREASURY_ADDRESS, &treasury); + } + + /// Read-only view: returns the current protocol treasury address. + /// + /// Returns `None` if no treasury address has been configured. + /// Use this method for indexers and read-only callers that need the current + /// treasury routing target. + pub fn get_treasury_address(env: Env) -> Option
{ + env.storage() + .persistent() + .get(&constants::storage::TREASURY_ADDRESS) + } + + /// Sets the protocol admin address. + /// + /// Only callable by an authorized admin. Stores the admin address used + /// for protocol administration. + pub fn set_protocol_admin(env: Env, admin: Address, new_admin: Address) { + admin.require_auth(); + if env + .storage() + .persistent() + .get::(&constants::storage::ADMIN_ADDRESS) + .as_ref() + == Some(&new_admin) + { + return; + } + env.storage() + .persistent() + .set(&constants::storage::ADMIN_ADDRESS, &new_admin); + } + + /// Read-only view: returns the current protocol admin address. + /// + /// Returns `None` if no admin address has been configured. + /// Use this method for indexers and read-only callers that need the current + /// protocol admin address. + pub fn get_protocol_admin(env: Env) -> Option
{ + env.storage() + .persistent() + .get(&constants::storage::ADMIN_ADDRESS) + } + + /// Read-only view: returns the current protocol fee recipient address. + /// + /// Returns `None` if no protocol fee recipient address has been configured. + /// Use this method for indexers and read-only callers that need the current + /// protocol fee recipient address. + pub fn get_protocol_fee_recipient(env: Env) -> Option
{ + env.storage() + .persistent() + .get(&constants::storage::PROTOCOL_FEE_RECIPIENT) + } + + /// Read-only view: returns the accrued protocol fee balance for the configured recipient. + /// + /// Returns `0` when no protocol fees have been accrued from sell execution. + pub fn get_protocol_recipient_balance(env: Env) -> i128 { + read_protocol_fee_recipient_balance(&env) + } + + /// Sets the protocol fee recipient address. + /// + /// Only callable by an authorized admin. Rejects the Stellar zero address + /// to prevent silent fee burning. + /// + /// Parameter validation: + /// - `admin`: must authorize the call (`require_auth`). + /// - `recipient`: must not be the Stellar zero address, otherwise + /// [`ContractError::ZeroAddress`]. + pub fn set_protocol_fee_recipient( + env: Env, + admin: Address, + recipient: Address, + ) -> Result<(), ContractError> { + admin.require_auth(); + validate_non_zero_address(&env, &recipient)?; + + if env + .storage() + .persistent() + .get::(&constants::storage::PROTOCOL_FEE_RECIPIENT) + .as_ref() + == Some(&recipient) + { + return Ok(()); + } + env.storage() + .persistent() + .set(&constants::storage::PROTOCOL_FEE_RECIPIENT, &recipient); + Ok(()) + } + + /// Read-only view: returns whether protocol configuration has been initialized. + /// + /// Returns `true` once a protocol fee configuration has been stored and `false` + /// otherwise. Does not mutate contract state. + pub fn is_protocol_config_initialized(env: Env) -> bool { + read_protocol_fee_config(&env).is_some() + } + + /// Read-only view: returns the current protocol fee configuration. + /// + /// Returns a stable [`ProtocolFeeView`] regardless of whether a fee config has been set. + /// When no config is stored, `is_configured` is `false` and both bps fields are `0`. + /// Use this method for indexers and read-only callers that need a non-optional result. + pub fn get_protocol_fee_view(env: Env) -> ProtocolFeeView { + match read_protocol_fee_config(&env) { + Some(config) => ProtocolFeeView { + creator_bps: config.creator_bps, + protocol_bps: config.protocol_bps, + is_configured: true, + }, + None => ProtocolFeeView { + creator_bps: 0, + protocol_bps: 0, + is_configured: false, + }, + } + } + + pub fn compute_fees_for_payment(env: Env, total: i128) -> Result<(i128, i128), ContractError> { + let config = read_required_protocol_fee_config(&env)?; + fee::checked_compute_fee_split(total, config.creator_bps, config.protocol_bps) + .ok_or(ContractError::Overflow) + } + + /// Read-only view: returns the fee configuration for a specific creator. + /// + /// Returns a stable [`CreatorFeeView`] regardless of whether the creator is registered + /// or a fee config has been set. When `is_registered` is `false`, the creator does not + /// exist and both bps fields are `0`. When `is_configured` is `false`, no global fee + /// config has been set. Use this method for indexers and read-only callers that need + /// a non-optional result. + pub fn get_creator_fee_config(env: Env, creator: Address) -> CreatorFeeView { + let is_registered = read_registered_creator_profile(&env, &creator).is_ok(); + + if !is_registered { + return CreatorFeeView { + creator_bps: 0, + protocol_bps: 0, + is_registered: false, + is_configured: false, + }; + } + + match env + .storage() + .persistent() + .get::(&constants::storage::FEE_CONFIG) + { + Some(config) => CreatorFeeView { + creator_bps: config.creator_bps, + protocol_bps: config.protocol_bps, + is_registered: true, + is_configured: true, + }, + None => CreatorFeeView { + creator_bps: 0, + protocol_bps: 0, + is_registered: true, + is_configured: false, + }, + } + } + + /// Read-only view: returns a quote for buying a key. + /// + /// Returns a [`QuoteResponse`] containing the current price and fee breakdown. + /// Fees are calculated based on the fixed key price. + pub fn get_buy_quote(env: Env, creator: Address) -> Result { + let Some(price) = resolve_quote_inputs(&env, &creator)? else { + return Ok(zero_quote_response()); + }; + let (creator_fee, protocol_fee) = Self::compute_fees_for_payment(env.clone(), price)?; + checked_format_quote_response(price, creator_fee, protocol_fee, true) + } + + /// Read-only view: returns a quote for selling a key. + /// + /// Returns a [`QuoteResponse`] containing the current price and fee breakdown. + /// Fees are calculated based on the fixed key price. + /// Rejects with [`ContractError::InsufficientBalance`] if the holder has no keys. + pub fn get_sell_quote( + env: Env, + creator: Address, + holder: Address, + ) -> Result { + let Some(price) = resolve_quote_inputs(&env, &creator)? else { + return Ok(zero_quote_response()); + }; + + let balance = Self::get_key_balance(env.clone(), creator, holder); + if balance == 0 { + return Err(ContractError::InsufficientBalance); + } + + let (creator_fee, protocol_fee) = Self::compute_fees_for_payment(env.clone(), price)?; + checked_format_quote_response(price, creator_fee, protocol_fee, false) + } } fn read_holders_list(env: &Env, creator: &Address) -> Vec
{ - let key = DataKey::HoldersList(creator.clone()); - env - .storage() - .persistent() - .get(&key) - .unwrap_or(Vec::new(env)) + let key = DataKey::HoldersList(creator.clone()); + env.storage() + .persistent() + .get(&key) + .unwrap_or(Vec::new(env)) } fn write_holders_list(env: &Env, creator: &Address, holders: &Vec
) { - let key = DataKey::HoldersList(creator.clone()); - env.storage().persistent().set(&key, holders); + let key = DataKey::HoldersList(creator.clone()); + env.storage().persistent().set(&key, holders); } -fn read_claimable_dividend( - env: &Env, - creator: &Address, - holder: &Address, -) -> i128 { - let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); - env.storage().persistent().get(&key).unwrap_or(0) +fn read_claimable_dividend(env: &Env, creator: &Address, holder: &Address) -> i128 { + let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); + env.storage().persistent().get(&key).unwrap_or(0) } -fn write_claimable_dividend( - env: &Env, - creator: &Address, - holder: &Address, - amount: i128, -) { - let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); - env.storage().persistent().set(&key, &amount); +fn write_claimable_dividend(env: &Env, creator: &Address, holder: &Address, amount: i128) { + let key = DataKey::DividendClaimable(creator.clone(), holder.clone()); + env.storage().persistent().set(&key, &amount); } #[cfg(test)] mod tests { - use super::fee; - - #[test] - fn test_fee_split_90_10_1000() { - let (creator, protocol) = fee::compute_fee_split(1000, 9000, 1000); - assert_eq!(creator, 900); - assert_eq!(protocol, 100); - assert_eq!(creator + protocol, 1000); - } - - #[test] - fn test_fee_split_100_creator() { - let (creator, protocol) = fee::compute_fee_split(1000, 10000, 0); - assert_eq!(creator, 1000); - assert_eq!(protocol, 0); - assert_eq!(creator + protocol, 1000); - } - - #[test] - fn test_fee_split_100_protocol() { - let (creator, protocol) = fee::compute_fee_split(1000, 0, 10000); - assert_eq!(creator, 0); - assert_eq!(protocol, 1000); - assert_eq!(creator + protocol, 1000); - } - - #[test] - fn test_fee_split_remainder_to_creator() { - // 999 * 1000 / 10000 = 99 (protocol floor), creator gets remainder - let (creator, protocol) = fee::compute_fee_split(999, 9000, 1000); - assert_eq!(creator, 900); - assert_eq!(protocol, 99); - assert_eq!(creator + protocol, 999); - } - - #[test] - fn test_fee_split_zero_total() { - let (creator, protocol) = fee::compute_fee_split(0, 9000, 1000); - assert_eq!(creator, 0); - assert_eq!(protocol, 0); - } - - #[test] - fn test_fee_split_dust_total_one() { - // 1 * 1000 / 10000 = 0 protocol, creator gets full amount - let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); - assert_eq!(creator, 1); - assert_eq!(protocol, 0); - assert_eq!(creator + protocol, 1); - } - - #[test] - fn test_fee_split_balance_conservation() { - for total in [100_i128, 1, 999, 10000, 1234567] { - let (creator, protocol) = fee::compute_fee_split(total, 9000, 1000); - assert_eq!(creator + protocol, total, "total={}", total); - } - } - - #[test] - fn test_checked_mul_i128_success() { - assert_eq!(fee::checked_mul_i128(100, 10), Some(1000)); - } - - #[test] - fn test_checked_mul_i128_rejects_overflow() { - assert_eq!(fee::checked_mul_i128(i128::MAX, 2), None); - assert_eq!(fee::checked_mul_i128(i128::MIN, 2), None); - } - - #[test] - fn test_checked_div_i128_success() { - assert_eq!(fee::checked_div_i128(100, 10), Some(10)); - } - - #[test] - fn test_checked_div_i128_rejects_zero_divisor() { - assert_eq!(fee::checked_div_i128(100, 0), None); - } - - #[test] - fn test_checked_sub_i128_success() { - assert_eq!(fee::checked_sub_i128(100, 10), Some(90)); - } - - #[test] - fn test_checked_sub_i128_underflow() { - assert_eq!(fee::checked_sub_i128(i128::MIN, 1), None); - } - - #[test] - fn test_checked_add_i128_success() { - assert_eq!(fee::checked_add_i128(100, 10), Some(110)); - } - - #[test] - fn test_checked_add_i128_overflow() { - assert_eq!(fee::checked_add_i128(i128::MAX, 1), None); - } - - #[test] - fn test_checked_add_i128_zero() { - assert_eq!(fee::checked_add_i128(0, 0), Some(0)); - assert_eq!(fee::checked_add_i128(100, 0), Some(100)); - assert_eq!(fee::checked_add_i128(0, 100), Some(100)); - } - - #[test] - fn test_checked_add_i128_negative_values() { - assert_eq!(fee::checked_add_i128(-10, 20), Some(10)); - assert_eq!(fee::checked_add_i128(10, -20), Some(-10)); - assert_eq!(fee::checked_add_i128(-10, -10), Some(-20)); - } - - #[test] - fn test_checked_add_i128_boundary_values() { - assert_eq!(fee::checked_add_i128(i128::MAX, 0), Some(i128::MAX)); - assert_eq!(fee::checked_add_i128(i128::MIN, 0), Some(i128::MIN)); - assert_eq!(fee::checked_add_i128(0, i128::MAX), Some(i128::MAX)); - assert_eq!(fee::checked_add_i128(0, i128::MIN), Some(i128::MIN)); - } - - #[test] - fn test_checked_add_i128_deterministic_error() { - // Verify that overflow always returns None, never panics - assert_eq!(fee::checked_add_i128(i128::MAX, i128::MAX), None); - assert_eq!(fee::checked_add_i128(i128::MIN, i128::MIN), None); - } - - #[test] - fn test_checked_div_i128_rejects_overflow() { - assert_eq!(fee::checked_div_i128(i128::MIN, -1), None); - } - - /// Both operands at `i128::MAX / 2 + 1` must overflow. - /// - /// `(i128::MAX / 2 + 1) + (i128::MAX / 2 + 1) == i128::MAX + 1`, which - /// exceeds i128 capacity, so the helper must return `None` rather than wrap. - #[test] - fn test_checked_add_i128_both_at_half_max_plus_one_overflows() { - let half_plus_one = i128::MAX / 2 + 1; - assert_eq!(fee::checked_add_i128(half_plus_one, half_plus_one), None); - } - - /// Both operands at `i128::MAX / 2` must not overflow. - /// - /// `(i128::MAX / 2) + (i128::MAX / 2) == i128::MAX - 1`, which fits in i128, - /// so the helper must return the correct sum just below the overflow boundary. - #[test] - fn test_checked_add_i128_both_at_half_max_succeeds() { - let half = i128::MAX / 2; - assert_eq!(fee::checked_add_i128(half, half), Some(half + half)); - } - - #[test] - fn test_normalize_quote_amount_preserves_positive_amount() { - assert_eq!(super::normalize_quote_amount(100), Ok(Some(100))); - } - - #[test] - fn test_normalize_quote_amount_maps_zero_to_noop() { - assert_eq!(super::normalize_quote_amount(0), Ok(None)); - } - - #[test] - fn test_normalize_quote_amount_rejects_negative_amount() { - assert_eq!( - super::normalize_quote_amount(-1), - Err(super::ContractError::NotPositiveAmount) - ); - } - - #[test] - fn test_normalize_quote_amount_rejects_large_amount() { - let large = super::fee::MAX_SAFE_AMOUNT + 1; - assert_eq!( - super::normalize_quote_amount(large), - Err(super::ContractError::Overflow) - ); - } - - #[test] - fn test_checked_format_quote_response_buy_success() { - let res = super::checked_format_quote_response(1000, 90, 10, true).unwrap(); - assert_eq!(res.price, 1000); - assert_eq!(res.creator_fee, 90); - assert_eq!(res.protocol_fee, 10); - assert_eq!(res.total_amount, 1100); - } - - #[test] - fn test_checked_format_quote_response_sell_success() { - let res = - super::checked_format_quote_response(1000, 90, 10, false).unwrap(); - assert_eq!(res.price, 1000); - assert_eq!(res.creator_fee, 90); - assert_eq!(res.protocol_fee, 10); - assert_eq!(res.total_amount, 900); - } - - #[test] - fn test_checked_format_quote_response_buy_overflow_fees() { - let res = super::checked_format_quote_response(1000, i128::MAX, 1, true); - assert_eq!(res, Err(super::ContractError::Overflow)); - } - - #[test] - fn test_checked_format_quote_response_buy_overflow_total() { - let res = super::checked_format_quote_response(i128::MAX, 1, 0, true); - assert_eq!(res, Err(super::ContractError::Overflow)); - } - - #[test] - fn test_checked_format_quote_response_sell_underflow_total() { - let res = super::checked_format_quote_response(i128::MIN, 1, 0, false); - assert_eq!(res, Err(super::ContractError::SellUnderflow)); - } - - #[test] - fn test_apply_percentage_fee_success() { - assert_eq!(fee::apply_percentage_fee(1000, 1000), Some(100)); - assert_eq!(fee::apply_percentage_fee(1000, 0), Some(0)); - assert_eq!(fee::apply_percentage_fee(1000, 10000), Some(1000)); - } - - #[test] - fn test_apply_percentage_fee_zero_amount() { - assert_eq!(fee::apply_percentage_fee(0, 1000), Some(0)); - } - - #[test] - fn test_apply_percentage_fee_negative_amount() { - assert_eq!(fee::apply_percentage_fee(-100, 1000), Some(0)); - } - - #[test] - fn test_apply_percentage_fee_rounding() { - // 999 * 1000 / 10000 = 99.9 -> 99 - assert_eq!(fee::apply_percentage_fee(999, 1000), Some(99)); - } - - #[test] - fn test_apply_percentage_fee_overflow() { - // Multiplication overflows before division - assert_eq!(fee::apply_percentage_fee(i128::MAX, 2), None); - } - - #[test] - fn test_assert_valid_fee_bps() { - // Valid scenarios - assert_eq!(fee::assert_valid_fee_bps(10000, 0), Ok(())); - assert_eq!(fee::assert_valid_fee_bps(5000, 5000), Ok(())); - assert_eq!(fee::assert_valid_fee_bps(9000, 1000), Ok(())); - - // Invalid Sum - assert_eq!( - fee::assert_valid_fee_bps(9000, 2000), - Err(super::ContractError::InvalidFeeConfig) - ); - assert_eq!( - fee::assert_valid_fee_bps(0, 0), - Err(super::ContractError::InvalidFeeConfig) - ); + use super::fee; + + #[test] + fn test_fee_split_90_10_1000() { + let (creator, protocol) = fee::compute_fee_split(1000, 9000, 1000); + assert_eq!(creator, 900); + assert_eq!(protocol, 100); + assert_eq!(creator + protocol, 1000); + } - // Protocol Cap Exceeded (PROTOCOL_BPS_MAX = 5000) - assert_eq!( - fee::assert_valid_fee_bps(4999, 5001), - Err(super::ContractError::ProtocolFeeExceedsCap) - ); - assert_eq!( - fee::assert_valid_fee_bps(0, 10000), - Err(super::ContractError::ProtocolFeeExceedsCap) - ); + #[test] + fn test_fee_split_100_creator() { + let (creator, protocol) = fee::compute_fee_split(1000, 10000, 0); + assert_eq!(creator, 1000); + assert_eq!(protocol, 0); + assert_eq!(creator + protocol, 1000); + } - // Overflow - assert_eq!( - fee::assert_valid_fee_bps(u32::MAX, 1), - Err(super::ContractError::InvalidFeeConfig) - ); - } - - #[test] - fn test_validate_fee_bps() { - // Valid - assert!(fee::validate_fee_bps(10000, 0)); - assert!(fee::validate_fee_bps(5000, 5000)); - assert!(fee::validate_fee_bps(9000, 1000)); - - // Invalid Sum - assert!(!fee::validate_fee_bps(9000, 2000)); - assert!(!fee::validate_fee_bps(0, 0)); - - // Protocol Cap Exceeded - assert!(!fee::validate_fee_bps(4999, 5001)); - - // Overflow - assert!(!fee::validate_fee_bps(u32::MAX, 1)); - } - - // --- checked_fee_sum unit tests --- - - /// Verifies that `checked_fee_sum` returns the correct sum for two ordinary - /// positive fee components. - #[test] - fn test_checked_fee_sum_success() { - assert_eq!(fee::checked_fee_sum(900, 100), Some(1000)); - assert_eq!(fee::checked_fee_sum(0, 0), Some(0)); - assert_eq!(fee::checked_fee_sum(500, 500), Some(1000)); - } - - /// Verifies that `checked_fee_sum` returns `None` when the addition would - /// overflow `i128`, preventing silent wrapping in fee total calculations. - #[test] - fn test_checked_fee_sum_overflow_returns_none() { - assert_eq!(fee::checked_fee_sum(i128::MAX, 1), None); - assert_eq!(fee::checked_fee_sum(i128::MAX, i128::MAX), None); - } - - /// Edge case: verifies `checked_fee_sum` at the boundary where one component - /// is exactly `i128::MAX` and the other is zero — the only non-overflowing - /// case at that boundary. - #[test] - fn test_checked_fee_sum_boundary_max_plus_zero() { - assert_eq!(fee::checked_fee_sum(i128::MAX, 0), Some(i128::MAX)); - assert_eq!(fee::checked_fee_sum(0, i128::MAX), Some(i128::MAX)); - // One above the boundary must overflow - assert_eq!(fee::checked_fee_sum(i128::MAX, 1), None); - } - - // --- BPS truncation on small amounts --- - - /// Bps calculation on very small amounts produces zero due to integer division - /// truncation. These tests document the behavior at the lower precision boundary. - /// - /// Formula: `amount * bps / 10_000` (floor division). - /// When the product `amount * bps < 10_000`, the result truncates to zero. - #[test] - fn test_apply_percentage_fee_truncation_1_stroop() { - // 1 * 1000 / 10_000 = 0.1 → truncated to 0 - // At 1 stroop with 10% bps, the fee is zero — value is silently lost. - let result = fee::apply_percentage_fee(1, 1000); - assert_eq!(result, Some(0), "1 stroop at 1000 bps truncates to 0"); - } - - #[test] - fn test_apply_percentage_fee_truncation_10_stroops() { - // 10 * 1000 / 10_000 = 1.0 → exactly 1 - // At 10 stroops with 10% bps, the fee is exactly 1. - let result = fee::apply_percentage_fee(10, 1000); - assert_eq!(result, Some(1), "10 stroops at 1000 bps yields 1"); - } - - #[test] - fn test_apply_percentage_fee_truncation_100_stroops() { - // 100 * 1000 / 10_000 = 10.0 → exactly 10 - let result = fee::apply_percentage_fee(100, 1000); - assert_eq!(result, Some(10), "100 stroops at 1000 bps yields 10"); - } - - #[test] - fn test_fee_split_truncation_1_stroop() { - // 1 * 1000 / 10_000 = 0 protocol, 1 creator (remainder to creator) - // Truncation causes the full amount to go to creator. - let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); - assert_eq!(protocol, 0, "1 stroop: protocol fee truncated to 0"); - assert_eq!(creator, 1, "1 stroop: creator gets full amount"); - assert_eq!(creator + protocol, 1, "conservation holds"); - } - - #[test] - fn test_fee_split_truncation_10_stroops() { - // 10 * 1000 / 10_000 = 1 protocol, 9 creator - let (creator, protocol) = fee::compute_fee_split(10, 9000, 1000); - assert_eq!(protocol, 1, "10 stroops: protocol fee is 1"); - assert_eq!(creator, 9, "10 stroops: creator gets 9"); - assert_eq!(creator + protocol, 10, "conservation holds"); - } - - #[test] - fn test_fee_split_truncation_100_stroops() { - // 100 * 1000 / 10_000 = 10 protocol, 90 creator - let (creator, protocol) = fee::compute_fee_split(100, 9000, 1000); - assert_eq!(protocol, 10, "100 stroops: protocol fee is 10"); - assert_eq!(creator, 90, "100 stroops: creator gets 90"); - assert_eq!(creator + protocol, 100, "conservation holds"); - } - - // --- Zero address validation --- - - #[test] - fn test_validate_non_zero_address_rejects_zero() { - use soroban_sdk::{Address, Env, String}; - let env = Env::default(); - let zero_str = String::from_str( - &env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ); - let zero_addr = Address::from_string(&zero_str); - let result = super::validate_non_zero_address(&env, &zero_addr); - assert_eq!(result, Err(super::ContractError::ZeroAddress)); - } - - #[test] - fn test_validate_non_zero_address_accepts_valid() { - use soroban_sdk::{testutils::Address as _, Address, Env}; - let env = Env::default(); - let valid = Address::generate(&env); - let result = super::validate_non_zero_address(&env, &valid); - assert_eq!(result, Ok(())); - } + #[test] + fn test_fee_split_100_protocol() { + let (creator, protocol) = fee::compute_fee_split(1000, 0, 10000); + assert_eq!(creator, 0); + assert_eq!(protocol, 1000); + assert_eq!(creator + protocol, 1000); + } + + #[test] + fn test_fee_split_remainder_to_creator() { + // 999 * 1000 / 10000 = 99 (protocol floor), creator gets remainder + let (creator, protocol) = fee::compute_fee_split(999, 9000, 1000); + assert_eq!(creator, 900); + assert_eq!(protocol, 99); + assert_eq!(creator + protocol, 999); + } + + #[test] + fn test_fee_split_zero_total() { + let (creator, protocol) = fee::compute_fee_split(0, 9000, 1000); + assert_eq!(creator, 0); + assert_eq!(protocol, 0); + } + + #[test] + fn test_fee_split_dust_total_one() { + // 1 * 1000 / 10000 = 0 protocol, creator gets full amount + let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); + assert_eq!(creator, 1); + assert_eq!(protocol, 0); + assert_eq!(creator + protocol, 1); + } + + #[test] + fn test_fee_split_balance_conservation() { + for total in [100_i128, 1, 999, 10000, 1234567] { + let (creator, protocol) = fee::compute_fee_split(total, 9000, 1000); + assert_eq!(creator + protocol, total, "total={}", total); + } + } + + #[test] + fn test_checked_mul_i128_success() { + assert_eq!(fee::checked_mul_i128(100, 10), Some(1000)); + } + + #[test] + fn test_checked_mul_i128_rejects_overflow() { + assert_eq!(fee::checked_mul_i128(i128::MAX, 2), None); + assert_eq!(fee::checked_mul_i128(i128::MIN, 2), None); + } + + #[test] + fn test_checked_div_i128_success() { + assert_eq!(fee::checked_div_i128(100, 10), Some(10)); + } + + #[test] + fn test_checked_div_i128_rejects_zero_divisor() { + assert_eq!(fee::checked_div_i128(100, 0), None); + } + + #[test] + fn test_checked_sub_i128_success() { + assert_eq!(fee::checked_sub_i128(100, 10), Some(90)); + } + + #[test] + fn test_checked_sub_i128_underflow() { + assert_eq!(fee::checked_sub_i128(i128::MIN, 1), None); + } + + #[test] + fn test_checked_add_i128_success() { + assert_eq!(fee::checked_add_i128(100, 10), Some(110)); + } + + #[test] + fn test_checked_add_i128_overflow() { + assert_eq!(fee::checked_add_i128(i128::MAX, 1), None); + } + + #[test] + fn test_checked_add_i128_zero() { + assert_eq!(fee::checked_add_i128(0, 0), Some(0)); + assert_eq!(fee::checked_add_i128(100, 0), Some(100)); + assert_eq!(fee::checked_add_i128(0, 100), Some(100)); + } + + #[test] + fn test_checked_add_i128_negative_values() { + assert_eq!(fee::checked_add_i128(-10, 20), Some(10)); + assert_eq!(fee::checked_add_i128(10, -20), Some(-10)); + assert_eq!(fee::checked_add_i128(-10, -10), Some(-20)); + } + + #[test] + fn test_checked_add_i128_boundary_values() { + assert_eq!(fee::checked_add_i128(i128::MAX, 0), Some(i128::MAX)); + assert_eq!(fee::checked_add_i128(i128::MIN, 0), Some(i128::MIN)); + assert_eq!(fee::checked_add_i128(0, i128::MAX), Some(i128::MAX)); + assert_eq!(fee::checked_add_i128(0, i128::MIN), Some(i128::MIN)); + } + + #[test] + fn test_checked_add_i128_deterministic_error() { + // Verify that overflow always returns None, never panics + assert_eq!(fee::checked_add_i128(i128::MAX, i128::MAX), None); + assert_eq!(fee::checked_add_i128(i128::MIN, i128::MIN), None); + } + + #[test] + fn test_checked_div_i128_rejects_overflow() { + assert_eq!(fee::checked_div_i128(i128::MIN, -1), None); + } + + /// Both operands at `i128::MAX / 2 + 1` must overflow. + /// + /// `(i128::MAX / 2 + 1) + (i128::MAX / 2 + 1) == i128::MAX + 1`, which + /// exceeds i128 capacity, so the helper must return `None` rather than wrap. + #[test] + fn test_checked_add_i128_both_at_half_max_plus_one_overflows() { + let half_plus_one = i128::MAX / 2 + 1; + assert_eq!(fee::checked_add_i128(half_plus_one, half_plus_one), None); + } + + /// Both operands at `i128::MAX / 2` must not overflow. + /// + /// `(i128::MAX / 2) + (i128::MAX / 2) == i128::MAX - 1`, which fits in i128, + /// so the helper must return the correct sum just below the overflow boundary. + #[test] + fn test_checked_add_i128_both_at_half_max_succeeds() { + let half = i128::MAX / 2; + assert_eq!(fee::checked_add_i128(half, half), Some(half + half)); + } + + #[test] + fn test_normalize_quote_amount_preserves_positive_amount() { + assert_eq!(super::normalize_quote_amount(100), Ok(Some(100))); + } + + #[test] + fn test_normalize_quote_amount_maps_zero_to_noop() { + assert_eq!(super::normalize_quote_amount(0), Ok(None)); + } + + #[test] + fn test_normalize_quote_amount_rejects_negative_amount() { + assert_eq!( + super::normalize_quote_amount(-1), + Err(super::ContractError::NotPositiveAmount) + ); + } + + #[test] + fn test_normalize_quote_amount_rejects_large_amount() { + let large = super::fee::MAX_SAFE_AMOUNT + 1; + assert_eq!( + super::normalize_quote_amount(large), + Err(super::ContractError::Overflow) + ); + } + + #[test] + fn test_checked_format_quote_response_buy_success() { + let res = super::checked_format_quote_response(1000, 90, 10, true).unwrap(); + assert_eq!(res.price, 1000); + assert_eq!(res.creator_fee, 90); + assert_eq!(res.protocol_fee, 10); + assert_eq!(res.total_amount, 1100); + } + + #[test] + fn test_checked_format_quote_response_sell_success() { + let res = super::checked_format_quote_response(1000, 90, 10, false).unwrap(); + assert_eq!(res.price, 1000); + assert_eq!(res.creator_fee, 90); + assert_eq!(res.protocol_fee, 10); + assert_eq!(res.total_amount, 900); + } + + #[test] + fn test_checked_format_quote_response_buy_overflow_fees() { + let res = super::checked_format_quote_response(1000, i128::MAX, 1, true); + assert_eq!(res, Err(super::ContractError::Overflow)); + } + + #[test] + fn test_checked_format_quote_response_buy_overflow_total() { + let res = super::checked_format_quote_response(i128::MAX, 1, 0, true); + assert_eq!(res, Err(super::ContractError::Overflow)); + } + + #[test] + fn test_checked_format_quote_response_sell_underflow_total() { + let res = super::checked_format_quote_response(i128::MIN, 1, 0, false); + assert_eq!(res, Err(super::ContractError::SellUnderflow)); + } + + #[test] + fn test_apply_percentage_fee_success() { + assert_eq!(fee::apply_percentage_fee(1000, 1000), Some(100)); + assert_eq!(fee::apply_percentage_fee(1000, 0), Some(0)); + assert_eq!(fee::apply_percentage_fee(1000, 10000), Some(1000)); + } + + #[test] + fn test_apply_percentage_fee_zero_amount() { + assert_eq!(fee::apply_percentage_fee(0, 1000), Some(0)); + } + + #[test] + fn test_apply_percentage_fee_negative_amount() { + assert_eq!(fee::apply_percentage_fee(-100, 1000), Some(0)); + } + + #[test] + fn test_apply_percentage_fee_rounding() { + // 999 * 1000 / 10000 = 99.9 -> 99 + assert_eq!(fee::apply_percentage_fee(999, 1000), Some(99)); + } + + #[test] + fn test_apply_percentage_fee_overflow() { + // Multiplication overflows before division + assert_eq!(fee::apply_percentage_fee(i128::MAX, 2), None); + } + + #[test] + fn test_assert_valid_fee_bps() { + // Valid scenarios + assert_eq!(fee::assert_valid_fee_bps(10000, 0), Ok(())); + assert_eq!(fee::assert_valid_fee_bps(5000, 5000), Ok(())); + assert_eq!(fee::assert_valid_fee_bps(9000, 1000), Ok(())); + + // Invalid Sum + assert_eq!( + fee::assert_valid_fee_bps(9000, 2000), + Err(super::ContractError::InvalidFeeConfig) + ); + assert_eq!( + fee::assert_valid_fee_bps(0, 0), + Err(super::ContractError::InvalidFeeConfig) + ); + + // Protocol Cap Exceeded (PROTOCOL_BPS_MAX = 5000) + assert_eq!( + fee::assert_valid_fee_bps(4999, 5001), + Err(super::ContractError::ProtocolFeeExceedsCap) + ); + assert_eq!( + fee::assert_valid_fee_bps(0, 10000), + Err(super::ContractError::ProtocolFeeExceedsCap) + ); + + // Overflow + assert_eq!( + fee::assert_valid_fee_bps(u32::MAX, 1), + Err(super::ContractError::InvalidFeeConfig) + ); + } + + #[test] + fn test_validate_fee_bps() { + // Valid + assert!(fee::validate_fee_bps(10000, 0)); + assert!(fee::validate_fee_bps(5000, 5000)); + assert!(fee::validate_fee_bps(9000, 1000)); + + // Invalid Sum + assert!(!fee::validate_fee_bps(9000, 2000)); + assert!(!fee::validate_fee_bps(0, 0)); + + // Protocol Cap Exceeded + assert!(!fee::validate_fee_bps(4999, 5001)); + + // Overflow + assert!(!fee::validate_fee_bps(u32::MAX, 1)); + } + + // --- checked_fee_sum unit tests --- + + /// Verifies that `checked_fee_sum` returns the correct sum for two ordinary + /// positive fee components. + #[test] + fn test_checked_fee_sum_success() { + assert_eq!(fee::checked_fee_sum(900, 100), Some(1000)); + assert_eq!(fee::checked_fee_sum(0, 0), Some(0)); + assert_eq!(fee::checked_fee_sum(500, 500), Some(1000)); + } + + /// Verifies that `checked_fee_sum` returns `None` when the addition would + /// overflow `i128`, preventing silent wrapping in fee total calculations. + #[test] + fn test_checked_fee_sum_overflow_returns_none() { + assert_eq!(fee::checked_fee_sum(i128::MAX, 1), None); + assert_eq!(fee::checked_fee_sum(i128::MAX, i128::MAX), None); + } + + /// Edge case: verifies `checked_fee_sum` at the boundary where one component + /// is exactly `i128::MAX` and the other is zero — the only non-overflowing + /// case at that boundary. + #[test] + fn test_checked_fee_sum_boundary_max_plus_zero() { + assert_eq!(fee::checked_fee_sum(i128::MAX, 0), Some(i128::MAX)); + assert_eq!(fee::checked_fee_sum(0, i128::MAX), Some(i128::MAX)); + // One above the boundary must overflow + assert_eq!(fee::checked_fee_sum(i128::MAX, 1), None); + } + + // --- BPS truncation on small amounts --- + + /// Bps calculation on very small amounts produces zero due to integer division + /// truncation. These tests document the behavior at the lower precision boundary. + /// + /// Formula: `amount * bps / 10_000` (floor division). + /// When the product `amount * bps < 10_000`, the result truncates to zero. + #[test] + fn test_apply_percentage_fee_truncation_1_stroop() { + // 1 * 1000 / 10_000 = 0.1 → truncated to 0 + // At 1 stroop with 10% bps, the fee is zero — value is silently lost. + let result = fee::apply_percentage_fee(1, 1000); + assert_eq!(result, Some(0), "1 stroop at 1000 bps truncates to 0"); + } + + #[test] + fn test_apply_percentage_fee_truncation_10_stroops() { + // 10 * 1000 / 10_000 = 1.0 → exactly 1 + // At 10 stroops with 10% bps, the fee is exactly 1. + let result = fee::apply_percentage_fee(10, 1000); + assert_eq!(result, Some(1), "10 stroops at 1000 bps yields 1"); + } + + #[test] + fn test_apply_percentage_fee_truncation_100_stroops() { + // 100 * 1000 / 10_000 = 10.0 → exactly 10 + let result = fee::apply_percentage_fee(100, 1000); + assert_eq!(result, Some(10), "100 stroops at 1000 bps yields 10"); + } + + #[test] + fn test_fee_split_truncation_1_stroop() { + // 1 * 1000 / 10_000 = 0 protocol, 1 creator (remainder to creator) + // Truncation causes the full amount to go to creator. + let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); + assert_eq!(protocol, 0, "1 stroop: protocol fee truncated to 0"); + assert_eq!(creator, 1, "1 stroop: creator gets full amount"); + assert_eq!(creator + protocol, 1, "conservation holds"); + } + + #[test] + fn test_fee_split_truncation_10_stroops() { + // 10 * 1000 / 10_000 = 1 protocol, 9 creator + let (creator, protocol) = fee::compute_fee_split(10, 9000, 1000); + assert_eq!(protocol, 1, "10 stroops: protocol fee is 1"); + assert_eq!(creator, 9, "10 stroops: creator gets 9"); + assert_eq!(creator + protocol, 10, "conservation holds"); + } + + #[test] + fn test_fee_split_truncation_100_stroops() { + // 100 * 1000 / 10_000 = 10 protocol, 90 creator + let (creator, protocol) = fee::compute_fee_split(100, 9000, 1000); + assert_eq!(protocol, 10, "100 stroops: protocol fee is 10"); + assert_eq!(creator, 90, "100 stroops: creator gets 90"); + assert_eq!(creator + protocol, 100, "conservation holds"); + } + + // --- Zero address validation --- + + #[test] + fn test_validate_non_zero_address_rejects_zero() { + use soroban_sdk::{Address, Env, String}; + let env = Env::default(); + let zero_str = String::from_str( + &env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ); + let zero_addr = Address::from_string(&zero_str); + let result = super::validate_non_zero_address(&env, &zero_addr); + assert_eq!(result, Err(super::ContractError::ZeroAddress)); + } + + #[test] + fn test_validate_non_zero_address_accepts_valid() { + use soroban_sdk::{testutils::Address as _, Address, Env}; + let env = Env::default(); + let valid = Address::generate(&env); + let result = super::validate_non_zero_address(&env, &valid); + assert_eq!(result, Ok(())); + } } #[cfg(test)] From a7ea75befe00b4ef9162233dafd5663459464f3f Mon Sep 17 00:00:00 2001 From: Syed Ghufran Hassan Date: Wed, 24 Jun 2026 17:00:48 +0500 Subject: [PATCH 08/11] Update lib.rs --- creator-keys/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index bca1f78..ad7b661 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -1,7 +1,9 @@ #![no_std] pub mod quote_view_errors; -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, token, Address, Env, String, Vec}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, token, Address, Env, String, Vec, +}; pub mod events; From 996a7726461bca09d0cd0105491e39a2039a4c87 Mon Sep 17 00:00:00 2001 From: Syed Ghufran Hassan Date: Wed, 24 Jun 2026 17:13:11 +0500 Subject: [PATCH 09/11] Update lib.rs --- creator-keys/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index ad7b661..d56a105 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -2,7 +2,7 @@ pub mod quote_view_errors; use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token, Address, Env, String, Vec, + contract, contracterror, contractimpl, contracttype, Address, Env, String, Vec, }; pub mod events; From d3a57f767f96933d8d15ba78b4960556941dd941 Mon Sep 17 00:00:00 2001 From: Syed Ghufran Hassan Date: Wed, 24 Jun 2026 17:14:58 +0500 Subject: [PATCH 10/11] Update lib.rs --- creator-keys/src/lib.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index d56a105..49cae0e 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -1,9 +1,7 @@ #![no_std] pub mod quote_view_errors; -use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, Address, Env, String, Vec, -}; +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, String, Vec}; pub mod events; From 4537559e5a9d39a2f03c91d4166560d3f4459e0d Mon Sep 17 00:00:00 2001 From: Syed Ghufran Hassan Date: Wed, 24 Jun 2026 17:23:01 +0500 Subject: [PATCH 11/11] Update lib.rs --- creator-keys/src/lib.rs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 49cae0e..dcd149a 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -984,15 +984,19 @@ impl CreatorKeysContract { } } - pub fn distribute_dividend( - env: Env, - creator: Address, - amount: i128, - ) -> Result<(), ContractError> { - let caller = env.invoker(); - let native_address = env.ledger().get_native_token(); + pub fn distribute_dividend( + env: Env, + creator: Address, + amount: i128, + ) -> Result<(), ContractError> { + let caller = env.caller(); // use caller() instead of invoker() + let native_address = Address::from_str( + &env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ); let native_token = soroban_sdk::token::TokenClient::new(&env, &native_address); native_token.transfer(&caller, &env.current_contract_address(), &amount); + // Load creator profile and validate let profile = read_registered_creator_profile(&env, &creator)?; @@ -1055,7 +1059,7 @@ impl CreatorKeysContract { /// Resets the claimable balance to zero. /// Emits a `DividendClaimed` event. pub fn claim_dividend(env: Env, creator: Address) -> Result<(), ContractError> { - let caller = env.invoker(); + let caller = env.caller(); // 1. Read claimable balance let claimable = read_claimable_dividend(&env, &creator, &caller); @@ -1067,7 +1071,10 @@ impl CreatorKeysContract { write_claimable_dividend(&env, &creator, &caller, 0); // 3. Transfer XLM from contract to caller - let native_address = env.ledger().get_native_token(); + let native_address = Address::from_str( + &env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ); let native_token = soroban_sdk::token::TokenClient::new(&env, &native_address); native_token.transfer(&env.current_contract_address(), &caller, &claimable);