Skip to content

Add Minimum Liquidity Lock For AMM Pools#34

Closed
3esmit wants to merge 8 commits intologos-blockchain:mainfrom
3esmit:fix/amm-minimum-liquidity
Closed

Add Minimum Liquidity Lock For AMM Pools#34
3esmit wants to merge 8 commits intologos-blockchain:mainfrom
3esmit:fix/amm-minimum-liquidity

Conversation

@3esmit
Copy link
Copy Markdown
Collaborator

@3esmit 3esmit commented Apr 8, 2026

Fixes #6

Summary

This PR adds a minimum liquidity lock to the AMM so pools cannot be fully drained to zero LP supply.

On initialization, the AMM now locks MINIMUM_LIQUIDITY LP tokens in a dedicated lock holding PDA and gives the pool creator the remaining LP supply. Liquidity removal is updated to preserve that invariant.

Changes

  • add MINIMUM_LIQUIDITY and document the invariant in AMM core types
  • add a dedicated LP lock holding PDA derivation
  • update pool initialization to lock minimum LP on creation
  • update liquidity removal to reject withdrawals of the locked minimum liquidity
  • keep pool supply active at the locked-liquidity floor instead of transitioning to zero
  • expand unit and integration coverage for locked-liquidity behavior

Testing

  • cargo test -p amm_program
  • cargo test -p integration_tests amm

Notes

  • This PR changes internal chained-call shape during LP initialization, but does not change AMM public instruction interfaces.
  • The lock is enforced through a dedicated PDA-owned LP holding account.
  • The user-visible outcome remains the same: total LP supply is initialized correctly, the first provider receives initial_lp - MINIMUM_LIQUIDITY, and pools cannot be drained below the locked minimum.
  • A follow-up optimization is possible in new_definition: mint the full LP supply into the lock holding account first and transfer the user share out, which would avoid the intermediate LP definition reconstruction step currently used to support the second LP chained call.
  • That optimization is intentionally not included in this PR so the code changes stay short and focused on the minimum-liquidity lock behavior itself.

Copilot AI review requested due to automatic review settings April 8, 2026 21:20
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a minimum-liquidity lock mechanism for the AMM so pools cannot be drained to a zero LP supply state, by permanently locking MINIMUM_LIQUIDITY LP tokens in a dedicated lock-holding PDA and preventing remove_liquidity from withdrawing below that floor.

Changes:

  • Add MINIMUM_LIQUIDITY and a new PDA derivation for the LP lock-holding account in amm_core.
  • Update pool initialization (new_definition) to lock MINIMUM_LIQUIDITY and mint only the remaining LP to the creator.
  • Update remove_liquidity and expand unit/integration tests to enforce and validate the locked-liquidity invariant.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
amm/core/src/lib.rs Adds MINIMUM_LIQUIDITY, documents the invariant, and introduces LP lock-holding PDA derivation.
amm/src/new_definition.rs Updates LP initialization flow to mint locked minimum LP to a lock PDA and mint remaining LP to the user.
amm/src/remove.rs Prevents removals that would bring LP supply below the minimum-liquidity floor and keeps pool active.
amm/src/tests.rs Adds/updates unit tests for locked-liquidity initialization and removal behavior.
integration_tests/tests/amm.rs Updates integration expectations for initial user LP balance (net of locked minimum).
Comments suppressed due to low confidence (1)

amm/src/new_definition.rs:109

  • is_new_pool is computed once, but later the code repeats if pool.account == Account::default() when deciding whether to return a claimed post-state. Using is_new_pool consistently would avoid duplicate comparisons and reduce the risk of future divergence if the "new pool" condition changes.
    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)
            .expect("AMM program expects a valid Pool account")
    };

    assert!(
        !pool_account_data.active,
        "Cannot initialize an active Pool Definition"
    );

    // 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();
    let pool_post_definition = PoolDefinition {
        definition_token_a_id,
        definition_token_b_id,
        vault_a_id: vault_a.account_id,
        vault_b_id: vault_b.account_id,
        liquidity_pool_id: pool_definition_lp.account_id,
        liquidity_pool_supply: initial_lp,
        reserve_a: token_a_amount.into(),
        reserve_b: token_b_amount.into(),
        fees: 0u128, // TODO: we assume all fees are 0 for now.
        active: true,
    };

    pool_post.data = Data::from(&pool_post_definition);
    let pool_post: AccountPostState = if pool.account == Account::default() {
        AccountPostState::new_claimed(pool_post.clone())
    } else {
        AccountPostState::new(pool_post.clone())
    };

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@3esmit 3esmit changed the title feat(amm): add minimum liquidity lock Add Minimum Liquidity Lock For AMM Pools Apr 9, 2026
@3esmit 3esmit self-assigned this Apr 9, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

amm/core/src/lib.rs:30

  • The Instruction::NewDefinition docs enumerate the required accounts, but the implementation now also requires the LP-lock holding PDA account to exist/be writable for the token-program chained call that locks MINIMUM_LIQUIDITY. Please update the required-accounts list to include this PDA (and how to derive it), otherwise clients will construct transactions that are missing a required account.
    /// 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
    /// - User Holding Account for Token A (authorized)
    /// - User Holding Account for Token B (authorized)
    /// - User Holding Account for Pool Liquidity
    NewDefinition {

amm/src/new_definition.rs:223

  • new_definition returns (post_states.clone(), chained_calls), which unnecessarily clones the full Vec<AccountPostState> (and any inner data) right before returning. Return post_states directly to avoid extra allocation/copies and keep the function consistent with the other AMM instruction helpers.
    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(user_holding_a.account.clone()),
        AccountPostState::new(user_holding_b.account.clone()),
        AccountPostState::new(user_holding_lp.account.clone()),
    ];

    (post_states.clone(), chained_calls)
}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@3esmit
Copy link
Copy Markdown
Collaborator Author

3esmit commented Apr 9, 2026

CI failed due GitHub API rate limit exceeded for 52.234.42.40 on an unauthenticated request.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +83 to +93
// 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,
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove_liquidity now unconditionally asserts liquidity_pool_supply > MINIMUM_LIQUIDITY, which rejects any active pool with LP supply <= MINIMUM_LIQUIDITY (including pools created before the lock was introduced that may legitimately have smaller initial LP supply). This can make such pools impossible to withdraw from after upgrading. Consider gating the minimum-liquidity floor enforcement to pools that were created under the new rules (or otherwise allowing the legacy path to drain below MINIMUM_LIQUIDITY / to zero), and only rejecting the exact locked-floor case for new pools.

Suggested change
// 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,
// Pools created with a permanent minimum-liquidity lock must retain that floor, but legacy
// pools created before the lock was introduced may legitimately have total supply at or below
// MINIMUM_LIQUIDITY and must still be removable.
let max_removable_liquidity = if pool_def_data.liquidity_pool_supply > MINIMUM_LIQUIDITY {
pool_def_data.liquidity_pool_supply - MINIMUM_LIQUIDITY
} else {
pool_def_data.liquidity_pool_supply
};
// The remove instruction never sees the LP lock account directly, so for pools that are above
// the locked floor we still refuse any request that would burn through the permanent minimum.
assert!(
remove_liquidity_amount <= max_removable_liquidity,

Copilot uses AI. Check for mistakes.
Comment on lines 18 to 32
/// 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
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description states the AMM public instruction interfaces are unchanged, but NewDefinition now requires an additional account (lp_lock_holding) as reflected in the IDL and the Instruction::NewDefinition required-accounts documentation. Please update the PR description to acknowledge this client-facing account-list/order change (even if the enum variant data is unchanged).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator

@0x-r4bbit 0x-r4bbit left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's get the commit updated and merge

vault_a: AccountWithMetadata,
vault_b: AccountWithMetadata,
pool_definition_lp: AccountWithMetadata,
lp_lock_holding: AccountWithMetadata,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a breaking change. Let's make sure we cover this via conventional commits


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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that this is necessary, but it feels off to me.
Not making this a blocker, but we should explore how to handle such cases nicer in the future.

/cc @schouhy

@0x-r4bbit
Copy link
Copy Markdown
Collaborator

Landed via #39

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement dead shares to avoid pool drain and first depositor attacks

3 participants