diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 818d6fb6..acecd2a0 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -201,6 +201,7 @@ pub enum ContractError { CircuitBreakerTripped = 102, InvalidSignature = 103, NonceAlreadyUsed = 104, + // --- Holder cap (Issue #862) --- /// The requested holder cap is above the currently stored cap; the cap can /// only be tightened (reduced), never raised above the value set at launch. CapCannotIncrease = 105, @@ -1329,6 +1330,24 @@ pub struct QuoteResponse { pub total_amount: i128, } +/// Liquidity depth result returned by the batch-trade pre-validation oracles +/// [`CreatorKeysContract::get_max_buy`] and [`CreatorKeysContract::get_max_sell`] +/// (issue #1035). +/// +/// Both prices are raw bonding-curve prices per key (before fees), expressed in +/// XLM stroops. +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub struct LiquidityDepthQuote { + /// Largest trade amount (in keys) whose full execution keeps the bonding-curve + /// price movement within the requested impact tolerance. + pub max_amount: u32, + /// Bonding-curve price per key at the current supply. + pub current_price: i128, + /// Bonding-curve price per key expected after executing `max_amount` in full. + pub expected_price: i128, +} + /// Supply snapshot for a key, returned by [`CreatorKeysContract::get_supply_info`] /// (issue #997). #[derive(Clone, Debug, PartialEq)] @@ -4128,6 +4147,82 @@ fn checked_pow_i128(base: i128, exp: u32) -> Result { Ok(result) } +/// Upper bound on how far the liquidity-depth scan walks when a key has no +/// configured supply cap and its curve price never moves outside the requested +/// impact tolerance (for example a flat preset, or a zero slope). +pub const MAX_DEPTH_SCAN_LIMIT: u32 = 1_000_000; + +/// Computes the bonding-curve price per key after trading `amount` keys against +/// a curve currently at `supply`. +/// +/// Buys price the curve at `supply + amount`; sells price it at +/// `supply - amount` (bounded below by zero via checked subtraction). +fn depth_post_trade_price( + env: &Env, + creator: &Address, + base_price: i128, + supply: u32, + amount: u32, + is_buy: bool, +) -> Result { + let target_supply = if is_buy { + supply.checked_add(amount).ok_or(ContractError::Overflow)? + } else { + supply + .checked_sub(amount) + .ok_or(ContractError::SellUnderflow)? + }; + compute_bonding_curve_price(env, creator, base_price, target_supply) +} + +/// Returns `true` when moving from `current_price` to `post_trade_price` stays +/// within `impact_bps` of the current price: +/// `(post - current).abs() * 10_000 / current <= impact_bps`. +/// +/// `current_price` must be strictly positive; callers handle the zero-liquidity +/// case before reaching this helper. Arithmetic saturates to "infeasible" on +/// overflow so the depth bisection simply narrows past such amounts. +fn depth_within_impact_limit(post_trade_price: i128, current_price: i128, impact_bps: u32) -> bool { + let Some(diff) = post_trade_price.checked_sub(current_price) else { + return false; + }; + let magnitude = if diff >= 0 { + diff + } else { + match diff.checked_neg() { + Some(neg) => neg, + None => return false, + } + }; + let Some(scaled) = magnitude.checked_mul(fee::BPS_MAX as i128) else { + return false; + }; + let impact_bps_i = scaled / current_price; + impact_bps_i <= impact_bps as i128 +} + +/// Finds the largest amount in `0..=max_depth` whose simulated price movement +/// stays within the impact tolerance. +/// +/// Relies on the post-trade price being monotonically non-decreasing in the +/// traded amount — true for every curve shape the protocol can configure +/// (flat, linear, quadratic, exponent overrides and graduated milestones all +/// use non-negative slopes) — so binary search over the prefix of feasible +/// amounts is exact. +fn find_max_depth(max_depth: u32, within_limit: impl Fn(u32) -> bool) -> u32 { + let mut lo: u32 = 0; + let mut hi: u32 = max_depth; + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + if within_limit(mid) { + lo = mid; + } else { + hi = mid - 1; + } + } + lo +} + fn zero_quote_response() -> QuoteResponse { QuoteResponse { price: 0, @@ -10910,6 +11005,147 @@ impl CreatorKeysContract { checked_format_quote_response(price, creator_fee, protocol_fee, false) } + /// Read-only liquidity depth oracle for batch-trade pre-validation (issue #1035). + /// + /// Returns the maximum number of keys that can be bought for `key_id` without + /// moving the bonding-curve price more than `max_impact_bps` above its current + /// level, together with the current per-key price and the price expected after + /// executing that full amount. + /// + /// Price impact is measured on the raw bonding-curve price (before fees): + /// `(price_after - price_now) * 10_000 / price_now <= max_impact_bps`. + /// An amount whose simulated post-trade price overflows is treated as exceeding + /// the tolerance. + /// + /// The scan depth is capped by the key's remaining supply when a cap is + /// configured, and by [`MAX_DEPTH_SCAN_LIMIT`] when it is not (for example a + /// flat curve where price never moves: the full scan depth is buyable at any + /// tolerance). + /// + /// Never mutates contract state. + /// + /// # Errors + /// - [`ContractError::InvalidImpactTolerance`] if `max_impact_bps` exceeds 10 000. + /// - [`ContractError::KeyPriceNotSet`] if no key price has been configured. + /// - [`ContractError::NotRegistered`] if `key_id` is not a registered creator. + pub fn get_max_buy( + env: Env, + key_id: Address, + max_impact_bps: u32, + ) -> Result { + if max_impact_bps > fee::BPS_MAX { + return Err(ContractError::InvalidImpactTolerance); + } + let base_price: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; + let profile = read_registered_creator_profile(&env, &key_id)?; + let supply = profile.supply; + + let current_price = compute_bonding_curve_price(&env, &key_id, base_price, supply)?; + // Zero-liquidity guard: a non-positive curve price has no meaningful + // impact ratio, so report an empty depth instead of dividing by it. + if current_price <= 0 { + return Ok(LiquidityDepthQuote { + max_amount: 0, + current_price, + expected_price: current_price, + }); + } + + let cap: u32 = env + .storage() + .persistent() + .get(&constants::storage::max_supply(&key_id)) + .unwrap_or(0); + let max_depth = if cap == 0 { + MAX_DEPTH_SCAN_LIMIT + } else { + cap.saturating_sub(supply) + }; + + let within_limit = |amount: u32| -> bool { + match depth_post_trade_price(&env, &key_id, base_price, supply, amount, true) { + Ok(post) => depth_within_impact_limit(post, current_price, max_impact_bps), + Err(_) => false, + } + }; + let max_amount = find_max_depth(max_depth, within_limit); + let expected_price = + depth_post_trade_price(&env, &key_id, base_price, supply, max_amount, true)?; + + Ok(LiquidityDepthQuote { + max_amount, + current_price, + expected_price, + }) + } + + /// Read-only liquidity depth oracle for batch-trade pre-validation (issue #1035). + /// + /// Returns the maximum number of keys that can be sold for `key_id` without + /// moving the bonding-curve price more than `min_impact_bps` below its current + /// level, together with the current per-key price and the price expected after + /// executing that full amount. + /// + /// Price impact is measured on the raw bonding-curve price (before fees and + /// before any configured bid-ask spread): + /// `(price_now - price_after) * 10_000 / price_now <= min_impact_bps`. + /// The scan never walks the curve below a supply of zero; at zero supply the + /// key has no sellable liquidity and an empty depth is returned. + /// + /// Never mutates contract state. + /// + /// # Errors + /// - [`ContractError::InvalidImpactTolerance`] if `min_impact_bps` exceeds 10 000. + /// - [`ContractError::KeyPriceNotSet`] if no key price has been configured. + /// - [`ContractError::NotRegistered`] if `key_id` is not a registered creator. + pub fn get_max_sell( + env: Env, + key_id: Address, + min_impact_bps: u32, + ) -> Result { + if min_impact_bps > fee::BPS_MAX { + return Err(ContractError::InvalidImpactTolerance); + } + let base_price: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; + let profile = read_registered_creator_profile(&env, &key_id)?; + let supply = profile.supply; + + let current_price = compute_bonding_curve_price(&env, &key_id, base_price, supply)?; + // Zero-liquidity guard: nothing is sellable at zero supply, and a + // non-positive curve price has no meaningful impact ratio. + if supply == 0 || current_price <= 0 { + return Ok(LiquidityDepthQuote { + max_amount: 0, + current_price, + expected_price: current_price, + }); + } + + let within_limit = |amount: u32| -> bool { + match depth_post_trade_price(&env, &key_id, base_price, supply, amount, false) { + Ok(post) => depth_within_impact_limit(post, current_price, min_impact_bps), + Err(_) => false, + } + }; + let max_amount = find_max_depth(supply, within_limit); + let expected_price = + depth_post_trade_price(&env, &key_id, base_price, supply, max_amount, false)?; + + Ok(LiquidityDepthQuote { + max_amount, + current_price, + expected_price, + }) + } + /// Deposits `amount` as a dividend for all current key holders of `creator`. /// /// The protocol fee is deducted first; the remainder is distributed proportionally diff --git a/creator-keys/src/test_issue_1035.rs b/creator-keys/src/test_issue_1035.rs new file mode 100644 index 00000000..741cf18c --- /dev/null +++ b/creator-keys/src/test_issue_1035.rs @@ -0,0 +1,428 @@ +//! Tests for issue #1035 (key-pair liquidity depth oracle for batch trade +//! pre-validation): `get_max_buy` / `get_max_sell` return the maximum trade +//! amount executable within a price-impact tolerance plus the expected +//! post-trade price, without mutating curve state. + +use crate::{ + ContractError, CreatorKeysContract, CreatorKeysContractClient, CurvePreset, + LiquidityDepthQuote, RegisterCreatorParams, +}; +use soroban_sdk::{testutils::Address as _, Address, Env, String}; + +const BASE_PRICE: i128 = 1_000; +const SLOPE: i128 = 10; + +struct Setup { + env: Env, + client: CreatorKeysContractClient<'static>, + admin: Address, + creator: Address, +} + +fn setup() -> Setup { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.set_protocol_admin(&admin, &admin); + client.set_treasury_address(&admin, &treasury); + client.set_key_price(&admin, &BASE_PRICE); + // A zero-sum fee config is rejected, so use the smallest legal split; at + // 1 bp the fee floors to zero stroops on these price magnitudes and does + // not affect the depth (curve-only) math under test. + client.set_fee_config(&admin, &1u32, &0u32); + client.set_protocol_fee_recipient(&admin, &treasury); + client.set_curve_slope(&admin, &SLOPE); + + let creator = Address::generate(&env); + register(&env, &client, &creator, "depth01", None, None); + + Setup { + env, + client, + admin, + creator, + } +} + +fn register( + env: &Env, + client: &CreatorKeysContractClient, + creator: &Address, + handle: &str, + preset: Option, + max_supply: Option, +) { + client.register_creator( + &RegisterCreatorParams { + creator: creator.clone(), + handle: String::from_str(env, handle), + }, + &None, + &max_supply, + &None, + &preset, + &None, + &None, + ); +} + +fn buy_keys(s: &Setup, creator: &Address, buyer: &Address, quantity: u32) { + for _ in 0..quantity { + s.client.buy_key(creator, buyer, &100_000i128, &None); + } +} + +/// Independent recomputation of the impact the oracle must respect: +/// `(post - current).abs() * 10_000 / current`, floored. +fn impact_bps(current: i128, post: i128) -> i128 { + ((post - current).abs() * 10_000) / current +} + +fn assert_depth_within_tolerance( + s: &Setup, + creator: &Address, + depth: &LiquidityDepthQuote, + supply: u32, + tolerance_bps: u32, + is_buy: bool, +) { + let post_supply = if is_buy { + supply + depth.max_amount + } else { + supply - depth.max_amount + }; + assert_eq!( + depth.expected_price, + s.client.get_price_at_supply(creator, &(post_supply as u64)), + "expected_price must equal the curve price after full execution" + ); + assert!( + impact_bps(depth.current_price, depth.expected_price) <= tolerance_bps as i128, + "full execution must stay within the impact tolerance" + ); + // Maximality: one more key must exceed the tolerance. For sells that extra + // key only exists while the curve would not be walked past zero supply. + let one_more_supply = if is_buy { + Some(supply + depth.max_amount + 1) + } else { + supply + .checked_sub(depth.max_amount) + .and_then(|remaining| remaining.checked_sub(1)) + }; + if depth.max_amount > 0 { + if let Some(one_more_supply) = one_more_supply { + let one_more_price = s + .client + .get_price_at_supply(creator, &(one_more_supply as u64)); + assert!( + impact_bps(depth.current_price, one_more_price) > tolerance_bps as i128, + "returned amount must be maximal for the tolerance" + ); + } + } +} + +// ─── get_max_buy ────────────────────────────────────────────────────────── + +#[test] +fn get_max_buy_returns_amount_for_linear_curve_within_tolerance() { + let s = setup(); + // P(s) = 1000 + 10s; from supply 0, impact(n) = 100n bps. + // 1 % tolerance: n = 1 (impact 100 bps); n = 2 would be 200 bps. + let depth = s.client.get_max_buy(&s.creator, &100u32); + assert_eq!(depth.current_price, 1_000); + assert_eq!(depth.max_amount, 1); + assert_eq!(depth.expected_price, 1_010); + assert_depth_within_tolerance(&s, &s.creator, &depth, 0, 100, true); +} + +#[test] +fn get_max_buy_scales_with_tolerance() { + let s = setup(); + // From supply 0: impact(n) = 100n bps, so 5 % permits exactly 5 keys. + let depth = s.client.get_max_buy(&s.creator, &500u32); + assert_eq!(depth.max_amount, 5); + assert_eq!(depth.expected_price, 1_050); + assert_depth_within_tolerance(&s, &s.creator, &depth, 0, 500, true); + + // 2.5 % permits 2 keys (200 bps), not 3 (300 bps). + let depth = s.client.get_max_buy(&s.creator, &250u32); + assert_eq!(depth.max_amount, 2); + assert_eq!(depth.expected_price, 1_020); + assert_depth_within_tolerance(&s, &s.creator, &depth, 0, 250, true); +} + +#[test] +fn get_max_buy_zero_tolerance_on_rising_curve_returns_zero() { + let s = setup(); + let depth = s.client.get_max_buy(&s.creator, &0u32); + assert_eq!(depth.max_amount, 0); + assert_eq!(depth.current_price, 1_000); + assert_eq!(depth.expected_price, 1_000); +} + +#[test] +fn get_max_buy_includes_exact_boundary_impact() { + let s = setup(); + buy_keys(&s, &s.creator, &Address::generate(&s.env), 10); + // From supply 10, P0 = 1100; impact(n) = floor(10n * 10000 / 1100). + // n = 11 -> floor(1_100_000/1100) = 1000 bps exactly, still within 1000. + let depth = s.client.get_max_buy(&s.creator, &1000u32); + assert_eq!(depth.current_price, 1_100); + assert_eq!(depth.max_amount, 11); + assert_eq!(depth.expected_price, 1_210); + assert_depth_within_tolerance(&s, &s.creator, &depth, 10, 1000, true); +} + +#[test] +fn get_max_buy_respects_supply_cap() { + let s = setup(); + let capped = Address::generate(&s.env); + register(&s.env, &s.client, &capped, "capped", None, Some(3)); + // Wide tolerance, but only 3 keys can ever be minted. + let depth = s.client.get_max_buy(&capped, &10_000u32); + assert_eq!(depth.max_amount, 3); + assert_eq!(depth.expected_price, 1_030); +} + +#[test] +fn get_max_buy_flat_curve_returns_full_scan_depth() { + let s = setup(); + let flat = Address::generate(&s.env); + register( + &s.env, + &s.client, + &flat, + "flatky", + Some(CurvePreset::Flat), + None, + ); + // Price never moves on a flat curve, so any tolerance admits the entire + // (uncapped) scan depth. + let depth = s.client.get_max_buy(&flat, &0u32); + assert_eq!(depth.max_amount, crate::MAX_DEPTH_SCAN_LIMIT); + assert_eq!(depth.current_price, BASE_PRICE); + assert_eq!(depth.expected_price, BASE_PRICE); + + // With a cap the depth is limited to what can still be minted. + let flat_capped = Address::generate(&s.env); + register( + &s.env, + &s.client, + &flat_capped, + "flatcp", + Some(CurvePreset::Flat), + Some(20), + ); + let depth = s.client.get_max_buy(&flat_capped, &0u32); + assert_eq!(depth.max_amount, 20); + assert_eq!(depth.expected_price, BASE_PRICE); +} + +#[test] +fn get_max_buy_quadratic_curve_math() { + let s = setup(); + let quad = Address::generate(&s.env); + register( + &s.env, + &s.client, + &quad, + "quadk", + Some(CurvePreset::Quadratic), + None, + ); + // Quadratic: P(s) = 1000 + s^2 (slope 1). From supply 0, + // impact(n) = floor(n^2 * 10000 / 1000) = 10n^2 bps. + // 10 % tolerance: n = 10 -> impact 1000 bps exactly (within); + // n = 11 -> 1210 bps (exceeds). + s.client.set_curve_slope(&s.admin, &1i128); + let depth = s.client.get_max_buy(&quad, &1000u32); + assert_eq!(depth.current_price, 1_000); + assert_eq!(depth.max_amount, 10); + assert_eq!(depth.expected_price, 1_100); + assert_depth_within_tolerance(&s, &quad, &depth, 0, 1000, true); +} + +#[test] +fn get_max_buy_matches_subsequent_execution_price() { + let s = setup(); + let depth = s.client.get_max_buy(&s.creator, &500u32); + assert_eq!(depth.max_amount, 5); + + let buyer = Address::generate(&s.env); + buy_keys(&s, &s.creator, &buyer, depth.max_amount); + assert_eq!(s.client.get_creator_supply(&s.creator), 5u32); + // The curve price the oracle predicted is the price now sitting at the + // post-trade supply. + assert_eq!( + s.client.get_price_at_supply(&s.creator, &5u64), + depth.expected_price + ); +} + +// ─── get_max_sell ───────────────────────────────────────────────────────── + +#[test] +fn get_max_sell_returns_amount_within_tolerance() { + let s = setup(); + let holder = Address::generate(&s.env); + buy_keys(&s, &s.creator, &holder, 5); + // From supply 5, P0 = 1050; impact(n) = floor(10n * 10000 / 1050). + // n = 1 -> 95 bps (within 100); n = 2 -> 190 bps. + let depth = s.client.get_max_sell(&s.creator, &100u32); + assert_eq!(depth.current_price, 1_050); + assert_eq!(depth.max_amount, 1); + assert_eq!(depth.expected_price, 1_040); + assert_depth_within_tolerance(&s, &s.creator, &depth, 5, 100, false); +} + +#[test] +fn get_max_sell_large_tolerance_returns_entire_supply() { + let s = setup(); + let holder = Address::generate(&s.env); + buy_keys(&s, &s.creator, &holder, 5); + // A 100 % tolerance admits selling everything down to zero supply. + let depth = s.client.get_max_sell(&s.creator, &10_000u32); + assert_eq!(depth.max_amount, 5); + assert_eq!(depth.expected_price, BASE_PRICE); + assert_depth_within_tolerance(&s, &s.creator, &depth, 5, 10_000, false); +} + +#[test] +fn get_max_sell_zero_supply_returns_empty_depth_gracefully() { + let s = setup(); + // Registered but no keys traded: zero sellable liquidity. + let depth = s.client.get_max_sell(&s.creator, &10_000u32); + assert_eq!(depth.max_amount, 0); + assert_eq!(depth.current_price, BASE_PRICE); + assert_eq!(depth.expected_price, BASE_PRICE); +} + +#[test] +fn get_max_sell_zero_tolerance_on_rising_curve_returns_zero() { + let s = setup(); + let holder = Address::generate(&s.env); + buy_keys(&s, &s.creator, &holder, 3); + let depth = s.client.get_max_sell(&s.creator, &0u32); + assert_eq!(depth.max_amount, 0); + assert_eq!(depth.current_price, 1_030); + assert_eq!(depth.expected_price, 1_030); +} + +#[test] +fn get_max_sell_flat_curve_returns_entire_supply() { + let s = setup(); + let flat = Address::generate(&s.env); + register( + &s.env, + &s.client, + &flat, + "flatsl", + Some(CurvePreset::Flat), + None, + ); + buy_keys(&s, &flat, &Address::generate(&s.env), 7); + // Price never moves, so everything is sellable within any tolerance. + let depth = s.client.get_max_sell(&flat, &0u32); + assert_eq!(depth.max_amount, 7); + assert_eq!(depth.current_price, BASE_PRICE); + assert_eq!(depth.expected_price, BASE_PRICE); +} + +// ─── read-only behaviour ────────────────────────────────────────────────── + +#[test] +fn depth_oracles_do_not_mutate_curve_state() { + let s = setup(); + let holder = Address::generate(&s.env); + buy_keys(&s, &s.creator, &holder, 4); + + let supply_before = s.client.get_creator_supply(&s.creator); + let balance_before = s.client.get_key_balance(&s.creator, &holder); + let slope_before = s.client.get_curve_slope(); + let bid_ask_before = s.client.get_bid_ask_price(&s.creator); + let buy_quote_before = s.client.get_buy_quote(&s.creator); + + let buy_depth = s.client.get_max_buy(&s.creator, &300u32); + let sell_depth = s.client.get_max_sell(&s.creator, &300u32); + // Repeated calls are stable. + let buy_depth_again = s.client.get_max_buy(&s.creator, &300u32); + let sell_depth_again = s.client.get_max_sell(&s.creator, &300u32); + + assert_eq!(buy_depth, buy_depth_again); + assert_eq!(sell_depth, sell_depth_again); + + assert_eq!(s.client.get_creator_supply(&s.creator), supply_before); + assert_eq!( + s.client.get_key_balance(&s.creator, &holder), + balance_before + ); + assert_eq!(s.client.get_curve_slope(), slope_before); + assert_eq!(s.client.get_bid_ask_price(&s.creator), bid_ask_before); + assert_eq!(s.client.get_buy_quote(&s.creator), buy_quote_before); +} + +// ─── error handling ─────────────────────────────────────────────────────── + +#[test] +fn depth_oracles_reject_unregistered_key() { + let s = setup(); + let unknown = Address::generate(&s.env); + assert_eq!( + s.client.try_get_max_buy(&unknown, &100u32), + Err(Ok(ContractError::NotRegistered)) + ); + assert_eq!( + s.client.try_get_max_sell(&unknown, &100u32), + Err(Ok(ContractError::NotRegistered)) + ); +} + +#[test] +fn depth_oracles_reject_impact_above_100_percent() { + let s = setup(); + assert_eq!( + s.client.try_get_max_buy(&s.creator, &10_001u32), + Err(Ok(ContractError::InvalidImpactTolerance)) + ); + assert_eq!( + s.client.try_get_max_sell(&s.creator, &10_001u32), + Err(Ok(ContractError::InvalidImpactTolerance)) + ); + // Exactly 100 % is accepted: impact(n) = 100n bps, so n = 100 fits. + assert_eq!( + s.client.try_get_max_buy(&s.creator, &10_000u32), + Ok(Ok(LiquidityDepthQuote { + max_amount: 100, + current_price: 1_000, + expected_price: 2_000, + })) + ); +} + +#[test] +fn depth_oracles_report_missing_key_price() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.set_protocol_admin(&admin, &admin); + let creator = Address::generate(&env); + register(&env, &client, &creator, "noprc", None, None); + + assert_eq!( + client.try_get_max_buy(&creator, &100u32), + Err(Ok(ContractError::KeyPriceNotSet)) + ); + assert_eq!( + client.try_get_max_sell(&creator, &100u32), + Err(Ok(ContractError::KeyPriceNotSet)) + ); +}