From 8cc9af71e5ec2668636fb873b6201246ecef81ab Mon Sep 17 00:00:00 2001 From: dreamgene Date: Sat, 21 Feb 2026 16:11:06 +0100 Subject: [PATCH 1/4] feat: implement add_liquidity in AMM contract (#77) - Accept USDC from LP and mint proportional LP tokens - Update reserves and k constant - Emit LiquidityAdded event - Add unit tests --- contracts/contracts/boxmeout/src/amm.rs | 271 +++++++++++++++++++++++- 1 file changed, 270 insertions(+), 1 deletion(-) diff --git a/contracts/contracts/boxmeout/src/amm.rs b/contracts/contracts/boxmeout/src/amm.rs index 5e5dca1..89401e9 100644 --- a/contracts/contracts/boxmeout/src/amm.rs +++ b/contracts/contracts/boxmeout/src/amm.rs @@ -1,7 +1,7 @@ // contracts/amm.rs - Automated Market Maker for Outcome Shares // Enables trading YES/NO outcome shares with dynamic odds pricing (Polymarket model) -use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, Symbol}; +use soroban_sdk::{contract, contractimpl, contracttype, token, Address, BytesN, Env, Symbol}; // Storage keys const ADMIN_KEY: &str = "admin"; @@ -30,6 +30,36 @@ pub struct Pool { pub created_at: u64, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LiquidityAdded { + pub provider: Address, + pub usdc_amount: u128, + pub lp_tokens_minted: u128, + pub new_reserve: u128, + pub k: u128, +} + +fn calculate_lp_tokens_to_mint( + current_lp_supply: u128, + current_total_liquidity: u128, + usdc_amount: u128, +) -> u128 { + if current_lp_supply == 0 { + // First LP receives 1:1 LP tokens for deposited liquidity. + return usdc_amount; + } + + if current_total_liquidity == 0 { + panic!("invalid pool liquidity"); + } + + usdc_amount + .checked_mul(current_lp_supply) + .and_then(|v| v.checked_div(current_total_liquidity)) + .expect("lp mint calculation overflow") +} + /// AUTOMATED MARKET MAKER - Manages liquidity pools and share trading #[contract] pub struct AMM; @@ -493,6 +523,131 @@ impl AMM { (yes_odds, no_odds) } + /// Add USDC liquidity to an existing pool and mint LP tokens proportionally. + /// Returns minted LP token amount. + pub fn add_liquidity( + env: Env, + lp_provider: Address, + market_id: BytesN<32>, + usdc_amount: u128, + ) -> u128 { + lp_provider.require_auth(); + + if usdc_amount == 0 { + panic!("usdc amount must be greater than 0"); + } + + let pool_exists_key = (Symbol::new(&env, POOL_EXISTS_KEY), market_id.clone()); + if !env.storage().persistent().has(&pool_exists_key) { + panic!("pool does not exist"); + } + + let yes_reserve_key = (Symbol::new(&env, POOL_YES_RESERVE_KEY), market_id.clone()); + let no_reserve_key = (Symbol::new(&env, POOL_NO_RESERVE_KEY), market_id.clone()); + let k_key = (Symbol::new(&env, POOL_K_KEY), market_id.clone()); + let lp_supply_key = (Symbol::new(&env, POOL_LP_SUPPLY_KEY), market_id.clone()); + let lp_balance_key = ( + Symbol::new(&env, POOL_LP_TOKENS_KEY), + market_id.clone(), + lp_provider.clone(), + ); + + let yes_reserve: u128 = env + .storage() + .persistent() + .get(&yes_reserve_key) + .expect("yes reserve not found"); + let no_reserve: u128 = env + .storage() + .persistent() + .get(&no_reserve_key) + .expect("no reserve not found"); + let current_total_liquidity = yes_reserve + .checked_add(no_reserve) + .expect("total liquidity overflow"); + let current_lp_supply: u128 = env.storage().persistent().get(&lp_supply_key).unwrap_or(0); + + let lp_tokens_to_mint = + calculate_lp_tokens_to_mint(current_lp_supply, current_total_liquidity, usdc_amount); + if lp_tokens_to_mint == 0 { + panic!("lp tokens to mint must be positive"); + } + + // Add liquidity proportionally to preserve pool pricing. + let yes_add = if current_total_liquidity == 0 { + usdc_amount / 2 + } else { + usdc_amount + .checked_mul(yes_reserve) + .and_then(|v| v.checked_div(current_total_liquidity)) + .expect("yes reserve add overflow") + }; + let no_add = usdc_amount + .checked_sub(yes_add) + .expect("liquidity split underflow"); + + if yes_add == 0 || no_add == 0 { + panic!("liquidity amount too small"); + } + + let new_yes_reserve = yes_reserve + .checked_add(yes_add) + .expect("yes reserve overflow"); + let new_no_reserve = no_reserve.checked_add(no_add).expect("no reserve overflow"); + let new_k = new_yes_reserve + .checked_mul(new_no_reserve) + .expect("k overflow"); + let new_total_liquidity = current_total_liquidity + .checked_add(usdc_amount) + .expect("total liquidity overflow"); + + let new_lp_supply = current_lp_supply + .checked_add(lp_tokens_to_mint) + .expect("lp supply overflow"); + let current_lp_balance: u128 = env.storage().persistent().get(&lp_balance_key).unwrap_or(0); + let new_lp_balance = current_lp_balance + .checked_add(lp_tokens_to_mint) + .expect("lp balance overflow"); + + env.storage() + .persistent() + .set(&yes_reserve_key, &new_yes_reserve); + env.storage() + .persistent() + .set(&no_reserve_key, &new_no_reserve); + env.storage().persistent().set(&k_key, &new_k); + env.storage() + .persistent() + .set(&lp_supply_key, &new_lp_supply); + env.storage() + .persistent() + .set(&lp_balance_key, &new_lp_balance); + + let usdc_token: Address = env + .storage() + .persistent() + .get(&Symbol::new(&env, USDC_KEY)) + .expect("usdc token not set"); + let token_client = token::Client::new(&env, &usdc_token); + token_client.transfer( + &lp_provider, + &env.current_contract_address(), + &(usdc_amount as i128), + ); + + let event = LiquidityAdded { + provider: lp_provider.clone(), + usdc_amount, + lp_tokens_minted: lp_tokens_to_mint, + new_reserve: new_total_liquidity, + k: new_k, + }; + env.events() + .publish((Symbol::new(&env, "liquidity_added"),), event); + + lp_tokens_to_mint + } + /// Remove liquidity from pool (redeem LP tokens) /// /// Validates LP token ownership, calculates proportional YES/NO withdrawal, @@ -650,6 +805,17 @@ impl AMM { (yes_reserve, no_reserve, total_liquidity, yes_odds, no_odds) } + /// Get current pool constant product value. + pub fn get_pool_k(env: Env, market_id: BytesN<32>) -> u128 { + let pool_exists_key = (Symbol::new(&env, POOL_EXISTS_KEY), market_id.clone()); + if !env.storage().persistent().has(&pool_exists_key) { + return 0; + } + + let k_key = (Symbol::new(&env, POOL_K_KEY), market_id); + env.storage().persistent().get(&k_key).unwrap_or(0) + } + /// Pure function: Calculate current YES/NO prices based on reserves /// Returns (yes_price, no_price) in basis points (10000 = 1.00 USDC) /// Accounts for trading fees in the price calculation @@ -711,3 +877,106 @@ impl AMM { // - calculate_spot_price() // - get_trade_history() } + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::{token, Address, Env}; + + fn create_token_contract<'a>(env: &Env, admin: &Address) -> token::StellarAssetClient<'a> { + let token_address = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + token::StellarAssetClient::new(env, &token_address) + } + + fn setup_amm_pool( + env: &Env, + ) -> ( + AMMClient<'_>, + token::StellarAssetClient<'_>, + Address, + Address, + BytesN<32>, + ) { + let admin = Address::generate(env); + let factory = Address::generate(env); + let usdc_admin = Address::generate(env); + let initial_lp = Address::generate(env); + let usdc = create_token_contract(env, &usdc_admin); + + let amm_id = env.register(AMM, ()); + let amm = AMMClient::new(env, &amm_id); + + env.mock_all_auths(); + amm.initialize(&admin, &factory, &usdc.address, &1_000_000_000u128); + + let market_id = BytesN::from_array(env, &[7u8; 32]); + usdc.mint(&initial_lp, &2_000_000i128); + amm.create_pool(&initial_lp, &market_id, &1_000_000u128); + + (amm, usdc, initial_lp, admin, market_id) + } + + #[test] + fn test_lp_tokens_first_provider() { + let usdc_amount = 1_000_000u128; + let total_lp_supply = 0u128; + let expected = usdc_amount; + + let minted = calculate_lp_tokens_to_mint(total_lp_supply, 0, usdc_amount); + assert_eq!(minted, expected); + } + + #[test] + fn test_lp_tokens_proportional() { + let usdc_amount = 500_000u128; + let reserve = 1_000_000u128; + let total_lp_supply = 1_000_000u128; + let expected = 500_000u128; + + let minted = calculate_lp_tokens_to_mint(total_lp_supply, reserve, usdc_amount); + assert_eq!(minted, expected); + } + + #[test] + fn test_reserves_updated_after_add() { + let env = Env::default(); + let (amm, usdc, _initial_lp, _admin, market_id) = setup_amm_pool(&env); + let second_lp = Address::generate(&env); + usdc.mint(&second_lp, &1_000_000i128); + + let (yes_before, no_before, total_before, _, _) = amm.get_pool_state(&market_id); + assert_eq!(yes_before, 500_000); + assert_eq!(no_before, 500_000); + assert_eq!(total_before, 1_000_000); + + let minted = amm.add_liquidity(&second_lp, &market_id, &500_000u128); + assert_eq!(minted, 500_000u128); + + let (yes_after, no_after, total_after, _, _) = amm.get_pool_state(&market_id); + assert_eq!(yes_after, 750_000); + assert_eq!(no_after, 750_000); + assert_eq!(total_after, 1_500_000); + } + + #[test] + fn test_k_constant_updated() { + let env = Env::default(); + let (amm, usdc, _initial_lp, _admin, market_id) = setup_amm_pool(&env); + let second_lp = Address::generate(&env); + usdc.mint(&second_lp, &1_000_000i128); + + let old_k = amm.get_pool_k(&market_id); + assert_eq!(old_k, 250_000_000_000); + + amm.add_liquidity(&second_lp, &market_id, &500_000u128); + + let (yes_after, no_after, _, _, _) = amm.get_pool_state(&market_id); + let new_k = amm.get_pool_k(&market_id); + assert_eq!(new_k, yes_after * no_after); + assert_eq!(new_k, 562_500_000_000); + assert!(new_k > old_k); + } +} From 6be303ee8703b0b84921914c6b376cf1fa489600 Mon Sep 17 00:00:00 2001 From: dreamgene Date: Sat, 21 Feb 2026 16:18:02 +0100 Subject: [PATCH 2/4] fix: expose contract modules on native target for integration tests --- contracts/contracts/boxmeout/src/lib.rs | 35 +++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/contracts/contracts/boxmeout/src/lib.rs b/contracts/contracts/boxmeout/src/lib.rs index fe4bc37..797aaf5 100644 --- a/contracts/contracts/boxmeout/src/lib.rs +++ b/contracts/contracts/boxmeout/src/lib.rs @@ -1,15 +1,40 @@ #![no_std] // lib.rs -#[cfg(any(feature = "amm", test, feature = "testutils"))] +#[cfg(any( + feature = "amm", + test, + feature = "testutils", + not(target_family = "wasm") +))] pub mod amm; -#[cfg(any(feature = "factory", test, feature = "testutils"))] +#[cfg(any( + feature = "factory", + test, + feature = "testutils", + not(target_family = "wasm") +))] pub mod factory; -#[cfg(any(feature = "market", test, feature = "testutils"))] +#[cfg(any( + feature = "market", + test, + feature = "testutils", + not(target_family = "wasm") +))] pub mod market; -#[cfg(any(feature = "oracle", test, feature = "testutils"))] +#[cfg(any( + feature = "oracle", + test, + feature = "testutils", + not(target_family = "wasm") +))] pub mod oracle; -#[cfg(any(feature = "treasury", test, feature = "testutils"))] +#[cfg(any( + feature = "treasury", + test, + feature = "testutils", + not(target_family = "wasm") +))] pub mod treasury; pub mod helpers; From b0b80c1a92e8338e01012375f3d05194e51998f9 Mon Sep 17 00:00:00 2001 From: dreamgene Date: Sat, 21 Feb 2026 16:30:51 +0100 Subject: [PATCH 3/4] fix: restore feature-gated module exports for clippy clean build --- contracts/contracts/boxmeout/src/lib.rs | 35 ++++--------------------- 1 file changed, 5 insertions(+), 30 deletions(-) diff --git a/contracts/contracts/boxmeout/src/lib.rs b/contracts/contracts/boxmeout/src/lib.rs index 5ecd171..d844790 100644 --- a/contracts/contracts/boxmeout/src/lib.rs +++ b/contracts/contracts/boxmeout/src/lib.rs @@ -1,40 +1,15 @@ #![no_std] // lib.rs -#[cfg(any( - feature = "amm", - test, - feature = "testutils", - not(target_family = "wasm") -))] +#[cfg(any(feature = "amm", test, feature = "testutils"))] pub mod amm; -#[cfg(any( - feature = "factory", - test, - feature = "testutils", - not(target_family = "wasm") -))] +#[cfg(any(feature = "factory", test, feature = "testutils"))] pub mod factory; -#[cfg(any( - feature = "market", - test, - feature = "testutils", - not(target_family = "wasm") -))] +#[cfg(any(feature = "market", test, feature = "testutils"))] pub mod market; -#[cfg(any( - feature = "oracle", - test, - feature = "testutils", - not(target_family = "wasm") -))] +#[cfg(any(feature = "oracle", test, feature = "testutils"))] pub mod oracle; -#[cfg(any( - feature = "treasury", - test, - feature = "testutils", - not(target_family = "wasm") -))] +#[cfg(any(feature = "treasury", test, feature = "testutils"))] pub mod treasury; pub mod helpers; From b0b7932eadf3e450e58875eacaf761a9bab94fac Mon Sep 17 00:00:00 2001 From: dreamgene Date: Sat, 21 Feb 2026 16:39:48 +0100 Subject: [PATCH 4/4] fix: remove duplicate soroban imports in amm contract --- contracts/contracts/boxmeout/src/amm.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/contracts/contracts/boxmeout/src/amm.rs b/contracts/contracts/boxmeout/src/amm.rs index 3118b97..9abc05b 100644 --- a/contracts/contracts/boxmeout/src/amm.rs +++ b/contracts/contracts/boxmeout/src/amm.rs @@ -1,8 +1,9 @@ // contracts/amm.rs - Automated Market Maker for Outcome Shares // Enables trading YES/NO outcome shares with dynamic odds pricing (Polymarket model) -use soroban_sdk::{contract, contractimpl, contracttype, token, Address, BytesN, Env, Symbol}; -use soroban_sdk::{contract, contractevent, contractimpl, token, Address, BytesN, Env, Symbol}; +use soroban_sdk::{ + contract, contractevent, contractimpl, contracttype, token, Address, BytesN, Env, Symbol, +}; #[contractevent] pub struct AmmInitializedEvent {