From d32fcb8e97b866f574269acdf646f5b9d8345353 Mon Sep 17 00:00:00 2001 From: Vhickcrypt Date: Sun, 18 Oct 2026 09:09:43 +0100 Subject: [PATCH] feat: Implement a slippage-protected multi-buy function that atomi (#717) --- creator-keys/src/events.rs | 34 ++++++ creator-keys/src/test_issues.rs | 180 ++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+) diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index df43d63b..321b4242 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -3197,6 +3197,40 @@ pub fn batch_buy_fee_collected_topics( ) } +// ============================================================================ +// Feature: multi-buy across multiple creators with per-leg slippage +// ============================================================================ + +/// Event name emitted once when every leg of a `multi_buy` succeeds. +pub const MULTI_BUY_COMPLETED_EVENT_NAME: Symbol = symbol_short!("multi_buy"); + +/// Stable multi-buy-completed event payload. +/// +/// Event shape: +/// - topics: `(MULTI_BUY_COMPLETED_EVENT_NAME, buyer)` +/// - data: `MultiBuyCompletedEvent` +/// +/// Emitted exactly once per successful `multi_buy` call, after every leg has +/// executed. A failed leg rolls back the whole transaction, so this event is +/// never emitted for a partially executed multi-buy. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct MultiBuyCompletedEvent { + /// Address of the buyer performing the multi-buy. + pub buyer: Address, + /// Number of legs executed in this multi-buy. + pub leg_count: u32, + /// Sum of all per-leg gross costs (including fees). + pub total_cost: i128, + /// Ledger sequence number at the time of completion. + pub ledger: u32, +} + +/// Shared multi-buy-completed event topics tuple. +pub fn multi_buy_completed_topics(buyer: &Address) -> (Symbol, Address) { + (MULTI_BUY_COMPLETED_EVENT_NAME, buyer.clone()) +} + // ============================================================================ // Feature: creator reputation scoring // ============================================================================ diff --git a/creator-keys/src/test_issues.rs b/creator-keys/src/test_issues.rs index 8e19d5e1..d4def568 100644 --- a/creator-keys/src/test_issues.rs +++ b/creator-keys/src/test_issues.rs @@ -1358,4 +1358,184 @@ mod issue_tests { let result = client.try_migrate_curve(&non_admin, &2, &key_ids); assert_eq!(result, Err(Ok(ContractError::Unauthorized))); } + + // ========================================================================= + // Tests for multi_buy (#487) + // ========================================================================= + + fn make_leg(env: &Env, creator: Address, amount: u32, max_price: i128) -> crate::MultiBuyLeg { + crate::MultiBuyLeg { + creator, + amount, + max_price, + } + } + + #[test] + fn test_multi_buy_three_legs_all_within_max_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); + client.set_fee_config(&admin, &9_000, &1_000); + client.set_key_price(&admin, &100); + + let c1 = register_creator(&env, &client, None); + let c2 = register_creator(&env, &client, None); + let c3 = register_creator(&env, &client, None); + let buyer = Address::generate(&env); + + let legs = soroban_sdk::Vec::from_array( + &env, + [ + make_leg(&env, c1.clone(), 2u32, 10_000i128), + make_leg(&env, c2.clone(), 3u32, 10_000i128), + make_leg(&env, c3.clone(), 1u32, 10_000i128), + ], + ); + + let deadline = env.ledger().sequence() + 100; + let results = client.multi_buy(&buyer, &legs, &deadline); + + assert_eq!(results.len(), 3); + assert_eq!(client.get_total_key_supply(&c1), 2u32); + assert_eq!(client.get_total_key_supply(&c2), 3u32); + assert_eq!(client.get_total_key_supply(&c3), 1u32); + } + + #[test] + fn test_multi_buy_rolls_back_when_second_leg_exceeds_max_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); + client.set_fee_config(&admin, &9_000, &1_000); + client.set_key_price(&admin, &100); + + let c1 = register_creator(&env, &client, None); + let c2 = register_creator(&env, &client, None); + let c3 = register_creator(&env, &client, None); + let buyer = Address::generate(&env); + + let legs = soroban_sdk::Vec::from_array( + &env, + [ + make_leg(&env, c1.clone(), 1u32, 10_000i128), + make_leg(&env, c2.clone(), 1u32, 1i128), + make_leg(&env, c3.clone(), 1u32, 10_000i128), + ], + ); + + let deadline = env.ledger().sequence() + 100; + let result = client.try_multi_buy(&buyer, &legs, &deadline); + assert!(result.is_err(), "multi_buy should revert when a leg exceeds max_price"); + + assert_eq!(client.get_total_key_supply(&c1), 0u32); + assert_eq!(client.get_total_key_supply(&c2), 0u32); + assert_eq!(client.get_total_key_supply(&c3), 0u32); + } + + #[test] + fn test_multi_buy_reverts_on_duplicate_creator() { + 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); + client.set_fee_config(&admin, &9_000, &1_000); + client.set_key_price(&admin, &100); + + let c1 = register_creator(&env, &client, None); + let buyer = Address::generate(&env); + + let legs = soroban_sdk::Vec::from_array( + &env, + [ + make_leg(&env, c1.clone(), 1u32, 10_000i128), + make_leg(&env, c1.clone(), 1u32, 10_000i128), + ], + ); + + let deadline = env.ledger().sequence() + 100; + let result = client.try_multi_buy(&buyer, &legs, &deadline); + assert_eq!(result, Err(Ok(ContractError::DuplicateCreator))); + } + + #[test] + fn test_multi_buy_reverts_on_too_many_legs() { + 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); + client.set_fee_config(&admin, &9_000, &1_000); + client.set_key_price(&admin, &100); + + let buyer = Address::generate(&env); + let mut legs = soroban_sdk::Vec::new(&env); + for _ in 0..11 { + let c = register_creator(&env, &client, None); + legs.push_back(make_leg(&env, c, 1u32, 10_000i128)); + } + + let deadline = env.ledger().sequence() + 100; + let result = client.try_multi_buy(&buyer, &legs, &deadline); + assert_eq!(result, Err(Ok(ContractError::TooManyLegs))); + } + + #[test] + fn test_multi_buy_reverts_on_deadline_passed() { + 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); + client.set_fee_config(&admin, &9_000, &1_000); + client.set_key_price(&admin, &100); + + let c1 = register_creator(&env, &client, None); + let buyer = Address::generate(&env); + + let legs = soroban_sdk::Vec::from_array(&env, [make_leg(&env, c1, 1u32, 10_000i128)]); + + let mut l = env.ledger().get(); + l.sequence_number = 1000; + env.ledger().set(l); + + let result = client.try_multi_buy(&buyer, &legs, &999u32); + assert_eq!(result, Err(Ok(ContractError::DeadlinePassed))); + } + + #[test] + fn test_multi_buy_reverts_on_insufficient_funds() { + 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); + client.set_fee_config(&admin, &9_000, &1_000); + client.set_key_price(&admin, &100); + + let c1 = register_creator(&env, &client, None); + let buyer = Address::generate(&env); + + // Worst-case cost = amount * max_price = 1 * i128::MAX, unaffordable. + let legs = soroban_sdk::Vec::from_array( + &env, + [make_leg(&env, c1.clone(), 1u32, i128::MAX)], + ); + + let deadline = env.ledger().sequence() + 100; + let result = client.try_multi_buy(&buyer, &legs, &deadline); + assert_eq!(result, Err(Ok(ContractError::InsufficientFunds))); + assert_eq!(client.get_total_key_supply(&c1), 0u32); + } }