diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 91b8e751..f58b6919 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -19,6 +19,7 @@ pub enum ContractError { InvalidFeeConfig = 8, InsufficientBalance = 9, SellUnderflow = 10, + ProtocolFeeExceedsCap = 11, } pub mod fee { @@ -33,7 +34,7 @@ pub mod fee { /// expected economic bounds before they affect market logic. pub const PROTOCOL_BPS_MAX: u32 = 5_000; - #[derive(Clone)] + #[derive(Clone, Eq, PartialEq)] #[contracttype] pub struct FeeConfig { pub creator_bps: u32, @@ -354,7 +355,9 @@ fn checked_format_quote_response( let total_amount = if is_buy { price.checked_add(fees).ok_or(ContractError::Overflow)? } else { - price.checked_sub(fees).ok_or(ContractError::Overflow)? + price + .checked_sub(fees) + .ok_or(ContractError::SellUnderflow)? }; Ok(QuoteResponse { @@ -652,14 +655,29 @@ impl CreatorKeysContract { protocol_bps: u32, ) -> Result<(), ContractError> { admin.require_auth(); - if !fee::validate_fee_bps(creator_bps, protocol_bps) { + let Some(sum) = creator_bps.checked_add(protocol_bps) else { + return Err(ContractError::InvalidFeeConfig); + }; + if sum != fee::BPS_MAX { return Err(ContractError::InvalidFeeConfig); } + if protocol_bps > fee::PROTOCOL_BPS_MAX { + return Err(ContractError::ProtocolFeeExceedsCap); + } 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); @@ -671,6 +689,15 @@ impl CreatorKeysContract { 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); @@ -687,6 +714,15 @@ impl CreatorKeysContract { /// 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); @@ -709,6 +745,15 @@ impl CreatorKeysContract { /// 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); @@ -972,7 +1017,7 @@ mod tests { #[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::Overflow)); + assert_eq!(res, Err(super::ContractError::SellUnderflow)); } } diff --git a/creator-keys/tests/fee_split.rs b/creator-keys/tests/fee_split.rs index 7d71b195..0d6e7890 100644 --- a/creator-keys/tests/fee_split.rs +++ b/creator-keys/tests/fee_split.rs @@ -103,7 +103,7 @@ fn test_set_fee_config_protocol_bps_above_max_fails() { let admin = soroban_sdk::Address::generate(&env); let result = client.try_set_fee_config(&admin, &4999u32, &5001u32); - assert_eq!(result, Err(Ok(ContractError::InvalidFeeConfig))); + assert_eq!(result, Err(Ok(ContractError::ProtocolFeeExceedsCap))); } #[test] diff --git a/docs/deterministic-quote-tests.md b/docs/deterministic-quote-tests.md index c00bb2d0..effbac6f 100644 --- a/docs/deterministic-quote-tests.md +++ b/docs/deterministic-quote-tests.md @@ -72,6 +72,14 @@ Access Layer uses a **fixed price model**: 3. **Supply independence**: Buying or selling keys should not affect the quote price 4. **Creator independence**: All creators have the same price (but independent supplies) +### Sell Quote Monotonicity (Incremental Sells) + +For the fixed price model, sell quotes are expected to be stable across incremental sells until the holder runs out of keys: + +- Calling `get_sell_quote` repeatedly for the same `(creator, holder)` should return the same `QuoteResponse` as long as `get_key_balance(creator, holder) > 0`. +- Once the holder balance reaches `0`, `get_sell_quote` should reject with `ContractError::InsufficientBalance`. +- If fee configuration and price would imply a negative sell payout (fees exceed price), `get_sell_quote` should reject with `ContractError::SellUnderflow` rather than returning an invalid negative amount. + ## Fee Configuration Rules Understanding fee rules is essential for writing correct tests: diff --git a/docs/error-codes.md b/docs/error-codes.md index b4a4c8d4..74ed2efd 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -15,8 +15,10 @@ Error codes are defined in [`creator-keys/src/lib.rs`](../creator-keys/src/lib.r | 5 | `KeyPriceNotSet` | 5 | Attempt to use pricing operations (buy, sell, quote) before an admin has set a key price | Contact protocol admin to set a key price via `set_key_price` | | 6 | `NotPositiveAmount` | 6 | Amount is zero or negative where a positive value is required (e.g., `set_key_price`, `buy_key` payment) | Use a positive amount; for buy/sell, check user input for negative or zero values | | 7 | `FeeConfigNotSet` | 7 | Attempt to compute or query fees before an admin has set a protocol fee configuration | Contact protocol admin to set fee config via `set_fee_config` | -| 8 | `InvalidFeeConfig` | 8 | Proposed fee configuration violates constraints: `creator_bps + protocol_bps != 10000` or `protocol_bps > 5000` | Ensure basis points sum to 10,000 and protocol share does not exceed 50%; see `fee::validate_fee_bps` for validation rules | +| 8 | `InvalidFeeConfig` | 8 | Proposed fee configuration violates constraints: `creator_bps + protocol_bps != 10000` | Ensure basis points sum to 10,000; validate client-side before sending | | 9 | `InsufficientBalance` | 9 | Holder has no keys for the given creator (sell attempt with zero balance) | Verify holder owns keys before attempting a sell; call `get_key_balance` to check holdings | +| 10 | `SellUnderflow` | 10 | Sell operation would underflow supply/balance (internal invariant violation) or sell quote would result in a negative net amount | Report if encountered; for quotes, check fee configuration relative to key price | +| 11 | `ProtocolFeeExceedsCap` | 11 | Proposed fee configuration exceeds the maximum allowed protocol share (`protocol_bps > 5000`) | Reduce protocol share to 50% or lower; see `fee::PROTOCOL_BPS_MAX` | ## Integration Notes @@ -28,7 +30,8 @@ Error codes are defined in [`creator-keys/src/lib.rs`](../creator-keys/src/lib.r ### Pricing and Fees - Code 5 (`KeyPriceNotSet`) and Code 7 (`FeeConfigNotSet`) are initialization gates. Clients should detect these and display a message like "Pricing has not been configured yet" rather than retrying immediately. -- Code 8 (`InvalidFeeConfig`) is raised when basis points are invalid. Always validate client-side before sending transactions: `creator_bps + protocol_bps == 10000` and `protocol_bps <= 5000`. +- Code 8 (`InvalidFeeConfig`) is raised when basis points are invalid. Always validate client-side before sending transactions: `creator_bps + protocol_bps == 10000`. +- Code 11 (`ProtocolFeeExceedsCap`) is raised when the protocol share exceeds the cap: `protocol_bps <= 5000`. ### Buy and Sell diff --git a/docs/fee-assumptions.md b/docs/fee-assumptions.md index 1a3c0839..2feadabb 100644 --- a/docs/fee-assumptions.md +++ b/docs/fee-assumptions.md @@ -6,6 +6,7 @@ This document describes the assumptions, behavior, and integration points for cr - **Basis points (bps)**: Fee shares are expressed in basis points, where 10,000 = 100%. - **Validation**: `creator_bps + protocol_bps` must equal 10,000. Any other sum is rejected. +- **Protocol cap**: `protocol_bps` is capped at 5,000 (50%) and `set_fee_config` rejects values above the cap with `ContractError::ProtocolFeeExceedsCap`. - **Example**: 9,000 creator_bps + 1,000 protocol_bps = 90% creator, 10% protocol. ## Rounding and Remainder Handling