Insurance-residual drain on Percolator.
The first Critical disclosure produced by the Jelleo loop. A structural property of the haircut-residual formula combined with use_insurance_buffer's vault-preserving design lets a single attacker drain the insurance fund via a self-trade. Disclosed to Anatoly Yakovenko in PR #39.
A drain disguised as a documented design.
Percolator is Anatoly Yakovenko's perpetual-futures protocol on Solana. F7 is a structural drain of the insurance fund triggered by a single attacker controlling both sides of a trade. The vector is not a bug in any individual function — every individual protection holds in isolation — it's an interaction between three intentional design choices that together form a primitive for funneling insurance balance into a winning self-trade.
use_insurance_buffer mutates the insurance counter only, never the underlying vault. The haircut residual vault − c_tot − insurance therefore grows by the absorbed amount, and the K/F-winning side of a self-trade claims that residual as matured PnL.
The bug is structural — every individual protection in Percolator's documented invariant set is correct in isolation. The exploit emerges from the interaction of three intentional design decisions: (1) bilateral-signer self-trade is permitted with matcher_program = [0;32], (2) use_insurance_buffer is designed to preserve the vault counter (insurance is "outside" the vault by accounting), and (3) the haircut residual is computed from current vault and insurance balances rather than tracking insurance consumed during the epoch.
Hypothesis F7, dispatched on cycle 2026-04-22.
F7 was hypothesis number seven in the Percolator library — generated by the Layer-2 LLM pass after Layer-1 static analysis surfaced the insurance/vault accumulator pair as a candidate for asymmetric mutation.
The dispatch path:
- Layer-1 (static): the analyzer flagged
use_insurance_bufferfor unilateral counter mutation — it decrementsinsurance_fund.balancewithout a paired vault adjustment. - Layer-2 (LLM hypothesis generation): against the bug-class library "insurance-residual coupling," the LLM generated F7's hypothesis: if vault stays constant when insurance shrinks, any formula computing a residual from
vault − …−insurancegrows by the absorbed amount. - Layer-3 (Anchor-aware analysis): traced the residual into
finalize_touched_accounts_post_liveatpercolator/src/percolator.rs:3567-3576, confirmed the formula structure. - Layer-4 (LiteSVM PoC): built a self-trade fork harness exercising
InitUser+InitLP+TradeNoCpi+LiquidateAtOracle, observed the predicted A.matured = ΔK·N. PoC committed at43cdcd8. - Layer-5 (commit watch) → manual triage: the verdict was forwarded to disclosure, posted as PR #39 to
aeyakovenko/percolator-prog.
Total automated wall-clock from hypothesis generation to LiteSVM-confirmed PoC: approximately 18 minutes. Total Anthropic API spend on the F7-producing cycle: $7.43.
Three lines, three files.
1. The asymmetric mutation — percolator.rs:2291-2301
fn use_insurance_buffer(&mut self, pay: i128) -> i128 {
let take = pay.min(self.insurance_fund.balance);
self.insurance_fund.balance -= take; // only this counter moves
// vault is intentionally NOT decremented:
// insurance is accounted as held "outside" the vault.
take
}
2. The dev-documented intent — percolator.rs:2303-2321
An adjacent comment block documents the residual-grows-on-absorption behavior as intentional. From the dev's perspective: when insurance covers a deficit, the vault stays consistent because the K/F-winning counterparty is independent and will eventually claim the matching surplus. This is true when bankrupt and winning sides are independent; F7's contribution is showing the comment is vacuous when the attacker is both sides.
3. The residual formula — percolator.rs:3567-3576
let senior_sum = c_tot + insurance;
let residual = vault.saturating_sub(senior_sum);
let h_num = residual.min(matured_pos_tot);
let is_whole = h_num >= matured_pos_tot;
if is_whole {
// matured converts whole to capital — claimed at withdraw
}
When insurance shrinks by pay and vault is unchanged, residual grows by pay. The K/F-winning side, whose matured equals ΔK·N, finds h_num = matured exactly — and converts the entire matured amount to claimable capital.
Each invariant is correct. In isolation.
| Protection (Toly's documented set) | Status | Mechanism |
|---|---|---|
| Loss settlement local-before-insurance | Holds | settle_losses charges loser's capital first. |
| K/F PnL conservation (A loss = B gain) | Holds | Side accumulators preserve this exactly across all paths. |
Haircut residual cap R = V − (C_tot + I) |
Vacuous | Residual grows by the insurance-absorbed amount, so the "cap" never bites — it caps growth, not the amount claimed. |
The third row is F7. It's not a missing check — the cap is enforced exactly as documented. The structural property is that the cap doesn't oppose the attacker's profit; the attacker's profit shows up as residual growth, which the cap unconditionally permits.
A drains (ΔK − IM) · N per cycle.
Setup: attacker opens A (long N) and B (short N) via TradeNoCpi self-trade with matcher_program = [0;32]. Each posts 0.2N capital (IM = 20%). Insurance = 5 SOL (the actual mainnet balance at disclosure time). Vault = 5 + 0.4N.
Step 1 — adverse swing past IM
Wait for any oracle move ΔK > 0.2. Pyth SOL/USD provides this routinely — historical look-back shows ΔK > 0.2 events multiple times per quarter on a 24-hour window.
Step 2 — liquidate B
Attacker calls LiquidateAtOracle(B). This is permissionless (verified on-chain: liquidationAuthority = [0;32]):
touch B→B.pnl = -ΔK·N(settled from side accumulator).settle_losses(B)→ chargesB.capital = 0.2N→B.pnl = -(ΔK-0.2)·N.attach_effective_position(B, 0)→B.basis = 0; the side K-accumulator is not decremented.enqueue_adl→use_insurance_buffer((ΔK-0.2)·N)→ insurance shrinks; vault unchanged.
Post-step state: vault = 5 + 0.4N, insurance = 5 − (ΔK − 0.2)·N, c_tot = 0.2N (A only).
Step 3 — touch A (any path)
Anything that calls accrue_market_to + touch_account_live_local for A — a Trade, a Withdraw, a KeeperCrank — will work. settle_side_effects_live(A) reads current K_long (still at +ΔK; never decremented in Step 2): A.pnl = +ΔK·N, routed through warmup → matured.
Step 4 — conversion at finalize_touched_accounts_post_live
senior_sum = c_tot + insurance
= 0.2N + (5 − (ΔK − 0.2)·N)
residual = vault − senior_sum
= (5 + 0.4N) − (0.2N + 5 − (ΔK − 0.2)·N)
= ΔK·N
h_num = min(residual, matured_pos_tot)
= min(ΔK·N, ΔK·N)
= ΔK·N
is_whole = true → A's matured converts whole to capital
Step 5 — withdraw
A withdraws 0.2N + ΔK·N. Net P&L:
A_in = 0.2N
B_in = 0.2N
A_out = 0.2N + ΔK·N
B_out = 0
─────────
Net = (0.2N + ΔK·N) − (0.2N + 0.2N)
= (ΔK − 0.2)·N
Insurance loss = (ΔK − 0.2)·N.
Exact match.
5 SOL · single cycle.
At N = 25 SOL and ΔK = 0.4 (one ~40% adverse swing), the attacker captures (0.4 − 0.2) · 25 = 5 SOL — the entire insurance fund balance at the time of disclosure. IM = 0.2 is the break-even. Any ΔK > 0.2 is profitable; the attacker can wait at ~0.11 SOL/day maintenance fee (maintenanceFeePerSlot = 265) for a natural Pyth move that crosses the threshold.
Permissionless instructions used
All available on mainnet without admin gate (admin / insuranceAuthority / insuranceOperator / hyperpAuthority all verified [0;32]):
InitUser/InitLP—matcher_program = [0;32]accepted byInitLPTradeNoCpi— bilateral signers;NoOpMatcherfills at oracleLiquidateAtOracle— permissionless, no admin gateKeeperCrank/Withdraw/ secondTradeNoCpi— anything that triggers A's touch
Three independent options. Any one suffices.
- Make insurance-buffer use also debit the vault. If insurance is held inside the vault accounting, the residual stops growing on absorption. This is the patch verified locally and proposed in PR #39.
- Track consumed insurance in the residual formula.
residual = vault − c_tot − I − I_consumed_this_epoch. The "outside the vault" accounting story stays intact, but the residual no longer inflates. - Reject the bilateral-signer self-trade primitive. If
matcher_program = [0;32]with both sides controlled by the same operator is rejected, F7's preconditions never assemble. This is the largest behavioral change but the smallest mechanism change.
Option 1 was the patch packaged in PR #39 with a LiteSVM regression test (commit 43cdcd8). Local verification: zero regressions across 277 existing tests, F7's PoC test fails as expected with the patch reverted, passes with the patch applied.
From hypothesis to upstream PR.
finalize_touched_accounts_post_live; Layer-4 PoC fires positive on the first run.43cdcd8) exercises the full self-trade primitive end-to-end.aeyakovenko/percolator-prog. Contains: root-cause writeup, end-to-end balance proof, the patch, and the LiteSVM PoC test.F7's structural pattern, swept across the cluster.
Once F7 transitioned to confirmed, the propagation hook fired automatically — LLM-emitted six structural sibling hypotheses, then the propagation engine ran a regex + tree-sitter AST sweep across the cross-protocol corpus (Drift, Mango, Marginfi, Kamino-lend, Jupiter swap, OpenBook v2, Orca whirlpools, Phoenix). The pattern: any protocol that maintains a vault counter and an out-of-vault insurance counter is a structural sibling of Percolator's F7.
The six derived siblings
Each sibling is a falsifiable claim about an invariant adjacent to F7's root cause. Together they form the structural perimeter of the insurance-counter-vault-divergence class.
- SIB-V7-1 ·
insurance-counter-vault-divergence-on-liquidation— during a liquidation absorbing a shortfall, the corresponding vault debit must occur atomically and match the absorbed amount exactly. Critical - SIB-V7-2 ·
insurance-counter-vault-divergence-on-settle— on settle-PnL where a deficit is socialized, the net change ininsurance_fund.balanceplus the net change in vault token balance must sum to zero. Critical - SIB-V7-3 ·
insurance-counter-vault-divergence-on-withdraw— any authorized withdrawal from the insurance fund must emit a corresponding SPL token transfer out of the protocol vault of identical magnitude. Critical - SIB-V7-4 ·
insurance-counter-vault-divergence-on-deposit— deposits into the insurance fund must increase the counter by exactly the amount transferred into the vault, no double-credit, no over-counting. High - SIB-V7-5 ·
cross-market-insurance-vault-aliasing— markets that share an insurance fund must not allow one market's vault delta to be credited against another market's insurance counter. Critical - SIB-V7-6 ·
self-trade-residual-skew-cross-protocol— any protocol whose residual formula composes vault, insurance, and per-side counters must not allow a self-trade primitive to widen the residual asymmetrically. Critical
Cross-protocol propagation status
The corpus sweep runs every regex signature for the F7 class plus the insurance_balance_mutation AST query against every .rs file in the indexed corpus on every confirmed Med+ finding. The "Confirmed siblings" counter above updates from snapshot.json as new findings clear the verification chain.
Each sibling that confirms fires the same disclosure pipeline F7 went through: propagation methodology · full bug-class catalog.
audit-pipeline propagate chain <finding-id> and surfaces in the customer dashboard manifest.
Continuous beats point-in-time.
F7 is the canonical test case for why the Jelleo loop is structured the way it is. The bug is not detectable by a single static-analysis pass: every individual function holds its documented invariant. It is not detectable by reading the dev's intent: the design is intentional, and the comment block at percolator.rs:2303-2321 explicitly endorses it. It is detectable by simulating the interaction of three protections under a self-trade primitive — which is exactly what Layer-2 hypothesis generation plus Layer-4 PoC dispatch was designed to do.
The four pillars that produced F7 are the same four pillars proposed for every protocol on the funded plan:
- P1 — detection (Layers 1-6): the loop that landed F7 in 18 minutes of automated wall-clock.
- P2 — propagation: the F7 bug class is now a sibling-hypothesis seed for every protocol with a vault/insurance accumulator pair.
- P3 — fix bundle: PR #39 ships the patch, the regression test, and the worked balance proof — not just a finding.
- P4 — attestation: every cycle that touched F7 was signed Ed25519. The public key lives at /keys/jelleo.ed25519.pub; the cycle-receipt archive is at api.jelleo.com/cycles/ ↗.
F7 is one disclosure. The thesis behind Jelleo is that the same loop, scaled across the cluster, produces an F7-class disclosure on every protocol that runs it long enough — and that the cost of running the loop is structurally lower than the cost of the attacks it prevents.
Maintain a Solana protocol? Want this loop on yours?
Request integration →