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
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.
| Surface | Exposure | Control |
|---|---|---|
withdraw.lp_mint | Any mint accepted; share computed from its supply (CA-01) | address = pool.lp_mint and has_one = lp_mint |
withdraw vaults | Relationship to the pool not declared | has_one = vault_a, vault_b on the pool |
collect_fees.authority | Key compared but never required to sign (CA-02) | Signer bound by has_one = fee_authority |
| Reserve arithmetic | u64 product can overflow in release builds (CA-03) | u128 intermediate values |
| Token program | Must be the SPL Token program | Program<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)
- Attacker (adversary) to SPL Token program: initialize_mint(M); mint_to(attacker, 1) (attack step)
- Attacker (adversary) to corvid_amm: withdraw(lp_amount = 1, lp_mint = M) (attack step)
- corvid_amm (on-chain) to SPL Token program: burn 1 of M. supply(M) = 1 → share 100% (normal step)
- corvid_amm (on-chain) to Pool vaults: transfer reserve A and B to attacker (attack step)
Withdraw context| Account | Type | Constraint (reviewed) | Constraint (remediated) |
|---|---|---|---|
| pool | Account<Pool> | mut | mut, has_one = lp_mint, has_one = vault_a, has_one = vault_b |
| lp_mint | Account<Mint> | mut | mut, address = pool.lp_mint |
| user_lp | Account<TokenAccount> | mut | mut, token::mint = lp_mint, token::authority = user |
| vault_a, vault_b | Account<TokenAccount> | mut | mut (bound by has_one on pool) |
| user | Signer | — | — |
| token_program | Program<Token> | — | — |
Timeline
Assessment begins on the release commit
Detection
CA-01 reproduced on a local validator
Containment
Mainnet program upgrade postponed
Fix
Remediation commit with constraints and negative tests
Verification
Fix verified
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
#[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.
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.
| Component | Weight | Range | During case | Today | Score points |
|---|---|---|---|---|---|
| Code Security | 30% | 40.0 | 74.0 | +10.2 | |
| Operational Security | 20% | 76.0 | 76.0 | 0.0 | |
| Governance Risk | 15% | 72.0 | 72.0 | 0.0 | |
| Economic Security | 15% | 78.0 | 78.0 | 0.0 | |
| Infrastructure Resilience | 10% | 80.0 | 80.0 | 0.0 | |
| Transparency | 10% | 82.0 | 82.0 | 0.0 | |
| Quantix Risk Index | 100% | 65.9 | 76.1 | +10.2 |
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.
- 01Constrain
lp_minttopool.lp_mintand addhas_oneconstraints on the pool - 02Require the fee authority to sign
- 03Add a negative test for every account substitution
- 04Use u128 for intermediate reserve products (CA-03)
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
- 01
On Solana every account is input; validate relationships, not just types.
- 02
A key comparison without a signature check is not authorisation.
- 03
Write a negative test for each account constraint.
- 04
Treat every
UncheckedAccountas a review flag.

