diff --git a/creator-keys/src/batch_trade.rs b/creator-keys/src/batch_trade.rs new file mode 100644 index 00000000..70c51eb7 --- /dev/null +++ b/creator-keys/src/batch_trade.rs @@ -0,0 +1,279 @@ +//! Atomic multi-key trades using the existing bonding-curve execution paths. +use crate::{ + compute_bonding_curve_price, compute_sell_proceeds, compute_slippage_bps, compute_trade_fee, + constants, fee, read_protocol_fee_config, read_registered_creator_profile, read_royalty_config, + AuctionConfig, ContractError, CreatorKeysContract, CreatorKeysContractArgs, + CreatorKeysContractClient, SwapDirection, +}; +use soroban_sdk::{contractimpl, contracttype, symbol_short, Address, Env, Map, Vec}; + +pub const MAX_BATCH_SWAP_ITEMS: u32 = 15; + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct BatchSwapItem { + pub key_id: Address, + pub amount: u32, + pub direction: SwapDirection, + pub max_slippage_bps: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct BatchSwapOutput { + pub key_id: Address, + pub amount: u32, + pub direction: SwapDirection, + /// Buy: gross payment, including allocated fees. Sell: net proceeds. + pub output: i128, + pub price_impact_bps: u32, + pub fee: i128, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct BatchTradeQuote { + pub items: Vec, + pub total_fee: i128, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct BatchSwapExecuted { + pub trader: Address, + pub items: Vec, + pub total_fee: i128, + pub ledger: u32, +} + +fn validate(items: &Vec) -> Result<(), ContractError> { + if items.is_empty() || items.len() > MAX_BATCH_SWAP_ITEMS { + return Err(ContractError::BatchSizeExceeded); + } + for item in items.iter() { + if item.amount == 0 { + return Err(ContractError::NotPositiveAmount); + } + if item.max_slippage_bps > 10_000 { + return Err(ContractError::InvalidFeeConfig); + } + } + Ok(()) +} + +fn buy_fees(env: &Env, creator: &Address, price: i128) -> Result { + let trade_fee = compute_trade_fee(env, creator, price)?; + let mut net = price + .checked_sub(trade_fee) + .ok_or(ContractError::Overflow)?; + let mut fees = trade_fee; + if env + .storage() + .persistent() + .has(&constants::storage::LP_CONTRACT_ADDRESS) + { + let bps: u32 = env + .storage() + .persistent() + .get(&constants::storage::LP_ALLOCATION_BPS) + .unwrap_or(0); + let allocation = fee::apply_percentage_fee(net, bps).ok_or(ContractError::Overflow)?; + net = net.checked_sub(allocation).ok_or(ContractError::Overflow)?; + fees = fees + .checked_add(allocation) + .ok_or(ContractError::Overflow)?; + } + if let Some(config) = read_protocol_fee_config(env) { + let (creator_fee, protocol_fee) = + fee::checked_compute_fee_split(net, config.creator_bps, config.protocol_bps) + .ok_or(ContractError::Overflow)?; + fees = fees + .checked_add(creator_fee) + .and_then(|v| v.checked_add(protocol_fee)) + .ok_or(ContractError::Overflow)?; + } + if let Some(royalty) = read_royalty_config(env, creator) { + fees = fees + .checked_add( + fee::apply_percentage_fee(price, royalty.buy_fee_bps) + .ok_or(ContractError::Overflow)?, + ) + .ok_or(ContractError::Overflow)?; + } + Ok(fees) +} + +fn quote_item( + env: &Env, + item: &BatchSwapItem, + supply: u32, +) -> Result<(BatchSwapOutput, u32), ContractError> { + let base: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; + let after = match item.direction { + SwapDirection::Buy => supply + .checked_add(item.amount) + .ok_or(ContractError::Overflow)?, + SwapDirection::Sell => supply + .checked_sub(item.amount) + .ok_or(ContractError::InsufficientSupply)?, + }; + let pre = compute_bonding_curve_price(env, &item.key_id, base, supply)?; + let post = compute_bonding_curve_price(env, &item.key_id, base, after)?; + let impact = compute_slippage_bps(pre, post)?; + let mut output: i128 = 0; + let mut fees: i128 = 0; + match item.direction { + SwapDirection::Buy => { + let auction: Option = env + .storage() + .persistent() + .get(&constants::storage::auction_config(&item.key_id)); + let auction_price = auction + .filter(|a| supply < a.auction_supply) + .map(|a| a.auction_price); + for index in 0..item.amount { + let price = match auction_price { + Some(price) => price, + None => compute_bonding_curve_price( + env, + &item.key_id, + base, + supply.checked_add(index).ok_or(ContractError::Overflow)?, + )?, + }; + output = output.checked_add(price).ok_or(ContractError::Overflow)?; + } + fees = buy_fees(env, &item.key_id, output)?; + } + SwapDirection::Sell => { + for index in 1..=item.amount { + let price = compute_bonding_curve_price( + env, + &item.key_id, + base, + supply + .checked_sub(index) + .ok_or(ContractError::InsufficientSupply)?, + )?; + let proceeds = compute_sell_proceeds(env, &item.key_id, price)?; + output = output + .checked_add(proceeds) + .ok_or(ContractError::Overflow)?; + let mut charged = price.checked_sub(proceeds).ok_or(ContractError::Overflow)?; + if let Some(royalty) = read_royalty_config(env, &item.key_id) { + charged = charged + .checked_add( + fee::apply_percentage_fee(price, royalty.sell_fee_bps) + .ok_or(ContractError::Overflow)?, + ) + .ok_or(ContractError::Overflow)?; + } + fees = fees.checked_add(charged).ok_or(ContractError::Overflow)?; + } + } + } + Ok(( + BatchSwapOutput { + key_id: item.key_id.clone(), + amount: item.amount, + direction: item.direction, + output, + price_impact_bps: impact, + fee: fees, + }, + after, + )) +} + +#[contractimpl] +impl CreatorKeysContract { + /// Atomically execute up to 15 items. Zero tolerance retains swap's opt-out. + pub fn batch_swap( + env: Env, + trader: Address, + items: Vec, + ) -> Result { + trader.require_auth(); + validate(&items)?; + let mut outputs = Vec::new(&env); + let mut total_fee: i128 = 0; + for item in items.iter() { + let supply = read_registered_creator_profile(&env, &item.key_id)?.supply; + let (mut result, _) = quote_item(&env, &item, supply)?; + if item.max_slippage_bps != 0 && result.price_impact_bps > item.max_slippage_bps { + return Err(ContractError::SlippageExceeded); + } + match item.direction { + SwapDirection::Buy => { + let outcome = Self::buy_keys_core( + env.clone(), + item.key_id.clone(), + trader.clone(), + item.amount, + result.output, + None, + None, + None, + )?; + if outcome.keys_bought != item.amount { + return Err(ContractError::SupplyCapExceeded); + } + result.output = outcome.total_price; + } + SwapDirection::Sell => { + let orders = Vec::from_array(&env, [(item.key_id.clone(), item.amount)]); + let sold = Self::batch_sell_core(env.clone(), trader.clone(), orders)?; + result.output = sold + .get(0) + .ok_or(ContractError::InsufficientSupply)? + .proceeds; + } + } + total_fee = total_fee + .checked_add(result.fee) + .ok_or(ContractError::Overflow)?; + outputs.push_back(result); + } + let receipt = BatchSwapExecuted { + trader: trader.clone(), + items: outputs, + total_fee, + ledger: env.ledger().sequence(), + }; + env.events() + .publish((symbol_short!("batch_sw"), trader), receipt.clone()); + Ok(receipt) + } + + /// Price items in order using virtual supplies, including repeated keys. + /// Pricing only: this does not authorize trades or bypass execution guards. + pub fn get_batch_quote( + env: Env, + items: Vec, + ) -> Result { + validate(&items)?; + let mut supplies: Map = Map::new(&env); + let mut outputs = Vec::new(&env); + let mut total_fee: i128 = 0; + for item in items.iter() { + let supply = match supplies.get(item.key_id.clone()) { + Some(supply) => supply, + None => read_registered_creator_profile(&env, &item.key_id)?.supply, + }; + let (result, after) = quote_item(&env, &item, supply)?; + supplies.set(item.key_id, after); + total_fee = total_fee + .checked_add(result.fee) + .ok_or(ContractError::Overflow)?; + outputs.push_back(result); + } + Ok(BatchTradeQuote { + items: outputs, + total_fee, + }) + } +} diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index df4671e3..780ff11d 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -64,6 +64,7 @@ pub enum CircuitBreakerRemoteError { pub mod acl_dividend_twap_gov; pub mod acl_limits_merge_sunset; +pub mod batch_trade; pub mod bundle; pub mod community_polls; /// Bonding-curve migration, key subscriptions, and atomic swaps. @@ -17577,6 +17578,15 @@ impl CreatorKeysContract { orders: Vec<(Address, u32)>, ) -> Result, ContractError> { seller.require_auth(); + Self::batch_sell_core(env, seller, orders) + } + + /// Shared sell path. The caller must authorize the seller once per frame. + pub(crate) fn batch_sell_core( + env: Env, + seller: Address, + orders: Vec<(Address, u32)>, + ) -> Result, ContractError> { assert_global_trading_not_halted(&env)?; assert_not_paused(&env)?; assert_not_blacklisted(&env, &seller)?; diff --git a/creator-keys/tests/batch_swap.rs b/creator-keys/tests/batch_swap.rs new file mode 100644 index 00000000..bfbed287 --- /dev/null +++ b/creator-keys/tests/batch_swap.rs @@ -0,0 +1,144 @@ +mod contract_test_env; +use contract_test_env::{ + register_creator_keys, register_test_creator, set_pricing_and_fees, test_env_with_auths, +}; +use creator_keys::{ + batch_trade::{BatchSwapExecuted, BatchSwapItem}, + ContractError, SwapDirection, +}; +use soroban_sdk::{ + testutils::{Address as _, Events, Ledger}, + Address, IntoVal, Symbol, Vec, +}; + +fn item(key: &Address, amount: u32, direction: SwapDirection, tolerance: u32) -> BatchSwapItem { + BatchSwapItem { + key_id: key.clone(), + amount, + direction, + max_slippage_bps: tolerance, + } +} + +#[test] +fn quote_matches_execution_and_event_including_mixed_directions() { + let env = test_env_with_auths(); + let (client, _) = register_creator_keys(&env); + let admin = set_pricing_and_fees(&env, &client, 1_000, 500, 500); + client.set_protocol_fee_recipient(&admin, &Address::generate(&env)); + client.set_curve_slope(&admin, &10); + let a = register_test_creator(&env, &client, "batch_a"); + let b = register_test_creator(&env, &client, "batch_b"); + let trader = Address::generate(&env); + client.buy_keys(&b, &trader, &3, &10_000, &None); + env.ledger().with_mut(|ledger| { + ledger.sequence_number += 1; + ledger.timestamp += 100; + }); + let items = Vec::from_array( + &env, + [ + item(&a, 2, SwapDirection::Buy, 500), + item(&b, 1, SwapDirection::Sell, 500), + ], + ); + let quote = client.get_batch_quote(&items); + assert_eq!(client.get_supply(&a), 0); + assert_eq!(client.get_supply(&b), 3); + let receipt = client.batch_swap(&trader, &items); + let mut found = false; + for (_, topics, data) in env.events().all().iter() { + let name: Symbol = topics.get(0).unwrap().into_val(&env); + if name == Symbol::new(&env, "batch_sw") { + let event: BatchSwapExecuted = data.into_val(&env); + assert_eq!(event, receipt); + found = true; + } + } + assert!(found); + assert_eq!(receipt.items, quote.items); + assert_eq!(receipt.total_fee, quote.total_fee); + assert_eq!(client.get_supply(&a), 2); + assert_eq!(client.get_supply(&b), 2); + let sum: i128 = receipt.items.iter().map(|output| output.fee).sum(); + assert_eq!(receipt.total_fee, sum); +} + +#[test] +fn later_slippage_failure_rolls_back_prior_items() { + let env = test_env_with_auths(); + let (client, _) = register_creator_keys(&env); + let admin = set_pricing_and_fees(&env, &client, 1_000, 500, 500); + client.set_protocol_fee_recipient(&admin, &Address::generate(&env)); + client.set_curve_slope(&admin, &10); + let a = register_test_creator(&env, &client, "rollback_a"); + let b = register_test_creator(&env, &client, "rollback_b"); + let trader = Address::generate(&env); + let items = Vec::from_array( + &env, + [ + item(&a, 1, SwapDirection::Buy, 500), + item(&b, 1, SwapDirection::Buy, 1), + ], + ); + assert_eq!( + client.try_batch_swap(&trader, &items), + Err(Ok(ContractError::SlippageExceeded)) + ); + for key in [&a, &b] { + assert_eq!(client.get_supply(key), 0); + assert_eq!(client.get_key_balance(key, &trader), 0); + } +} + +#[test] +fn rejects_empty_and_sixteen_items_and_zero_amount() { + let env = test_env_with_auths(); + let (client, _) = register_creator_keys(&env); + let trader = Address::generate(&env); + let mut items = Vec::new(&env); + assert_eq!( + client.try_batch_swap(&trader, &items), + Err(Ok(ContractError::BatchSizeExceeded)) + ); + for _ in 0..16 { + items.push_back(item(&trader, 1, SwapDirection::Buy, 0)); + } + assert_eq!( + client.try_get_batch_quote(&items), + Err(Ok(ContractError::BatchSizeExceeded)) + ); + assert_eq!( + client.try_batch_swap(&trader, &items), + Err(Ok(ContractError::BatchSizeExceeded)) + ); + assert_eq!( + client.try_get_batch_quote(&Vec::from_array( + &env, + [item(&trader, 0, SwapDirection::Buy, 0)] + )), + Err(Ok(ContractError::NotPositiveAmount)) + ); +} + +#[test] +fn repeated_key_quotes_walk_virtual_supply() { + let env = test_env_with_auths(); + let (client, _) = register_creator_keys(&env); + let admin = set_pricing_and_fees(&env, &client, 1_000, 500, 500); + client.set_protocol_fee_recipient(&admin, &Address::generate(&env)); + client.set_curve_slope(&admin, &10); + let key = register_test_creator(&env, &client, "repeated"); + let trader = Address::generate(&env); + let items = Vec::from_array( + &env, + [ + item(&key, 1, SwapDirection::Buy, 0), + item(&key, 1, SwapDirection::Buy, 0), + ], + ); + let quote = client.get_batch_quote(&items); + assert_eq!(quote.items.get(0).unwrap().output, 1_000); + assert_eq!(quote.items.get(1).unwrap().output, 1_010); + assert_eq!(client.batch_swap(&trader, &items).items, quote.items); +} diff --git a/docs/batch-swaps.md b/docs/batch-swaps.md new file mode 100644 index 00000000..418b7f78 --- /dev/null +++ b/docs/batch-swaps.md @@ -0,0 +1,32 @@ +# Atomic batch swaps + +`batch_swap(trader, items)` authorizes the trader once and executes 1–15 +`BatchSwapItem` entries in order. Each entry contains `key_id`, `amount`, +`direction` and `max_slippage_bps`. The same trader owns every buy or sell. +Zero amount is rejected, as are tolerances above 10,000 basis points. A zero +tolerance retains the existing `swap` opt-out convention. + +Each item checks the bonding curve's relative spot-price impact before its +trade. Exceeding any item's tolerance returns `SlippageExceeded`; the Soroban +transaction rolls back earlier items, fees and events. Supply caps must allow +a complete fill. Buys reuse `buy_keys_core`; sells reuse the same authorized +core as `batch_sell`, so their pause, blacklist, balance, freeze and lockup +guards remain in force. Buying and immediately selling the same position can +therefore still fail the flash-loan guard. + +`get_batch_quote(items)` estimates each item in order without executing trades. +Virtual supplies account for repeated keys. It returns the same per-item +schema and summed fee used by a receipt. As with other quote views, execution +can fail a trading guard or differ if state changes before submission. + +`BatchSwapOutput.output` follows `swap`: the gross payment on a buy and the net +proceeds on a sell. `fee` reports creator, protocol and trade-fee allocations, +plus configured royalty and buy LP allocations; these allocations are included +in existing accounting and must not be added to a buy's payment again. Launch +penalties remain governed by the existing sell path. + +The `BatchSwapExecuted` receipt has `trader`, `items`, `total_fee` and `ledger`. +Its event topics are `batch_sw`, trader; the payload is the complete receipt. +No existing event, storage schema or numeric error is changed. Tests cover +mixed trades, quote agreement, repeated-key pricing, event fields, limits and +rollback when a later item fails slippage.