Skip to content
Quantix Security Labs

Account validation in a Solana AMM

On Solana the caller chooses every account. Two missing constraints in a Corvid AMM release would have let anyone drain a pool with a token mint they created, and collect protocol fees without a signature.
CriticalAccount validationResearch assessment · 8 min read
Illustration: a fence of constraint posts in front of an AMM pool with two posts missing; a counterfeit mint passes through the gap while pool tokens stream out.

The case at a glance

Corvid AMM

Solana · DEXSecurity profileResearch case · Quantix Protocol Research

During the case
B65.9
Current profile
BBB76.1
To containment
45 min
To verified fix
15.9 days
  1. 03Problem

    The review’s goal was to show, for every instruction, that no choice of accounts by the caller could make the program act on state it should not trust.

    Read section
  2. 06Vulnerability

    CriticalAccount validation

    Anchor account types check that an account deserialises as the expected type and is owned by the expected program.

    Read section
  3. 08Remediation

    1. 01Constrain lp_mint to pool.lp_mint and add has_one constraints on the pool
    2. 02Require the fee authority to sign
    3. 03Add a negative test for every account substitution

    and 1 more steps

    Read section
  4. 09Outcome

    CA-01 and CA-02 were fixed and verified before the program upgrade went ahead.

    Risk Index B → BBB, +10.2 points

    Read section

Background

Corvid AMM is a constant-product market maker written with Anchor. Each pool is a program-derived account that is the authority over two SPL token vaults and an LP mint; liquidity providers receive LP tokens and burn them to withdraw.

Solana programs receive every account they touch from the transaction. The program, not the runtime, must check that each account is the one it expects: the right owner, the right address, the right relationship to other accounts, and a signature wherever authority is required. The May 2026 review covered a new release ahead of a program upgrade on mainnet.

Architecture

The corvid_amm program exposes pool initialisation, swaps, deposits, withdrawals and fee collection. The Pool PDA stores reserves, the LP mint, vault addresses and the fee authority; Token vaults hold reserves under the pool’s authority; the Fee collector path withdraws protocol fees tracked separately from reserves.

Figure 1Corvid AMM: components and paths involved in this case

Corvid AMM: architecture relevant to this case6 elements and 5 connections. Highlighted components are directly involved in the case; dashed red arrows are attack paths, dotted brass arrows are control paths.withdraw(lp_amount)withdraw(foreign lp_mint)reads lp_mint, vaultstransfer (pool authority)collect_fees(unsigned authority)Liquidity providersdeposit / withdrawCaller with substitutedaccountsown mint, unsignedauthoritycorvid_amm programPool initialisation, swaps,deposits and withdrawalsPool PDAHolds pool state andauthority over token vaultsToken vaultsSPL token accounts owned bythe pool authorityFee collectorProtocol fee accounting andwithdrawal
Protocol componentInvolved in this caseExternal partyAttack pathControl path

Elements:

  • Liquidity providers (external party): deposit / withdraw
  • Caller with substituted accounts (adversary): own mint, unsigned authority
  • corvid_amm program (protocol component, involved in this case): Pool initialisation, swaps, deposits and withdrawals
  • Pool PDA (protocol component, involved in this case): Holds pool state and authority over token vaults
  • Token vaults (protocol component): SPL token accounts owned by the pool authority
  • Fee collector (protocol component): Protocol fee accounting and withdrawal

Connections:

  • Liquidity providers to corvid_amm program: withdraw(lp_amount) (data or call flow)
  • Caller with substituted accounts to corvid_amm program: withdraw(foreign lp_mint) (attack path)
  • corvid_amm program to Pool PDA: reads lp_mint, vaults (data or call flow)
  • corvid_amm program to Token vaults: transfer (pool authority) (data or call flow)
  • Caller with substituted accounts to Fee collector: collect_fees(unsigned authority) (attack path)

Components in the Corvid AMM profile

  • corvid_amm program(involved in this case)

    Pool initialisation, swaps, deposits and withdrawals

  • Pool PDA(involved in this case)

    Holds pool state and authority over token vaults

  • Token vaults

    SPL token accounts owned by the pool authority

  • Fee collector

    Protocol fee accounting and withdrawal

Security challenge

The review’s goal was to show, for every instruction, that no choice of accounts by the caller could make the program act on state it should not trust. The two findings in this case came from asking that question systematically of withdraw and collect_fees.

Attack surface

Account-substitution analysis lists each account an instruction takes and what happens if the caller supplies a different one.

Attack surface examined in Corvid AMM
SurfaceExposureControl
withdraw.lp_mintAny mint accepted; share computed from its supply (CA-01)address = pool.lp_mint and has_one = lp_mint
withdraw vaultsRelationship to the pool not declaredhas_one = vault_a, vault_b on the pool
collect_fees.authorityKey compared but never required to sign (CA-02)Signer bound by has_one = fee_authority
Reserve arithmeticu64 product can overflow in release builds (CA-03)u128 intermediate values
Token programMust be the SPL Token programProgram<Token>

Technical investigation

withdraw returns lp_amount / lp_mint.supply of each reserve and burns against the supplied mint. The accounts struct declared lp_mint: Account<Mint>, which proves only that the account is a mint owned by the SPL Token program. A caller could create a mint, mint one token to themselves and pass both: the program burned one token, computed a share of one in one, and transferred both vaults.

collect_fees took the authority as an UncheckedAccount and compared its key with pool.fee_authority. Public keys are public; without a signer requirement anyone could pass the authority’s key and redirect accumulated fees.

Both were reproduced on a local validator before they were reported. The table lists every account in the Withdraw context with its constraints before and after remediation.

Figure 2Draining a pool with a self-created LP mint (CA-01)

Draining a pool with a self-created LP mint (CA-01)AttackerSPL Token programcorvid_ammPool vaults1. initialize_mint(M); mint_to(attacker, 1)2. withdraw(lp_amount = 1, lp_mint = M)3. burn 1 of Msupply(M) = 1 → share 100%4. transfer reserve A and B to attacker
Ordinary stepAttack step
  1. Attacker (adversary) to SPL Token program: initialize_mint(M); mint_to(attacker, 1) (attack step)
  2. Attacker (adversary) to corvid_amm: withdraw(lp_amount = 1, lp_mint = M) (attack step)
  3. corvid_amm (on-chain) to SPL Token program: burn 1 of M. supply(M) = 1 → share 100% (normal step)
  4. corvid_amm (on-chain) to Pool vaults: transfer reserve A and B to attacker (attack step)
Accounts in the Withdraw context
AccountTypeConstraint (reviewed)Constraint (remediated)
poolAccount<Pool>mutmut, has_one = lp_mint, has_one = vault_a, has_one = vault_b
lp_mintAccount<Mint>mutmut, address = pool.lp_mint
user_lpAccount<TokenAccount>mutmut, token::mint = lp_mint, token::authority = user
vault_a, vault_bAccount<TokenAccount>mutmut (bound by has_one on pool)
userSigner——
token_programProgram<Token>——

Timeline

  1. Assessment begins on the release commit

  2. Detection

    CA-01 reproduced on a local validator

  3. Containment

    Mainnet program upgrade postponed

  4. Fix

    Remediation commit with constraints and negative tests

  5. Verification

    Fix verified

  6. Assessment report published

Root cause

Anchor account types check that an account deserialises as the expected type and is owned by the expected program. They do not check that it is the specific account the program’s state refers to. Every relationship between accounts must be declared, and two were not.

Class
Account validation
Severity
Critical
Components involved
corvid_amm programPool PDA
Findings
CA-01CA-02
programs/corvid-amm/src/instructions (reviewed commit)rust
#[derive(Accounts)]pub struct Withdraw<'info> {    #[account(mut)]    pub pool: Account<'info, Pool>,    #[account(mut)]    pub lp_mint: Account<'info, Mint>,          // any mint is accepted    // ...} #[derive(Accounts)]pub struct CollectFees<'info> {    pub pool: Account<'info, Pool>,    /// CHECK: compared against pool.fee_authority    pub authority: UncheckedAccount<'info>,      // never required to sign    // ...}

Risk evaluation

At the start of the assessment the critical and high findings and the remaining medium and low items were open, which the model reflects in Code Security. The after state is the current profile, where one low-severity item remains acknowledged.

65.9BElevated risk
During the caseB65.9
76.1BBBModerate risk
Current profileBBB76.1
CodeSecurityOperationalSecurityGovernanceRiskEconomicSecurityInfrastructureResilienceTransparencyDuring the case, Code Security: 40During the case, Operational Security: 76During the case, Governance Risk: 72During the case, Economic Security: 78During the case, Infrastructure Resilience: 80During the case, Transparency: 82Current profile, Code Security: 74Current profile, Operational Security: 76Current profile, Governance Risk: 72Current profile, Economic Security: 78Current profile, Infrastructure Resilience: 80Current profile, Transparency: 82
During the caseCurrent profile

The score moves from 65.9 (B) to 76.1 (BBB), a change of +10.2 points. The largest single contribution is Code Security: restoring it from 40.0 to 74.0 alone moves the score from 65.9 to 76.1.

Basis for the case state: CA-01 to CA-06 open at the start of the assessment.
ComponentWeightRangeDuring caseTodayScore points
Code Security30%40.074.0+10.2
Operational Security20%76.076.00.0
Governance Risk15%72.072.00.0
Economic Security15%78.078.00.0
Infrastructure Resilience10%80.080.00.0
Transparency10%82.082.00.0
Quantix Risk Index100%65.976.1+10.2
During the caseCurrent profileBasis for the case state: CA-01 to CA-06 open at the start of the assessment.

Scores use the six weighted components of the Quantix Risk Index. Open findings reduce Code Security by a fixed penalty per severity; the current profile is the protocol’s entry in Quantix Intelligence.

Mitigation strategy

Both contexts now declare every relationship. The pool carries has_one constraints for its LP mint and both vaults, the LP mint is constrained to the pool’s address, and the fee authority is a Signer bound to the pool by has_one.

Each constraint has a negative test that passes the wrong account and expects the instruction to fail with a constraint error.

  1. 01Constrain lp_mint to pool.lp_mint and add has_one constraints on the pool
  2. 02Require the fee authority to sign
  3. 03Add a negative test for every account substitution
  4. 04Use u128 for intermediate reserve products (CA-03)
Remediation commit (verified)diff
 pub struct Withdraw<'info> {-    #[account(mut)]+    #[account(mut, has_one = lp_mint, has_one = vault_a, has_one = vault_b)]     pub pool: Account<'info, Pool>,-    #[account(mut)]+    #[account(mut, address = pool.lp_mint)]     pub lp_mint: Account<'info, Mint>, }  pub struct CollectFees<'info> {-    pub pool: Account<'info, Pool>,-    /// CHECK: compared against pool.fee_authority-    pub authority: UncheckedAccount<'info>,+    #[account(mut, has_one = fee_authority)]+    pub pool: Account<'info, Pool>,+    pub fee_authority: Signer<'info>, }

Security outcome

CA-01 and CA-02 were fixed and verified before the program upgrade went ahead. The account-substitution tests run in CI for every instruction.

Listing every account and asking what happens if it is replaced is now the first step of every Solana assessment in the Quantix methodology.

Detection to containment
45 min
Detection to first fix
12.8 days
Detection to verified fix
15.9 days
Risk Index change
B → BBB (+10.2)

Lessons learned

  1. 01

    On Solana every account is input; validate relationships, not just types.

  2. 02

    A key comparison without a signature check is not authorisation.

  3. 03

    Write a negative test for each account constraint.

  4. 04

    Treat every UncheckedAccount as a review flag.

Have a problem like this one?

Tell us what you are building and where it runs. We will reply with the review plan we would use for your architecture.