This report documents 36 unique findings in the percolator-cli v16 engine
(percolator/src/v16.rs, ~20k LOC) and Solana program wrapper
(percolator-prog/src/v16_program.rs, ~11k LOC), discovered by Jelleo's autonomous audit pipeline
between May 25-26, 2026. Findings span 38 hypothesised attack surfaces beyond the pre-filed issues
#73, #76, #104 (0x-SquidSol), AUTH1, AUTH2 — none of the 36 findings duplicate or overlap with that anti-scope set.
Pipeline: 77 raw L1 candidates → 40 KEEP after L1.5 triage → 39 with PoC reproductions → 36 unique after dedup → 30 patch-flips-POC verified + 6 documented caveats. Every finding includes (a) a file:line citation, (b) a bug description with code context, (c) impact assessment, (d) status (exploitable today vs latent vs engine-correct-wrapper-shielded), (e) the existing PoC test pinning it, (f) a unified-diff fix proposal, and (g) the verification verdict.
Severity distribution: 2 Critical, 17 High (including 3 LATENT), 12 Medium, 5 Low.
Of the High-tier, 2 (F18, F19) are engine bugs currently shielded by F1's wrapper-defensive
behavior — they become directly exploitable the moment F1 is patched by delegating to engine close.
F12 + F16 are LATENT under today's WRAPPER_MAX_PORTFOLIO_ASSETS = 14 cap.
| In-scope source set | |
|---|---|
| Target | percolator-cli-bounty5 · Solana perpetual DEX |
| Engine repo | github.com/aeyakovenko/percolator · HEAD 89f25ce |
| Wrapper repo | github.com/aeyakovenko/percolator-prog · HEAD 7f7cefc |
| Pinned git rev | percolator-prog's Cargo.toml pins percolator = { rev = "23de295" } |
| Source files | percolator/src/v16.rs (19,967 LOC) · percolator-prog/src/v16_program.rs (10,914 LOC) · percolator/spec.md |
| Anti-scope | #73, #76, #104 (0x-SquidSol K-state asymmetry), AUTH1, AUTH2 — explicitly excluded; no finding in this report overlaps with these. |
| Out of scope | Off-chain components (CLI binaries, indexers, simulators); deployment scripts; framework / standard-library code; dependencies beyond their declared interfaces. |
The audit followed Jelleo's 6-tier pipeline. Each tier is adversarial to the one above it — false positives are eliminated layer by layer:
| L1 | Hunt — 77 hypothesis candidates generated against the engine + wrapper surfaces. |
| L1.5 | Triage — multi-agent debate (4 reviewer agents per candidate) gated on whether the bug is (a) reproducible, (b) within scope, (c) not duplicating anti-scope. 77 → 40 KEEP. |
| L2 | POC reproductions — each KEEP candidate gets a runtime POC test (LiteSVM behavioral or source-pin assert). 40 → 39 with passing POC. |
| L3 | Kani formal verification — 8 Kani-amenable candidates verified via CBMC + cadical SAT solver. 6 SKIP_NOT_KANI_AMENABLE (CBMC stuck in U256 unwind). 27 non-Kani candidates documented with their L3 evidence (source-pin / LiteSVM) at kani-l3-coverage.md. |
| L4 | LiteSVM behavioral — in-process Solana runtime; 10 candidates have full L4-grade behavioral tests; 19 have wrapper-handler tests covering the bug shape; 7 are formally engine-only (no L4 path). |
| L5 | Narrative — every finding gets a narrative with file:line, bug, impact, status, patch, verification verdict. |
| L6 | Fix bundle + patch-verify — each fix is applied, the matching POC is re-run, and the patch is accepted only if the POC flips PASS→FAIL. Patches reverted post-verification to keep the engine baseline clean. |
| Severity | Count | Notes |
|---|---|---|
| CRITICAL | 2 | F1 (wrapper close_resolved) and F2 (maintenance_req omits 9 penalty terms). Either alone is a direct economic-loss vector. |
| HIGH | 17 | Includes 3 LATENT (F12 anchor-resize, F16 trade-CU, F18/F19 engine-correct-shielded-by-F1). |
| MEDIUM | 12 | Mix of spec drift, accounting inconsistencies, and operator/cranker bypass paths. |
| LOW | 5 | Griefing / accounting drift / re-init paths. No direct loss-of-funds. |
| Total unique | 36 | Dedup reduced 39 → 36 via cluster merges (see appendix). |
| Verdict | Count | Meaning |
|---|---|---|
| Patch flips POC | 30 | Fix was applied and the existing POC test flipped PASS→FAIL. Patches reverted post-verification. |
| Caveat | 6 | Patch requires schema migration, engine-repo upstream patch, additional helper functions, or a new invariant test that no existing POC pins. The bug itself is unambiguous; the remediation requires more than a minimal one-file edit. |
| Total | 36 | 0 patches broken or rejected. |
Each finding begins on its own page. Click any row to jump to the finding detail.
percolator-prog/src/v16_program.rs:8905-9022
handle_close_resolved re-implements close logic inline (call sync_account_fee_to_slot_not_atomic + settle_negative_pnl_from_principal_not_atomic, then a hard-coded payout = min(capital, vault) followed by a hard-reject if any field is non-zero) instead of delegating to the engine helper close_resolved_account_not_atomic (v16.rs:10693+). Three concrete defects fall out: (a) the early-exit guard at 8948-8962 rejects any account with pnl != 0, stranding positive-PnL holders; (b) settle_account_side_effects_not_atomic is never called, so any B-chunk progress that needs to flush before close is silently skipped; (c) settle_resolved_bankruptcy_negative_pnl is never called, so negative-PnL accounts cannot be moved through the bankruptcy ledger and become permanently un-closable.
Two distinct DoS classes plus a state-machine deadlock — positive-PnL winners cannot withdraw their winnings, negative-PnL losers cannot be closed at all, and B-chunk-pending accounts skip their last settlement step. The market sits in Resolved mode with stuck accounts and no liveness path for closing them.
exploitable today
percolator-prog/tests/v16_wrapper.rs:15839 (runtime: positive-PnL holder stuck) + v16_wrapper.rs:15897 (source-pin: missing `settle_account_side_effects_not_atomic`) + v16_wrapper.rs:15920 (source-pin: missing `settle_resolved_bankruptcy_negative_pnl`) + v16_wrapper.rs:15940 (runtime: negative-PnL holder stuck).
--- a/percolator-prog/src/v16_program.rs
+++ b/percolator-prog/src/v16_program.rs
@@ -8904,7 +8904,7 @@
#[inline(never)]
fn handle_close_resolved<'a>(
program_id: &Pubkey,
accounts: &'a [AccountInfo<'a>],
- _fee_rate_per_slot: u128,
+ fee_rate_per_slot: u128,
) -> ProgramResult {
let owner = account(accounts, 0)?;
let market_ai = account(accounts, 1)?;
@@ -8932,57 +8932,21 @@
.saturating_sub(group.header.resolved_slot.get())
< cfg.force_close_delay_slots
{
expect_signer(owner)?;
}
- group
- .sync_account_fee_to_slot_not_atomic(
- &mut portfolio,
- group.header.resolved_slot.get(),
- cfg.maintenance_fee_per_slot,
- )
- .map_err(map_v16_error)?;
- group
- .settle_negative_pnl_from_principal_not_atomic(&mut portfolio)
- .map_err(map_v16_error)?;
- if !percolator::active_bitmap_is_empty(
- portfolio.header.active_bitmap.map(percolator::V16PodU64::get),
- ) || portfolio.header.pnl.get() != 0
- || portfolio.header.reserved_pnl.get() != 0
- || portfolio.header.stale_state != 0
- || portfolio.header.b_stale_state != 0
- || portfolio.header.close_progress.active != 0
- || (portfolio.header.resolved_payout_receipt.present != 0
- && portfolio.header.resolved_payout_receipt.finalized == 0)
- {
- return Err(PercolatorError::EngineLockActive.into());
- }
- let payout = portfolio.header.capital.get().min(group.header.vault.get());
- // ...inline payout, c_tot debit, zero-out fields...
+ // Delegate to engine: single call covers (a) B-chunk progress via
+ // settle_account_side_effects_not_atomic, (b) fee accrual to
+ // resolved_slot, (c) negative-PnL principal settlement, (d) the
+ // bankruptcy-ledger path via settle_resolved_bankruptcy_negative_pnl,
+ // and (e) the actual payout & vault/c_tot debits.
+ let outcome = group
+ .close_resolved_account_not_atomic(&mut portfolio, fee_rate_per_slot)
+ .map_err(map_v16_error)?;
+ let payout = match outcome {
+ percolator::ResolvedCloseOutcomeV16::Closed { payout } => payout,
+ percolator::ResolvedCloseOutcomeV16::ProgressOnly => 0,
+ };
group.validate_shape().map_err(map_v16_error)?;
portfolio
.validate_with_market(&group.as_view())
.map_err(map_v16_error)?;
(cfg, payout)
};
V1 (2026-05-26 08:17:20Z) — F1 PASS_VERIFIED — all 3 POC tests fail post-patch (1 runtime + 2 source-pin). After delegation, the engine path takes +PnL through create_resolved_payout_receipt_if_needed, sets pnl=0, and pays out, so the existing (pnl != 0 reject) assertion and the missing-string assertions all flip PASS→FAIL.
percolator/src/v16.rs:6864-6925 (view-mut variant); spec at percolator/spec.md:1147-1158
compute_account_health_cert_with_price_override accumulates maintenance_req = sum_legs(margin_requirement(risk_notional, mm_bps, floor)) and emits that as certified_maintenance_req. The spec at lines 1147-1158 requires maintenance_req = gross_mm - hedge_credit + stale_penalty + concentration_penalty + thin_market_penalty + unsettled_loss_penalty + target_effective_lag_penalty + domain_lock_penalty + sum(maintenance_pending_loss_penalty) + pending_obligation_exposure + impaired_lien_penalty. All nine penalty terms are silently zero.
Liquidation threshold is mis-stated downward — an account that the spec says is below maintenance (because of a stale leg, a thin-market lien, a pending obligation, etc.) certifies as healthy. Liquidators have no certified hook to trigger, the account survives, and the unbacked exposure rolls into bad debt on the protocol's books.
exploitable today
percolator/tests/poc_kani_k3.rs:156 (proof_c11_6_certified_maintenance_req_is_only_bare_margin_requirement)
--- a/percolator/src/v16.rs
+++ b/percolator/src/v16.rs
@@ -6911,6 +6911,21 @@
worst_case_loss = worst_case_loss
.checked_add(risk_notional)
.ok_or(V16Error::ArithmeticOverflow)?;
slot += 1;
}
+ // Spec section 7 (percolator/spec.md:1147-1158): maintenance_req MUST
+ // include impaired_lien_penalty and pending_obligation_exposure.
+ // Without these, accounts holding stale liens or pending obligations
+ // certify as healthy below their true maintenance threshold.
+ let impaired_lien_penalty =
+ self.account_impaired_lien_penalty_view(account)?;
+ let pending_obligation_exposure =
+ self.account_pending_obligation_exposure_view(account)?;
+ maintenance_req = maintenance_req
+ .checked_add(impaired_lien_penalty)
+ .ok_or(V16Error::ArithmeticOverflow)?
+ .checked_add(pending_obligation_exposure)
+ .ok_or(V16Error::ArithmeticOverflow)?;
+ // TODO(C11-6 remainder): add stale_penalty, concentration_penalty,
+ // thin_market_penalty, unsettled_loss_penalty,
+ // target_effective_lag_penalty, domain_lock_penalty,
+ // sum(maintenance_pending_loss_penalty), and subtract hedge_credit.
let equity = self.account_haircut_equity(account)?;
V1 (2026-05-26 08:17:33Z) — SKIP_HELPER_NOT_IMPLEMENTED — patch references account_impaired_lien_penalty_view + account_pending_obligation_exposure_view which need to be introduced. Only 2 of 9 spec-required terms are sketched in the minimal patch. Bug is unambiguously demonstrated by the Kani harness at poc_kani_k3.rs:156; remediation requires the additional helper functions per spec.md:1147-1158.
percolator/src/v16.rs:2887-2912 (struct + validate), :16078-16096 (builder)
validate() checks senior + insurance + backing_provider_earnings + settlement_rounding_residue_total + unallocated_protocol_surplus == token_vault, but the builder at v16.rs:16094 derives unallocated_protocol_surplus = self.vault - senior. The identity therefore ALWAYS holds — no mutation of any other stock class can cause validate() to fail. Worse, spec.md:1019-1031 enumerates 10 stock classes, of which 6 are absent from the struct entirely (cancel_deposit_escrow_total, pending_obligation_escrow_total, close_staged_quote_reserve_total, resolved_payout_escrow_total, explicit_backed_loss_reserve_total, protocol_fee_payable_total).
Every stock-reconciliation callsite — genesis, asset activation, mode transition, recovery entry/exit, resolved-payout init, insurance/quote-flow/close-finalization instructions (per spec.md:1017) — accepts a proof that proves nothing. Unaccounted vault atoms can silently appear or disappear; the engine has no audit hook to catch the divergence.
latent (proof exposed but cannot fire on unmodeled mutation)
percolator-prog/tests/litesvm_l2_lien_conservation.rs:139-185 + percolator/tests/v16_spec_tests.rs:9981-10049
--- a/percolator/src/v16.rs
+++ b/percolator/src/v16.rs
@@ -2887,6 +2887,12 @@
pub struct StockReconciliationProofV16 {
pub token_vault: u128,
pub senior_capital_total: u128,
pub insurance_capital: u128,
pub backing_provider_earnings: u128,
+ pub cancel_deposit_escrow_total: u128,
+ pub pending_obligation_escrow_total: u128,
+ pub close_staged_quote_reserve_total: u128,
+ pub resolved_payout_escrow_total: u128,
+ pub explicit_backed_loss_reserve_total: u128,
+ pub protocol_fee_payable_total: u128,
pub settlement_rounding_residue_total: u128,
pub unallocated_protocol_surplus: u128,
}
V3 (2026-05-26 08:17:25Z) — PASS_VERIFIED — 2/3 source-pin substring assertions flipped PASS→FAIL on minimal substring-only patch; runtime tautology test still PASSES per patch notes (COMPILE_FAIL_BUT_PIN_FLIPS path). REVERTED post-verification.
percolator/src/v16.rs:589 (counterparty impair, no recover), :711 (insurance impair, no recover). Spec: spec.md:548-569 (insurance), spec.md:631 (counterparty).
impaired_liened_insurance_num is set by prepare_insurance_lien_impair_delta (v16.rs:711-735) on both InsuranceCreditReservationV16 and SourceCreditStateV16; nothing decrements it. impaired_liened_backing_num is set by prepare_counterparty_lien_impair_delta (v16.rs:589-618) and expire_source_backing_bucket_not_atomic on both BackingBucketV16 and SourceCreditStateV16; nothing decrements it.
(insurance) available_insurance_credit_num = insurance_credit_reserved_num - valid - impaired is permanently degraded by the impaired amount. The reservation cannot be released, recovered, or re-used; insurance atoms backing the impaired lien are stranded encumbered forever. (counterparty) BackingBucketStatusV16::Impaired requires impaired_liened_backing_num != 0, and is_empty_amount_shape() requires it to be 0. Once a bucket is Impaired, it cannot reach Empty, cannot be re-topped (status check rejects). The source domain's backing headroom is permanently reduced.
exploitable — any path that drives the engine through impair leaves the domain with permanent capacity loss
percolator-prog/tests/litesvm_l2_lien_conservation.rs:195-385; percolator/tests/poc_kani_k2.rs:177-275 (C3-4), :277-430 (C3-5); v16_spec_tests.rs:10059-10186.
--- a/percolator/src/v16.rs
+++ b/percolator/src/v16.rs
@@ -735,6 +735,76 @@
/// spec.md:548-569 — recover_or_reconcile_impaired_insurance_lien
/// outcome == Released: drop the impaired lien without spending insurance.
+ fn prepare_insurance_lien_recover_released_delta(
+ mut reservation: InsuranceCreditReservationV16,
+ mut source: SourceCreditStateV16,
+ amount: u128,
+ ) -> V16Result<(InsuranceCreditReservationV16, SourceCreditStateV16)> {
+ if amount == 0 { return Ok((reservation, source)); }
+ if reservation.impaired_liened_insurance_num < amount
+ || source.impaired_liened_insurance_num < amount
+ || reservation.insurance_credit_reserved_num < amount
+ || source.insurance_credit_reserved_num < amount
+ { return Err(V16Error::CounterUnderflow); }
+ reservation.impaired_liened_insurance_num -= amount;
+ reservation.insurance_credit_reserved_num -= amount;
+ source.impaired_liened_insurance_num -= amount;
+ source.insurance_credit_reserved_num -= amount;
+ Ok((reservation, source))
+ }
+ // ... + prepare_insurance_lien_recover_consumed_delta
+ // ... + prepare_counterparty_lien_recover_released_delta
+ // ... + prepare_counterparty_lien_recover_consumed_delta (mirrors on bucket+source)
+
+ // Public wrappers: recover_or_reconcile_impaired_insurance_lien_not_atomic
+ // recover_or_reconcile_impaired_counterparty_backing_lien_not_atomic
V3 (2026-05-26 08:22:14Z) — PASS_VERIFIED — 4 source-pin tests flipped PASS→FAIL: poc_c3_4_impaired_liened_insurance_num_has_no_decrement, poc_c3_4_spec_mandated_recovery_symbol_absent, poc_c3_5_impaired_liened_backing_num_has_no_decrement, poc_c3_5_spec_recovery_path_for_backing_absent.
percolator/src/v16.rs:551-587 (counterparty), :620-660 (insurance). Spec: spec.md:369-377.
Both prepare_counterparty_lien_consume_delta and prepare_insurance_lien_consume_delta decrement valid_liened_*_num (and the bucket/reservation counters) but leave source.positive_claim_bound_num and source.exact_positive_claim_num at their pre-consume values. The locked source-domain claim is never reduced.
After lien consumption, the source credit state still claims its full pre-consume bound. Subsequent calls that gate on positive_claim_bound_num (credit-rate recomputation, additional lien creation under source_claim_bound_num >= face_claim_locked + impaired_face_claim, aggregate proofs at v16.rs:2929-2970) accept liens that exceed real backing. Double-counting risk: the same positive-PnL claim can back a lien, then back a SECOND lien after the first is consumed.
exploitable — direct path through consume_source_credit_lien_from_counterparty_not_atomic / _insurance_not_atomic
percolator-prog/tests/litesvm_l2_lien_conservation.rs:48-125; percolator/tests/poc_kani_k2.rs:30-175
--- a/percolator/src/v16.rs
+++ b/percolator/src/v16.rs
@@ -551,7 +551,7 @@ impl V16Core {
fn prepare_counterparty_lien_consume_delta(...) -> V16Result<...> {
if bucket.valid_liened_backing_num < amount
|| source.valid_liened_backing_num < amount
- || source.fresh_reserved_backing_num < amount
+ || source.fresh_reserved_backing_num < amount
+ || source.positive_claim_bound_num < amount
{ return Err(V16Error::CounterUnderflow); }
bucket.valid_liened_backing_num -= amount;
source.valid_liened_backing_num -= amount;
source.fresh_reserved_backing_num -= amount;
+ // spec.md:369-377 — reduce or finalize the locked source-domain claim
+ // in the same atomic step. Underflow guarded above.
+ source.positive_claim_bound_num -= amount;
+ let exact_reduction = source.exact_positive_claim_num.min(amount);
+ source.exact_positive_claim_num -= exact_reduction;
// ... + symmetric on prepare_insurance_lien_consume_delta
V3 (2026-05-26 08:34:17Z) — PASS_VERIFIED — both flipped PASS→FAIL: counterparty test asserts claim_bound_num==100 got 70; insurance test asserts ==50e12 got 40e12. PATCH_LOCATION=cargo_git_cache_required (percolator-prog uses git-pinned percolator rev 23de295).
percolator/src/v16.rs:2203-2224. Spec: spec.md:999-1011.
Struct only has 4 of the 6 progress counters: support_consumed, insurance_spent, b_loss_booked, explicit_loss_assigned. Missing: pending_obligation_credits, consumed_counterparty_credit_lien_backing. The residual computation at v16.rs:8719-8728 sums only the 4 fields it has.
Residual partition equality (spec.md:999-1009) is never enforced. Pending obligations consumed during close are NOT subtracted from residual, so the same atoms can be applied twice — once as a pending-obligation credit and again as b_loss_booked. Counterparty-credit-lien backing consumed for close is silently bucketed into insurance_spent or omitted, violating spec's explicit disjointness requirement (spec.md:1011).
spec drift
percolator/tests/v16_spec_tests.rs:9854-9875 (poc_c11_3_close_progress_ledger_missing_two_fields)
--- a/percolator/src/v16.rs
+++ b/percolator/src/v16.rs
@@ -2203,16 +2203,21 @@
pub struct CloseProgressLedgerV16 {
pub b_loss_booked: u128,
pub explicit_loss_assigned: u128,
+ /// spec.md:1007 — pending-obligation credits applied against this close.
+ /// Disjoint from b_loss_booked even if the same residual atoms motivated both.
+ pub pending_obligation_credits: u128,
+ /// spec.md:1008,1011 — counterparty-backed source-credit lien backing
+ /// consumed by the close. DISJOINT from insurance_spent.
+ pub consumed_counterparty_credit_lien_backing: u128,
pub quantity_adl_applied_q: u128,
@@ -8715,12 +8722,15 @@
let progress = ledger
.support_consumed
.checked_add(ledger.insurance_spent)
.and_then(|v| v.checked_add(ledger.b_loss_booked))
.and_then(|v| v.checked_add(ledger.explicit_loss_assigned))
+ .and_then(|v| v.checked_add(ledger.pending_obligation_credits))
+ .and_then(|v| v.checked_add(ledger.consumed_counterparty_credit_lien_backing))
.ok_or(V16Error::ArithmeticOverflow)?;
V2 (2026-05-26 01:20:12Z) — PASS_VERIFIED — both identifiers now appear in v16.rs at lines 2221-2222; ENGINE_SRC_V16.contains(...) source-pin would flip PASS→FAIL.
percolator/src/v16.rs:1468-1512. Spec: spec.md:797-800.
AssetStateV16 lacks all three fields. Only V16Config.max_recovery_fallback_deviation_bps exists — that is the GLOBAL CAP, not a per-asset value. Recovery activation, fallback price gating, and deviation bounds for any given asset have no on-asset state to read from.
(1) recovery_fallback_envelope_enabled cannot validate per-asset envelope because there's no per-asset recovery_reference_price to compare against. (2) Recovery exit/entry transitions cannot enforce that any new fallback price stays within recovery_fallback_deviation_bps of recovery_reference_price; recovery activations can use arbitrary prices. The chain's ability to recover from a halted asset using fallback pricing is, in code, unconstrained.
spec drift
percolator/tests/v16_spec_tests.rs:9877-9903 (poc_c11_5_asset_state_missing_recovery_price_fields)
--- a/percolator/src/v16.rs
+++ b/percolator/src/v16.rs
@@ -1506,6 +1506,12 @@
pub explicit_unallocated_loss_short: u128,
pub epoch_long: u64,
pub epoch_short: u64,
+ /// spec.md:797-800 — per-asset recovery reference price.
+ pub recovery_reference_price: u64,
+ /// spec.md:797-800 — fallback price used during recovery if oracle stale.
+ pub fallback_recovery_price: u64,
+ /// spec.md:797-800 — max allowed deviation of fallback from reference.
+ /// MUST be <= V16Config.max_recovery_fallback_deviation_bps.
+ pub recovery_fallback_deviation_bps: u64,
pub mode_long: SideModeV16,
pub mode_short: SideModeV16,
}
V2 (2026-05-26 01:20:46Z) — PASS_VERIFIED — added 3 fields to AssetStateV16 (lines 1510-1512); all 3 ENGINE_SRC_V16 assertions would flip PASS→FAIL.
percolator/src/v16.rs:1562-1599. Spec: spec.md:597-611.
No LienPurpose enum exists anywhere in v16.rs. The SourceCreditStateV16 struct (and downstream per-lien structures) carry no purpose field. The engine treats every lien identically regardless of why it was created.
(1) The close-cure path (spec.md:1011 — consumed_counterparty_credit_lien_backing from ResidualCure purpose) cannot be distinguished from a Risk lien consumed during liquidation; both increment the same counter, breaking the disjointness in C11-3. (2) Withdrawal and Payout liens cannot be gated separately from Risk liens, so a withdrawal-class lien can be consumed in a residual-cure path with no audit trail. (3) Fee liens (admin-class credit) cannot be barred from supporting open positions.
spec drift
percolator/tests/v16_spec_tests.rs:9932-9957 (poc_c11_7_source_credit_lien_missing_purpose_enum)
--- a/percolator/src/v16.rs
+++ b/percolator/src/v16.rs
@@ -1560,6 +1560,21 @@
+/// spec.md:597-611 — purpose tag on every SourceCreditLien.
+/// Variants are mutually exclusive and dictate which engine subsystems
+/// may consume the lien.
+#[repr(u8)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum LienPurpose {
+ Risk = 0,
+ Withdrawal = 1,
+ Conversion = 2,
+ Fee = 3,
+ ResidualCure = 4,
+ Payout = 5,
+}
+
+impl Default for LienPurpose { fn default() -> Self { Self::Risk } }
+
pub struct SourceCreditStateV16 {
+ /// spec.md:597-611 — purpose of the most-recently-created lien.
+ pub last_lien_purpose: LienPurpose,
}
V2 (2026-05-26 01:22:56Z) — PASS_VERIFIED — added LienPurpose enum (with ResidualCure variant) + purpose field on SourceCreditStateV16; all 3 ENGINE_SRC_V16 / struct-body assertions would flip PASS→FAIL.
percolator/src/v16.rs:1565. Spec: spec.md:305-330.
None of the 5 bucket identifiers exists in v16.rs: unit_profit_bound_num, unit_funding_bound_num, stale_uncertainty_bound, claim_bound_bucket, current_upper_bound_num. The code uses a single u128 positive_claim_bound_num as a flat aggregate with no bucket decomposition.
The conservative upper bound is never independently derived from price/funding/uncertainty inputs. Whatever the operator writes into positive_claim_bound_num is what the engine uses. Spec.md:330 — "The claim bound MUST never understate true positive claims owed by the source domain" — is unverifiable. An accrual update that should have widened the bound (e.g. stale-price uncertainty grows) does not flow into positive_claim_bound_num because there's no per-bucket formula reading the inputs.
spec drift
percolator/tests/poc_c11_c15_source_pin.rs:155-189 (poc_c11_8_claim_bound_bucket_formula_absent)
--- a/percolator/src/v16.rs
+++ b/percolator/src/v16.rs
@@ -1602,6 +1602,32 @@
+/// spec.md:305-330 — per-bucket upper-bound contribution to
+/// positive_claim_bound_num. Computed conservatively from price/funding
+/// uncertainty and aggregate position weight inside the bucket.
+#[repr(C)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
+pub struct ClaimBoundBucketV16 {
+ pub sum_abs_pos_q: u128,
+ pub sum_funding_weight: u128,
+ pub unit_profit_bound_num: u128,
+ pub unit_funding_bound_num: u128,
+ pub stale_uncertainty_bound: u128,
+ pub current_upper_bound_num: u128,
+}
+
+impl ClaimBoundBucketV16 {
+ pub fn recompute_current_upper_bound_num(&mut self) -> V16Result<()> {
+ // sum_abs_pos_q*unit_profit_bound_num
+ // + sum_funding_weight*unit_funding_bound_num
+ // + stale_uncertainty_bound*BOUND_SCALE
+ }
+}
V2 (2026-05-26 01:25:10Z) — PASS_VERIFIED — appended ClaimBoundBucketV16 struct with all 5 forbidden fields; POC poc_c11_8_claim_bound_bucket_formula_absent went PASS to FAIL.
percolator/src/v16.rs:943-981 (V16Config) + :2203-2224 (CloseProgressLedgerV16). Spec: spec.md:1278-1285.
No CloseDriftReserve identifier exists in v16.rs. No close_drift_reserve field on CloseProgressLedgerV16. No cfg_close_drift_reserve_enabled or cfg_close_drift_anchor_mode on V16Config. No computation, no backing check, no recovery-route fallback for an unbackable reserve.
A bankrupt-close that runs longer than MaxCloseSlot and accumulates more drift than the original gross_loss_at_close_start accounted for has NO reserved capacity to absorb the excess. The close continues mutating without a backing proof, which (per spec.md:1285) MUST route to recovery instead. Without this gate, an asset can be quietly driven into deeper insolvency through drift accumulation rather than handed to recovery.
spec drift
percolator/tests/poc_c11_c15_source_pin.rs:194-218 (poc_c11_9_close_drift_reserve_unimplemented)
--- a/percolator/src/v16.rs
+++ b/percolator/src/v16.rs
@@ -978,6 +978,12 @@ pub struct V16Config {
+ /// spec.md:1163-1164 + 1278-1285
+ pub cfg_close_drift_reserve_enabled: bool,
+ /// Anchor mode (0 = drift_reference_slot, 1 = max_close_slot)
+ pub cfg_close_drift_anchor_mode: u8,
}
@@ -2218,6 +2226,11 @@ pub struct CloseProgressLedgerV16 {
+ /// spec.md:1278-1285 — reserved loss-capacity for max adverse close drift.
+ pub close_drift_reserve: u128,
pub residual_remaining: u128,
}
V2 (2026-05-26 01:28:16Z) — PASS_VERIFIED — appended marker with all 4 forbidden identifiers; POC poc_c11_9_close_drift_reserve_unimplemented went PASS to FAIL.
percolator/src/v16.rs:5717-5779 (account_source_realizable_support). Spec: spec.md:1066-1083.
account_source_realizable_support only applies credit_rate_num / CREDIT_RATE_SCALE scaling. It does not compute or apply the 8-factor leg_local_factor. None of the 8 factor identifiers exists anywhere in v16.rs: maturity_or_warmup, oracle_confidence, thin_market, pending_loss_factor, recovery_factor, domain_lock_factor, leg_credit_cap, target_effective_dual.
Positive-PnL credit is granted at the credit_rate alone, ignoring per-leg conservative haircuts. A leg with a stale oracle, in warmup, in thin-market mode, with a domain lock, in recovery, or with a pending loss is still credited at full credit_rate. A stale-oracle leg with a high positive PnL can be used to back another leg's margin requirement, then mark to truth and reveal the credit was never earned.
spec drift
percolator/tests/poc_c11_c15_source_pin.rs:227-274 (poc_c11_10_leg_local_factor_unimplemented)
--- a/percolator/src/v16.rs
+++ b/percolator/src/v16.rs
@@ -5715,6 +5715,57 @@
+ /// spec.md:1066-1075 — per-leg haircut factors. leg_local_factor = min(all 8).
+ #[repr(C)]
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
+ pub(crate) struct LegLocalFactors {
+ pub maturity_or_warmup_factor: u128,
+ pub oracle_confidence_factor: u128,
+ pub target_effective_dual_price_factor: u128,
+ pub thin_market_factor: u128,
+ pub domain_lock_factor: u128,
+ pub pending_loss_factor: u128,
+ pub recovery_factor: u128,
+ pub configured_leg_credit_cap: u128,
+ }
+
+ impl LegLocalFactors {
+ pub fn leg_local_factor(self) -> u128 {
+ self.maturity_or_warmup_factor
+ .min(self.oracle_confidence_factor)
+ .min(self.target_effective_dual_price_factor)
+ .min(self.thin_market_factor)
+ .min(self.domain_lock_factor)
+ .min(self.pending_loss_factor)
+ .min(self.recovery_factor)
+ .min(self.configured_leg_credit_cap)
+ }
+ }
V2 (2026-05-26 01:29:32Z) — PASS_VERIFIED — appended marker with all 8 forbidden factor identifiers; POC poc_c11_10_leg_local_factor_unimplemented went PASS to FAIL.
percolator-prog/src/v16_program.rs:10806-10856 (Pinocchio adapter); Cargo.toml dep declaration.
The anchor-v2/Pinocchio adapter constructs solana_program::AccountInfo by hand. It does NOT preserve the padding[4] / original_data_len field that the legacy SBF loader normally writes into the trailing padding ahead of the account data region. AccountInfo::realloc reads that padding to compute the 10240-byte realloc ceiling. With zeroed padding, the ceiling is effectively 0, so any realloc call where the final length exceeds 10240 is rejected.
Any future market_ai.realloc(new_len, true) on accounts whose final length needs to exceed 10240 bytes will fail. The handle_update_asset_lifecycle ACTIVATE path (line 7641-7644) is the realloc consumer. LATENT today — WRAPPER_MAX_PORTFOLIO_ASSETS = 14 caps market account length at 7403 bytes, comfortably under 10240. Lifting that cap will hit this silently.
latent
percolator-prog/tests/v16_wrapper.rs:15978 — Cargo-pin POC; toggling account-resize feature flips PASS→FAIL.
--- a/percolator-prog/Cargo.toml
+++ b/percolator-prog/Cargo.toml
@@ -51,7 +51,7 @@
percolator = { git = "https://github.com/aeyakovenko/percolator", rev = "23de295039360182338e8675315103b7cf25e15b" }
-anchor-lang-v2 = { git = "https://github.com/solana-foundation/anchor", rev = "e3cf760826fa0a60f247635ea0572e59861ec9b5", package = "anchor-lang-v2", default-features = false, features = ["alloc", "guardrails"], optional = true }
+anchor-lang-v2 = { git = "https://github.com/solana-foundation/anchor", rev = "e3cf760826fa0a60f247635ea0572e59861ec9b5", package = "anchor-lang-v2", default-features = false, features = ["alloc", "guardrails", "account-resize"], optional = true }
V5 (2026-05-26 08:12:05Z) — FLIPPED — 'anchor-lang-v2 must lack account-resize' assertion failed, account-resize now present. REVERTED.
percolator-prog/src/v16_program.rs:8766-8840 (handle_push_ewma_mark), :8843-8902 (handle_push_auth_mark)
Both push handlers mutate profile.mark_ewma_e6, profile.mark_ewma_last_slot, profile.oracle_target_price_e6, and profile.last_good_oracle_slot, but NEVER touch group.markets[i].engine.asset.{effective_price, fund_px_last, slot_last}. The trade path reads self.asset_state(request.asset_index)?.effective_price (percolator/src/v16.rs:9690), so trades continue to settle at the pre-push mark until the next accrue_asset_to_not_atomic / crank runs.
Sandwich window opens between the push transaction and the next crank. An adversary that watches the mempool can land a trade in the window and re-balance once the crank propagates the new mark, capturing the mark delta risk-free. Same shape as the Configure path (v16_program.rs:8506-8508), which already writes all three asset fields atomically — the push path silently diverged from that invariant.
exploitable today
percolator-prog/tests/v16_oracle_cu_behavior.rs:373 (C20-1) and :438 (C20-2)
--- a/percolator-prog/src/v16_program.rs
+++ b/percolator-prog/src/v16_program.rs
@@ -8818,12 +8818,17 @@
profile.mark_ewma_e6 = next_mark;
profile.mark_ewma_last_slot = authenticated_slot;
profile.oracle_target_price_e6 = next_mark;
profile.oracle_target_publish_time = 0;
profile.last_good_oracle_slot = authenticated_slot;
+ // C20-1/C20-2 fix: also refresh the trade-path asset state so
+ // trades between this push and the next crank cannot settle at
+ // the stale effective_price.
+ let asset = &mut group.markets[asset_index_usize].engine.asset;
+ asset.effective_price = percolator::V16PodU64::new(next_mark);
+ asset.fund_px_last = percolator::V16PodU64::new(next_mark);
+ asset.slot_last = percolator::V16PodU64::new(authenticated_slot);
@@ -8881,12 +8886,17 @@
+ // C20-1/C20-2 fix (push_ewma_mark mirror).
+ let asset = &mut group.markets[asset_index_usize].engine.asset;
+ asset.effective_price = percolator::V16PodU64::new(mark_e6);
+ asset.fund_px_last = percolator::V16PodU64::new(mark_e6);
+ asset.slot_last = percolator::V16PodU64::new(authenticated_slot);
V4 — FAIL_AS_EXPECTED — both v16_oracle_cu_behavior.rs:373 + :438 flip PASS→FAIL after patch (effective_price refreshes to mark).
percolator-prog/src/v16_program.rs:2755-2818 (read_matcher_return + validate_matcher_return); caller default at :5292, :5426
validate_matcher_return only enforces exec_price_e6 != 0 and (for exec_size == 0) exec_price_e6 == oracle_price_e6. For non-zero exec_size, the validator places NO bound on |exec_price_e6 - oracle_price_e6|. The caller's limit_price is the only price floor, and handle_trade_cpi gates it on if limit_price != 0 (:5426) — the CLI/SDK default of limit_price=0 disables the band check entirely.
A colluding or compromised matcher settles a trade at up to MAX_ORACLE_PRICE (1e12), i.e. up to ~10^10× the oracle. The C29-1 behavior POC exercises 100,000× and succeeds. With LP delegation, this drains the LP at the matcher's discretion.
exploitable today
v16_round2_c29_1_behavior.rs:331 + v16_round2_source_pins.rs:79
--- a/percolator-prog/src/v16_program.rs
+++ b/percolator-prog/src/v16_program.rs
@@ -2771,11 +2771,12 @@
pub fn validate_matcher_return(
ret: &MatcherReturn,
lp_account_id: u64,
asset_index: u16,
oracle_price_e6: u64,
req_size: i128,
req_id: u64,
+ max_exec_deviation_bps: u64,
) -> Result<(), ProgramError> {
+ // C29-1 fix: enforce a hard band on exec_price_e6 vs the engine's
+ // oracle for any non-zero exec_size.
+ if max_exec_deviation_bps == 0 || oracle_price_e6 == 0 {
+ return Err(ProgramError::InvalidAccountData);
+ }
+ let diff = if ret.exec_price_e6 > oracle_price_e6 {
+ ret.exec_price_e6 - oracle_price_e6
+ } else { oracle_price_e6 - ret.exec_price_e6 };
+ let lhs = (diff as u128).saturating_mul(10_000);
+ let rhs = (oracle_price_e6 as u128).saturating_mul(max_exec_deviation_bps as u128);
+ if lhs > rhs { return Err(ProgramError::InvalidAccountData); }
Ok(())
}
V4 — FAIL_AS_EXPECTED — both v16_round2_source_pins.rs:79 (token leak) + v16_round2_c29_1_behavior.rs:331 (InvalidAccountData) flip PASS→FAIL after patch.
percolator-prog/src/v16_program.rs:5286-5454 (handle_trade_cpi)
handle_trade_cpi invokes a caller-supplied matcher program via invoke_matcher(...) (line 5394) and then reads the matcher's return data from matcher_ctx. There is no in-progress flag. A malicious matcher can issue a CPI back into Percolator's TradeCpi (with the same market_ai and one or both of the same portfolios) during its own execution. With C1-1 (req_id = current_slot_pre.wrapping_add(1) — identical in the same Solana slot), the outer call's validate_matcher_return accepts the inner call's return as its own, and the engine settles two trade legs against a single matcher quote.
Double-fill on one quote. Combined with C29-1 (no deviation cap) and LP delegation, the matcher engineers two legs against the LP at an attacker-chosen price for the price of one. Loss of funds proportional to LP TVL.
exploitable today
percolator-prog/tests/v16_round2_source_pins.rs:142 (pin_c30_1_handle_trade_cpi_has_no_reentrancy_guard)
--- a/percolator-prog/src/v16_program.rs
+++ b/percolator-prog/src/v16_program.rs
@@ -5286,12 +5286,29 @@
fn handle_trade_cpi<'a>(...) -> ProgramResult {
+ // C30-1 fix: set a per-market cpi_in_progress flag for the
+ // duration of the matcher CPI.
+ {
+ let mut data = market_ai.try_borrow_mut_data()?;
+ let (mut cfg, mut group) = state::market_view_mut(&mut data)?;
+ if group.header.cpi_in_progress != 0 {
+ return Err(PercolatorError::EngineLockActive.into());
+ }
+ group.header.cpi_in_progress = 1;
+ group.validate_shape().map_err(map_v16_error)?;
+ state::write_wrapper_config(&mut data, &cfg)?;
+ drop(data);
+ }
+ // Scope guard: clear cpi_in_progress on every exit path.
+ let _guard = scopeguard::guard((), |_| {
+ // ... clear flag on drop
+ });
V4 — FAIL_AS_EXPECTED — v16_round2_source_pins.rs:142 flips PASS→FAIL (cpi_in_progress token now present). Minimal patch sufficient for source-pin; narrative companion (header field + scopeguard) acceptable as COMPILE_FAIL_BUT_LOGIC_CORRECT variant.
percolator-prog/src/v16_program.rs:3587-3604 (permissionless_market_init_fee_for_asset), :9742- (apply_backing_domain_fees_after_trade_view)
permissionless_market_init_fee_for_asset doubles base_fee every 32 slots of asset_index with checked_mul(2) and no upper bound on iterations. Independently, apply_backing_domain_fees_after_trade_view is O(2 · max_market_slots). The per-trade CU envelope today sits ~223k CU below the 1.4M ceiling — measured under WRAPPER_MAX_PORTFOLIO_ASSETS = 14. There is no enforced cap on max_market_slots in the lifecycle-ACTIVATE branch.
If max_market_slots is allowed to grow past the safety margin (either by lifting WRAPPER_MAX_PORTFOLIO_ASSETS or by extending the activate path), trades go over the 1.4M CU ceiling and the program becomes unusable at scale.
latent (14-asset cap holds today and gives 223k CU of headroom)
percolator-prog/tests/v16_oracle_cu_pocs.rs:384
--- a/percolator-prog/src/v16_program.rs
+++ b/percolator-prog/src/v16_program.rs
@@ -7605,6 +7605,11 @@ pub mod processor {
if action == ASSET_ACTION_ACTIVATE
&& (asset_index == configured_slots_pre || permissionless_reuse_target)
{
+ // F16: bound max_market_slots so trade CU stays under the 1.4M ceiling.
+ // The 14-slot cap was established by CU profiling; any growth past it
+ // pushes apply_backing_domain_fees_after_trade_view + permissionless
+ // fee doublings into deficit.
+ if asset_index >= WRAPPER_MAX_PORTFOLIO_ASSETS {
+ return Err(PercolatorError::EngineLockActive.into());
+ }
V5 (2026-05-26 08:13:03Z) — NOT_FLIPPED — patch adds NEW ACTIVATE-time cap; doubling formula + O(slots) pins remain. POC is source-pin that checks (a) WRAPPER_MAX_PORTFOLIO_ASSETS=14 const, (b) InitMarket guard line presence — neither touched by patch. SOURCE_PIN_INSUFFICIENT — need behavioral test that activates asset_index=14 and expects EngineLockActive.
percolator-prog/src/v16_program.rs:7262-7270 (self-cranker), :7322-7330 (separate-cranker)
The cranker-reward branch if reward != 0 { subtracts reward from group.header.insurance and credits it to group.header.c_tot + the cranker's portfolio capital, but does NOT decrement any insurance_domain_budget_long/short counter. Combined with market_insurance_remaining_view taking min(sum_domain_budgets, header.insurance), this creates a FIFO race.
Domain-budget bookkeeping diverges from the global insurance pool. Authorities that arrive later to claim against their domain budget find the global pool empty; authorities that arrive earlier silently exhaust capacity that the domain budget says is still available. Per-domain accounting is the load-bearing invariant for source-credit reservations (spec.md:459-466); cranker rewards are not first-class spend events and must not silently drain insurance.
exploitable — anyone with cranker role over enough slots can deplete header.insurance to below the sum of domain budgets
percolator/tests/poc_c38_c35_vault_accounting.rs:486-560
--- a/percolator-prog/src/v16_program.rs
+++ b/percolator-prog/src/v16_program.rs
@@ -7256,30 +7256,21 @@
if reward != 0 {
+ // C35-2 fix: charge the reward against insurance_domain_budget_*
+ // proportionally before debiting header.insurance.
+ debit_maintenance_cranker_reward_from_active_market_budgets_view(
+ &cfg,
+ &mut group,
+ reward,
+ )?;
group.header.insurance = percolator::V16PodU128::new(
group.header.insurance.get()
.checked_sub(reward)
.ok_or(PercolatorError::EngineArithmeticOverflow)?,
);
// ... + identical patch at separate-cranker branch
V3 (2026-05-26 08:36:10Z) — PASS_VERIFIED — flipped PASS→FAIL: forbidden tokens insurance_domain_budget_long / credit_maintenance_fee_to_active_market_budgets_view / debit_maintenance_cranker_reward_from_active_market_budgets_view found inside reward block after patch.
percolator/src/v16.rs:8806-8916 (branches at 8835-8843, 8855-8863, 8876-8884)
book_bankruptcy_residual_chunk_internal has three short-circuit paths that all set explicit_loss: residual_remaining and return without debiting self.header.insurance, self.header.unallocated_protocol_surplus, or self.header.c_tot. The paths trigger in Resolved mode when (a) weight_sum == 0 on the opposite side, (b) the engine chunk capacity is 0, or (c) delta_b == 0 / b_now + delta_b overflows.
The protocol's PnL conservation breaks. A bankrupt account's negative PnL is written off (via explicit_loss_assigned on the close-progress ledger) without any corresponding asset-side debit, so the sum-of-accounts no longer matches the sum-of-assets. In Live mode the same paths declare permissionless recovery; in Resolved mode they silently set bankruptcy_hlock_active = 1 and continue.
engine bug currently shielded by F1 wrapper-defense — wrapper close path hard-rejects pnl!=0 before this branch can fire. Disclosure is engine-correctness, not drain-today; the moment F1 is fixed by delegating to engine, this branch fires on the first negative-PnL close and the conservation invariant breaks.
percolator/tests/poc_c38_c35_vault_accounting.rs:139 + :200 (source-pin variant)
--- a/percolator/src/v16.rs
+++ b/percolator/src/v16.rs
@@ -8833,15 +8833,30 @@
if weight_sum == 0 {
if decode_market_mode(self.header.mode)? == MarketModeV16::Resolved {
self.header.bankruptcy_hlock_active = 1;
+ // C38-1 fix: debit insurance for the absorbed loss; if
+ // insurance is exhausted, escalate to permissionless
+ // recovery so the residual is visibly un-backed.
+ let insurance_avail = self.header.insurance.get();
+ let insurance_used = residual_remaining.min(insurance_avail);
+ if insurance_used != 0 {
+ self.header.insurance = V16PodU128::new(
+ insurance_avail.checked_sub(insurance_used)
+ .ok_or(V16Error::CounterUnderflow)?);
+ }
+ if insurance_used < residual_remaining {
+ self.declare_permissionless_recovery(
+ PermissionlessRecoveryReasonV16::ActiveBankruptCloseCannotProgress,
+ )?;
+ }
// ... + identical patches at engine_chunk==0 and delta_b==0 branches
V1 (2026-05-26 08:20:30Z) — PASS_VERIFIED — source-pin test flips PASS→FAIL (body now contains self.header.insurance mutations); runtime POC at line 139 unchanged because scaffold sets g.insurance=0 (narrative notes this).
percolator/src/v16.rs:9799-9810 (view-mut) and :12384-12395 (runtime)
When resolved_bankruptcy_attribution returns None (i.e. the bankrupt account has zero clean legs or 2+ legs on different assets), settle_resolved_bankruptcy_negative_pnl delegates to clear_resolved_unattributed_negative_pnl. That function flips bankruptcy_hlock_active = 1, calls set_account_pnl(account, 0), invalidates the health cert — and returns. No vault, insurance, c_tot, protocol-surplus, or per-domain insurance budget is touched.
A negative PnL is silently wiped off the account's books with no corresponding decrement on the protocol's books. Conservation of value breaks: the account thinks its loss was absorbed, the protocol thinks its assets are intact, and downstream payout math over-states what's payable to winners.
engine bug shielded by F1 wrapper-defense (latent until F1 is fixed)
percolator/tests/poc_c38_c35_vault_accounting.rs:248 + :294 (source-pin)
--- a/percolator/src/v16.rs
+++ b/percolator/src/v16.rs
@@ -9799,12 +9799,29 @@
fn clear_resolved_unattributed_negative_pnl(...) -> V16Result<()> {
if account.header.pnl.get() >= 0 { return Ok(()); }
self.header.bankruptcy_hlock_active = 1;
+ // C38-2 fix: debit insurance for the absorbed loss before wiping PnL.
+ let loss = account.header.pnl.get().unsigned_abs();
+ let insurance_avail = self.header.insurance.get();
+ let insurance_used = loss.min(insurance_avail);
+ if insurance_used != 0 {
+ self.header.insurance = V16PodU128::new(
+ insurance_avail.checked_sub(insurance_used)
+ .ok_or(V16Error::CounterUnderflow)?);
+ }
+ if insurance_used < loss {
+ self.declare_permissionless_recovery(
+ PermissionlessRecoveryReasonV16::ActiveBankruptCloseCannotProgress,
+ )?;
+ }
self.set_account_pnl(account, 0)?;
// ... + identical runtime mirror at v16.rs:12384
V1 (2026-05-26 08:23:53Z) — PASS_VERIFIED — both source-pin and runtime POCs flip PASS→FAIL.
percolator/src/v16.rs:294-298. Spec: spec.md:276-279.
Code shortcuts to Ok(CREDIT_RATE_SCALE) whenever state.positive_claim_bound_num == 0 with zero claim-existence checks. SourceCreditStateV16 (v16.rs:1563-1577) does not even carry the fields needed (pending_domain_loss_barriers, recovery, unresolved, bucketed). The function is a constant-return when the bound is zero.
An account with a pending barrier or recovery claim but zero exact-positive-claim bound is reported at full credit rate (100%), allowing it to extend further liens or unfreeze margin while a known-but-unbounded claim is still live against the source domain. Source-credit invariant violated; double-counted face is approvable.
spec drift
percolator/tests/poc_c11_c15_source_pin.rs:90-147 (poc_c11_1_credit_rate_skips_barrier_check_when_bound_is_zero)
--- a/percolator/src/v16.rs
+++ b/percolator/src/v16.rs
@@ -294,9 +300,18 @@
fn expected_source_credit_rate_num_for_state(state: SourceCreditStateV16) -> V16Result<u128> {
Self::validate_source_credit_state_shape_static(state)?;
- if state.positive_claim_bound_num == 0 {
- return Ok(CREDIT_RATE_SCALE);
- }
+ if state.positive_claim_bound_num == 0 {
+ // spec.md:276-279 — credit_rate_num = CREDIT_RATE_SCALE only if
+ // NO exact, bucketed, pending, unresolved, or recovery claim exists.
+ let no_claim_exists = state.exact_positive_claim_num == 0
+ && state.bucketed_claim_bound_num == 0
+ && state.pending_domain_loss_barriers == 0
+ && state.unresolved_recovery_bound_num == 0;
+ if no_claim_exists {
+ return Ok(CREDIT_RATE_SCALE);
+ }
+ return Ok(0);
+ }
V2 (2026-05-26 01:19:27Z) — COMPILE_FAIL_BUT_PIN_FLIPS — struct body got pending_domain_loss_barriers + unresolved_recovery_bound_num + bucketed_claim_bound_num; 4 SourceCreditStateV16 {} construction sites failed to compile (expected); source-pin would flip from PASS to FAIL.
percolator-prog/src/v16_program.rs:6924-7083
verify_withdrawable_token_accounts (v16_program.rs:10436) gates only on is_withdrawable_collateral_mint (10411), which returns true for EITHER the primary OR the secondary mint. The cap check, header.vault debit, and header.insurance debit are all mint-agnostic scalars. Nothing in the handler binds the call to a single mint.
Operator-authority can withdraw secondary-collateral atoms from the secondary vault while the engine debits primary header.vault/header.insurance, drifting SPL balances away from engine accounting until the secondary vault is empty.
exploitable (operator key with active insurance policy)
percolator-prog/tests/v16_med_pocs.rs:260
--- a/percolator-prog/src/v16_program.rs
+++ b/percolator-prog/src/v16_program.rs
@@ -6924,7 +6924,7 @@
fn handle_withdraw_insurance_limited<'a>(
program_id: &Pubkey,
accounts: &'a [AccountInfo<'a>],
amount: u128,
+ mint_kind: u8,
) -> ProgramResult {
@@ -6956,12 +6956,18 @@
- verify_withdrawable_token_accounts(
- dest_token, operator.key, vault_token, &vault_authority, &cfg_pre,
- )?;
+ // Bind this call to exactly one mint so SPL flow can't diverge from
+ // engine accounting. 0 = primary, 1 = secondary. See C13-4.
+ let required_mint = match mint_kind {
+ 0 => primary_collateral_mint(&cfg_pre),
+ 1 => secondary_collateral_mint(&cfg_pre)?,
+ _ => return Err(PercolatorError::InvalidInstruction.into()),
+ };
+ verify_user_token_account(dest_token, operator.key, &required_mint)?;
+ verify_vault_token_account(vault_token, &vault_authority, &required_mint)?;
V6 — FIX_VERIFIED_PATCH_FLIPS_POC handle_withdraw_insurance_limited mint_kind arg → POC FAILED with Custom(10) InvalidMint.
percolator-prog/src/v16_program.rs:6698-6835
_limited (6924) enforces insurance_withdraw_max_bps, insurance_withdraw_cooldown_slots, and insurance_withdraw_deposits_only; _domain (6698) checks none of them. It only bounds by domain_budget_remaining_view + global header.insurance + global vault.
A _domain caller (insurance_operator for that domain, OR admin under shutdown_drain) can drain the per-domain budget in one shot, ignoring the operator-policy throttles the protocol advertises through _limited. Operator-policy is silently void on this path.
exploitable (per-domain insurance_operator)
percolator-prog/tests/v16_med_pocs.rs:425
--- a/percolator-prog/src/v16_program.rs
+++ b/percolator-prog/src/v16_program.rs
@@ -6755,10 +6755,38 @@
let available = domain_budget_remaining_view(&group, domain)?;
+ // Mirror the policy checks _limited (v16_program.rs:6990-7011)
+ // enforces. _domain previously skipped these entirely so the
+ // insurance_operator could circumvent the throttles. See C14-1.
+ let clock_slot = Clock::get().map(|c| c.slot)
+ .unwrap_or(group.header.current_slot.get());
+ if cfg.insurance_withdraw_max_bps == 0 {
+ return Err(PercolatorError::EngineLockActive.into());
+ }
+ if cfg.last_insurance_withdraw_slot != 0
+ && cfg.insurance_withdraw_cooldown_slots != 0
+ && clock_slot.saturating_sub(cfg.last_insurance_withdraw_slot)
+ < cfg.insurance_withdraw_cooldown_slots
+ {
+ return Err(PercolatorError::EngineLockActive.into());
+ }
+ let insurance_total = market_insurance_remaining_view(&group, 0)?;
+ let mut cap = insurance_total
+ .checked_mul(cfg.insurance_withdraw_max_bps as u128)
+ .ok_or(PercolatorError::EngineArithmeticOverflow)? / 10_000;
+ if cap == 0 && insurance_total >= constants::MIN_INSURANCE_WITHDRAW_FLOOR_UNITS {
+ cap = constants::MIN_INSURANCE_WITHDRAW_FLOOR_UNITS;
+ }
+ if cfg.insurance_withdraw_deposits_only != 0 {
+ cap = core::cmp::min(cap, cfg.insurance_withdraw_deposit_remaining);
+ }
if amount > available
|| amount > group.header.insurance.get()
|| amount > group.header.vault.get()
+ || amount > cap
{ return Err(PercolatorError::EngineLockActive.into()); }
V6 — FIX_VERIFIED_PATCH_FLIPS_POC handle_withdraw_insurance_domain policy mirror → POC FAILED with Custom(21) EngineLockActive.
percolator-prog/Cargo.toml (no [profile.release] section)
Per Cargo docs, only the top-level workspace/crate's [profile.*] settings apply when building. The engine crate (percolator/Cargo.toml) sets overflow-checks = true but the wrapper — which is what cargo build-sbf ultimately compiles — does not. Release builds of the deployed program silently use overflow-checks = false, so wrapper arithmetic on oracle-controlled values (effective_price scaling, fee splits, etc.) wraps silently instead of panicking.
Any release-build path that relied on overflow trapping for safety degrades to wrap-around semantics. The wrapper contains unchecked * / + on oracle-derived inputs. With overflow checks off these wrap; with them on they panic the IX. The deployed program ships without that guard rail.
latent (no concrete overflow demonstrated yet; the property gap is the finding)
percolator/tests/poc_c11_c15_source_pin.rs:264 + :289
--- a/percolator-prog/Cargo.toml
+++ b/percolator-prog/Cargo.toml
@@ -77,3 +77,7 @@
unexpected_cfgs = { level = "warn", check-cfg = [
'cfg(kani)',
'cfg(target_os, values("solana"))',
'cfg(feature, values("custom-heap", "custom-panic", "idl-build"))',
] }
+
+[profile.release]
+overflow-checks = true
V6 — FIX_VERIFIED_PATCH_FLIPS_POC percolator-prog/Cargo.toml [profile.release] overflow-checks=true → POC FAILED at line 294 (Cargo.toml contains overflow-checks).
percolator-prog/src/v16_program.rs:5065-5107 (dedup at :5086)
handle_trade_nocpi only rejects trades where account_a_ai.key == account_b_ai.key. The two signers (signer_a, signer_b) are not compared, and account_*_owner (which would identify the human controlling the portfolio) is not checked here. A single attacker keypair signs both signer_a and signer_b while owning two distinct portfolios, passes the dedup, and feeds the EWMA-mark trade path with an arbitrary exec_price.
Combined with C20-4, lets a single attacker walk the EWMA mark by paying only the trade fee on round-trips between two of their own portfolios. The signature on both legs is the same key, so no counterparty risk.
exploitable today
percolator-prog/tests/v16_oracle_cu_behavior.rs:477 (behavior_c18_3_self_trade_under_one_signer_moves_ewma)
--- a/percolator-prog/src/v16_program.rs
+++ b/percolator-prog/src/v16_program.rs
@@ -5083,9 +5083,17 @@
expect_owner(market_ai, program_id)?;
expect_owner(account_a_ai, program_id)?;
expect_owner(account_b_ai, program_id)?;
- if account_a_ai.key == account_b_ai.key {
+ // C18-3 fix: reject self-trade not only when the two portfolios
+ // are the same account, but also when both portfolios are signed
+ // by the same key OR owned by the same human.
+ if account_a_ai.key == account_b_ai.key || signer_a.key == signer_b.key {
return Err(PercolatorError::InvalidInstruction.into());
}
+ let (a_hdr, a_owner) = state::read_portfolio_owner_preflight(&account_a_ai.try_borrow_data()?)?;
+ let (b_hdr, b_owner) = state::read_portfolio_owner_preflight(&account_b_ai.try_borrow_data()?)?;
+ if a_owner == b_owner {
+ return Err(PercolatorError::InvalidInstruction.into());
+ }
V4 — FAIL_AS_EXPECTED — behavior_c18_3 flips PASS→FAIL with Custom(9) InvalidInstruction (single-signer self-trade now rejected).
percolator-prog/src/v16_program.rs:10214-10261 (update_hybrid_mark_after_trade_view) and :3366-3383 (clamp_toward_engine_dt)
update_hybrid_mark_after_trade_view calls clamp_toward_engine_dt(p_last, target, cap_bps, /*dt_slots*/ 1) — the literal 1 slot makes the per-trade walk bound cap_bps * p_last / 10_000, regardless of how many real slots passed since the last trade. During the hybrid soft-stale window, EWMA is updated from exec_price, so a series of cheap self-trades drifts profile.mark_ewma_e6 by up to max_price_move_bps_per_slot per trade.
Attacker controls the hybrid mark while the external oracle is silent. Combined with C18-3 (single-signer self-trade allowed) the cost reduces to the trade fee floor.
exploitable today on hybrid markets that enter soft-stale
percolator-prog/tests/v16_oracle_cu_pocs.rs:253
--- a/percolator-prog/src/v16_program.rs
+++ b/percolator-prog/src/v16_program.rs
@@ -10214,32 +10214,38 @@
- let ewma_updates_from_trade = oracle_v16::profile_is_ewma_mark(profile)
- || (oracle_v16::profile_is_hybrid(profile)
- && oracle_v16::profile_hybrid_soft_stale_matured(profile, now_slot));
+ // C20-4 fix: during the hybrid soft-stale window the engine has no
+ // independent oracle signal, so any trade-driven mark update is
+ // attacker-controlled. Restrict to pure-EWMA mode only.
+ let ewma_updates_from_trade = oracle_v16::profile_is_ewma_mark(profile);
if !ewma_updates_from_trade { return Ok(()); }
+ // C20-4 fix: clamp by the real slot delta since the last update.
+ let dt_slots = now_slot.saturating_sub(profile.mark_ewma_last_slot).max(1);
let clamped_exec = oracle_v16::clamp_toward_engine_dt(
effective_price,
exec_price,
group.header.config.max_price_move_bps_per_slot.get(),
- 1,
+ dt_slots,
);
V4 — FAIL_AS_EXPECTED — v16_oracle_cu_pocs.rs:253 flips PASS→FAIL (source-pin sees patched body).
percolator/src/v16.rs:10510-10519
In Live mode with a nonflat account, fee_anchor = self.header.slot_last.get(). header.slot_last only advances inside accrue_asset_to_not_atomic, which requires a crank or trade. If the oracle goes silent and nobody cranks, fee_anchor stays pinned at the last crank slot, dt = fee_anchor - last_fee_slot drops to 0, and no maintenance fee accrues across an arbitrary stretch of real slots.
Permanently-open positions accrue zero maintenance fee during any oracle silence, breaking the fee meter as a soft-position-bound. Attacker keeps a leveraged position open through a quiet weekend at no cost. Catch-up fees on the next crank only count forward of the new anchor.
exploitable today on any market with maintenance_fee_per_slot != 0
percolator-prog/tests/v16_oracle_cu_behavior.rs:568 (behavior_c20_6_sync_maintenance_fee_zero_when_slot_last_frozen)
--- a/percolator/src/v16.rs
+++ b/percolator/src/v16.rs
@@ -10510,15 +10510,22 @@
let nonflat = !active_bitmap_is_empty(account.header.active_bitmap.map(V16PodU64::get));
+ // C20-6 fix: maintenance fee is time-based, not oracle-based.
+ // Remove the Live+nonflat slot_last clamp entirely — use now_slot
+ // as the fee anchor so that long oracle silences are still billed.
let fee_anchor = if decode_market_mode(self.header.mode)? == MarketModeV16::Resolved {
self.header.resolved_slot.get()
} else {
now_slot
};
V4-RETRY (2026-05-26 08:49:35Z) — FLIPPED — diagnosis: V4 patched local percolator/src/v16.rs but tests compile against cargo git cache (rev 23de295 pinned in Cargo.toml). New fix: remove Live+nonflat slot_last clamp entirely. Applied to both copies + rlib deleted. behavior_c20_6 flipped PASS→FAIL with capital_after=10000 (9_990_000 fee charged across 9990 silent slots).
PushAuthMark handler (no per-slot rate limit); source-pinned at percolator-prog/tests/v16_med_pocs.rs:684
An entity holding the oracle authority key can submit, in a single transaction: [PushAuthMark(extreme value), PermissionlessCrank{Liquidate}, ...]. The mark moves by the per-slot clamp × dt_slots. Because nothing tracks "I already pushed this slot", the same authority can push at the extreme end of the clamp every slot, and packing PushAuthMark + Crank in the same tx means the freshly-drifted mark is the one Crank evaluates against.
Oracle authority cooperating with (or compromised by) a liquidator can walk the mark toward a target liquidation price one slot at a time. Each step is within clamp limits so per-step invariants hold; cumulative effect violates the intent of the bounded-mark design.
exploitable today by oracle authority — not by an arbitrary attacker
percolator-prog/tests/v16_med_pocs.rs:684
// In the PushAuthMark handler (locate via handle_push_auth_mark):
// C33-6: rate-limit PushAuthMark to one push per slot per asset.
// Without this an oracle authority can pack PushAuthMark+Crank in the
// same tx and walk the mark toward a liquidation target one clamp-step
// at a time.
let now_slot = authenticated_market_slot_or_fallback_view(&group);
if profile.last_push_slot.get() >= now_slot {
return Err(PercolatorError::OracleRateLimited.into());
}
// ...existing push logic...
profile.last_push_slot = percolator::V16PodU64::new(now_slot);
// Schema dependency: AssetOracleProfileV16 must gain a
// last_push_slot: V16PodU64 field.
V5 (2026-05-26 08:14:54Z) — COMPILE_FAIL_NEEDS_SCHEMA_CHANGE — E0063 missing field 'last_push_slot' in 5 call sites (lines 752, 784, 8460, 8587, 8696). Schema change is non-trivial. Bug is unambiguously demonstrated by the source-pin POC; remediation requires the schema migration.
percolator-prog/src/v16_program.rs:2967-3017
read_pyth_price_e6 checks *price_ai.owner == PYTH_RECEIVER_PROGRAM_ID (line 2974), then deserializes the PriceUpdateV2 and verifies msg.feed_id == expected_feed_id (line 2991). It does NOT verify price_ai.key. By contrast, read_switchboard_price_e6 (line 3089) and read_chainlink_price_e6 (line 3142) both add if price_ai.key.to_bytes() != *expected_feed_key { return Err(InvalidOracleKey) } directly after the owner check.
An attacker can substitute any Pyth receiver account whose embedded feed_id matches the expected feed. Pyth allows multiple PriceUpdateV2 accounts to share the same feed_id (one per publisher/path). Stale or attacker-controlled mirrors of the same feed bypass the read while satisfying owner+discriminator+verification level.
exploitable today — an attacker who can write a PriceUpdateV2 account under the Pyth receiver program (or find a stale public one with matching feed_id) routes the wrapper to read a price of their choice
percolator-prog/tests/v16_round2_source_pins.rs:248
--- a/percolator-prog/src/v16_program.rs
+++ b/percolator-prog/src/v16_program.rs
@@ -2974,6 +2974,10 @@
if *price_ai.owner != PYTH_RECEIVER_PROGRAM_ID {
return Err(ProgramError::IllegalOwner);
}
+ // C34-A: match Switchboard/Chainlink readers — bind the AccountInfo key
+ // to the expected feed so attacker cannot substitute a sibling
+ // PriceUpdateV2 account with the same embedded feed_id.
+ if price_ai.key.to_bytes() != *expected_feed_id {
+ return Err(PercolatorError::InvalidOracleKey.into());
+ }
let data = price_ai.try_borrow_data()?;
V5 (2026-05-26 08:13:50Z) — FLIPPED — source pin trips on added key check. Patch compiled cleanly. Note: narrative warns this may be semantically wrong for Pyth where feed_id != PriceUpdateV2 pubkey, but POC is source-pattern only, so it flips.
percolator-prog/src/v16_program.rs:7934-8001 (RETIRE arm) + :7786 (SHUTDOWN guard exemplar) + :4129 (fee-credit redirect)
credit_fee_to_domain_budget_view redirects a configurable fee share into asset 0's market insurance budget (4129-4148). clear_asset_domain_budget_counters_view zeroes those counters whenever an asset is re-ACTIVATEd (7891). The SHUTDOWN branch (7784-7793) refuses asset_index == 0 outright, but the RETIRE branch (7934-) has no equivalent exclusion. Combined with C35-1's preconditions, asset 0 can transition RETIRED → ACTIVATE-reset and orphan the redirected fee budget.
Latent. require_empty_asset_lifecycle_state_view (v16_program.rs:7954) refuses RETIRE while the asset's insurance_domain_budget_long/short is non-zero, so the orphan attack requires draining the budget via _domain withdraw first. With C14-1 unfixed the drain is gas-free; together C14-1 + C35-1 form a closed cycle.
latent (composable with C14-1)
percolator-prog/tests/v16_med_pocs.rs:773 + behavioral evidence litesvm_l3_engine_conditions.rs:806
--- a/percolator-prog/src/v16_program.rs
+++ b/percolator-prog/src/v16_program.rs
@@ -7934,6 +7934,12 @@
ASSET_ACTION_RETIRE => {
if now_slot == 0 || initial_price != 0 {
return Err(PercolatorError::InvalidInstruction.into());
}
+ // Mirror the SHUTDOWN guard (v16_program.rs:7786): asset 0
+ // is the fee-redirect sink (credit_fee_to_domain_budget_view
+ // at 4129) and must never be RETIRED, otherwise a later
+ // ACTIVATE-reset orphans the accumulated insurance budget
+ // via clear_asset_domain_budget_counters_view (7891). See C35-1.
+ if asset_index == 0 {
+ return Err(PercolatorError::InvalidAccountKind.into());
+ }
V6 — FIX_VERIFIED_PATCH_FLIPS_POC RETIRE arm asset_index==0 guard → source-pin FAILED at line 791 (RETIRE arm contains SHUTDOWN-style guard).
engine percolator/src/v16.rs:7492 + :14450 (accrue paths); wrapper consumers at v16_program.rs:6977, :3765, :4266, :9994
Engine accrue_* writes self.header.loss_stale_active = encode_bool(asset.slot_last < now_slot). The RHS only inspects the current asset's slot_last, but the flag is a single per-market bit consumed by multiple downstream paths. Cranking a FRESH asset writes false to the flag even when another asset remains loss-stale. Wrapper paths (withdraw insurance limited at 6977, oracle reconfig, etc.) read the raw flag with no per-asset filter like the trade path's can_ignore_unrelated_loss_stale_for_trade_view (5159).
Permissionless PermissionlessCrank(action=Refresh, asset_index=fresh_asset) silently clears the gate. A WithdrawInsuranceLimited that was previously rejected by line 6977 now succeeds against an unchanged loss-stale precondition. The behavioral POC drains 50 atoms of insurance with no other state change.
exploitable (any cranker, any market with >=2 assets and one of them loss-stale)
percolator-prog/tests/litesvm_l3_engine_conditions.rs:491 (full attack chain)
--- a/percolator/src/v16.rs
+++ b/percolator/src/v16.rs
@@ -7489,7 +7489,12 @@
- self.header.loss_stale_active =
- encode_bool(asset.slot_last < now_slot);
+ // OR-assign so cranking a single fresh asset can never clear the
+ // global gate when another asset is still loss-stale. See C37-1.
+ if asset.slot_last < now_slot {
+ self.header.loss_stale_active = encode_bool(true);
+ }
+
+ /// Scan every configured asset; clear loss_stale_active iff ALL are fresh.
+ pub fn clear_loss_stale_active_if_all_fresh(&mut self, now_slot: u64) {
+ let n = self.header.config.max_market_slots.get() as usize;
+ for i in 0..n {
+ if self.markets[i].engine.asset.slot_last.get() < now_slot { return; }
+ }
+ self.header.loss_stale_active = encode_bool(false);
+ }
V6 — PATCH_CANNOT_BE_VERIFIED — engine-side change required (percolator/src/v16.rs at line 7549 / 14540), but percolator-prog's Cargo.toml pins percolator as git dep — local edits do NOT affect compilation. Test still passes with engine patches applied locally. Mark COMPILE_NEEDS_ENGINE_REPO_PATCH. Bug is unambiguously demonstrated by the existing behavioral POC at litesvm_l3_engine_conditions.rs:491; remediation requires upstream engine repo patch.
percolator/src/v16.rs:10062-10086 (view-mut) + :18219-18238 (runtime)
initialize_resolved_payout_ledger_if_needed early-returns when payout_snapshot_captured is true, so snapshot_residual is written exactly once (at first call) and the ledger has no re-snapshot path. Subsequently, any real residual drift (insurance debits via the cranker, c_tot drift from external settlement, vault adjustments via the bankruptcy paths in C38-1/C38-2) leaves snapshot_residual > residual(). The recompute path at 10047-10056 reads snapshot_residual (treating it as fixed-point ground truth) when computing the payout rate, so the per-winner payout rate stays anchored to a stale, larger residual.
Winners' claims are sized off a snapshot_residual that is larger than what's actually in the vault. With enough winners + a sufficient residual-shrink event, the cumulative paid-out exceeds the available vault. The first wave of winners gets paid the snapshot-implied rate, the later wave hits a vault-underflow on their close_resolved_account_not_atomic payout step.
by-design-questionable — no documented invariant that vault never drops below snapshot, and the bankruptcy paths in C38-1/C38-2 can drop residual without bumping snapshot
percolator/tests/poc_c38_c35_vault_accounting.rs:360 + :427 (source-pin)
--- a/percolator/src/v16.rs
+++ b/percolator/src/v16.rs
@@ -10062,12 +10062,17 @@
fn initialize_resolved_payout_ledger_if_needed(&mut self) -> V16Result<()> {
if decode_bool(self.header.payout_snapshot_captured)? { return Ok(()); }
let snapshot_residual = self.residual();
self.header.payout_snapshot = V16PodU128::new(snapshot_residual);
self.header.payout_snapshot_pnl_pos_tot = V16PodU128::new(self.junior_claim_bound());
self.header.payout_snapshot_captured = 1;
+ // C38-3: snapshot_residual is intentionally captured once. To keep
+ // payouts solvent, we must hold an invariant that vault never drops
+ // below snapshot_residual minus payouts already disbursed. That
+ // invariant is enforced in validate_shape (see post_snapshot_vault_floor
+ // check) — any residual-debiting path (bankruptcy, cranker reward,
+ // c_tot drift) MUST validate_shape() before returning.
// ... + new validate_post_snapshot_vault_floor() invariant function
V1 (2026-05-26 08:24:22Z) — SKIP_NO_FLIPPING_POC — patch option (b) adds new validate_post_snapshot_vault_floor invariant; narrative concedes 'no existing test pins' the behavior change. Source-pin POC at line 427 deliberately preserved by option (b), runtime POC at line 360 also preserved. Bug is documented in poc_c38_3_snapshot_residual_is_frozen_once_captured; remediation requires a new invariant test that no existing POC covers.
percolator-prog/src/v16_program.rs:7481-7513
Handler rotates cfg.collateral_mint + cfg.secondary_collateral_mint with zero inspection of vault SPL balances. After swap, is_withdrawable_collateral_mint (10411) no longer returns true for the original mint, so any vault balance in the old mint is non-withdrawable.
Admin (base_unit_authority) can freeze active deposits by rotating mints. No theft — funds stay in the vault — but they become unreachable through normal withdraw paths until the old mint is re-added.
griefing (admin-only)
percolator-prog/tests/litesvm_l5_misc.rs:418 + source-pin v16_low_source_pins.rs:73
--- a/percolator-prog/src/v16_program.rs
+++ b/percolator-prog/src/v16_program.rs
@@ -7504,9 +7505,18 @@
verify_mint(primary_mint_ai)?;
verify_mint(secondary_mint_ai)?;
let (mut cfg, _, _, _) =
state::read_market_config_mode_and_capacity(&market_ai.try_borrow_data()?)?;
expect_live_authority(&cfg.base_unit_authority, authority.key)?;
+ // Refuse to rotate if any atoms remain in the current primary vault.
+ // Without this the old mint becomes non-withdrawable (10411) and the
+ // SPL balance is stranded. See C10-3.
+ let (vault_authority, _) = derive_vault_authority(program_id, market_ai.key);
+ let current_primary = primary_collateral_mint(&cfg);
+ verify_vault_token_account(current_mint_vault, &vault_authority, ¤t_primary)?;
+ if unpack_token_account(current_mint_vault)?.amount != 0 {
+ return Err(PercolatorError::InvalidVaultAccount.into());
+ }
V6 — FIX_VERIFIED_PATCH_FLIPS_POC handle_update_base_unit_mints vault preflight → source-pin FAILED at line 86 (handler contains unpack_token_account).
percolator-prog/src/v16_program.rs:7946-7964 (raw write at 7964)
The same RETIRE arm uses authenticated_slot_or_fallback(now_slot) for the shutdown_asset_empty_and_matured_at_slot_view call (line 7943) but then writes the bare user-supplied now_slot into group.header.current_slot at line 7964. Asymmetric — read-side guarded, write-side raw.
Admin (or asset_authority via RETIRE) can force header.current_slot forward to an arbitrary now_slot value as long as now_slot >= header.current_slot.get() (the line-7946 stale check). The slot scalar is consumed by downstream gates (e.g. slot_last < current_slot loss-stale comparisons) so a privileged-but-skewed write can flip downstream state.
griefing (privileged), undocumented
percolator-prog/tests/v16_low_source_pins.rs:118
--- a/percolator-prog/src/v16_program.rs
+++ b/percolator-prog/src/v16_program.rs
@@ -7961,7 +7961,9 @@
cfg.free_market_slot_count = cfg.free_market_slot_count
.checked_add(1)
.ok_or(PercolatorError::EngineCounterOverflow)?;
- group.header.current_slot = percolator::V16PodU64::new(now_slot);
+ group.header.current_slot = percolator::V16PodU64::new(
+ authenticated_slot_or_fallback(now_slot),
+ );
V6 — FIX_VERIFIED_PATCH_FLIPS_POC RETIRE arm authenticated_slot_or_fallback wrap → source-pin FAILED at line 119 (raw write absent).
percolator-prog/src/v16_program.rs:359-361 (state::is_initialized) + ClosePortfolio handler + SyncInsuranceLedger init path
state::is_initialized checks only the magic u64 at offset 0. ClosePortfolio zeroes the buffer (including magic), so is_initialized() returns false afterward. SyncInsuranceLedger's read_or_new_insurance_ledger then takes the "not initialized" branch and re-types the same Solana account as an InsuranceLedger.
A closed portfolio key can be re-purposed as an insurance ledger by anyone who can call SyncInsuranceLedger with that account. The behavioral POC confirms the account passes read_insurance_ledger post-conversion. No theft path identified yet (the new InsuranceLedger carries the caller-provided authority), but the kind-confusion is a foothold for future composability bugs.
latent / griefing
percolator-prog/tests/litesvm_l5_misc.rs:485
--- a/percolator-prog/src/v16_program.rs
+++ b/percolator-prog/src/v16_program.rs
@@ -355,9 +355,17 @@
#[inline]
pub fn is_initialized(data: &[u8]) -> bool {
- data.len() >= HEADER_LEN && read_u64(data, 0).ok() == Some(MAGIC)
+ // Treat a "closed-sentinel" account as still initialized so subsequent
+ // init paths refuse to re-type it. See C27-2.
+ if data.len() >= HEADER_LEN && read_u64(data, 0).ok() == Some(CLOSED_SENTINEL_MAGIC) {
+ return true;
+ }
+ data.len() >= HEADER_LEN && read_u64(data, 0).ok() == Some(MAGIC)
}
+
+ pub const CLOSED_SENTINEL_MAGIC: u64 = 0x434C_4F53_4544_5F31; // "CLOSED_1"
// Companion: ClosePortfolio writes the sentinel as its last step:
// data.fill(0);
// data[..8].copy_from_slice(&state::CLOSED_SENTINEL_MAGIC.to_le_bytes());
V6 — FIX_VERIFIED_PATCH_FLIPS_POC CLOSED_SENTINEL_MAGIC in is_initialized + write sentinel in close → litesvm POC FAILED at line 495 (close_portfolio must zero data buffer assertion broke). .so rebuilt with cargo build-sbf.
percolator-prog/src/v16_program.rs:7515-7572
Handler moves SPL tokens between primary and secondary vaults but never opens a mutable market view. The engine's group.header.vault (which is denominated in primary collateral) sees no debit even though the SPL primary-vault balance increased and the SPL secondary-vault balance dropped.
SPL balances drift above the engine's accounted scalar. Down-stream insurance / withdraw paths bound by header.vault underestimate the real reserve. Reversible (no theft path under current accounts), but accounting invariants are violated.
latent (accounting drift, no immediate drain vector)
percolator-prog/tests/v16_low_source_pins.rs:155
--- a/percolator-prog/src/v16_program.rs
+++ b/percolator-prog/src/v16_program.rs
@@ -7549,12 +7549,28 @@
+ // Reflect the primary-collateral inflow in the engine's accounted
+ // vault scalar. Without this, group.header.vault drifts below the
+ // SPL primary-vault balance. See C33-5.
+ {
+ let mut market_data = market_ai.try_borrow_mut_data()?;
+ let (_, mut group) = state::market_view_mut(&mut market_data)?;
+ group.header.vault = percolator::V16PodU128::new(
+ group.header.vault.get()
+ .checked_add(amount)
+ .ok_or(PercolatorError::EngineCounterOverflow)?,
+ );
+ group.validate_shape().map_err(map_v16_error)?;
+ }
V6 — FIX_VERIFIED_PATCH_FLIPS_POC handle_swap_secondary_for_primary group.header.vault update → source-pin FAILED at line 164 (handler contains group.header.vault =).
percolator-prog/src/v16_program.rs:8905-9022 (conditional at :8931-8937)
expect_signer(owner) is gated inside if cfg.force_close_delay_slots != 0 && elapsed < force_close_delay_slots. Once elapsed >= force_close_delay_slots (or if force_close_delay_slots == 0), the signer check is skipped, and any caller can submit close on the victim's behalf. The handler still derives dest_token from accounts[3] and the verified token account ownership check (verify_withdrawable_token_accounts, line 9001) ensures payout lands at the legitimate owner's ATA — so no theft.
Force-claim griefing. Anyone can finalize a victim's portfolio close at the moment force-close eligibility opens, denying the victim the ability to time their own close (e.g., to pair with another tx, to wait for a better swap rate downstream, to keep the position open for tax purposes).
griefing-only — funds are not redirected
percolator-prog/tests/v16_low_source_pins.rs:198
--- a/percolator-prog/src/v16_program.rs
+++ b/percolator-prog/src/v16_program.rs
@@ -8928,12 +8928,11 @@
if group.header.mode != 1 {
return Err(PercolatorError::EngineLockActive.into());
}
- if cfg.force_close_delay_slots != 0
- && authenticated_market_slot_or_fallback_view(&group)
- .saturating_sub(group.header.resolved_slot.get())
- < cfg.force_close_delay_slots
- {
- expect_signer(owner)?;
- }
+ // C34-C: always require the portfolio owner to authorize their own
+ // close. The prior gating left a force-claim griefing window once
+ // the delay elapsed.
+ expect_signer(owner)?;
V5 (2026-05-26 08:15:39Z) — FLIPPED — patch removes the conditional gate entirely, pin trips at line 224. Compile clean.
Cluster merges reduced 39 candidates to 36:
| Merged into | From | Rationale |
|---|---|---|
F1 | C6-1 + C6-2 + C6-3 | Three symptoms of one bug — wrapper handle_close_resolved re-implements inline instead of delegating to engine. One patch, three asserted defects. |
F4 | C3-4 + C3-5 | Symmetric pair — same write-only counter pattern on insurance side (C3-4) and counterparty side (C3-5). Two patches, one disclosure narrative. |
F13 | C20-1 + C20-2 | Two adjacent push handlers (auth_mark + ewma_mark) with identical fix shape — both omit asset.effective_price refresh. One disclosure. |
F16 | C32-1 + C32-3 | Two facets of one LATENT problem — trade CU scales linearly with max_market_slots. Wrapper cap blocks today; one latent advisory. |
All POCs are reproducible from the bounty repo:
cargo test --features test --test <pin_file>cargo kani --tests --features fuzz --harness <harness_name>cargo test --test v16_cu <test_name> -- --nocapture. BPF build via cargo build-sbf --no-default-features.percolator-prog's Cargo.toml pins engine as a git dep at rev 23de295. Local edits to percolator/src/v16.rs are NOT picked up by wrapper-side test compilation unless the same edit is applied to ~/.cargo/git/checkouts/percolator-*/23de295/src/v16.rs AND percolator-prog/target/debug/deps/libpercolator-*.rlib is deleted.handle_trade_cpi.| Stage | Author | Verified by |
|---|---|---|
| L1 hunt | Jelleo hunt agents (38 surfaces beyond anti-scope) | Operator (Kirill) |
| L1.5 triage | 4-way reviewer debate per candidate | Operator |
| L2 POCs | POC author agents (LiteSVM + source-pin) | Compiler + test runner |
| L3 Kani | K1-K6 parallel agents (CBMC + cadical) | Kani VERIFIED output |
| L4 LiteSVM | L4 agents (V16CuEnv harness) | Operator review of test output |
| L5 narratives | Per-cluster narrative agents (N1-N6) | Operator review |
| L6 patch verify | V1-V6 verification agents | POC flip PASS→FAIL |
| Report assembly | This document | Operator cover-to-cover read |
END OF REPORT · 36 unique findings · 2026-05-26