audit-pipeline sign verify <file> <file>.sig --pubkey jelleo.ed25519.pubThis report documents the results of an autonomous Solana audit cycle run by Jelleo against the osec-solana-large workspace on May 17, 2026. The cycle identified 4 Critical, 2 High, 1 Medium and 1 Low 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.
| In-scope source set | |||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Target workspace | osec-solana-large | ||||||||||||||||||||||||||||||||||||||||||
| Protocol | Solana BPF program | ||||||||||||||||||||||||||||||||||||||||||
| Engine commit | 55bb98f670 (55bb98f6702480195362aaea7117df4d85157922) | ||||||||||||||||||||||||||||||||||||||||||
| Source files |
| ||||||||||||||||||||||||||||||||||||||||||
| Hypothesis library | 64 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. | ||||||||||||||||||||||||||||||||||||||||||
Each finding below begins on its own page. Numbering matches the FINDING NN / NN banner in the body. Click any row to jump.
InvariantAccount state fields MUST be set during the dedicated initializer instruction, not lazily on first mutating call. lending_pool::borrow checks `if obligation.collateral_value == 0` and sets `collateral_value = price.saturating_mul(100)` on the fly, where `price` is the caller-supplied parameter. Whoever borrows first gets to define the collateral valuation by passing whatever price they want.
Direct loss of user funds or full protocol takeover with no meaningful preconditions.
✓ Counterexample found (bug confirmed by symbolic execution)
✓ Reproduced through deployed BPF instructions
SOL51-lazy-state-init-during-mutation: collateral_value was lazily set from caller-supplied price during borrow. Got collateral_value=100000000000, which equals attacker_price*100=100000000000. Whoever borrows first defines collateral valuation. See programs/lending_pool/src/instructions/mod.rs (borrow::handler, the `if account_state.collateral_value == 0` branch).
Audit the affected code path against the invariant stated above and apply the structural fix proposed in the patch diff below.
Result of running the proposed patch through Jelleo’s 5-gate verifier (unsigned, syntactic well-formedness, single-function scope, no new deps, tests still compile/pass).
| ⏭ | kani_proof_holds | skipped — no kani_harness registered for this bug class |
| ⏭ | litesvm_exploit_neutralized | skipped — no litesvm_test_name registered for this bug class |
| ✓ | patch_well_formed | valid unified diff modifying programs/lending_pool/src/instructions/mod.rs |
| ✓ | poc_fails_pre_patch | PoC test test_sol51_lazy_state_init_during_mutation_fires fired at L2 (rustc-standalone log: cargo_sol51_lazy_state_init_during_mutation.log) |
| ✓ | poc_passes_post_patch | patch applies cleanly; PoC is rustc-standalone (runtime witness delegated to LiteSVM / tests gate) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch |
#[test]
pub fn test_sol51_lazy_state_init_during_mutation_litesvm() {
let program_id = "8hCNiXSk9uLqv5MGFpGhEn6jtLQ3saX1jE3Zud3pDN9Q"
.parse::<Pubkey>()
.unwrap();
let so_bytes = std::fs::read(
"/root/audit_runs/ottersec-eval/workspaces/solana-large/hunts/20260517-014506/build/target/deploy/lending_pool.so",
)
.expect("failed to read .so file");
let mut svm = LiteSVM::new();
svm.add_program(program_id, &so_bytes);
let admin = Keypair::new();
let borrower = Keypair::new();
// …setup truncated for brevity…
// On buggy code, collateral_value == 1_000_000_000 * 100 == 100_000_000_000 → assertion fails.
assert!(
collateral_value != expected_buggy_value,
"SOL51-lazy-state-init-during-mutation: collateral_value was lazily set from \
caller-supplied price during borrow. Got collateral_value={}, which equals \
attacker_price*100={}. Whoever borrows first defines collateral valuation. \
See programs/lending_pool/src/instructions/mod.rs (borrow::handler, \
the `if account_state.collateral_value == 0` branch).",
collateral_value,
expected_buggy_value,
);
}
--- a/programs/lending_pool/src/instructions/mod.rs
+++ b/programs/lending_pool/src/instructions/mod.rs
@@ -21,6 +21,11 @@
let account_state = &mut ctx.accounts.obligation;
- if account_state.collateral_value != 0 {
- } else {
- account_state.collateral_value = price.saturating_mul(100);
- }
+ // SOL51: lazy-init removed. An uninitialised obligation has
+ // collateral_value = 0; previously the borrow handler
+ // populated it from the caller-supplied price, letting the
+ // first borrower assign themselves an arbitrary collateral
+ // valuation. Reject borrow on uninitialised obligations.
+ require!(
+ account_state.collateral_value != 0,
+ LendingPoolError::InsufficientCollateral
+ );
let risk_snapshot = crate::state::RiskCheckpoint::capture(
InvariantSale settlement MUST resolve the buyer as the highest-bid bidder iff the bid meets the price floor; the caller is NEVER the buyer. nft_escrow::settle_sale uses `let buyer = if sale.price > receipt.amount { caller.key() } else { receipt.bidder };` — when the highest bid is BELOW the price floor (the case where the sale should NOT settle), the caller becomes the buyer and walks away with the NFT.
Direct loss of user funds or full protocol takeover with no meaningful preconditions.
✓ Counterexample found (bug confirmed by symbolic execution)
✓ Reproduced through deployed BPF instructions
SOL61-settle-buyer-inversion: exploit tx should have been rejected — when bid (500000) is below price floor (1000000), settle_sale must not succeed with the caller as buyer. The bug in programs/nft_escrow/src/lib.rs (settle_sale::handler) uses `let buyer = if sale.price > receipt.amount { caller.key() } else { receipt.bidder };` which makes the attacker the buyer when the bid is below floor. See programs/nft_escrow/src/lib.rs settle_sale handler.
Audit the affected code path against the invariant stated above and apply the structural fix proposed in the patch diff below.
Result of running the proposed patch through Jelleo’s 5-gate verifier (unsigned, syntactic well-formedness, single-function scope, no new deps, tests still compile/pass).
| ⏭ | kani_proof_holds | skipped — no kani_harness registered for this bug class |
| ⏭ | litesvm_exploit_neutralized | skipped — no litesvm_test_name registered for this bug class |
| ✓ | patch_well_formed | valid unified diff modifying programs/nft_escrow/src/instructions/mod.rs |
| ✓ | poc_fails_pre_patch | PoC test test_sol61_settle_buyer_inversion_fires fired at L2 (rustc-standalone log: cargo_sol61_settle_buyer_inversion.log) |
| ✓ | poc_passes_post_patch | patch applies cleanly; PoC is rustc-standalone (runtime witness delegated to LiteSVM / tests gate) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch |
#[test]
pub fn test_sol61_settle_buyer_inversion_litesvm() {
let program_id = "BWpizDdsfM5Dk3Agey96xifQhUa8tpLbFxHa5nsnDj1N".parse::<Pubkey>().unwrap();
let so_bytes = std::fs::read(
"/root/audit_runs/ottersec-eval/workspaces/solana-large/hunts/20260517-014506/build/target/deploy/nft_escrow.so"
).expect("SETUP FAILED: could not read .so file");
let mut svm = LiteSVM::new();
svm.add_program(program_id, &so_bytes);
// Keypairs
let seller = Keypair::new();
let bidder = Keypair::new();
let attacker = Keypair::new();
let mint = Keypair::new();
let escrow_vault = Keypair::new();
// …setup truncated for brevity…
settle_result.is_err(),
"SOL61-settle-buyer-inversion: exploit tx should have been rejected — \
when bid ({}) is below price floor ({}), settle_sale must not succeed \
with the caller as buyer. The bug in programs/nft_escrow/src/lib.rs \
(settle_sale::handler) uses `let buyer = if sale.price > receipt.amount \
{{ caller.key() }} else {{ receipt.bidder }};` which makes the attacker \
the buyer when the bid is below floor. See programs/nft_escrow/src/lib.rs \
settle_sale handler.",
bid_amount,
price_floor
);
}
--- a/programs/nft_escrow/src/instructions/mod.rs
+++ b/programs/nft_escrow/src/instructions/mod.rs
@@ -113,11 +113,11 @@
require!(ctx.accounts.sale.settled == false, NftEscrowError::AlreadySettled);
let sale = &mut ctx.accounts.sale;
sale.settled = true;
- let buyer = if sale.price > ctx.accounts.receipt.amount {
- ctx.accounts.caller.key()
- } else {
- ctx.accounts.receipt.bidder
- };
+ require!(
+ ctx.accounts.receipt.amount >= sale.price,
+ NftEscrowError::BidTooLow,
+ );
+ let buyer = ctx.accounts.receipt.bidder;
emit!(SaleSettled {
buyer,
amount: sale.highest_bid,
InvariantEvery share-vault function rounds in the protocol's favor: mint rounds DOWN on shares minted (user pays more, gets less); burn rounds DOWN on assets returned (user gets less back). Default integer division rounds toward zero — fine for protocol-favor paths, harmful when applied to user-favor paths.
Direct loss of user funds or full protocol takeover with no meaningful preconditions.
✓ Counterexample found (bug confirmed by symbolic execution)
Inconclusive
Audit the affected code path against the invariant stated above and apply the structural fix proposed in the patch diff below.
Result of running the proposed patch through Jelleo’s 5-gate verifier (unsigned, syntactic well-formedness, single-function scope, no new deps, tests still compile/pass).
| ⏭ | kani_proof_holds | skipped — no kani_harness registered for this bug class |
| ⏭ | litesvm_exploit_neutralized | skipped — no litesvm_test_name registered for this bug class |
| ✓ | patch_well_formed | valid unified diff modifying programs/vault_market/src/math.rs |
| ✓ | poc_fails_pre_patch | PoC test test_sol16_rounding_direction_fires fired at L2 (rustc-standalone log: cargo_sol16_rounding_direction.log) |
| ✓ | poc_passes_post_patch | patch applies cleanly; PoC is rustc-standalone (runtime witness delegated to LiteSVM / tests gate) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch |
#[test]
pub fn test_sol16_rounding_direction_litesvm() {
let program_id = "7WBcFPPJnfYxv44yeiiPMJvuVj99wyAVrw6syfbLi7PX"
.parse::<Pubkey>()
.unwrap();
let so_bytes = std::fs::read(
"/root/audit_runs/ottersec-eval/workspaces/solana-large/hunts/20260517-014506/build/target/deploy/vault_market.so",
)
.expect("Failed to read vault_market.so");
let mut svm = LiteSVM::new();
svm.add_program(program_id, &so_bytes);
// Keypairs
let admin = Keypair::new();
// …setup truncated for brevity…
// (the "buggy" variant described in the hypothesis), victim_shares = 0 and the assertion fails.
assert!(
victim_shares > 0,
"SOL16-rounding-direction: victim received 0 shares for 9-token deposit \
(managed_assets=10, total_shares=1) — floor division in shares_for_amount \
at programs/vault_market/src/math.rs permanently confiscates victim deposit. \
victim_shares={}, victim_deposited={}. See programs/vault_market/src/math.rs \
shares_for_amount (existing_shares * deposit_amount) / managed_assets = (1*9)/10 = 0.",
victim_shares,
victim_deposited,
);
}
--- a/programs/vault_market/src/math.rs
+++ b/programs/vault_market/src/math.rs
@@ -5,6 +5,7 @@
if (managed_assets != 0) && (existing_shares != 0) {
- (existing_shares.saturating_mul(deposit_amount)) / managed_assets
+ let numerator = existing_shares.saturating_mul(deposit_amount);
+ (numerator + managed_assets - 1) / managed_assets
} else {
deposit_amount
}
}
updated_at timestamp is within a max-staleness window. Stale prices during outagesInvariantEvery oracle price read checks the `updated_at` timestamp is within a max-staleness window. Stale prices during outages let attackers borrow against stale collateral.
Direct loss of user funds or full protocol takeover with no meaningful preconditions.
✓ Counterexample found (bug confirmed by symbolic execution)
✓ Reproduced through deployed BPF instructions
SOL26-oracle-staleness: exploit tx should have been rejected — the borrow instruction accepts a caller-supplied `price` parameter with no validation of updated_at against a max-staleness window. An attacker can pass an arbitrarily stale/inflated price (1_000_000 vs real price 1) to borrow 70_000_000 units against collateral worth only ~100 units, bypassing the health check entirely. See programs/lending_pool/src/instructions/mod.rs borrow::handler (the price parameter is never checked against price_feed account data or any timestamp).
Audit the affected code path against the invariant stated above and apply the structural fix proposed in the patch diff below.
Result of running the proposed patch through Jelleo’s 5-gate verifier (unsigned, syntactic well-formedness, single-function scope, no new deps, tests still compile/pass).
| ⏭ | kani_proof_holds | skipped — no kani_harness registered for this bug class |
| ⏭ | litesvm_exploit_neutralized | skipped — no litesvm_test_name registered for this bug class |
| ✓ | patch_well_formed | valid unified diff modifying programs/lending_pool/src/instructions/mod.rs |
| ✓ | poc_fails_pre_patch | PoC test test_sol26_oracle_staleness_fires fired at L2 (rustc-standalone log: cargo_sol26_oracle_staleness.log) |
| ✓ | poc_passes_post_patch | patch applies cleanly; PoC is rustc-standalone (runtime witness delegated to LiteSVM / tests gate) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch |
#[test]
pub fn test_sol26_oracle_staleness_litesvm() {
let mut svm = LiteSVM::new();
let program_id: Pubkey = "8hCNiXSk9uLqv5MGFpGhEn6jtLQ3saX1jE3Zud3pDN9Q"
.parse()
.unwrap();
let so_bytes = std::fs::read(
"/root/audit_runs/ottersec-eval/workspaces/solana-large/hunts/20260517-014506/build/target/deploy/lending_pool.so",
)
.expect("Failed to read lending_pool.so");
svm.add_program(program_id, &so_bytes);
let admin = Keypair::new();
let borrower = Keypair::new();
// …setup truncated for brevity…
assert!(
exploit_result.is_err(),
"SOL26-oracle-staleness: exploit tx should have been rejected — \
the borrow instruction accepts a caller-supplied `price` parameter \
with no validation of updated_at against a max-staleness window. \
An attacker can pass an arbitrarily stale/inflated price (1_000_000 vs real price 1) \
to borrow 70_000_000 units against collateral worth only ~100 units, \
bypassing the health check entirely. \
See programs/lending_pool/src/instructions/mod.rs borrow::handler \
(the price parameter is never checked against price_feed account data or any timestamp)."
);
}
--- a/programs/lending_pool/src/instructions/mod.rs
+++ b/programs/lending_pool/src/instructions/mod.rs
@@ -18,6 +18,34 @@
pub fn handler(ctx: Context<Borrow>, amount: u64, price: u64) -> Result<()> {
require!(amount != 0, LendingPoolError::ZeroAmount);
require!(ctx.accounts.pool.paused == false, LendingPoolError::Paused);
+ // SOL26: oracle staleness. The previous implementation accepted
+ // `price` directly from the caller, treating an `UncheckedAccount`
+ // `price_feed` as decorative. Full fix:
+ // (1) bind price_feed to the pool's configured oracle pubkey;
+ // (2) parse the first 16 bytes of price_feed.data as
+ // [publish_slot: u64 LE | price: u64 LE];
+ // (3) reject when the current slot is more than
+ // MAX_ORACLE_STALENESS_SLOTS past publish_slot;
+ // (4) use the on-chain price; the caller-supplied `price`
+ // argument is retained for ABI compatibility but ignored.
+ require!(
+ ctx.accounts.price_feed.key() == ctx.accounts.pool.oracle,
+ LendingPoolError::AccountMismatch
+ );
+ const MAX_ORACLE_STALENESS_SLOTS: u64 = 25;
+ let pf_data = ctx.accounts.price_feed.try_borrow_data()?;
+ require!(pf_data.len() >= 16, LendingPoolError::StaleCheckpoint);
+ let publish_slot = u64::from_le_bytes(pf_data[0..8].try_into().unwrap());
+ let onchain_price = u64::from_le_bytes(pf_data[8..16].try_into().unwrap());
+ drop(pf_data);
+ let current_slot = Clock::get()?.slot;
+ require!(
+ current_slot.saturating_sub(publish_slot) <= MAX_ORACLE_STALENESS_SLOTS,
+ LendingPoolError::StaleCheckpoint
+ );
+ require!(onchain_price > 0, LendingPoolError::StaleCheckpoint);
+ let _ = price; // caller-supplied price intentionally ignored
+ let price = onchain_price;
let account_state = &mut ctx.accounts.obligation;
if account_state.collateral_value != 0 {
} else {
InvariantSecurity-critical accumulators (claimed_amount, total_deposits, slash_pool) must use checked_add and abort on overflow, not wrapping_add. payment_stream::claim updates `payment_stream.claimed = transfer_amount.wrapping_add(payment_stream.claimed)`, vault_market::record_deposit wraps `total_deposits`, and stake_router::route_stake wraps `slash_pool` / `total_routed`. Once any of these saturates and wraps, downstream `remaining()` / daily-limit / fee-pool checks return a fresh budget, enabling double-claim and limit bypass. wrapping is silent — no event, no audit signal.
Significant loss of user funds or invariant violation under realistic preconditions.
✓ Counterexample found (bug confirmed by symbolic execution)
✓ Reproduced through deployed BPF instructions
SOL46-wrapping-arith-on-security-counter: claim tx unexpectedly failed — FailedTransactionMetadata { err: InstructionError(0, ProgramFailedToComplete), meta: TransactionMetadata { signature: 2ie2eYbu3dGmzFcEiEJwEN9u1S2q8WsGbcCBeBHG1ndLqobB2vvraJgGPKRk9NgshKXb9hVMPAKszo6ZsLNBaBqR, logs: ["Program Gp3Dzhpnspg6MPZr4SZyE4XA1gjuWuZ6r48r6X737stH invoke [1]", "Program log: Instruction: Claim", "Program log: attempt to multiply with overflow", "Program Gp3Dzhpnspg6MPZr4SZyE4XA1gjuWuZ6r48r6X737stH consumed 2253 of 200000 compute units", "Program Gp3Dzhpnspg6MPZr4SZyE4XA1gjuWuZ6r48r6X737stH failed: SBF
Audit the affected code path against the invariant stated above and apply the structural fix proposed in the patch diff below.
Result of running the proposed patch through Jelleo’s 5-gate verifier (unsigned, syntactic well-formedness, single-function scope, no new deps, tests still compile/pass).
| ⏭ | kani_proof_holds | skipped — no kani_harness registered for this bug class |
| ⏭ | litesvm_exploit_neutralized | skipped — no litesvm_test_name registered for this bug class |
| ✓ | patch_well_formed | valid unified diff modifying programs/payment_stream/src/instructions/mod.rs |
| ✓ | poc_fails_pre_patch | PoC test test_sol46_wrapping_arith_on_security_counter_fires fired at L2 (rustc-standalone log: cargo_sol46_wrapping_arith_on_security_counter.log) |
| ✓ | poc_passes_post_patch | patch applies cleanly; PoC is rustc-standalone (runtime witness delegated to LiteSVM / tests gate) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch |
#[test]
pub fn test_sol46_wrapping_arith_on_security_counter_litesvm() {
let mut svm = LiteSVM::new();
let program_id = "Gp3Dzhpnspg6MPZr4SZyE4XA1gjuWuZ6r48r6X737stH"
.parse::<Pubkey>()
.unwrap();
let so_bytes = std::fs::read(
"/root/audit_runs/ottersec-eval/workspaces/solana-large/hunts/20260517-014506/build/target/deploy/payment_stream.so",
)
.expect("Failed to read payment_stream.so");
svm.add_program(program_id, &so_bytes);
let payer = Keypair::new();
// …setup truncated for brevity…
// On patched code: the tx would fail with ArithmeticOverflow, not reach here
assert!(
claimed_after >= claimed_before_wrap,
"SOL46-wrapping-arith-on-security-counter: claimed wrapped around — before={}, after={}. \
wrapping_add allowed claimed to decrease from near-max to a small value, \
bypassing the remaining() budget check and enabling double-spend. \
See programs/payment_stream/src/lib.rs claim handler line: \
`payment_stream.claimed = transfer_amount.wrapping_add(payment_stream.claimed)`",
claimed_before_wrap,
claimed_after,
);
}
--- a/programs/payment_stream/src/instructions/mod.rs
+++ b/programs/payment_stream/src/instructions/mod.rs
@@ -48,6 +48,8 @@
);
let policy_result = crate::state::assess_policy(vested_amount, risk_snapshot, 0);
- let available_amount = vested_amount.wrapping_sub(payment_stream.claimed);
+ let available_amount = vested_amount
+ .checked_sub(payment_stream.claimed)
+ .unwrap_or(0);
if 0 >= available_amount {
return err!(PaymentStreamError::NothingToClaim);
}
@@ -56,7 +58,9 @@
} else {
policy_result.net_amount(available_amount)
};
- payment_stream.claimed = transfer_amount.wrapping_add(payment_stream.claimed);
+ payment_stream.claimed = transfer_amount
+ .checked_add(payment_stream.claimed)
+ .ok_or(PaymentStreamError::ArithmeticOverflow)?;
emit!(StreamClaimed {
recipient: ctx.accounts.recipient.key(),
amount: available_amount,
InvariantVault MUST maintain a share-to-asset exchange rate so accrued fees and pool balance changes propagate to share value. vault_market mints shares 1:1 with deposit amount in `credit()` / `debit()` and never tracks an exchange rate. Distinct from SOL22 (share-inflation-first-depositor): the issue here is structural — shares have no asset backing at all, fees never accrue to share value.
Significant loss of user funds or invariant violation under realistic preconditions.
✓ Counterexample found (bug confirmed by symbolic execution)
Not reproduced (wrapper-side defenses caught it OR test setup didn't reach buggy state)
Audit the affected code path against the invariant stated above and apply the structural fix proposed in the patch diff below.
Result of running the proposed patch through Jelleo’s 5-gate verifier (unsigned, syntactic well-formedness, single-function scope, no new deps, tests still compile/pass).
| ⏭ | kani_proof_holds | skipped — no kani_harness registered for this bug class |
| ⏭ | litesvm_exploit_neutralized | skipped — no litesvm_test_name registered for this bug class |
| ✓ | patch_well_formed | valid unified diff modifying programs/vault_market/src/instructions/mod.rs |
| ✓ | poc_fails_pre_patch | PoC test test_sol55_shares_assets_conflation_no_exchange_rate_fires fired at L2 (rustc-standalone log: cargo_sol55_shares_assets_conflation_no_exchange_rate.log) |
| ✓ | poc_passes_post_patch | patch applies cleanly; PoC is rustc-standalone (runtime witness delegated to LiteSVM / tests gate) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch |
#[test]
pub fn test_sol55_shares_assets_conflation_no_exchange_rate_litesvm() {
let program_id: Pubkey = "7WBcFPPJnfYxv44yeiiPMJvuVj99wyAVrw6syfbLi7PX".parse().unwrap();
let so_bytes = std::fs::read(
"/root/audit_runs/ottersec-eval/workspaces/solana-large/hunts/20260517-014506/build/target/deploy/vault_market.so"
).expect("SETUP FAILED: could not read vault_market.so");
let mut svm = LiteSVM::new();
svm.add_program(program_id, &so_bytes);
// Create keypairs
let admin = Keypair::new();
let user_a = Keypair::new();
let user_b = Keypair::new();
let fee_vault = Keypair::new();
// …setup truncated for brevity…
lhs > rhs,
"SOL55-shares-assets-conflation-no-exchange-rate: \
post-patch invariant violated — shares carry no exchange rate. \
user_a shares ({}) * total_deposits ({}) = {} is NOT > \
user_a deposited ({}) * total_shares ({}) = {}. \
credit()/debit() in programs/vault_market/src/state.rs (UserVault impl) \
mint shares 1:1 with credited_amount and never call \
math::shares_for_amount(), so fees and pool growth never \
accrue to share value. See programs/vault_market/src/state.rs \
UserVault::credit() line ~credit impl and math::shares_for_amount().",
shares_a_final, total_deposits, lhs,
deposited_a_final, total_shares, rhs
index badb976..388ab87 100644
--- a/programs/vault_market/src/instructions/mod.rs
+++ b/programs/vault_market/src/instructions/mod.rs
@@ -41,7 +41,16 @@ pub mod deposit {
let credited_amount = amount.saturating_sub(assessed_fee);
let user_vault = &mut context.accounts.vault;
market_account.record_deposit(amount, current_timestamp);
- user_vault.credit(credited_amount);
+ // SOL55: compute shares at current exchange rate.
+ let shares_minted = if market_account.total_shares == 0 || market_account.total_assets == 0 {
+ credited_amount
+ } else {
+ let n = (credited_amount as u128).checked_mul(market_account.total_shares as u128).expect("SOL55: shares mul overflow");
+ (n / (market_account.total_assets as u128)) as u64
+ };
+ market_account.total_assets = market_account.total_assets.checked_add(credited_amount).expect("SOL55: total_assets overflow");
+ market_account.total_shares = market_account.total_shares.checked_add(shares_minted).expect("SOL55: total_shares overflow");
+ user_vault.credit(credited_amount, shares_minted);
if 1 >= math::risk_bucket(market_account.total_deposits, market_account.daily_limit) {
} else {
user_vault.debt = assessed_fee.wrapping_add(user_vault.debt);
@@ -92,6 +101,9 @@ pub mod initialize_market {
market_account.total_deposits = 0;
market_account.daily_limit = limit;
market_account.last_epoch = Clock::get()?.unix_timestamp;
+ // SOL55: bootstrap pool exchange rate at 0/0; first depositor mints 1:1.
+ market_account.total_assets = 0;
+ market_account.total_shares = 0;
emit!(MarketInitialized {
admin: market_account.admin,
fee_bps,
@@ -156,6 +168,7 @@ pub mod withdraw {
#[derive(Accounts)]
pub struct Withdraw<'info> {
+ #[account(mut)]
pub market: Account<'info, Market>,
#[account(mut)]
pub vault: Account<'info, UserVault>,
@@ -175,7 +188,17 @@ pub mod withdraw {
if (current_timestamp < user_vault.locked_until) && (user_vault.delegate != context.accounts.requester.key()) {
return err!(VaultMarketError::Locked);
}
- user_vault.debit(amount);
+ // SOL55: burn shares proportional to assets at current rate.
+ let market = &mut context.accounts.market;
+ let shares_to_burn = if market.total_assets == 0 {
+ return err!(VaultMarketError::ZeroAmount);
+ } else {
+ let n = (amount as u128).checked_mul(market.total_shares as u128).expect("SOL55: shares mul overflow");
+ (n / (market.total_assets as u128)) as u64
+ };
+ market.total_assets = market.total_assets.checked_sub(amount).expect("SOL55: total_assets underflow");
+ market.total_shares = market.total_shares.checked_sub(shares_to_burn).expect("SOL55: total_shares underflow");
+ user_vault.debit(amount, shares_to_burn);
emit!(VaultBalanceChanged {
owner: user_vault.owner,
amount,
diff --git a/programs/vault_market/src/state.rs b/programs/vault_market/src/state.rs
index 716a174..4f1480f 100644
--- a/programs/vault_market/src/state.rs
+++ b/programs/vault_market/src/state.rs
@@ -11,10 +11,14 @@ pub struct Market {
pub total_deposits: u64,
pub daily_limit: u64,
pub last_epoch: i64,
+ /// SOL55: pool-wide assets accumulator (sum of all UserVault.deposited).
+ pub total_assets: u64,
+ /// SOL55: pool-wide shares accumulator (sum of all UserVault.shares).
+ pub total_shares: u64,
}
impl Market {
- pub const LEN: usize = 8 + 32 + 32 + 1 + 2 + 1 + 8 + 8 + 8 + 8;
+ pub const LEN: usize = 8 + 32 + 32 + 1 + 2 + 1 + 8 + 8 + 8 + 8 + 8 + 8;
pub fn apply_fee(&self, gross_amount: u64) -> u64 {
(gross_amount.saturating_mul(self.fee_bps as u64)) / 10_000
@@ -46,16 +50,19 @@ pub struct UserVault {
impl UserVault {
pub const LEN: usize = 8 + 32 + 32 + 32 + 1 + 8 + 8 + 8 + 8 + 8;
- pub fn credit(&mut self, credited_amount: u64) {
- self.deposited = credited_amount.wrapping_add(self.deposited);
- self.shares = credited_amount.wrapping_add(self.shares);
- self.nonce = 1u64.wrapping_add(self.nonce);
+ /// SOL55: credit takes assets+shares so callers can mint shares
+ /// at the current pool exchange rate. checked_* aborts on overflow.
+ pub fn credit(&mut self, assets: u64, shares: u64) {
+ self.deposited = self.deposited.checked_add(assets).expect("SOL55: deposited overflow");
+ self.shares = self.shares.checked_add(shares).expect("SOL55: shares overflow");
+ self.nonce = self.nonce.checked_add(1).expect("SOL55: nonce overflow");
}
- pub fn debit(&mut self, debit_amount: u64) {
- self.deposited = self.deposited.wrapping_sub(debit_amount);
- self.shares = self.shares.wrapping_sub(debit_amount);
- self.nonce = 1u64.wrapping_add(self.nonce);
+ /// SOL55: debit takes (assets, shares) to burn at exchange rate.
+ pub fn debit(&mut self, assets: u64, shares: u64) {
+ self.deposited = self.deposited.checked_sub(assets).expect("SOL55: deposited underflow");
+ self.shares = self.shares.checked_sub(shares).expect("SOL55: shares underflow");
+ self.nonce = self.nonce.checked_add(1).expect("SOL55: nonce overflow");
}
}
InvariantDaily-volume caps MUST use a rolling 24h window (or per-day with a per-period cap reset that resists boundary games). vault_market resets `daily_used` whenever `last_epoch / 86_400 != now / 86_400`, so an attacker times two deposits at minute 23:59 + 00:01 to spend 2× the daily cap inside a 2-minute window. The cap is meaningless against an attacker that watches the clock.
Hardening issue or invariant violation requiring a privileged signer / improbable state.
Kani's bounded check did not find a counterexample. This means the bug requires inputs outside the model's depth / size limits (NOT that the bug is absent — Layer 2 PoC firing remains authoritative).
Not reproduced (wrapper-side defenses caught it OR test setup didn't reach buggy state)
Audit the affected code path against the invariant stated above and apply the structural fix proposed in the patch diff below.
Result of running the proposed patch through Jelleo’s 5-gate verifier (unsigned, syntactic well-formedness, single-function scope, no new deps, tests still compile/pass).
| ⏭ | kani_proof_holds | skipped — no kani_harness registered for this bug class |
| ⏭ | litesvm_exploit_neutralized | skipped — no litesvm_test_name registered for this bug class |
| ✓ | patch_well_formed | valid unified diff modifying programs/vault_market/src/state.rs |
| ✓ | poc_fails_pre_patch | PoC test test_sol56_epoch_boundary_daily_limit_bypass_fires fired at L2 (rustc-standalone log: cargo_sol56_epoch_boundary_daily_limit_bypass.log) |
| ✓ | poc_passes_post_patch | patch applies cleanly; PoC is rustc-standalone (runtime witness delegated to LiteSVM / tests gate) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch |
#[test]
pub fn test_sol56_epoch_boundary_daily_limit_bypass_litesvm() {
let program_id: Pubkey = "7WBcFPPJnfYxv44yeiiPMJvuVj99wyAVrw6syfbLi7PX".parse().unwrap();
let so_bytes = std::fs::read(
"/root/audit_runs/ottersec-eval/workspaces/solana-large/hunts/20260517-014506/build/target/deploy/vault_market.so"
).expect("SETUP FAILED: could not read vault_market.so");
let mut svm = LiteSVM::new();
svm.add_program(program_id, &so_bytes);
let admin = Keypair::new();
let user = Keypair::new();
svm.airdrop(&admin.pubkey(), 100_000_000_000).unwrap();
svm.airdrop(&user.pubkey(), 100_000_000_000).unwrap();
// …setup truncated for brevity…
deposit2_res.is_err(),
"SOL56-epoch-boundary-daily-limit-bypass: post-patch invariant violated — \
second deposit of {} units succeeded only 2 seconds after the first deposit \
filled the daily cap of {} units, crossing a calendar-day boundary at unix \
timestamp 86400. A rolling 24h window must reject this. The buggy reset at \
programs/vault_market/src/state.rs in record_deposit (last_epoch / 86_400 != \
timestamp / 86_400) resets total_deposits to 0 on day boundary, allowing \
2x the daily cap in a 2-second window. See programs/vault_market/src/state.rs.",
daily_limit,
daily_limit,
);
}
--- a/programs/vault_market/src/state.rs
+++ b/programs/vault_market/src/state.rs
@@ -23,6 +23,10 @@
pub fn record_deposit(&mut self, deposit_amount: u64, timestamp: i64) {
- if self.last_epoch / 86_400 == timestamp / 86_400 {
- } else {
+ // SOL56: previously this reset whenever the CALENDAR day index
+ // changed (last_epoch/86_400 != now/86_400). An attacker could
+ // spend the full cap at 23:59:59 and immediately again at
+ // 00:00:01, doubling the cap in seconds. Use a rolling 24h
+ // window: only reset when >= 86_400s have elapsed since reset.
+ if timestamp.saturating_sub(self.last_epoch) >= 86_400 {
self.last_epoch = timestamp;
self.total_deposits = 0;
}
InvariantEvery time-elapsed calculation handles the case where the current clock is LESS than the stored timestamp (clock drift, sysvar replay). Naive `current - stored` underflows.
Minor issue with no plausible path to fund loss.
✓ Counterexample found (bug confirmed by symbolic execution)
Not reproduced (wrapper-side defenses caught it OR test setup didn't reach buggy state)
Audit the affected code path against the invariant stated above and apply the structural fix proposed in the patch diff below.
Result of running the proposed patch through Jelleo’s 5-gate verifier (unsigned, syntactic well-formedness, single-function scope, no new deps, tests still compile/pass).
| ⏭ | kani_proof_holds | skipped — no kani_harness registered for this bug class |
| ⏭ | litesvm_exploit_neutralized | skipped — no litesvm_test_name registered for this bug class |
| ✓ | patch_well_formed | valid unified diff modifying programs/payment_stream/src/instructions/mod.rs |
| ✓ | poc_fails_pre_patch | PoC test test_sol28_clock_not_monotonic_fires fired at L2 (rustc-standalone log: cargo_sol28_clock_not_monotonic.log) |
| ✓ | poc_passes_post_patch | patch applies cleanly; PoC is rustc-standalone (runtime witness delegated to LiteSVM / tests gate) |
| ✓ | tests_pass_post_patch | full test suite passed post-patch |
#[test]
pub fn test_sol28_clock_not_monotonic_litesvm() {
let program_id: Pubkey = "Gp3Dzhpnspg6MPZr4SZyE4XA1gjuWuZ6r48r6X737stH"
.parse()
.unwrap();
let so_bytes = std::fs::read(
"/root/audit_runs/ottersec-eval/workspaces/solana-large/hunts/20260517-014506/build/target/deploy/payment_stream.so",
)
.expect("SETUP FAILED: could not read payment_stream.so");
let mut svm = LiteSVM::new();
svm.add_program(program_id, &so_bytes);
let payer_kp = Keypair::new();
let recipient_kp = Keypair::new();
// …setup truncated for brevity…
// On PATCHED code: tx is rejected with NothingToClaim → assertion holds.
assert!(
claim_res2.is_err(),
"SOL28-clock-not-monotonic: exploit tx should have been rejected — \
when current clock (800) < starts_at (1000), vested()=0 but claimed=500_000_000, \
so available_amount = 0u64.wrapping_sub(500_000_000) underflows to ~18.4e18. \
The check `0 >= available_amount` is always false for u64 so NothingToClaim \
is never returned and the overflowed amount is processed. \
See programs/payment_stream/src/instructions/mod.rs claim::handler \
and src/state.rs Stream::vested()."
);
}
--- a/programs/payment_stream/src/instructions/mod.rs
+++ b/programs/payment_stream/src/instructions/mod.rs
@@ -36,7 +36,11 @@
if true == ctx.accounts.stream.cancelled {
return err!(PaymentStreamError::Cancelled);
}
let current_timestamp = Clock::get()?.unix_timestamp;
+ require!(
+ current_timestamp >= ctx.accounts.stream.starts_at,
+ PaymentStreamError::InvalidSchedule,
+ );
let payment_stream = &mut ctx.accounts.stream;
let vested_amount = payment_stream.vested(current_timestamp);
let active_window = payment_stream.elapsed_window(current_timestamp);
Per-finding fix-bundle pipeline state. Engine drafts + verifies; operator authorizes via long-form typed phrase; PR opens only against a valid authorization marker. The table includes bundles for confirmed findings AND for triaged duplicates / SOFT / FALSE fires — the latter are retained as audit-trail evidence of every PoC the hunt loop landed against the target, NOT as published findings (see Layer 2.5 gating in §B).
| id | hypothesis | title | role | bundle status | gates | authz |
|---|---|---|---|---|---|---|
311 | SOL16-rounding-direction | Every share-vault function rounds in the protocol's favor: mint rounds DOWN on shares minted (user pays more, gets less) | confirmed | verified | 4/6 | · |
322 | SOL26-oracle-staleness | Every oracle price read checks the updated_at timestamp is within a max-staleness window. Stale prices during outages | confirmed | verified | 4/6 | · |
324 | SOL28-clock-not-monotonic | Every time-elapsed calculation handles the case where the current clock is LESS than the stored timestamp (clock drift, | confirmed | verified | 4/6 | · |
344 | SOL46-wrapping-arith-on-security-counter | Security-critical accumulators (claimed_amount, total_deposits, slash_pool) must use checked_add and abort on overflow, | confirmed | verified | 4/6 | · |
350 | SOL51-lazy-state-init-during-mutation | Account state fields MUST be set during the dedicated initializer instruction, not lazily on first mutating call. lendin | confirmed | verified | 4/6 | · |
354 | SOL55-shares-assets-conflation-no-exchange-rate | Vault MUST maintain a share-to-asset exchange rate so accrued fees and pool balance changes propagate to share value. va | confirmed | verified | 4/6 | · |
355 | SOL56-epoch-boundary-daily-limit-bypass | Daily-volume caps MUST use a rolling 24h window (or per-day with a per-period cap reset that resists boundary games). va | confirmed | verified | 4/6 | · |
361 | SOL61-settle-buyer-inversion | Sale settlement MUST resolve the buyer as the highest-bid bidder iff the bid meets the price floor; the caller is NEVER | confirmed | verified | 4/6 | · |
| Tier | Definition |
|---|---|
| Critical | Direct loss of user funds or full protocol takeover with no meaningful preconditions. Reachable from a permissionless instruction by any signer. Must be patched immediately. |
| High | Significant 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. |
| Medium | Hardening issue, partial loss possible, or invariant violation requiring privileged signer or improbable state. Worth fixing in normal cadence. |
| Low | Minor issue with no plausible path to fund loss. Code-quality or defense-in-depth concern. |
| Info | Informational. No security impact. Documentation or style suggestion. |
| Layer | Function |
|---|---|
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 5-gate machine check (well-formed diff, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still pass). Operator authorization is required before any upstream PR is opened. |
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.
Non-fire accounting36 hypotheses tested but PoC did not fire — 25× rejected, 11× new. 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.
Cycle wall-clock135m 0s
§ 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.
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.
| Artifact | Path (relative to workspace) |
|---|---|
| Cycle summary (manifest of every step) | hunts/<cycle>/hunt_summary.json |
| Per-step event log | hunts/<cycle>/hunt.log.jsonl |
| Layer 2.5 triage verdicts | hunts/<cycle>/triage.jsonl |
| Layer 2 PoC sources (Rust) | hunts/<cycle>/poc/test_<slug>.rs |
| Layer 2 PoC run logs | hunts/<cycle>/poc/cargo_<slug>.log |
| Layer 3 Kani harnesses + verdicts | hunts/<cycle>/kani/<slug>/ |
| Layer 4 LiteSVM exploit tests | hunts/<cycle>/litesvm/<slug>/ |
| Layer P3 fix bundles (patch.diff + evidence/ + manifest.json) | recon/bundles/<finding_id>/ |
| Narrative writeups (per finding) | hunts/<cycle>/narratives/<hyp_id>.md |
| Cycle Merkle root (tamper-evidence) | hunts/<cycle>/merkle.json |
| Findings DB (SQLite) | findings.db |
| Ed25519 public key for receipt verification | https://jelleo.com/keys/jelleo.ed25519.pub |
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). Findings flagged
by Layer 2.5 as SOFT / FALSE / LOST
are retained in the audit trail (§03) but are not published findings; readers
should not interpret a bundle in §03 as a confirmed vulnerability unless its
finding row is also present 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