From fddd6e15bdd6a4ccc10b6993c3f4f1ae15cb7ae1 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 8 Apr 2026 17:48:13 -0300 Subject: [PATCH] feat(amm)!: introduce minimum liquidity lock on pool initialization Permanently lock `MINIMUM_LIQUIDITY` (1_000) LP tokens in a dedicated LP-lock holding PDA on pool creation, following the Uniswap v2 "dead shares" pattern. The pool creator receives `initial_lp - MINIMUM_LIQUIDITY` tokens instead of the full initial_lp amount. Adds `compute_lp_lock_holding_pda` and `LP_LOCK_HOLDING_PDA_SEED` to amm_core, updates new_definition to emit two sequential chained calls (create LP definition + lock holding, then mint user share), and adjusts remove liquidity to account for the permanently locked floor. BREAKING CHANGE: NewDefinition instruction requires an additional LP-lock holding account derived via `compute_lp_lock_holding_pda(amm_program_id, pool_id)`. --- amm/amm-idl.json | 6 + amm/core/src/lib.rs | 43 ++- amm/methods/guest/src/bin/amm.rs | 2 + amm/src/new_definition.rs | 91 +++++- amm/src/remove.rs | 24 +- amm/src/tests.rs | 540 +++++++++++++++++++++++++++---- integration_tests/tests/amm.rs | 84 ++++- 7 files changed, 708 insertions(+), 82 deletions(-) diff --git a/amm/amm-idl.json b/amm/amm-idl.json index 7017f15..64c9a06 100644 --- a/amm/amm-idl.json +++ b/amm/amm-idl.json @@ -29,6 +29,12 @@ "signer": false, "init": false }, + { + "name": "lp_lock_holding", + "writable": false, + "signer": false, + "init": false + }, { "name": "user_holding_a", "writable": false, diff --git a/amm/core/src/lib.rs b/amm/core/src/lib.rs index f9d20dd..c47e234 100644 --- a/amm/core/src/lib.rs +++ b/amm/core/src/lib.rs @@ -7,16 +7,26 @@ use nssa_core::{ }; use serde::{Deserialize, Serialize}; +// These stable seed bytes are part of the PDA derivation scheme and must stay unchanged for +// compatibility. +const LIQUIDITY_TOKEN_PDA_SEED: [u8; 32] = [0; 32]; +const LP_LOCK_HOLDING_PDA_SEED: [u8; 32] = [1; 32]; + /// AMM Program Instruction. #[derive(Serialize, Deserialize)] pub enum Instruction { /// Initializes a new Pool (or re-initializes an inactive Pool). /// + /// On initialization, `MINIMUM_LIQUIDITY` LP tokens are permanently locked + /// in the LP-lock holding PDA; the caller receives `initial_lp - MINIMUM_LIQUIDITY`. + /// /// Required accounts: /// - AMM Pool /// - Vault Holding Account for Token A /// - Vault Holding Account for Token B /// - Pool Liquidity Token Definition + /// - LP Lock Holding Account, derived as `compute_lp_lock_holding_pda(amm_program_id, + /// pool.account_id)` /// - User Holding Account for Token A (authorized) /// - User Holding Account for Token B (authorized) /// - User Holding Account for Pool Liquidity @@ -75,6 +85,8 @@ pub enum Instruction { }, } +pub const MINIMUM_LIQUIDITY: u128 = 1_000; + #[derive(Clone, Default, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] pub struct PoolDefinition { pub definition_token_a_id: AccountId, @@ -87,9 +99,13 @@ pub struct PoolDefinition { pub reserve_b: u128, /// Fees are currently not used pub fees: u128, - /// A pool becomes inactive (active = false) - /// once all of its liquidity has been removed (e.g., reserves are emptied and - /// liquidity_pool_supply = 0) + /// Indicates whether the pool is initialized for use. + /// `MINIMUM_LIQUIDITY` LP tokens are permanently locked at initialization + /// and cannot be removed, so `liquidity_pool_supply` will never drop below + /// `MINIMUM_LIQUIDITY` for pools created after the minimum-liquidity lock + /// was introduced. Reaching that floor does not by itself imply + /// `active = false`; pools may remain active with only the permanently + /// locked minimum liquidity remaining. pub active: bool, } @@ -186,7 +202,26 @@ pub fn compute_liquidity_token_pda_seed(pool_id: AccountId) -> PdaSeed { let mut bytes = [0; 64]; bytes[0..32].copy_from_slice(&pool_id.to_bytes()); - bytes[32..].copy_from_slice(&[0; 32]); + bytes[32..].copy_from_slice(&LIQUIDITY_TOKEN_PDA_SEED); + + PdaSeed::new( + Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .expect("Hash output must be exactly 32 bytes long"), + ) +} + +pub fn compute_lp_lock_holding_pda(amm_program_id: ProgramId, pool_id: AccountId) -> AccountId { + AccountId::from((&amm_program_id, &compute_lp_lock_holding_pda_seed(pool_id))) +} + +pub fn compute_lp_lock_holding_pda_seed(pool_id: AccountId) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256}; + + let mut bytes = [0; 64]; + bytes[0..32].copy_from_slice(&pool_id.to_bytes()); + bytes[32..].copy_from_slice(&LP_LOCK_HOLDING_PDA_SEED); PdaSeed::new( Impl::hash_bytes(&bytes) diff --git a/amm/methods/guest/src/bin/amm.rs b/amm/methods/guest/src/bin/amm.rs index c19c490..52ff58e 100644 --- a/amm/methods/guest/src/bin/amm.rs +++ b/amm/methods/guest/src/bin/amm.rs @@ -22,6 +22,7 @@ mod amm { vault_a: AccountWithMetadata, vault_b: AccountWithMetadata, pool_definition_lp: AccountWithMetadata, + lp_lock_holding: AccountWithMetadata, user_holding_a: AccountWithMetadata, user_holding_b: AccountWithMetadata, user_holding_lp: AccountWithMetadata, @@ -34,6 +35,7 @@ mod amm { vault_a, vault_b, pool_definition_lp, + lp_lock_holding, user_holding_a, user_holding_b, user_holding_lp, diff --git a/amm/src/new_definition.rs b/amm/src/new_definition.rs index 4e5d50d..c03ee61 100644 --- a/amm/src/new_definition.rs +++ b/amm/src/new_definition.rs @@ -1,13 +1,14 @@ use std::num::NonZeroU128; use amm_core::{ - compute_liquidity_token_pda, compute_liquidity_token_pda_seed, compute_pool_pda, - compute_vault_pda, PoolDefinition, + compute_liquidity_token_pda, compute_liquidity_token_pda_seed, compute_lp_lock_holding_pda, + compute_pool_pda, compute_vault_pda, PoolDefinition, MINIMUM_LIQUIDITY, }; use nssa_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, ProgramId}, }; +use token_core::TokenDefinition; #[expect(clippy::too_many_arguments, reason = "TODO: Fix later")] pub fn new_definition( @@ -15,6 +16,7 @@ pub fn new_definition( vault_a: AccountWithMetadata, vault_b: AccountWithMetadata, pool_definition_lp: AccountWithMetadata, + lp_lock_holding: AccountWithMetadata, user_holding_a: AccountWithMetadata, user_holding_b: AccountWithMetadata, user_holding_lp: AccountWithMetadata, @@ -61,10 +63,16 @@ pub fn new_definition( compute_liquidity_token_pda(amm_program_id, pool.account_id), "Liquidity pool Token Definition Account ID does not match PDA" ); + assert_eq!( + lp_lock_holding.account_id, + compute_lp_lock_holding_pda(amm_program_id, pool.account_id), + "LP lock holding Account ID does not match PDA" + ); // TODO: return here // Verify that Pool Account is not active - let pool_account_data = if pool.account == Account::default() { + let is_new_pool = pool.account == Account::default(); + let pool_account_data = if is_new_pool { PoolDefinition::default() } else { PoolDefinition::try_from(&pool.account.data) @@ -75,9 +83,20 @@ pub fn new_definition( !pool_account_data.active, "Cannot initialize an active Pool Definition" ); + if !is_new_pool { + assert_eq!( + pool_account_data.liquidity_pool_supply, 0, + "New definition: inactive Pool Definition must have zero LP supply before reinitialization" + ); + } // LP Token minting calculation let initial_lp = (token_a_amount.get() * token_b_amount.get()).isqrt(); + assert!( + initial_lp > MINIMUM_LIQUIDITY, + "Initial liquidity must exceed minimum liquidity lock" + ); + let user_lp = initial_lp - MINIMUM_LIQUIDITY; // Update pool account let mut pool_post = pool.account.clone(); @@ -120,39 +139,87 @@ pub fn new_definition( }, ); - // Chain call for liquidity token (TokenLP definition -> User LP Holding) - let instruction = if pool.account == Account::default() { + // Chain call for liquidity token lock holding + let lock_instruction = if is_new_pool { token_core::Instruction::NewFungibleDefinition { name: String::from("LP Token"), - total_supply: initial_lp, + total_supply: MINIMUM_LIQUIDITY, } } else { token_core::Instruction::Mint { - amount_to_mint: initial_lp, + amount_to_mint: MINIMUM_LIQUIDITY, } }; let mut pool_lp_auth = pool_definition_lp.clone(); pool_lp_auth.is_authorized = true; - let call_token_lp = ChainedCall::new( + let call_token_lp_lock = ChainedCall::new( token_program_id, - vec![pool_lp_auth.clone(), user_holding_lp.clone()], - &instruction, + vec![pool_lp_auth.clone(), lp_lock_holding.clone()], + &lock_instruction, ) .with_pda_seeds(vec![compute_liquidity_token_pda_seed(pool.account_id)]); - let chained_calls = vec![call_token_lp, call_token_b, call_token_a]; + let mut pool_lp_after_lock = pool_lp_auth.clone(); + if pool_definition_lp.account == Account::default() { + pool_lp_after_lock.account.program_owner = token_program_id; + pool_lp_after_lock.account.data = Data::from(&TokenDefinition::Fungible { + name: String::from("LP Token"), + total_supply: MINIMUM_LIQUIDITY, + metadata_id: None, + }); + } else { + let token_definition = TokenDefinition::try_from(&pool_definition_lp.account.data) + .expect("New definition: AMM Program expects a valid LP Token Definition Account"); + let TokenDefinition::Fungible { + name, + total_supply, + metadata_id, + } = token_definition + else { + panic!("New definition: LP Token Definition Account must be fungible"); + }; + assert_eq!( + total_supply, 0, + "New definition: existing LP Token Definition Account must have zero supply before reinitialization" + ); + + pool_lp_after_lock.account.data = Data::from(&TokenDefinition::Fungible { + name, + total_supply: total_supply + .checked_add(MINIMUM_LIQUIDITY) + .expect("LP total supply overflow on lock mint"), + metadata_id, + }); + } + + let call_token_lp_user = ChainedCall::new( + token_program_id, + vec![pool_lp_after_lock, user_holding_lp.clone()], + &token_core::Instruction::Mint { + amount_to_mint: user_lp, + }, + ) + .with_pda_seeds(vec![compute_liquidity_token_pda_seed(pool.account_id)]); + + let chained_calls = vec![ + call_token_lp_lock, + call_token_lp_user, + call_token_b, + call_token_a, + ]; let post_states = vec![ pool_post.clone(), AccountPostState::new(vault_a.account.clone()), AccountPostState::new(vault_b.account.clone()), AccountPostState::new(pool_definition_lp.account.clone()), + AccountPostState::new(lp_lock_holding.account.clone()), AccountPostState::new(user_holding_a.account.clone()), AccountPostState::new(user_holding_b.account.clone()), AccountPostState::new(user_holding_lp.account.clone()), ]; - (post_states.clone(), chained_calls) + (post_states, chained_calls) } diff --git a/amm/src/remove.rs b/amm/src/remove.rs index bc24a2d..3c2bf08 100644 --- a/amm/src/remove.rs +++ b/amm/src/remove.rs @@ -1,6 +1,8 @@ use std::num::NonZeroU128; -use amm_core::{compute_liquidity_token_pda_seed, compute_vault_pda_seed, PoolDefinition}; +use amm_core::{ + compute_liquidity_token_pda_seed, compute_vault_pda_seed, PoolDefinition, MINIMUM_LIQUIDITY, +}; use nssa_core::{ account::{AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall}, @@ -78,6 +80,19 @@ pub fn remove_liquidity( pool_def_data.liquidity_pool_id, "Invalid liquidity account provided" ); + // Honest flows should never reach the permanent lock through a valid remove instruction, but + // we still reject legacy or corrupted states that are already at the locked floor. + assert!( + pool_def_data.liquidity_pool_supply > MINIMUM_LIQUIDITY, + "Pool only contains locked liquidity" + ); + let unlocked_liquidity = pool_def_data.liquidity_pool_supply - MINIMUM_LIQUIDITY; + // The remove instruction never sees the LP lock account directly, so we must still refuse any + // request that would burn through the permanent floor even if ownership is already corrupted. + assert!( + remove_liquidity_amount <= unlocked_liquidity, + "Cannot remove locked minimum liquidity" + ); let withdraw_amount_a = (pool_def_data.reserve_a * remove_liquidity_amount) / pool_def_data.liquidity_pool_supply; @@ -95,10 +110,7 @@ pub fn remove_liquidity( ); // 4. Calculate LP to reduce cap by - let delta_lp: u128 = (pool_def_data.liquidity_pool_supply * remove_liquidity_amount) - / pool_def_data.liquidity_pool_supply; - - let active: bool = pool_def_data.liquidity_pool_supply - delta_lp != 0; + let delta_lp: u128 = remove_liquidity_amount; // 5. Update pool account let mut pool_post = pool.account.clone(); @@ -106,7 +118,7 @@ pub fn remove_liquidity( liquidity_pool_supply: pool_def_data.liquidity_pool_supply - delta_lp, reserve_a: pool_def_data.reserve_a - withdraw_amount_a, reserve_b: pool_def_data.reserve_b - withdraw_amount_b, - active, + active: true, ..pool_def_data.clone() }; diff --git a/amm/src/tests.rs b/amm/src/tests.rs index e82095c..f3f6e8c 100644 --- a/amm/src/tests.rs +++ b/amm/src/tests.rs @@ -3,8 +3,8 @@ use std::num::NonZero; use amm_core::{ - compute_liquidity_token_pda, compute_liquidity_token_pda_seed, compute_pool_pda, - compute_vault_pda, compute_vault_pda_seed, PoolDefinition, + compute_liquidity_token_pda, compute_liquidity_token_pda_seed, compute_lp_lock_holding_pda, + compute_pool_pda, compute_vault_pda, compute_vault_pda_seed, PoolDefinition, MINIMUM_LIQUIDITY, }; use nssa_core::{ account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, @@ -23,14 +23,15 @@ struct BalanceForTests; struct ChainedCallForTests; struct IdForTests; struct AccountWithMetadataForTests; +type AccountForTests = AccountWithMetadataForTests; impl BalanceForTests { fn vault_a_reserve_init() -> u128 { - 1_000 + 5_000 } fn vault_b_reserve_init() -> u128 { - 500 + 2_500 } fn vault_a_reserve_low() -> u128 { @@ -50,11 +51,11 @@ impl BalanceForTests { } fn user_token_a_balance() -> u128 { - 1_000 + 10_000 } fn user_token_b_balance() -> u128 { - 500 + 10_000 } fn user_token_lp_balance() -> u128 { @@ -106,52 +107,93 @@ impl BalanceForTests { } fn lp_supply_init() -> u128 { - // sqrt(vault_a_reserve_init * vault_b_reserve_init) = sqrt(1000 * 500) = 707 + // sqrt(vault_a_reserve_init * vault_b_reserve_init) = sqrt(5000 * 2500) = 3535 (BalanceForTests::vault_a_reserve_init() * BalanceForTests::vault_b_reserve_init()).isqrt() } + fn lp_user_init() -> u128 { + BalanceForTests::lp_supply_init() - MINIMUM_LIQUIDITY + } + fn vault_a_swap_test_1() -> u128 { - 1_500 + BalanceForTests::vault_a_reserve_init() + BalanceForTests::add_max_amount_a() } fn vault_a_swap_test_2() -> u128 { - 715 + BalanceForTests::vault_a_reserve_init() - BalanceForTests::swap_amount_out_a() } fn vault_b_swap_test_1() -> u128 { - 334 + BalanceForTests::vault_b_reserve_init() - BalanceForTests::swap_amount_out_b() } fn vault_b_swap_test_2() -> u128 { - 700 + BalanceForTests::vault_b_reserve_init() + BalanceForTests::add_max_amount_b() } fn min_amount_out() -> u128 { 200 } + fn min_amount_out_too_high() -> u128 { + BalanceForTests::swap_amount_out_b() + 1 + } + fn vault_a_add_successful() -> u128 { - 1_400 + BalanceForTests::vault_a_reserve_init() + BalanceForTests::add_successful_amount_a() } fn vault_b_add_successful() -> u128 { - 700 + BalanceForTests::vault_b_reserve_init() + BalanceForTests::add_successful_amount_b() } fn add_successful_amount_a() -> u128 { - 400 + (BalanceForTests::vault_a_reserve_init() * BalanceForTests::add_max_amount_b()) + / BalanceForTests::vault_b_reserve_init() } fn add_successful_amount_b() -> u128 { - 200 + BalanceForTests::add_max_amount_b() } fn vault_a_remove_successful() -> u128 { - 859 + BalanceForTests::vault_a_reserve_init() - BalanceForTests::remove_actual_a_successful() } fn vault_b_remove_successful() -> u128 { - 430 + BalanceForTests::vault_b_reserve_init() - BalanceForTests::remove_actual_b_successful() + } + + fn swap_amount_out_b() -> u128 { + (BalanceForTests::vault_b_reserve_init() * BalanceForTests::add_max_amount_a()) + / (BalanceForTests::vault_a_reserve_init() + BalanceForTests::add_max_amount_a()) + } + + fn swap_amount_out_a() -> u128 { + (BalanceForTests::vault_a_reserve_init() * BalanceForTests::add_max_amount_b()) + / (BalanceForTests::vault_b_reserve_init() + BalanceForTests::add_max_amount_b()) + } + + fn add_delta_lp_successful() -> u128 { + std::cmp::min( + BalanceForTests::lp_supply_init() * BalanceForTests::add_successful_amount_a() + / BalanceForTests::vault_a_reserve_init(), + BalanceForTests::lp_supply_init() * BalanceForTests::add_successful_amount_b() + / BalanceForTests::vault_b_reserve_init(), + ) + } + + fn remove_actual_b_successful() -> u128 { + (BalanceForTests::vault_b_reserve_init() * BalanceForTests::remove_amount_lp()) + / BalanceForTests::lp_supply_init() + } + + fn add_lp_supply_successful() -> u128 { + BalanceForTests::lp_supply_init() + BalanceForTests::add_delta_lp_successful() + } + + fn remove_lp_supply_successful() -> u128 { + BalanceForTests::lp_supply_init() - BalanceForTests::remove_amount_lp() } } @@ -170,7 +212,7 @@ impl ChainedCallForTests { } fn cc_swap_token_b_test_1() -> ChainedCall { - let swap_amount: u128 = 166; + let swap_amount = BalanceForTests::swap_amount_out_b(); let mut vault_b_auth = AccountWithMetadataForTests::vault_b_init(); vault_b_auth.is_authorized = true; @@ -189,7 +231,7 @@ impl ChainedCallForTests { } fn cc_swap_token_a_test_2() -> ChainedCall { - let swap_amount: u128 = 285; + let swap_amount = BalanceForTests::swap_amount_out_a(); let mut vault_a_auth = AccountWithMetadataForTests::vault_a_init(); vault_a_auth.is_authorized = true; @@ -257,7 +299,7 @@ impl ChainedCallForTests { AccountWithMetadataForTests::user_holding_lp_init(), ], &token_core::Instruction::Mint { - amount_to_mint: 282, + amount_to_mint: BalanceForTests::add_delta_lp_successful(), }, ) .with_pda_seeds(vec![compute_liquidity_token_pda_seed( @@ -290,7 +332,7 @@ impl ChainedCallForTests { TOKEN_PROGRAM_ID, vec![vault_b_auth, AccountWithMetadataForTests::user_holding_b()], &token_core::Instruction::Transfer { - amount_to_transfer: 70, + amount_to_transfer: BalanceForTests::remove_actual_b_successful(), }, ) .with_pda_seeds(vec![compute_vault_pda_seed( @@ -326,7 +368,7 @@ impl ChainedCallForTests { AccountWithMetadataForTests::vault_a_init(), ], &token_core::Instruction::Transfer { - amount_to_transfer: BalanceForTests::add_successful_amount_a(), + amount_to_transfer: BalanceForTests::vault_a_reserve_init(), }, ) } @@ -339,20 +381,36 @@ impl ChainedCallForTests { AccountWithMetadataForTests::vault_b_init(), ], &token_core::Instruction::Transfer { - amount_to_transfer: BalanceForTests::add_successful_amount_b(), + amount_to_transfer: BalanceForTests::vault_b_reserve_init(), }, ) } - fn cc_new_definition_token_lp() -> ChainedCall { + fn cc_new_definition_token_lp_lock() -> ChainedCall { + let mut pool_lp_auth = AccountForTests::pool_lp_reinitializable(); + pool_lp_auth.is_authorized = true; + + ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![pool_lp_auth, AccountForTests::lp_lock_holding_uninit()], + &token_core::Instruction::Mint { + amount_to_mint: MINIMUM_LIQUIDITY, + }, + ) + .with_pda_seeds(vec![compute_liquidity_token_pda_seed( + IdForTests::pool_definition_id(), + )]) + } + + fn cc_new_definition_token_lp_user() -> ChainedCall { ChainedCall::new( TOKEN_PROGRAM_ID, vec![ - AccountWithMetadataForTests::pool_lp_init(), - AccountWithMetadataForTests::user_holding_lp_uninit(), + AccountForTests::pool_lp_reinitialized_after_lock(), + AccountForTests::user_holding_lp_uninit(), ], &token_core::Instruction::Mint { - amount_to_mint: BalanceForTests::lp_supply_init(), + amount_to_mint: BalanceForTests::lp_user_init(), }, ) .with_pda_seeds(vec![compute_liquidity_token_pda_seed( @@ -374,6 +432,10 @@ impl IdForTests { compute_liquidity_token_pda(AMM_PROGRAM_ID, IdForTests::pool_definition_id()) } + fn lp_lock_holding_id() -> AccountId { + compute_lp_lock_holding_pda(AMM_PROGRAM_ID, IdForTests::pool_definition_id()) + } + fn user_token_a_id() -> AccountId { AccountId::new([45; 32]) } @@ -589,6 +651,65 @@ impl AccountWithMetadataForTests { } } + fn pool_lp_reinitializable() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: TOKEN_PROGRAM_ID, + balance: 0u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("test"), + total_supply: 0, + metadata_id: None, + }), + nonce: Nonce(0), + }, + is_authorized: true, + account_id: IdForTests::token_lp_definition_id(), + } + } + + fn pool_lp_reinitialized_after_lock() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: TOKEN_PROGRAM_ID, + balance: 0u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("test"), + total_supply: MINIMUM_LIQUIDITY, + metadata_id: None, + }), + nonce: Nonce(0), + }, + is_authorized: true, + account_id: IdForTests::token_lp_definition_id(), + } + } + + fn pool_lp_uninit() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: true, + account_id: IdForTests::token_lp_definition_id(), + } + } + + fn pool_lp_created_after_lock() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: TOKEN_PROGRAM_ID, + balance: 0u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("LP Token"), + total_supply: MINIMUM_LIQUIDITY, + metadata_id: None, + }), + nonce: Nonce(0), + }, + is_authorized: true, + account_id: IdForTests::token_lp_definition_id(), + } + } + fn pool_lp_with_wrong_id() -> AccountWithMetadata { AccountWithMetadata { account: Account { @@ -623,13 +744,17 @@ impl AccountWithMetadataForTests { } fn user_holding_lp_init() -> AccountWithMetadata { + AccountForTests::user_holding_lp_with_balance(BalanceForTests::user_token_lp_balance()) + } + + fn user_holding_lp_with_balance(balance: u128) -> AccountWithMetadata { AccountWithMetadata { account: Account { program_owner: TOKEN_PROGRAM_ID, balance: 0u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::token_lp_definition_id(), - balance: BalanceForTests::user_token_lp_balance(), + balance, }), nonce: Nonce(0), }, @@ -638,6 +763,22 @@ impl AccountWithMetadataForTests { } } + fn lp_lock_holding_uninit() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: IdForTests::lp_lock_holding_id(), + } + } + + fn lp_lock_holding_with_wrong_id() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: IdForTests::vault_a_id(), + } + } + fn pool_definition_init() -> AccountWithMetadata { AccountWithMetadata { account: Account { @@ -841,7 +982,7 @@ impl AccountWithMetadataForTests { vault_a_id: IdForTests::vault_a_id(), vault_b_id: IdForTests::vault_b_id(), liquidity_pool_id: IdForTests::token_lp_definition_id(), - liquidity_pool_supply: 989, + liquidity_pool_supply: BalanceForTests::add_lp_supply_successful(), reserve_a: BalanceForTests::vault_a_add_successful(), reserve_b: BalanceForTests::vault_b_add_successful(), fees: 0u128, @@ -865,7 +1006,7 @@ impl AccountWithMetadataForTests { vault_a_id: IdForTests::vault_a_id(), vault_b_id: IdForTests::vault_b_id(), liquidity_pool_id: IdForTests::token_lp_definition_id(), - liquidity_pool_supply: 607, + liquidity_pool_supply: BalanceForTests::remove_lp_supply_successful(), reserve_a: BalanceForTests::vault_a_remove_successful(), reserve_b: BalanceForTests::vault_b_remove_successful(), fees: 0u128, @@ -902,6 +1043,30 @@ impl AccountWithMetadataForTests { } } + fn pool_definition_reinitializable() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: ProgramId::default(), + balance: 0u128, + data: Data::from(&PoolDefinition { + definition_token_a_id: IdForTests::token_a_definition_id(), + definition_token_b_id: IdForTests::token_b_definition_id(), + vault_a_id: IdForTests::vault_a_id(), + vault_b_id: IdForTests::vault_b_id(), + liquidity_pool_id: IdForTests::token_lp_definition_id(), + liquidity_pool_supply: 0, + reserve_a: 0, + reserve_b: 0, + fees: 0u128, + active: false, + }), + nonce: Nonce(0), + }, + is_authorized: true, + account_id: IdForTests::pool_definition_id(), + } + } + fn pool_definition_with_wrong_id() -> AccountWithMetadata { AccountWithMetadata { account: Account { @@ -981,6 +1146,32 @@ impl AccountWithMetadataForTests { account_id: IdForTests::pool_definition_id(), } } + + /// Legacy/corrupted pool state whose reported supply has already been drained down to the + /// permanent lock (liquidity_pool_supply == MINIMUM_LIQUIDITY). + fn pool_definition_at_minimum_liquidity() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: ProgramId::default(), + balance: 0u128, + data: Data::from(&PoolDefinition { + definition_token_a_id: IdForTests::token_a_definition_id(), + definition_token_b_id: IdForTests::token_b_definition_id(), + vault_a_id: IdForTests::vault_a_id(), + vault_b_id: IdForTests::vault_b_id(), + liquidity_pool_id: IdForTests::token_lp_definition_id(), + liquidity_pool_supply: MINIMUM_LIQUIDITY, + reserve_a: BalanceForTests::vault_a_reserve_init(), + reserve_b: BalanceForTests::vault_b_reserve_init(), + fees: 0u128, + active: true, + }), + nonce: Nonce(0), + }, + is_authorized: true, + account_id: IdForTests::pool_definition_id(), + } + } } #[test] @@ -1322,6 +1513,44 @@ fn test_call_remove_liquidity_insufficient_balance_1() { ); } +#[should_panic(expected = "Pool only contains locked liquidity")] +#[test] +fn test_call_remove_liquidity_pool_at_minimum_liquidity() { + // Removing from a legacy/corrupted pool that is already at the locked floor must be rejected. + let _post_states = remove_liquidity( + AccountWithMetadataForTests::pool_definition_at_minimum_liquidity(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::user_holding_lp_with_balance(MINIMUM_LIQUIDITY), + NonZero::new(MINIMUM_LIQUIDITY).unwrap(), + 1, + 1, + ); +} + +#[should_panic(expected = "Cannot remove locked minimum liquidity")] +#[test] +fn test_call_remove_liquidity_exceeds_unlocked_supply() { + // Model corrupted ownership by giving the caller the full LP supply even though the lock + // account should permanently hold MINIMUM_LIQUIDITY. The guard must still refuse to burn + // through the permanent floor. + let _post_states = remove_liquidity( + AccountWithMetadataForTests::pool_definition_init(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::user_holding_lp_with_balance(BalanceForTests::lp_supply_init()), + NonZero::new(BalanceForTests::lp_supply_init()).unwrap(), + 1, + 1, + ); +} + #[should_panic( expected = "Insufficient minimal withdraw amount (Token B) provided for liquidity amount" )] @@ -1414,6 +1643,7 @@ fn test_call_new_definition_with_zero_balance_1() { AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::lp_lock_holding_uninit(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), @@ -1431,6 +1661,7 @@ fn test_call_new_definition_with_zero_balance_2() { AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::lp_lock_holding_uninit(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), @@ -1448,6 +1679,7 @@ fn test_call_new_definition_same_token_definition() { AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::lp_lock_holding_uninit(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_lp_uninit(), @@ -1465,6 +1697,25 @@ fn test_call_new_definition_wrong_liquidity_id() { AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::pool_lp_with_wrong_id(), + AccountWithMetadataForTests::lp_lock_holding_uninit(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::user_holding_lp_uninit(), + NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), + NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), + AMM_PROGRAM_ID, + ); +} + +#[should_panic(expected = "LP lock holding Account ID does not match PDA")] +#[test] +fn test_call_new_definition_wrong_lp_lock_holding_id() { + let _post_states = new_definition( + AccountWithMetadataForTests::pool_definition_init(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::lp_lock_holding_with_wrong_id(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), @@ -1482,6 +1733,7 @@ fn test_call_new_definition_wrong_pool_id() { AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::lp_lock_holding_uninit(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), @@ -1499,6 +1751,7 @@ fn test_call_new_definition_wrong_vault_id_1() { AccountWithMetadataForTests::vault_a_with_wrong_id(), AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::lp_lock_holding_uninit(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), @@ -1516,6 +1769,7 @@ fn test_call_new_definition_wrong_vault_id_2() { AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_with_wrong_id(), AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::lp_lock_holding_uninit(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), @@ -1533,6 +1787,7 @@ fn test_call_new_definition_cannot_initialize_active_pool() { AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init(), AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::lp_lock_holding_uninit(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), @@ -1542,14 +1797,33 @@ fn test_call_new_definition_cannot_initialize_active_pool() { ); } -#[should_panic(expected = "Cannot initialize an active Pool Definition")] +#[should_panic(expected = "Initial liquidity must exceed minimum liquidity lock")] +#[test] +fn test_call_new_definition_initial_lp_too_small() { + // isqrt(1000 * 1000) = 1000 == MINIMUM_LIQUIDITY, so the assertion fires. + let _post_states = new_definition( + AccountWithMetadataForTests::pool_definition_reinitializable(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::pool_lp_reinitializable(), + AccountWithMetadataForTests::lp_lock_holding_uninit(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::user_holding_lp_uninit(), + NonZero::new(MINIMUM_LIQUIDITY).unwrap(), + NonZero::new(MINIMUM_LIQUIDITY).unwrap(), + AMM_PROGRAM_ID, + ); +} + #[test] fn test_call_new_definition_chained_call_successful() { let (post_states, chained_calls) = new_definition( - AccountWithMetadataForTests::pool_definition_active(), + AccountWithMetadataForTests::pool_definition_reinitializable(), AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init(), - AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::pool_lp_reinitializable(), + AccountWithMetadataForTests::lp_lock_holding_uninit(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), @@ -1560,18 +1834,17 @@ fn test_call_new_definition_chained_call_successful() { let pool_post = post_states[0].clone(); - assert!( - AccountWithMetadataForTests::pool_definition_add_successful().account - == *pool_post.account() - ); + assert!(AccountWithMetadataForTests::pool_definition_init().account == *pool_post.account()); - let chained_call_lp = chained_calls[0].clone(); - let chained_call_b = chained_calls[1].clone(); - let chained_call_a = chained_calls[2].clone(); + let chained_call_lp_lock = chained_calls[0].clone(); + let chained_call_lp_user = chained_calls[1].clone(); + let chained_call_b = chained_calls[2].clone(); + let chained_call_a = chained_calls[3].clone(); assert!(chained_call_a == ChainedCallForTests::cc_new_definition_token_a()); assert!(chained_call_b == ChainedCallForTests::cc_new_definition_token_b()); - assert!(chained_call_lp == ChainedCallForTests::cc_new_definition_token_lp()); + assert!(chained_call_lp_lock == ChainedCallForTests::cc_new_definition_token_lp_lock()); + assert!(chained_call_lp_user == ChainedCallForTests::cc_new_definition_token_lp_user()); } #[should_panic(expected = "AccountId is not a token type for the pool")] @@ -1674,7 +1947,7 @@ fn test_call_swap_below_min_out() { AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), BalanceForTests::add_max_amount_a(), - BalanceForTests::min_amount_out(), + BalanceForTests::min_amount_out_too_high(), IdForTests::token_a_definition_id(), ); } @@ -1746,10 +2019,11 @@ fn test_call_swap_chained_call_successful_2() { #[test] fn test_new_definition_lp_asymmetric_amounts() { let (post_states, chained_calls) = new_definition( - AccountWithMetadataForTests::pool_definition_inactive(), + AccountWithMetadataForTests::pool_definition_reinitializable(), AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init(), - AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::pool_lp_reinitializable(), + AccountWithMetadataForTests::lp_lock_holding_uninit(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), @@ -1766,23 +2040,26 @@ fn test_new_definition_lp_asymmetric_amounts() { BalanceForTests::lp_supply_init() ); - let chained_call_lp = chained_calls[0].clone(); - assert!(chained_call_lp == ChainedCallForTests::cc_new_definition_token_lp()); + let chained_call_lp_lock = chained_calls[0].clone(); + let chained_call_lp_user = chained_calls[1].clone(); + assert!(chained_call_lp_lock == ChainedCallForTests::cc_new_definition_token_lp_lock()); + assert!(chained_call_lp_user == ChainedCallForTests::cc_new_definition_token_lp_user()); } #[test] fn test_new_definition_lp_symmetric_amounts() { - // token_a=100, token_b=100 → LP=sqrt(10_000)=100 - let token_a_amount = 100u128; - let token_b_amount = 100u128; + // token_a=2000, token_b=2000 → LP=sqrt(4_000_000)=2000 + let token_a_amount = 2_000u128; + let token_b_amount = 2_000u128; let expected_lp = (token_a_amount * token_b_amount).isqrt(); - assert_eq!(expected_lp, 100); + assert_eq!(expected_lp, 2_000); let (post_states, chained_calls) = new_definition( - AccountWithMetadataForTests::pool_definition_inactive(), + AccountWithMetadataForTests::pool_definition_reinitializable(), AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init(), - AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::pool_lp_reinitializable(), + AccountWithMetadataForTests::lp_lock_holding_uninit(), AccountWithMetadataForTests::user_holding_a(), AccountWithMetadataForTests::user_holding_b(), AccountWithMetadataForTests::user_holding_lp_uninit(), @@ -1795,20 +2072,167 @@ fn test_new_definition_lp_symmetric_amounts() { let pool_def = PoolDefinition::try_from(&pool_post.account().data).unwrap(); assert_eq!(pool_def.liquidity_pool_supply, expected_lp); - let chained_call_lp = chained_calls[0].clone(); - let expected_lp_call = ChainedCall::new( + let chained_call_lp_lock = chained_calls[0].clone(); + let chained_call_lp_user = chained_calls[1].clone(); + + let mut pool_lp_auth = AccountForTests::pool_lp_reinitializable(); + pool_lp_auth.is_authorized = true; + let expected_lp_lock_call = ChainedCall::new( TOKEN_PROGRAM_ID, vec![ - AccountWithMetadataForTests::pool_lp_init(), - AccountWithMetadataForTests::user_holding_lp_uninit(), + pool_lp_auth.clone(), + AccountForTests::lp_lock_holding_uninit(), ], &token_core::Instruction::Mint { - amount_to_mint: expected_lp, + amount_to_mint: MINIMUM_LIQUIDITY, }, ) .with_pda_seeds(vec![compute_liquidity_token_pda_seed( IdForTests::pool_definition_id(), )]); - assert_eq!(chained_call_lp, expected_lp_call); + let expected_lp_user_call = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![ + AccountForTests::pool_lp_reinitialized_after_lock(), + AccountForTests::user_holding_lp_uninit(), + ], + &token_core::Instruction::Mint { + amount_to_mint: expected_lp - MINIMUM_LIQUIDITY, + }, + ) + .with_pda_seeds(vec![compute_liquidity_token_pda_seed( + IdForTests::pool_definition_id(), + )]); + + assert_eq!(chained_call_lp_lock, expected_lp_lock_call); + assert_eq!(chained_call_lp_user, expected_lp_user_call); +} + +#[should_panic( + expected = "New definition: inactive Pool Definition must have zero LP supply before reinitialization" +)] +#[test] +fn test_call_new_definition_reinitialization_requires_zero_pool_supply() { + let _post_states = new_definition( + AccountWithMetadataForTests::pool_definition_inactive(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::pool_lp_reinitializable(), + AccountWithMetadataForTests::lp_lock_holding_uninit(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::user_holding_lp_uninit(), + NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), + NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), + AMM_PROGRAM_ID, + ); +} + +#[should_panic( + expected = "New definition: existing LP Token Definition Account must have zero supply before reinitialization" +)] +#[test] +fn test_call_new_definition_reinitialization_requires_zero_lp_definition_supply() { + let _post_states = new_definition( + AccountWithMetadataForTests::pool_definition_reinitializable(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::lp_lock_holding_uninit(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::user_holding_lp_uninit(), + NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), + NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), + AMM_PROGRAM_ID, + ); +} + +#[test] +fn test_minimum_liquidity_lock_and_remove_all_user_lp() { + let pool_uninitialized = AccountWithMetadata { + account: Account::default(), + is_authorized: true, + account_id: IdForTests::pool_definition_id(), + }; + let token_a_amount = BalanceForTests::vault_a_reserve_init(); + let token_b_amount = BalanceForTests::vault_b_reserve_init(); + let initial_lp = (token_a_amount * token_b_amount).isqrt(); + let user_lp = initial_lp - MINIMUM_LIQUIDITY; + + let (post_states, chained_calls) = new_definition( + pool_uninitialized, + AccountForTests::vault_a_init(), + AccountForTests::vault_b_init(), + AccountForTests::pool_lp_uninit(), + AccountForTests::lp_lock_holding_uninit(), + AccountForTests::user_holding_a(), + AccountForTests::user_holding_b(), + AccountForTests::user_holding_lp_uninit(), + NonZero::new(token_a_amount).unwrap(), + NonZero::new(token_b_amount).unwrap(), + AMM_PROGRAM_ID, + ); + + let mut pool_lp_auth = AccountForTests::pool_lp_uninit(); + pool_lp_auth.is_authorized = true; + + let expected_lock_call = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![ + pool_lp_auth.clone(), + AccountForTests::lp_lock_holding_uninit(), + ], + &token_core::Instruction::NewFungibleDefinition { + name: String::from("LP Token"), + total_supply: MINIMUM_LIQUIDITY, + }, + ) + .with_pda_seeds(vec![compute_liquidity_token_pda_seed( + IdForTests::pool_definition_id(), + )]); + let expected_user_call = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![ + AccountForTests::pool_lp_created_after_lock(), + AccountForTests::user_holding_lp_uninit(), + ], + &token_core::Instruction::Mint { + amount_to_mint: user_lp, + }, + ) + .with_pda_seeds(vec![compute_liquidity_token_pda_seed( + IdForTests::pool_definition_id(), + )]); + assert_eq!(chained_calls[0], expected_lock_call); + assert_eq!(chained_calls[1], expected_user_call); + + let pool_post = PoolDefinition::try_from(&post_states[0].account().data).unwrap(); + assert_eq!(pool_post.liquidity_pool_supply, initial_lp); + + let pool_for_remove = AccountWithMetadata { + account: post_states[0].account().clone(), + is_authorized: true, + account_id: IdForTests::pool_definition_id(), + }; + let (remove_post_states, _) = remove_liquidity( + pool_for_remove, + AccountForTests::vault_a_init(), + AccountForTests::vault_b_init(), + AccountForTests::pool_lp_init(), + AccountForTests::user_holding_a(), + AccountForTests::user_holding_b(), + AccountForTests::user_holding_lp_with_balance(user_lp), + NonZero::new(user_lp).unwrap(), + 1, + 1, + ); + + let pool_after_remove = + PoolDefinition::try_from(&remove_post_states[0].account().data).unwrap(); + assert_eq!(pool_after_remove.liquidity_pool_supply, MINIMUM_LIQUIDITY); + assert!(pool_after_remove.reserve_a > 0); + assert!(pool_after_remove.reserve_b > 0); + assert!(pool_after_remove.active); } diff --git a/integration_tests/tests/amm.rs b/integration_tests/tests/amm.rs index b7828dd..c4f050a 100644 --- a/integration_tests/tests/amm.rs +++ b/integration_tests/tests/amm.rs @@ -1,4 +1,4 @@ -use amm_core::PoolDefinition; +use amm_core::{PoolDefinition, MINIMUM_LIQUIDITY}; use nssa::{ program_deployment_transaction::{self, ProgramDeploymentTransaction}, public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State, @@ -54,6 +54,10 @@ impl Ids { amm_core::compute_liquidity_token_pda(Self::amm_program(), Self::pool_definition()) } + fn lp_lock_holding() -> AccountId { + amm_core::compute_lp_lock_holding_pda(Self::amm_program(), Self::pool_definition()) + } + fn vault_a() -> AccountId { amm_core::compute_vault_pda( Self::amm_program(), @@ -243,6 +247,10 @@ impl Balances { fn lp_supply_init() -> u128 { (Self::vault_a_init() * Self::vault_b_init()).isqrt() } + + fn lp_user_init() -> u128 { + Self::lp_supply_init() - MINIMUM_LIQUIDITY + } } impl Accounts { @@ -365,6 +373,18 @@ impl Accounts { } } + fn user_lp_holding_with_balance(balance: u128) -> Account { + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_lp_definition(), + balance, + }), + nonce: Nonce(0), + } + } + // --- Expected post-state accounts --- fn pool_definition_swap_1() -> Account { @@ -776,7 +796,7 @@ impl Accounts { balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: Ids::token_lp_definition(), - balance: Balances::lp_supply_init(), + balance: Balances::lp_user_init(), }), nonce: Nonce(0), } @@ -795,6 +815,18 @@ impl Accounts { } } + fn lp_lock_holding_new_init() -> Account { + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_lp_definition(), + balance: MINIMUM_LIQUIDITY, + }), + nonce: Nonce(0), + } + } + fn pool_definition_new_init() -> Account { Account { program_owner: Ids::amm_program(), @@ -946,6 +978,39 @@ fn amm_remove_liquidity() { ); } +#[test] +fn amm_remove_liquidity_insufficient_user_lp_fails() { + let mut state = state_for_amm_tests(); + state.force_insert_account(Ids::user_lp(), Accounts::user_lp_holding_with_balance(500)); + + let instruction = amm_core::Instruction::RemoveLiquidity { + remove_liquidity_amount: Balances::remove_lp(), + min_amount_to_remove_token_a: Balances::remove_min_a(), + min_amount_to_remove_token_b: Balances::remove_min_b(), + }; + + let message = public_transaction::Message::try_new( + Ids::amm_program(), + vec![ + Ids::pool_definition(), + Ids::vault_a(), + Ids::vault_b(), + Ids::token_lp_definition(), + Ids::user_a(), + Ids::user_b(), + Ids::user_lp(), + ], + vec![Nonce(0)], + instruction, + ) + .unwrap(); + + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::user_lp()]); + + let tx = PublicTransaction::new(message, witness_set); + assert!(state.transition_from_public_transaction(&tx, 0).is_err()); +} + #[test] fn amm_new_definition_inactive_initialized_pool_and_uninit_user_lp() { let mut state = state_for_amm_tests_with_new_def(); @@ -970,6 +1035,7 @@ fn amm_new_definition_inactive_initialized_pool_and_uninit_user_lp() { Ids::vault_a(), Ids::vault_b(), Ids::token_lp_definition(), + Ids::lp_lock_holding(), Ids::user_a(), Ids::user_b(), Ids::user_lp(), @@ -1001,6 +1067,10 @@ fn amm_new_definition_inactive_initialized_pool_and_uninit_user_lp() { state.get_account_by_id(Ids::token_lp_definition()), Accounts::token_lp_definition_new_init() ); + assert_eq!( + state.get_account_by_id(Ids::lp_lock_holding()), + Accounts::lp_lock_holding_new_init() + ); assert_eq!( state.get_account_by_id(Ids::user_a()), Accounts::user_a_holding_new_init() @@ -1040,6 +1110,7 @@ fn amm_new_definition_inactive_initialized_pool_init_user_lp() { Ids::vault_a(), Ids::vault_b(), Ids::token_lp_definition(), + Ids::lp_lock_holding(), Ids::user_a(), Ids::user_b(), Ids::user_lp(), @@ -1071,6 +1142,10 @@ fn amm_new_definition_inactive_initialized_pool_init_user_lp() { state.get_account_by_id(Ids::token_lp_definition()), Accounts::token_lp_definition_new_init() ); + assert_eq!( + state.get_account_by_id(Ids::lp_lock_holding()), + Accounts::lp_lock_holding_new_init() + ); assert_eq!( state.get_account_by_id(Ids::user_a()), Accounts::user_a_holding_new_init() @@ -1104,6 +1179,7 @@ fn amm_new_definition_uninitialized_pool() { Ids::vault_a(), Ids::vault_b(), Ids::token_lp_definition(), + Ids::lp_lock_holding(), Ids::user_a(), Ids::user_b(), Ids::user_lp(), @@ -1135,6 +1211,10 @@ fn amm_new_definition_uninitialized_pool() { state.get_account_by_id(Ids::token_lp_definition()), Accounts::token_lp_definition_new_init() ); + assert_eq!( + state.get_account_by_id(Ids::lp_lock_holding()), + Accounts::lp_lock_holding_new_init() + ); assert_eq!( state.get_account_by_id(Ids::user_a()), Accounts::user_a_holding_new_init()