From f883432d650af7660691a444ec26292313ac4721 Mon Sep 17 00:00:00 2001 From: attyolu Date: Mon, 31 Aug 2026 20:45:53 +0000 Subject: [PATCH 1/2] Add stable swap pool invariant tests --- apps/onchain/Cargo.toml | 1 + .../contracts/stable_swap_pool/Cargo.toml | 3 +- .../contracts/stable_swap_pool/src/lib.rs | 96 ++++++--- .../contracts/stable_swap_pool/src/test.rs | 188 +++++++++++++++++- 4 files changed, 247 insertions(+), 41 deletions(-) diff --git a/apps/onchain/Cargo.toml b/apps/onchain/Cargo.toml index c18c20e29..a980ee7fa 100644 --- a/apps/onchain/Cargo.toml +++ b/apps/onchain/Cargo.toml @@ -10,6 +10,7 @@ members = [ "contracts/project_registry", "contracts/protocol_registry", "contracts/reentrancy-guard", + "contracts/stable_swap_pool", "contracts/upgradable-contract", "contracts/vesting-wallet", "contracts/lumenpulse-curation", diff --git a/apps/onchain/contracts/stable_swap_pool/Cargo.toml b/apps/onchain/contracts/stable_swap_pool/Cargo.toml index 21981ae3c..47e562315 100644 --- a/apps/onchain/contracts/stable_swap_pool/Cargo.toml +++ b/apps/onchain/contracts/stable_swap_pool/Cargo.toml @@ -7,8 +7,7 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -soroban-sdk = "=21.5.1" -soroban-sdk-macros = "=21.5.1" +soroban-sdk = { workspace = true } reentrancy-guard = { path = "../reentrancy-guard" } [dev-dependencies] diff --git a/apps/onchain/contracts/stable_swap_pool/src/lib.rs b/apps/onchain/contracts/stable_swap_pool/src/lib.rs index 9fc60fec1..4a397cac1 100644 --- a/apps/onchain/contracts/stable_swap_pool/src/lib.rs +++ b/apps/onchain/contracts/stable_swap_pool/src/lib.rs @@ -3,7 +3,10 @@ mod events; mod storage; -use soroban_sdk::{contract, contractimpl, Address, Env, Symbol, Vec}; +#[cfg(test)] +mod test; + +use soroban_sdk::{contract, contracterror, contractimpl, Address, Env}; use soroban_sdk::token::TokenClient; use storage::DataKey; use reentrancy_guard::{acquire as acquire_reentrancy, release as release_reentrancy}; @@ -12,6 +15,18 @@ const AMPLIFICATION_FACTOR: i128 = 100; // A parameter for stable swap bonding c const SWAP_FEE_BP: u32 = 4; // 0.04% swap fee in basis points const LP_FEE_BP: u32 = 1; // 0.01% LP fee +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum StableSwapPoolError { + AlreadyInitialized = 1, + NotInitialized = 2, + InvalidAmount = 3, + SlippageExceeded = 4, + InsufficientBalance = 5, + Reentrancy = 6, +} + #[contract] pub struct StableSwapPoolContract; @@ -22,11 +37,11 @@ pub struct StableSwapPoolContract; /// - AMM-based pricing #[contractimpl] impl StableSwapPoolContract { - fn with_reentrancy_guard(env: &Env, f: F) -> Result + fn with_reentrancy_guard(env: &Env, f: F) -> Result where - F: FnOnce() -> Result, + F: FnOnce() -> Result, { - acquire_reentrancy(env).map_err(|_| Symbol::new(env, "reentrancy"))?; + acquire_reentrancy(env).map_err(|_| StableSwapPoolError::Reentrancy)?; let result = f(); release_reentrancy(env); result @@ -38,9 +53,10 @@ impl StableSwapPoolContract { admin: Address, token_a: Address, token_b: Address, - ) -> Result<(), Symbol> { + ) -> Result<(), StableSwapPoolError> { + admin.require_auth(); if env.storage().instance().has(&DataKey::Admin) { - return Err(Symbol::new(&env, "already_initialized")); + return Err(StableSwapPoolError::AlreadyInitialized); } env.storage().instance().set(&DataKey::Admin, &admin); @@ -50,7 +66,7 @@ impl StableSwapPoolContract { env.storage() .instance() .set(&DataKey::TokenB, &token_b); - env.storage().instance().bump(100, 100); + env.storage().instance().extend_ttl(100, 100); events::PoolInitializedEvent { admin, @@ -66,33 +82,35 @@ impl StableSwapPoolContract { /// Returns LP tokens minted pub fn add_liquidity( env: Env, + user: Address, amount_a: i128, amount_b: i128, min_lp: i128, - ) -> Result { + ) -> Result { + user.require_auth(); Self::with_reentrancy_guard(&env, || { if amount_a <= 0 || amount_b <= 0 { - return Err(Symbol::new(&env, "invalid_amount")); + return Err(StableSwapPoolError::InvalidAmount); } let token_a_addr: Address = env .storage() .instance() .get(&DataKey::TokenA) - .ok_or_else(|| Symbol::new(&env, "not_initialized"))?; + .ok_or(StableSwapPoolError::NotInitialized)?; let token_b_addr: Address = env .storage() .instance() .get(&DataKey::TokenB) - .ok_or_else(|| Symbol::new(&env, "not_initialized"))?; + .ok_or(StableSwapPoolError::NotInitialized)?; // Transfer tokens from caller let token_a = TokenClient::new(&env, &token_a_addr); let token_b = TokenClient::new(&env, &token_b_addr); - token_a.transfer(&env.invoker(), &env.current_contract_address(), &amount_a); - token_b.transfer(&env.invoker(), &env.current_contract_address(), &amount_b); + token_a.transfer(&user, &env.current_contract_address(), &amount_a); + token_b.transfer(&user, &env.current_contract_address(), &amount_b); // Calculate LP tokens let lp_supply: i128 = env @@ -128,7 +146,7 @@ impl StableSwapPoolContract { }; if lp_tokens < min_lp { - return Err(Symbol::new(&env, "slippage_exceeded")); + return Err(StableSwapPoolError::SlippageExceeded); } // Update reserves @@ -151,15 +169,15 @@ impl StableSwapPoolContract { let user_lp: i128 = env .storage() .persistent() - .get(&DataKey::UserLPBalance(env.invoker())) + .get(&DataKey::UserLPBalance(user.clone())) .unwrap_or(0); env.storage().persistent().set( - &DataKey::UserLPBalance(env.invoker()), + &DataKey::UserLPBalance(user.clone()), &(user_lp + lp_tokens), ); events::LiquidityAddedEvent { - user: env.invoker(), + user: user.clone(), amount_a, amount_b, lp_tokens, @@ -171,13 +189,18 @@ impl StableSwapPoolContract { } /// Remove liquidity (both tokens proportionally) - pub fn remove_liquidity(env: Env, lp_amount: i128, min_a: i128, min_b: i128) -> Result<(i128, i128), Symbol> { + pub fn remove_liquidity( + env: Env, + user: Address, + lp_amount: i128, + min_a: i128, + min_b: i128, + ) -> Result<(i128, i128), StableSwapPoolError> { + user.require_auth(); if lp_amount <= 0 { - return Err(Symbol::new(&env, "invalid_amount")); + return Err(StableSwapPoolError::InvalidAmount); } - let user = env.invoker(); - let user_lp: i128 = env .storage() .persistent() @@ -185,7 +208,7 @@ impl StableSwapPoolContract { .unwrap_or(0); if user_lp < lp_amount { - return Err(Symbol::new(&env, "insufficient_balance")); + return Err(StableSwapPoolError::InsufficientBalance); } let lp_supply: i128 = env @@ -211,7 +234,7 @@ impl StableSwapPoolContract { let out_b = (lp_amount * reserve_b) / lp_supply; if out_a < min_a || out_b < min_b { - return Err(Symbol::new(&env, "slippage_exceeded")); + return Err(StableSwapPoolError::SlippageExceeded); } // Update reserves @@ -238,12 +261,12 @@ impl StableSwapPoolContract { .storage() .instance() .get(&DataKey::TokenA) - .ok_or_else(|| Symbol::new(&env, "not_initialized"))?; + .ok_or(StableSwapPoolError::NotInitialized)?; let token_b_addr: Address = env .storage() .instance() .get(&DataKey::TokenB) - .ok_or_else(|| Symbol::new(&env, "not_initialized"))?; + .ok_or(StableSwapPoolError::NotInitialized)?; let token_a = TokenClient::new(&env, &token_a_addr); let token_b = TokenClient::new(&env, &token_b_addr); @@ -263,21 +286,28 @@ impl StableSwapPoolContract { } /// Swap tokens (A -> B or B -> A) - pub fn swap(env: Env, input_token: Address, amount_in: i128, min_out: i128) -> Result { + pub fn swap( + env: Env, + user: Address, + input_token: Address, + amount_in: i128, + min_out: i128, + ) -> Result { + user.require_auth(); if amount_in <= 0 { - return Err(Symbol::new(&env, "invalid_amount")); + return Err(StableSwapPoolError::InvalidAmount); } let token_a_addr: Address = env .storage() .instance() .get(&DataKey::TokenA) - .ok_or_else(|| Symbol::new(&env, "not_initialized"))?; + .ok_or(StableSwapPoolError::NotInitialized)?; let token_b_addr: Address = env .storage() .instance() .get(&DataKey::TokenB) - .ok_or_else(|| Symbol::new(&env, "not_initialized"))?; + .ok_or(StableSwapPoolError::NotInitialized)?; let (reserve_in, reserve_out, output_token) = if input_token == token_a_addr { let ra: i128 = env @@ -312,13 +342,13 @@ impl StableSwapPoolContract { let amount_out = (reserve_out * amount_after_fee) / (reserve_in + amount_after_fee); if amount_out < min_out { - return Err(Symbol::new(&env, "slippage_exceeded")); + return Err(StableSwapPoolError::SlippageExceeded); } Self::with_reentrancy_guard(&env, || { // Transfer input tokens from caller let token_in = TokenClient::new(&env, &input_token); - token_in.transfer(&env.invoker(), &env.current_contract_address(), &amount_in); + token_in.transfer(&user, &env.current_contract_address(), &amount_in); // Update reserves if input_token == token_a_addr { @@ -343,10 +373,10 @@ impl StableSwapPoolContract { // Transfer output tokens to caller let token_out = TokenClient::new(&env, &output_token); - token_out.transfer(&env.current_contract_address(), &env.invoker(), &amount_out); + token_out.transfer(&env.current_contract_address(), &user, &amount_out); events::SwapEvent { - user: env.invoker(), + user: user.clone(), input_token, amount_in, output_token, diff --git a/apps/onchain/contracts/stable_swap_pool/src/test.rs b/apps/onchain/contracts/stable_swap_pool/src/test.rs index 793ac7aed..3a007ffcd 100644 --- a/apps/onchain/contracts/stable_swap_pool/src/test.rs +++ b/apps/onchain/contracts/stable_swap_pool/src/test.rs @@ -1,7 +1,46 @@ use super::*; use soroban_sdk::testutils::Address as _; -use soroban_sdk::token::{StellarAssetClient, TokenClient}; -use soroban_sdk::{Address, Env, Symbol}; +use soroban_sdk::token::StellarAssetClient; +use soroban_sdk::{symbol_short, Address, Env}; + +fn setup_pool( + env: &Env, +) -> ( + Address, + Address, + StableSwapPoolContractClient<'_>, + StellarAssetClient<'_>, + StellarAssetClient<'_>, +) { + let admin = Address::generate(env); + let token_a_admin = Address::generate(env); + let token_b_admin = Address::generate(env); + let token_a_id = env.register_stellar_asset_contract_v2(token_a_admin.clone()); + let token_b_id = env.register_stellar_asset_contract_v2(token_b_admin.clone()); + + let pool_id = env.register(StableSwapPoolContract, ()); + let client = StableSwapPoolContractClient::new(env, &pool_id); + client.initialize(&admin, &token_a_id.address(), &token_b_id.address()); + + ( + token_a_id.address(), + token_b_id.address(), + client, + StellarAssetClient::new(env, &token_a_id.address()), + StellarAssetClient::new(env, &token_b_id.address()), + ) +} + +fn mint_tokens( + token_a: &StellarAssetClient, + token_b: &StellarAssetClient, + user: &Address, + amount_a: i128, + amount_b: i128, +) { + token_a.mint(user, &amount_a); + token_b.mint(user, &amount_b); +} #[test] fn test_reentrancy_guard_add_liquidity_rejects_when_locked() { @@ -17,11 +56,148 @@ fn test_reentrancy_guard_add_liquidity_rejects_when_locked() { client.initialize(&admin, &token_id.address(), &token_id.address()); - // Simulate reentrant lock state env.as_contract(&pool_id, || { - env.storage().instance().set(&symbol_short!("REENTRANT"), &true); + env.storage() + .instance() + .set(&symbol_short!("REENTRANT"), &true); }); - let result = client.try_add_liquidity(&100i128, &100i128, &0i128); - assert_eq!(result, Err(Ok(Symbol::new(&env, "reentrancy")))); + let result = client.try_add_liquidity(&admin, &100i128, &100i128, &0i128); + assert_eq!(result, Err(Ok(StableSwapPoolError::Reentrancy))); +} + +#[test] +fn test_add_swap_remove_sequence_keeps_pool_invariant() { + let env = Env::default(); + env.mock_all_auths(); + + let user = Address::generate(&env); + let (token_a_addr, _token_b_addr, client, token_a, token_b) = setup_pool(&env); + mint_tokens(&token_a, &token_b, &user, 40_000i128, 40_000i128); + + let lp_minted = client.add_liquidity(&user, &30_000i128, &30_000i128, &0i128); + assert!(lp_minted > 0); + assert_eq!(client.lp_balance(&user), lp_minted); + + let (reserve_a_before, reserve_b_before) = client.get_reserves(); + assert_eq!(reserve_a_before, 30_000i128); + assert_eq!(reserve_b_before, 30_000i128); + + let amount_in = 750i128; + let amount_out = client.swap(&user, &token_a_addr, &amount_in, &0i128); + assert!(amount_out > 0); + + let (reserve_a_after, reserve_b_after) = client.get_reserves(); + assert_eq!(reserve_a_after, reserve_a_before + amount_in); + assert_eq!(reserve_b_after, reserve_b_before - amount_out); + assert!(reserve_a_after > 0 && reserve_b_after > 0); + + let removed_lp = client.lp_balance(&user); + let (out_a, out_b) = client.remove_liquidity(&user, &removed_lp, &0i128, &0i128); + assert_eq!(out_a + out_b, reserve_a_after + reserve_b_after); + assert_eq!(client.get_reserves(), (0i128, 0i128)); + assert_eq!(client.lp_balance(&user), 0i128); + assert!(token_a.balance(&user) >= 0); + assert!(token_b.balance(&user) >= 0); +} + +#[test] +fn test_rounding_favors_pool_over_caller() { + let env = Env::default(); + env.mock_all_auths(); + + let user = Address::generate(&env); + let (token_a_addr, _token_b_addr, client, token_a, token_b) = setup_pool(&env); + mint_tokens(&token_a, &token_b, &user, 60_000i128, 60_000i128); + client.add_liquidity(&user, &50_000i128, &50_000i128, &0i128); + + let reserve_in = 50_000i128; + let reserve_out = 50_000i128; + let amount_in = 9i128; + let amount_after_fee = (amount_in * (10000 - SWAP_FEE_BP as i128)) / 10000; + let expected_out = (reserve_out * amount_after_fee) / (reserve_in + amount_after_fee); + + let actual_out = client.swap(&user, &token_a_addr, &amount_in, &0i128); + assert_eq!(actual_out, expected_out); + assert!(actual_out * (reserve_in + amount_after_fee) <= reserve_out * amount_after_fee); + + let (reserve_a, reserve_b) = client.get_reserves(); + assert_eq!(reserve_a, 50_009i128); + assert_eq!(reserve_b, 50_000i128 - actual_out); + assert!(actual_out < amount_in); +} + +#[test] +fn test_slippage_limits_and_minimums_are_enforced() { + let env = Env::default(); + env.mock_all_auths(); + + let user = Address::generate(&env); + let (token_a_addr, _token_b_addr, client, token_a, token_b) = setup_pool(&env); + mint_tokens(&token_a, &token_b, &user, 10_000i128, 10_000i128); + client.add_liquidity(&user, &10_000i128, &10_000i128, &0i128); + + let add_result = client.try_add_liquidity(&user, &100i128, &100i128, &1_000_000i128); + assert!(add_result.is_err()); + + let swap_result = client.try_swap(&user, &token_a_addr, &1_000i128, &1_000_000i128); + assert!(swap_result.is_err()); + + let lp_balance = client.lp_balance(&user); + let remove_result = client.try_remove_liquidity(&user, &lp_balance, &1_000_000i128, &1_000_000i128); + assert!(remove_result.is_err()); +} + +#[test] +fn test_extreme_imbalance_and_near_empty_states_do_not_panic() { + let env = Env::default(); + env.mock_all_auths(); + + let user = Address::generate(&env); + let (_token_a_addr, _token_b_addr, client, token_a, token_b) = setup_pool(&env); + mint_tokens(&token_a, &token_b, &user, 1_000_000_000_001i128, 1_000_000_000_001i128); + + let lp = client.add_liquidity(&user, &1_000_000_000_000i128, &1i128, &0i128); + assert!(lp > 0); + + let (reserve_a, reserve_b) = client.get_reserves(); + assert!(reserve_a > reserve_b); + assert!(reserve_b > 0); + + let user_2 = Address::generate(&env); + let (_, _, client_2, token_a_2, token_b_2) = setup_pool(&env); + mint_tokens(&token_a_2, &token_b_2, &user_2, 2i128, 2i128); + let near_empty_lp = client_2.add_liquidity(&user_2, &1i128, &1i128, &0i128); + assert!(near_empty_lp > 0); + + let remove = client_2.remove_liquidity(&user_2, &near_empty_lp, &0i128, &0i128); + assert_eq!(remove, (1i128, 1i128)); +} + +#[test] +fn test_fee_accounting_is_conserved_across_swaps() { + let env = Env::default(); + env.mock_all_auths(); + + let user = Address::generate(&env); + let (token_a_addr, _token_b_addr, client, token_a, token_b) = setup_pool(&env); + mint_tokens(&token_a, &token_b, &user, 300_000i128, 300_000i128); + client.add_liquidity(&user, &200_000i128, &200_000i128, &0i128); + + let mut total_in = 0i128; + let mut total_fee = 0i128; + for amount in [100i128, 250i128, 500i128] { + let fee = amount * SWAP_FEE_BP as i128 / 10000; + total_in += amount; + total_fee += fee; + + let amount_out = client.swap(&user, &token_a_addr, &amount, &0i128); + assert!(amount_out > 0); + + let (_, reserve_b) = client.get_reserves(); + assert!(reserve_b > 0); + } + + assert_eq!(total_fee, (100i128 + 250i128 + 500i128) * SWAP_FEE_BP as i128 / 10000); + assert!(total_in > total_fee); } From 7486b390dac25e5500fe3e712e19aedb9232a621 Mon Sep 17 00:00:00 2001 From: attyolu Date: Mon, 31 Aug 2026 20:50:27 +0000 Subject: [PATCH 2/2] Guard base worktree cleanup in CI --- .github/workflows/onchain.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/onchain.yml b/.github/workflows/onchain.yml index 95ef7c05a..5b1f53c9f 100644 --- a/.github/workflows/onchain.yml +++ b/.github/workflows/onchain.yml @@ -77,7 +77,12 @@ jobs: - name: Remove base worktree if: always() && github.event_name == 'pull_request' - run: git worktree remove --force "$RUNNER_TEMP/lumenpulse-base" + run: | + if git -C "$RUNNER_TEMP/lumenpulse-base" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git worktree remove --force "$RUNNER_TEMP/lumenpulse-base" + else + echo "Base worktree not present; skipping cleanup." + fi - name: Run tests if: env.SKIP_TESTS != 'true'