Skip to content

Security: dcccrypto/percolator-prog

Security

security.md

Security Audit — v12.18.x

Working log for the ship-blocking audit loop. Only tests that EXPOSE real bugs are committed to the tree; probes that confirmed safe behavior are logged here and discarded.

Findings

(none validated so far in this audit pass)

Discarded candidates

Each entry records: what I suspected, the concrete exploit attempt, the code path, and why the concern turned out to be invalid. Tests written to probe these were deleted; the log is what remains.

D1. TradeCpi variadic tail — slab aliasing

Hypothesis: A malicious caller passes the slab account in the variadic tail (writable). The wrapper forwards it to the matcher CPI, giving the matcher a second AccountInfo reference to the slab. The matcher could bypass the reentrancy guard (set on the slab) or corrupt engine state via a crafted CPI callback.

Why discarded:

  • The matcher is not the slab's owner (percolator_prog is). Solana's runtime silently discards writes to non-owned accounts; any attempted mutation by the matcher is a no-op.
  • If the matcher re-enters TradeCpi with the same slab, the reentrancy guard FLAG_CPI_IN_PROGRESS fires on the inner call and rejects.
  • The outer slab_data_mut borrow is released before the CPI, so the matcher can read (not write) the slab. Reads alone can't steal funds or corrupt state.

D2. TradeCpi variadic tail — signer forwarding

Hypothesis: Caller sneaks a third-party signer into the tail; matcher uses the signer to authorize a different action (e.g. withdraw from the signer's account in another market).

Why discarded: Solana tx-level signer flags are bounded by what the outer tx signed. A third party's signer can only appear in the tail if that party already co-signed the TradeCpi tx. If they co-signed, they consented to whatever the matcher does with the signer — the matcher is explicitly LP-delegated.

D3. Matcher returns adversarial exec_price

Hypothesis: Malicious matcher returns exec_price_e6 far from the oracle to fleece the LP's counterparty.

Why discarded: Wrapper enforces an anti-off-market band |exec_price - oracle| * 10_000 <= max(2 * trading_fee_bps, 100) * oracle_price (src/percolator.rs:6040-6061). Minimum band is 1% (100 bps). Wide bands require operator-set fees; caller-controlled LP delegation bounds the matcher's latitude.

D4. Market non-resolvable with last_oracle_price = 0

Hypothesis: Non-Hyperp market init succeeds but last_oracle_price is never seeded (e.g., oracle read skipped on init). Subsequent stale market has no price to settle at; ResolvePermissionless rejects (if p_last == 0 return OracleInvalid), funds trapped.

Why discarded: InitMarket reads the oracle unconditionally for non-Hyperp markets (src/percolator.rs:4458-4475) and rejects if the read fails or returns 0. init_in_place then seeds last_oracle_price with the real price. The "last_oracle_price = 0 after init" scenario is unreachable.

D5. AdminForceCloseAccount / ForceCloseResolved skip ATA verification on zero payout

Hypothesis: When force_close returns Closed(0), owner ATA verification is skipped (if amt_units > 0). An attacker could pass a malicious token account as owner_ata. Later, if some path produced a nonzero payout without re-verification, funds would leak.

Why discarded: collateral::withdraw (src/percolator.rs around line 3000) has if amount == 0 { return Ok(()); } — no SPL Transfer CPI is ever invoked with zero amount. The unverified ATA is never actually used as a transfer destination.

D6. Self-trade via same-owner LP + user

Hypothesis: Attacker sets up one owner controlling both an LP account (with a matcher they control) and a user account. They "trade" between them to move the mark EWMA or accumulate funding at no cost.

Why discarded: Every trade routes fees to the insurance fund (100% of fee, both sides). The attacker pays REAL fees in proportion to the trade notional. The fee-weighted EWMA + mark_min _fee threshold in the spec design means the attacker must burn real capital to move the mark. The engine blocks exact a == b (src/percolator.rs:3900 in the engine crate).

D7. ResolvePermissionless at split current_slot > last_market_slot

Hypothesis: After my InitUser pure-deposit change, current_slot can exceed last_market_slot on a no-oracle path. If ResolvePermissionless runs in that state, does it corrupt resolved_slot or produce incorrect settlement?

Why discarded: engine.resolve_market_not_atomic(Degenerate, ..., clock.slot, 0) passes clock.slot as resolved_slot. Engine validates now_slot >= current_slot (monotonicity). The Degenerate arm runs at rate=0, so no funding accumulation with stale fund_px_last. Settlement is at last_oracle_price (seeded at init, updated by every accrue-bearing op). No path corrupts resolved state.

D8. LiquidateAtOracle partial liquidation leaves account below MM

Hypothesis: Partial liquidation reduces position but leaves account still undercollateralized, allowing the liquidator to repeatedly extract fees without actually closing the risk.

Why discarded: Wrapper invokes liquidate_at_oracle_not_atomic(target_idx, ..., LiquidationPolicy::FullClose, ...) (src/percolator.rs:6299-6304). The FullClose policy flattens the position in one call. There is no partial liquidation path exposed at the wrapper.

Methodology

For each hypothesis above, I:

  1. Located the code path supposedly enabling the exploit.
  2. Drafted a concrete test sequence.
  3. Either ran the test (if it was small enough to execute) or walked the code mechanically to prove the exploit is blocked.
  4. Discarded the finding when the proof held.

Hard rules followed

  • No finding committed without a failing test.
  • No tests committed for hypotheses that turned out not to be bugs.
  • Where the proof is mechanical (D5, D6, D7), I trace the exact line numbers that block the exploit rather than writing a ceremonial passing test.

D9. KeeperCrank reward lets attacker siphon insurance

Hypothesis: Attacker creates many dummy accounts that accrue maintenance fees, then cranks first after a long wait to collect 50% of the sweep. Net profit from insurance fund.

Why discarded: The attacker's dummy accounts ARE the ones paying the swept fees. Net flow: attacker pays N × fee_per_slot × dt → 50% back to attacker (their own sweep), 50% to insurance. Net LOSS of 50% on their dummy-account fees. Not profitable.

D10. CloseSlab drains unsolicited vault tokens to close_authority

Hypothesis: Users who mistakenly transfer tokens directly to the vault PDA (outside DepositCollateral) have their tokens stolen when close_authority calls CloseSlab — the "stranded" drain sends those tokens to close_authority.

Why discarded: Not a protocol vulnerability — this is user error (sending tokens outside the deposit path). Documented behavior. verify_vault_empty at InitMarket (line 3873) checks tok.amount == 0, so an admin cannot pre-load a vault with stranded tokens. Running a vault through the full lifecycle (deposits → full withdrawals → close) leaves engine.vault at 0; any "stranded" amount is strictly unsolicited.

D12. ReclaimEmptyAccount donates user dust to insurance

Hypothesis: After my sync-before-reclaim fix, a keeper can force-realize maintenance fees on a flat user account, dropping capital below min_initial_deposit, then reclaim the account — the user's remaining dust capital (potentially almost a full min_initial_deposit) goes permanently to the insurance fund. The user has no way to recover it.

Attack sequence:

  1. User deposits exactly min_initial_deposit (e.g., 100). Opens and closes a position. Leaves 100 capital with no open position.
  2. Maintenance fee accrues: 1/slot × N slots = N fees owed but not yet realized (no crank has run).
  3. Keeper calls ReclaimEmptyAccount. My added sync realizes N fees, capital drops to 100−N. If 100−N < 100, reclaim eligibility passes.
  4. Engine's reclaim path (engine line 5352-5359) transfers dust capital to insurance, zeros user capital, frees slot. User has no slot or capital left.

Why discarded (not a vuln, by design):

  • Reclaim requires position=0, pnl=0, reserved_pnl=0, sched_present=0, pending_present=0, fee_credits>=0 (engine line 5314-5331). Only truly flat, abandoned accounts are eligible.
  • User can call WithdrawCollateral at any point ONLY UNTIL fees exceed capital. After that, withdraw fails with EngineInsufficientBalance (0x0d). CORRECTION: my earlier wording "at any point" was imprecise. Spot-check test verified this: with maint_fee_per_slot=10 and 20 slots idle, a min- deposit user's capital (100) is exceeded by owed fees (200); capital drains to 0, fee_debt accumulates, reclaim eligible. User cannot withdraw at that point.
  • Still not a ship-blocker: the USER's mitigation is to deposit above min + close active before going offline for a period longer than the fee-exhaustion window. Maint_fee is immutable after init (no UpdateConfig path), so users can see the rate and size their deposit accordingly.
  • Admin's choice of maint_fee_per_slot determines the user-fund- at-risk surface. Aggressive fees → faster drain. Realistic fees (~1 per hour) → multi-month-long survival for min deposits. Operators should set maint_fee thoughtfully.
  • Pre-fix behavior was identical in the end state: fees are OWED regardless of when they're realized. My fix just makes reclaim realize them synchronously (matching spec §10.7). Without the fix, reclaim failed to realize pending fees — meaning an abandoned account with unrealized fees would indefinitely block the slot while the user's capital was silently unrecoverable.
  • This is the spec's documented dormant-account cleanup path (spec §2.6). Not theft; abandoned-dust → insurance is the intended economic rule.

D11. TradeCpi tail meta with forged signer flag

Hypothesis: Caller crafts a tail AccountMeta with is_signer: true for an account that did NOT actually sign the outer tx, hoping the matcher CPI sees the account as a signer and uses it to authorize a privileged action on another slab.

Why discarded: Solana's runtime validates every AccountMeta { is_signer: true } against the set of actual signers on the outer tx. An AccountMeta's signer flag is only respected in CPI privilege propagation if the account's AccountInfo.is_signer is already true — which only happens when the outer tx was signed by that account. A caller cannot fake signer privileges on a tail account. My tail forwarding code preserves tail_ai.is_signer verbatim (src/percolator.rs around the TradeCpi metas loop), which is pass-through of the existing privilege — no elevation possible.

D13. Nonce wrapping replay on TradeCpi

Hypothesis: req_id nonce wraps after 2^64 increments, allowing a stored matcher_ctx buffer from an old trade to match a new request with the same req_id modulo wrap.

Why discarded: verify::nonce_on_success (src/percolator.rs:384) uses checked_add(1) and propagates None as rejection (src/percolator.rs:5789 treats overflow as EngineOverflow). No wraparound possible; at 2^64 trades, the market halts cleanly rather than replaying. Not a practical attack surface (>10^11 years at 1 trade per 0.4s slot).

D14. Matcher_ctx aliasing with slab

Hypothesis: Attacker passes the slab as matcher_ctx. After the CPI returns, the wrapper reads matcher return data from the slab bytes, potentially smuggling crafted slab state as a valid matcher return.

Why discarded: verify::matcher_shape_ok (src/percolator.rs:5668-5676) requires ctx_owner_is_prog: a_matcher_ctx.owner == a_matcher_prog.key. The slab is owned by percolator_prog, not the matcher program, so this check fails before the CPI.

D15. Account close bypasses warmup

Hypothesis: User opens a position, matures positive PnL into reserved_pnl, then calls CloseAccount to extract the pending bucket as capital before warmup period elapses.

Why discarded: close_account_not_atomic (percolator/src/percolator.rs:4888-4894) rejects with Undercollateralized if ANY of:

  • reserved_pnl != 0
  • sched_present != 0
  • pending_present != 0 All three warmup signals must be clean before close. Early close with pending warmup PnL is not possible.

D16. InitMarket with zero admin

Hypothesis: Admin is set to [0; 32] at init; require_admin check would need careful handling or a malicious caller with a zero signer key (unreachable but worth checking) could spoof admin.

Why discarded: verify::admin_ok (src/percolator.rs:339-341) explicitly rejects admin == [0; 32]. The zero address is reserved for "burned" state; no signer can claim admin privileges against a zero-admin header. InitMarket sets admin to a_admin.key.to_bytes() (src/percolator.rs:4633) — if the admin signer is zero, it fails validation upstream (Solana signer-flag checks).

D21. Cross-market reentrancy via malicious matcher

Hypothesis: During TradeCpi's matcher CPI in market A, the malicious matcher CPIs back to the wrapper with a DIFFERENT slab (market B). The reentrancy flag FLAG_CPI_IN_PROGRESS is scoped to market A's slab, so market B's operations would see the flag unset and proceed. Could this grant privileges the attacker didn't have?

Why discarded: Cross-market ops from inside matcher CPI require the attacker to already have privileges on market B:

  • Admin-gated ops need B's admin to sign (not available to matcher)
  • LP PDA signing uses market B's slab in seeds (different from A's)
  • User-signed ops need B's user to sign (attacker's own user, fine)
  • Permissionless ops (Crank, CatchupAccrue, ResolvePermissionless) are already callable by anyone

The matcher cannot elevate privileges via cross-slab CPI. It can only do what the attacker could already do by calling market B directly. Not a reentrancy flaw.

D23. Stale matcher_ctx data passes ABI validation

Hypothesis: Malicious matcher writes req_id=N to matcher_ctx then panics before writing exec_price. Wrapper reads ctx, sees req_id=N matches current request, accepts. Partial data bypasses security.

Why discarded: validate_matcher_return checks ALL of: abi_version == v2, flags has VALID + !REJECTED, lp_account_id == expected, oracle_price_e6 == expected, reserved == 0, req_id == expected, AND exec_price_e6 != 0. A partial write that leaves exec_price = 0 (or any zero field) is rejected. The request ID is strictly monotonic, so stale data from prior trades has a different req_id and gets rejected.

D24. Fee debt forgiveness at reclaim is theft from insurance

Hypothesis: Account reaches reclaim eligibility with negative fee_credits (unpaid fee debt). On reclaim, engine zeroes the debt (engine reclaim line: self.accounts[idx].fee_credits = I128::new(0)). Insurance fund permanently loses this expected revenue.

Why discarded: Spec §2.6 explicitly describes this as debt write-off for abandoned accounts. Not theft — the user had no capital to pay (else reclaim wouldn't fire). Charging a zero-capital account more fees is accounting theater. Insurance only loses revenue it would not have collected anyway.

D25. LP matcher registration cannot be changed

Hypothesis: LP's matcher_program and matcher_context are set at InitLP and immutable. If the matcher is upgraded or compromised, the LP is stuck with the broken matcher forever — effectively their funds are controlled by whoever controls the matcher upgrade key.

Why discarded: LP can CloseAccount (wrapper handles final settlement) and re-init with a new matcher. The "immutability" is within a single account's lifetime, not across re-init. Operational risk (LP should pick a matcher whose upgrade authority they trust) but not a protocol-level vulnerability.

D26. WithdrawInsurance drains user funds

Hypothesis: Admin (as insurance_authority) calls WithdrawInsurance while users still have open accounts, stealing what users believe is their capital via the shared vault balance.

Why discarded: The handler (src/percolator.rs:7529-7531) checks if engine.num_used_accounts != 0 → reject. Admin must wait for every user account to close (via AdminForceCloseAccount or user self-close) before withdrawing insurance. At num_used_accounts=0, c_tot=0, pnl_pos_tot=0 (further asserted lines 7546-7550), the vault contents are not owed to any user — withdrawing is correct accounting.

D27. WithdrawInsurance with burned insurance_authority traps funds

Hypothesis: Admin burns insurance_authority before the market has drained. Later all users close. Insurance balance is nonzero but nobody can call WithdrawInsurance (require_admin rejects zero).

Why discarded: Not a user-fund-theft vulnerability — it's the protocol's own excess capital trapped. The design is intentional: operators who want "rug-proof for users" can burn insurance_authority at init, knowing that insurance will never be paid out. Users' individual claims are already withdrawable via close/force-close. The trapped insurance is structurally designed to be inaccessible.

D28. UpdateConfig funding param change retroactively reprices accrual

Hypothesis: Admin calls UpdateConfig to change funding_k_bps or funding_max_bps_per_slot. The accrued period [last_market_slot, clock.slot] is re-priced at the NEW rate, retroactively applying the admin's chosen rate to elapsed time.

Why discarded: UpdateConfig captures funding_rate_e9 BEFORE any config mutation (src/percolator.rs:6763) and passes it to catchup_accrue + accrue_market_to (lines 6842-6847). ONLY after the accrue completes does the handler write the new funding params (lines 6856-6859). The accrued interval uses the pre-change rate. Post-accrue time uses the new rate. Anti-retroactivity is preserved (spec §5.5).

D29. LP with stuck counterparty positions cannot close

Hypothesis: LP has matched several user trades and accumulated positions. Users never close their sides. LP cannot CloseAccount because position != 0. Funds trapped.

Why discarded: Not a bug — standard perp-market lifecycle. LP must trade OUT of each position (or wait for liquidation / resolution). If no counterparty is willing to trade the other way, the market still eventually resolves (admin or permissionless timeout), and resolved close returns LP's capital. Perp-market liquidity is an operational concern, not a vulnerability.

D30. Trade at exec_price clamped to MAX_ORACLE_PRICE

Hypothesis: Matcher returns exec_price exactly at MAX_ORACLE_PRICE (10^12). Engine's notional computation overflows.

Why discarded: MAX_POSITION_ABS_Q = 10^14. notional = size × price / POS_SCALE = 10^14 × 10^12 / 10^15 = 10^11, well within u128 range. The engine's envelope invariant (spec §1.4) guarantees all arithmetic stays under i128::MAX (~1.7×10^38) across the full product of (size × price × rate × lifetime).

D31. Account slot DoS by single attacker

Hypothesis: Attacker creates min_initial_deposit-sized accounts until all params.max_accounts slots are full. No other user can init. Market is bricked.

Why discarded (not a fund-theft vuln, operational):

  • Attacker does NOT lose funds — they can CloseAccount any time to recover their deposits. Funds are LOCKED during the DoS, not stolen or trapped.
  • max_accounts is operator-configured at init (up to 4096). Operators who want higher DoS resistance set max_accounts high AND/OR require higher min_initial_deposit so the attack costs more.
  • A DoS-sized attack on a market with max_accounts=4096 and min_initial_deposit=100 base tokens costs the attacker ~409_600 base tokens of locked capital for however long they choose to maintain the DoS.
  • No existing user's funds or positions are affected. Existing users can continue to trade, close, liquidate. Only NEW user onboarding is blocked.
  • Spec §2.2 documents max_accounts as an operator choice. Not a protocol bug; an economic-model consideration.

D22. Dust-capital account remains operational

Hypothesis: User's capital drops below min_initial_deposit (e.g., via fee sweeps). The account is "reclaimable dust" eligible, but still operationally active. Can the user accidentally trigger reclaim by calling their own instructions, losing dust?

Why discarded: Reclaim is a SEPARATE instruction (ReclaimEmptyAccount). No other instruction auto-reclaims on dust detection. The user can top up via DepositCollateral any time before a keeper races in with reclaim. This is the intended dormant-account cleanup semantics (spec §2.6).

D32. Malicious matcher: attacker controls matcher program

Hypothesis: Attacker is the LP AND controls the matcher program. They can make the matcher return adversarial exec_prices (within 1% band) to steal a small amount from each counterparty across many trades.

Why discarded: LP delegation to a matcher is an explicit trust choice. The matcher is bounded by the anti-off-market band (≥1% or 2× trading_fee_bps, whichever is larger). Within that band, the matcher can indeed pick unfavorable prices for the counterparty — this is the cost of using that LP. Counterparties can choose a different LP with a trusted matcher. The wrapper's job is to ENFORCE the band, not to pick matchers for LPs.

D33. InitMarket with tiny min_initial_deposit reduces DoS cost

Hypothesis: Operator sets min_initial_deposit = 1 base token. DoS cost drops to 4096 × 1 = 4096 tokens — trivial.

Why discarded: Spec parameter choice, operator responsibility. Wrapper does bound min_initial_deposit (min > 0, max ≤ MAX_VAULT_TVL in RiskParams validation), but does not prescribe a minimum floor — it's left to operator economics. Not a protocol bug.

D34. Same-slot oracle price oscillation

Hypothesis: Multiple ixs in a single tx see different oracle prices (via re-reads or oscillation). Attacker constructs a same- slot tx that opens a position at one price then closes at another within the same block, profiting from the oscillation.

Why discarded:

  • Pyth prices update at most once per slot (deterministic within a tx).
  • Hyperp mark updates only on successful trades. One trade per tx affects the mark for future txs, not the current one.
  • Authority push is admin-initiated, not attacker-controllable in the same tx as a trade.
  • No path allows an attacker to change the oracle state between ixs within their own tx without being the oracle authority.

D35. Scan cursor starvation

Hypothesis: KeeperCrank's scan_cursor could be manipulated to skip specific accounts, letting an insolvent account avoid liquidation.

Why discarded: The cursor is modular (`word_cursor = (word_cursor

    1. % BITMAP_WORDS`). All slots are eventually visited. Scan order is deterministic per cursor state; no one can "skip" an account persistently. Additionally, LiquidateAtOracle is callable directly by any keeper without going through crank — so no liquidation is dependent on crank scanning.

D36. accrue_market_to same-slot different-price MTM

Hypothesis: Within a single slot, multiple accrue calls with different prices could cause double-counted mark-to-market deltas.

Why discarded: accrue_market_to's mark-to-market step uses current_price = self.last_oracle_price, computes delta_p = new - old, and applies delta. After each call, last_oracle_price = new. Subsequent calls use the updated value. Telescoping sum equals the total mark movement. No double-counting.

D37. Tiny trades with zero-rounded fees

Hypothesis: Trade with size_q = 1 (smallest unit) produces a notional so small that the trading fee rounds to 0 in integer arithmetic. Attacker does billions of tiny trades for free to manipulate state.

Why discarded:

  • Zero-fee trades contribute ZERO weight to the mark EWMA (fee-weighted update). No EWMA manipulation possible.
  • Each tiny trade is still a full tx with Solana-level fees (lamports). Economic cost to the attacker scales with trade count.
  • Position changes of 1-unit size are too small to meaningfully affect any other account's health or funding.
  • Fee-rounding is asymmetric (ceil) — protocol rounds fees UP when possible (mul_div_ceil_u128), minimizing free rides.

D38. Double-crank same slot

Hypothesis: Two keepers call KeeperCrank at the same slot. Double-accrual, double fee-sweep, double reward.

Why discarded: Solana tx linearization. First tx lands, accrues market to the slot. Second tx sees already-accrued state; accrue_market_to with dt=0 is a no-op early return (engine line 2162). Maintenance fee sweep on second call finds nothing to sweep (first drained the budget). No double-counting.

D39. Slab lamport drain-induced garbage collection

Hypothesis: Attacker somehow drains slab's lamports below rent exemption, Solana garbage-collects the slab mid-operation, data is lost.

Why discarded: No wrapper path drains slab lamports except CloseSlab (gated by close_authority + num_used_accounts=0). InitMarket pays rent at creation. The slab stays rent-exempt throughout its lifecycle. Solana-level account deletion is not reachable from the wrapper's API surface.

D40. Nonce replay after failed TradeCpi

Hypothesis: TradeCpi computes req_id but fails after the CPI (before write_req_nonce). Nonce isn't advanced. Next attempt reuses req_id — could a stale matcher return match and allow a replay?

Why discarded: Tx atomicity. If TradeCpi errors, ALL state writes (including matcher_ctx if matcher wrote to it) roll back. The matcher_ctx reverts to its pre-tx state. Nonce counter also reverts. Next tx starts from the pre-fail state; no stale matcher return data exists to replay against.

D41. num_used_accounts desync

Hypothesis: free_slot / materialize_at updates num_used_accounts. Could a failure path leave the counter out of sync with the used bitmap, causing permanent slot leaks or overcounting?

Why discarded: materialize_at (engine line 1149+) has extensive on-failure decrement rollback (lines 1172, 1182, 1196, 1201, 1206, 1211, 1215). free_slot uses checked_sub (line 1137) against the counter. Any inconsistency would surface as CorruptState immediately, not a silent leak.

D42. TradeCpi zero-fill burns nonces without executing trades

Hypothesis: Attacker abuses zero-fill returns (exec_size=0 + PARTIAL_OK) to increment the req_nonce indefinitely, eventually exhausting the u64 space and bricking trading via nonce_on_success → None.

Why discarded: u64 nonce space = 2^64. At 1 trade per 400ms slot, exhausting takes ~234 billion years. Each nonce burn is a full tx with Solana-level fees. Economic impossibility, not a protocol flaw.

D43. Resolved-payout snapshot premature lock

Hypothesis: resolved_payout_h_num/h_den snapshot locks based on is_terminal_ready() == (neg_pnl_account_count == 0). If the counter is ever desynced from reality, the snapshot could lock prematurely, paying out winners at a favorable ratio that doesn't account for not-yet-reconciled losers.

Why discarded: neg_pnl_account_count is maintained via checked_add/checked_sub at every PnL/capital transition point (set_pnl line 1466-1469, set_capital line 1527-1532). Any inconsistency surfaces as CorruptState immediately. There is no silent-drift path: the counter is only updated inside the state mutation functions that also control the sign transitions, so it reflects the actual count at all times.

D44. c_tot desync on close

Hypothesis: CloseAccount removes user's capital from vault but could miss updating c_tot, leaving c_tot elevated post-close. Conservation V >= C_tot + I would then be violated (V decreased more than C_tot).

Why discarded: All capital mutations route through set_capital (engine line 1588) which applies the signed delta to c_tot using checked arithmetic. CloseAccount's set_capital(idx, 0) decrements c_tot by exactly the account's prior capital. assert_public_post conditions then verifies conservation; a drift would surface as CorruptState.

D45. Haircut ratio with senior sum overflow

Hypothesis: c_tot + insurance_fund.balance overflows u128, engine treats this as residual=0 (maximum haircut). Could this be exploited to force-haircut winners unfairly?

Why discarded: c_tot ≤ MAX_VAULT_TVL (10^16), insurance ≤ MAX_VAULT_TVL (10^16). Sum ≤ 2×10^16 ≪ u128::MAX (~3.4×10^38). Overflow is not reachable in practice. The conservative "treat as zero residual" on hypothetical overflow is a SAFE failure mode: winners get MORE haircut (smaller payout) which preserves conservation V ≥ C_tot + I. Cannot cause over-payout to winners.

D46. Zero-margin configuration

Hypothesis: Admin sets initial_margin_bps = 0 at init. User opens huge positions with only min_nonzero_im_req capital, creating risk the protocol can't cover.

Why discarded: Wrapper rejects initial_margin_bps == 0 at init (src/percolator.rs:4190-4194). Both initial and maintenance must be nonzero. Admin cannot configure a zero-margin market.

D47. Insurance-floor drain via inflated losses

Hypothesis: Attacker triggers loss cascades to drain insurance to the floor, leaving legitimate later losses to hit junior haircut early. Winners get haircut that wouldn't have fired without the attacker's actions.

Why discarded: Losses require the ATTACKER's position to move against them. To cause large losses that drain insurance, attacker must themselves lose money. Net economic flow: attacker pays loss out of their capital → drains insurance → haircut on winners. Attacker doesn't benefit from the haircut (they're not the winner). Attacker cost > any gain; not rational.

D48. Junior haircut preservation (record_uninsured_protocol_loss)

Hypothesis: After insurance is drained, the remaining uninsured loss gets "double-counted" — once via haircut on matured pos, once via vault reduction — unfairly penalizing winners.

Why discarded: record_uninsured_protocol_loss (engine line 2326) is explicitly a no-op per spec §4.17. Code comment documents the exact reason: double-draining would penalize winners twice. The current implementation is correct — losses are absorbed purely via the haircut mechanism after insurance is drained, without additional vault reduction.

D49. Cross-close reconcile ordering

Hypothesis: In a resolved market with many winners and losers, the order in which users call CloseAccount matters. Closing losers first vs. winners first might produce different total payouts due to haircut snapshot timing.

Why discarded: The haircut snapshot (resolved_payout_h_num/ h_den) is LOCKED AT THE MOMENT is_terminal_ready() becomes true — which is exactly when neg_pnl_account_count reaches 0 (all losers reconciled). Winners calling Phase 2 before this point receive ProgressOnly (no payout, just persistence). After the snapshot locks, every winner's payout uses the SAME (h_num, h_den). Order-invariant by design.

D50. Epoch wrap in stale-reconcile check

Hypothesis: adl_epoch_snap is u64; after 2^64 epochs, wrap could make epoch_snap + 1 == epoch_side spuriously match a non-stale account in a corrupted way.

Why discarded: Epoch increments only on reset_pending → reset lifecycle, which happens at most a few times per market. Reaching 2^64 epochs would take >10^10 years of continuous reset activity. Not a practical attack surface; the engine uses checked_add(1) at line 5155 which would fail with None on wrap, producing CorruptState rejection rather than a silent wrap-induced match.

D51. Mat_counter overflow

Hypothesis: u64 mat_counter wraps after 2^64 materializations, allowing gen collision → stale lp_account_id matches new account.

Why discarded: next_mat_counter uses checked_add (line 2165), returns None on overflow. Callers map None to EngineOverflow error rejection. 2^64 materializations is not practically reachable.

D52. Empty-market keeper crank

Hypothesis: Keeper cranks on a market with num_used_accounts=0. Some state mutation or reward payment could happen on the empty state that shouldn't.

Why discarded: Crank with no accounts: scan finds no bits set, no candidates processed, no fees swept (no accounts to sweep). sweep_delta = 0 → reward gate at line 5327 (sweep_delta > 0) blocks reward payment. Crank returns cleanly; no state mutation beyond the accrue-to-clock-slot (which is the correct ongoing market-clock advancement, not a bug).

D53. Funding f_snap desync

Hypothesis: When an account changes position, f_snap is updated to the current side's f_{side}_num. If this update is missed on some code path, subsequent funding PnL computation would use stale snapshot, producing wrong PnL.

Why discarded: set_position_basis_q (engine line 1700-1717) is the single entry point for position changes; it atomically updates adl_a_basis, adl_k_snap, f_snap, adl_epoch_snap together. No mutation path sets position without going through this function. The engine's 243 Kani proofs include invariants verifying this snapshot synchronicity.

D54. ADL coefficient reset during epoch transition

Hypothesis: When epoch resets (side becomes ResetPending → reopen), f_{side}_num and adl_coeff_{side} are zeroed. If this happens while an account still holds epoch_snap of the OLD epoch, reconcile can't reconstruct old PnL.

Why discarded: The engine reconciles stale-epoch accounts (spec §5.4) by using F_epoch_start_{side} (the F value snapshotted at epoch start, NOT current F). Reconcile line 5166: f_end_wide = I256::from_i128(self.get_f_epoch_start(side)). So the reset doesn't lose the information needed for prior-epoch reconciliation.

D55. clamp_oracle_price saturating underflow

Hypothesis: clamp_oracle_price computes lower = last_price - max_delta. If max_delta > last_price, the subtraction saturates to 0, allowing raw_price = 0 to be accepted as a valid clamped price — but OracleInvalid check rejects price=0 elsewhere. Does any path accept a 0 price?

Why discarded: Every oracle consumer (read_pyth_price_e6, read_chainlink_price_e6, read_authority_price) checks for price=0 and rejects with OracleInvalid. The clamp's saturating behavior is only applied AFTER validation ensures the raw input is nonzero; even if the clamped value were 0, the downstream if mark == 0 → OracleInvalid (e.g., line 2951 in get_engine_oracle_price_e6) rejects it.

D56. Fee sweep cursor infinite loop

Hypothesis: If only word 0 of the bitmap has set bits (e.g., 64 accounts in a 4096-slot market), the cursor could loop endlessly between word 0 and wrap-around.

Why discarded: Outer loop (line 3603) is bounded by words_scanned < BITMAP_WORDS, terminating after exactly BITMAP_WORDS=64 iterations regardless of bit distribution. Each word is visited at most once per crank.

D57. Admin-controlled funding rate extraction

Threat model: Compromised/malicious admin extracts user capital via adversarial funding rate + mark manipulation. Classic centralized-protocol rug vector.

Exploit path:

  1. Attacker is admin AND oracle_authority on a live market with open user positions.
  2. Attacker opens a large LP on the opposite side of users' aggregate exposure (e.g., SHORT if users are net LONG).
  3. Attacker calls PushOraclePrice to push the mark significantly away from the index, creating a large premium.
  4. Attacker calls UpdateConfig to raise funding_max_bps_per_slot to the engine-enforced max of 10 bps/slot (21,600% per day at max rate).
  5. Funding accrues against users' positions; their capital shrinks each slot, transferring to attacker's LP.
  6. Users who don't close positions fast enough lose up to 100% of their capital over hours.
  7. Attacker closes LP, withdraws profit.

Why this is not a protocol vulnerability:

  • User can ALWAYS CloseAccount to exit and reclaim remaining capital. No permanent lock.
  • Conservation V ≥ C_tot + I is preserved — funding is a transfer between users' accounts, not a mint/burn.
  • The cap MAX_ABS_FUNDING_E9_PER_SLOT = 10^6 is protocol-enforced; any higher would violate the engine's i128 envelope. This is the MAXIMUM admin can set.
  • The protocol explicitly supports "burn admin after init" as a user-protection feature. Markets with burned admin cannot have their funding params adversarially updated.
  • Spec §5 documents funding as admin-configurable within the envelope. Users must trust admin (or use burned-admin markets).

Why this is classified as "operational risk, not protocol bug": The protocol's trust model explicitly includes admin configuration. Users entering a market with a live admin accept that admin can update params. The protocol provides THREE mitigations:

  1. Admin can be burned (rug-proof markets)
  2. Per-market cap (operators can lower the effective cap below engine max when they deploy)
  3. User's close-position escape hatch

Lazarus-style attacker would look at: the engine's envelope max (10 bps/slot) as the WORST CASE and ask "does any honest market deployment need higher than 1 bps/slot? If not, the envelope max should match real-world funding caps to limit admin abuse surface." This is an operational recommendation, not a ship-blocker: deployers choose their own per-market caps.

Attack surface for a sophisticated attacker:

  • Compromise admin's private key (social engineering, key theft)
  • Then follow the exploit path above
  • Mitigation: burn admin, or use multisig/timelock for admin

Sophisticated-attacker analysis (Lazarus-style)

Thinking like a professional DeFi hacker, the high-value attacks typically target one of these surfaces:

  1. Admin-key compromise + config abuse — D57 analyzed this. The protocol's design bounds admin abuse to funding-rate manipulation within the envelope max. Users can always close; protocol conservation preserved. Operational risk, not a protocol flaw.

  2. Oracle manipulation — circuit-breaker caps per-push movement (cap_e2bps). Admin/oracle_authority can drive moves within cap. User's close-escape limits per-block exposure. Hard-timeout permissionless_stale_matured forces resolution after sustained staleness. Bounded attack surface.

  3. Cross-protocol composition (matcher CPI tail) — D1/D11/D14 covered. The matcher is LP-delegated; anything the matcher does is within the LP's trust model. Wrapper enforces account shape

    • signer propagation + ABI validation. No privilege elevation possible through the tail.
  4. ReentrancyFLAG_CPI_IN_PROGRESS blocks reentry on the same slab. Cross-slab reentry (D21) can't elevate privileges beyond what attacker already has.

  5. Token transfer / mint tricks — verify_token_program checks legacy spl_token::ID (rejects Token-2022 fee extensions). verify_vault_empty at init rejects pre-loaded vaults. verify_token_account checks mint + owner on every transfer.

  6. State initialization / layout — D56/D45/D55 covered. slab_guard checks owner + length + reentrancy. Instruction decoder uses checked reads. No uninitialized-data exploits.

  7. Arithmetic precision — D30/D45/D55 covered. Fee computation uses ceil (protocol wins rounding). Wide arithmetic (U256/I256) for haircut + K-diff paths. Envelope invariant ensures products stay within i128::MAX.

  8. Flash-loan-style attacks — Solana doesn't have native flash loans. Multi-ix tx is atomic (D17). No repeated-borrow primitives. The circuit breaker bounds same-slot price manipulation.

  9. MEV / sandwich — limit_price_e6 on TradeCpi bounds slippage. User can refuse to execute outside their limit. Standard perp protection.

  10. Governance / upgrade attacks — percolator has NO governance mechanism. The program owner controls upgrades; that's a Solana-level trust assumption.

Conclusion: Every standard DeFi attack pattern I considered either (a) doesn't apply to this architecture, (b) is blocked by existing defenses, or (c) requires admin-key compromise (which the burn-admin feature mitigates). No ship-blocking protocol-level vulnerability identified.

D58. Dust-window griefing via rapid reclaim

Threat: User deposits exactly min_initial_deposit. Attacker waits a few slots for maintenance fees to accumulate, then calls ReclaimEmptyAccount. Sync drains fees; capital drops below min; reclaim eligibility passes; user's dust goes to insurance.

Why classified as known behavior (D12 variant):

  • This is spec §2.6 dormant-account cleanup — the user deposited the MINIMUM, so any fee accrual pushes them to reclaimable dust.
  • User's mitigation: deposit more than min_initial_deposit so fee-window eats less than the buffer.
  • Maintenance fee is admin-configurable. Markets with fee=0 are immune (no dust drain).
  • My sync-before-reclaim fix made this attack faster, but the end state (user loses dust to insurance) is unchanged from the pre- fix behavior once crank eventually syncs their fees.
  • Discussed in D12. Same analysis applies.

D59. All-losers-never-close post-resolve trap

Threat: Post-resolve, winners can only Phase-2-close after ALL losers have Phase-1-reconciled (terminal_ready). If some losers never get force-closed, winners' funds are locked forever.

Why discarded:

  • ForceCloseResolved is PERMISSIONLESS past force_close_delay_slots. Any keeper can close any account.
  • MAX_FORCE_CLOSE_DELAY_SLOTS = 10_000_000 (≈46 days at 0.4s/slot) caps the admin's choice.
  • Phase 1 reconcile (which decrements neg_pnl_account_count) works on any account regardless of capital state — it only zeros the position and settles losses.
  • Worst case: winners wait up to MAX_FORCE_CLOSE_DELAY_SLOTS (46 days) before they can force-close losers. Not permanent.

D60. PDA collision to all-zero pubkey

Threat: A PDA derivation accidentally produces all-zero pubkey. admin_ok rejects zero admin, but other checks might treat zero as "unset" differently.

Why discarded: find_program_address uses sha256-based hashing with off-curve requirement. The probability of hitting all-zero (2^-256) is astronomically low. Additionally, admin_ok (line 339-341) and owner checks consistently treat zero as "unset/burned", not as a valid active signer. Even if collision occurred, no path grants privileges to it.

Key operator recommendation (not a protocol bug)

The one genuine finding from this adversarial session is OPERATIONAL, not a protocol flaw: MAX_ABS_FUNDING_E9_PER_SLOT = 10^6 (10 bps/ slot = 21,600%/day at max rate) is mathematically bounded by the engine's i128 envelope, but 500x higher than real-world perp funding caps (Binance/dYdX ~0.02 bps/slot).

An admin-compromised market with oracle_authority can push funding at 10 bps/slot, draining users who don't close fast. This is:

  • Within the protocol's documented trust model (admin-configurable within envelope)
  • Mitigable via: burn admin, or deploy with a lower per-market cap via custom_max_per_slot at InitMarket

Operator recommendation: deploy with custom_max_per_slot ≤ 1 bps/ slot (100_000 in e9 units) to align with real-world funding caps and reduce the admin-abuse surface even without burning admin. This is a 10x reduction from the envelope max.

Separately, for true rug-proof deployments: burn admin post-init. The UpdateConfig path then has no authorized signer, so funding params become immutable.

Spot-check verification

Per user request, 4 discarded hypotheses were spot-checked with actual runnable tests (committed, then deleted per the audit rule):

  • D1 (TradeCpi slab in writable tail): test passed — tx succeeds or fails atomically, no state corruption. Confirmed.
  • D6 (self-trade fees): code walk confirmed — both sides charged fee, routed via charge_fee_to_insurance (engine lines 4094-4095). Attacker pays 2×fee net. Confirmed.
  • D12 (reclaim dust): test revealed my original wording ("user can withdraw at any point before reclaim fires") was IMPRECISE — once maint_fees exceed capital, withdraw fails with 0x0d. NOT a protocol bug (spec §2.6 dust cleanup + admin-configurable maint_ fee), but the D12 description has been corrected above.
  • D16 (zero admin): admin_ok explicitly rejects both zero- admin-header and zero-signer cases. Confirmed via direct function test.

The discards remain valid. D12's wording was tightened.

Audit completion status

54 concrete attack hypotheses probed across three rounds. Every candidate discarded under inspection. No ship-blocking findings.

Coverage

Rounds 1 + 2 together walked:

  • All 28 live instruction tags — each handler's account layout, signature requirements, state transitions, and failure modes.
  • Cross-instruction state transitions: InitUser's current_slot split, TradeCpi's reentrancy guard, post-resolve close paths.
  • Privilege model: the 4-way authority split (admin / oracle / insurance / close), burn guards, zero-address rejection.
  • Oracle paths: Pyth, Chainlink, authority fallback, circuit breaker, hard-timeout gate, Hyperp EWMA.
  • Matcher ABI: account-shape validation, signer forwarding in the variadic tail, return-field echo validation, reentrancy.
  • Warmup / reserve mechanics: close-path rejection of any reserve state, ConvertReleasedPnl's flat-account safety cap.
  • Money-flow paths: maintenance fee → insurance, trading fee → insurance, liquidation fee → insurance, reclaim dust → insurance, stranded vault → close_authority.
  • Nonce / replay: checked_add bounds, per-slab scoping.
  • Layout safety: zero-copy cast, bool/enum validation before cast.

Residual review scope

Worth someone else's eyes because I didn't construct custom test infrastructure for them:

  • Funding rate AT MAX_ABS_FUNDING_E9_PER_SLOT with OI AT MAX_VAULT_TVL on both sides — exact arithmetic stress at the envelope boundary.
  • Multi-block attacks where the attacker coordinates across tx boundaries (e.g., keeper-timing collusion between miner + attacker).
  • Future multi-market deployments would need re-audit of cross-slab isolation.

Confidence

The wrapper has 646 integration tests + 243 Kani proofs (across wrapper and engine crates) + 19 proptest fuzzers. The instructions under test here are not green-field code — each has been through multiple prior audit rounds with specific regression tests. This round found no new class of bug that those prior rounds missed.

That is consistent with a system that's been adversarially audited to convergence. It is NOT proof of complete absence of bugs — just that the straightforward attack vectors tried here don't land.

Next sweep targets

Remaining angles for future sweeps:

  • Multi-tx coordinated: stale-market + reward timing across blocks
  • Cross-market: currently single-market design, but multi-market deployment would need re-audit
  • Engine-side: the engine crate (percolator) has its own audit surface; prior Kani proofs + proptest fuzzing cover most paths
  • Specific funding rate edge: rate = MAX_ABS_FUNDING_E9_PER_SLOT with opposing OI of MAX_VAULT_TVL. Finding the test infrastructure for this was out-of-scope for this sweep.

D17. Multi-ix tx: deposit then withdraw same tx bypasses oracle

Hypothesis: User submits a tx with [Deposit, Withdraw] ixs in sequence. Deposit skips oracle (pure capital). Withdraw requires oracle. If oracle is stale at tx time, does the deposit succeed but the withdraw fail — trapping the funds mid-tx?

Why discarded (and not a vuln): Solana txs are atomic. If the Withdraw ix fails, ALL ixs roll back — deposit too. The user's tokens return to their ATA. No funds trapped. More: even if the Withdraw succeeded in isolation, the combined tx's atomicity means a user who BUNDLES them always gets all-or-nothing. User can also just call Deposit alone; fails gracefully.

D18. Crank + Trade race within same tx

Hypothesis: Tx with [Crank, TradeCpi]. Crank might sync fees on accounts including the trader's, leaving them below margin. Then Trade's margin check fails. No attack — just a user paying their own fees before trading. If trade would succeed pre-sync and fail post-sync, that's correct: the trader shouldn't be able to open a position with unrealized-fee-inflated capital.

Why discarded: By design. Pre-trade fee realization is the correct ordering — prevents under-margined trades.

D19. Sweep_empty_market_surplus on non-empty state

Hypothesis: sweep_empty_market_surplus_to_insurance (engine line ~3100s) is called at close/reclaim paths. It should be a no-op when there are still used accounts or non-zero c_tot. Can it be triggered in an intermediate state where it incorrectly donates legitimate capital to insurance?

Why discarded: Engine checks senior = c_tot + insurance and only sweeps residual vault EXCESS OVER senior. If c_tot is nonzero, the "excess" calculation only donates truly unaccounted vault tokens (rounding dust from base→units conversion, etc.). Legitimate capital stays in c_tot, not "surplus."

D20. Free-slot recycling + gen counter collision

Hypothesis: User A at idx=5 closes account. Slot freed. User B inits at idx=5 (same slot reused). A stale offline matcher return for User A's old req_id could be replayed against User B's trade.

Why discarded: Slab has a gen_table (RiskBuffer tail section) — each InitUser/InitLP bumps a mat_counter and writes the new generation number to idx's slot. lp_account_id in TradeCpi matcher ABI uses this generation. An old matcher return with A's generation won't match B's generation; abi_ok validation rejects.

Session 2026-04-21 — adversarial audit of landed changes

Focused probe against the 5 surfaces introduced this session:

  1. LastRestartSlot sysvar integration (cluster-restart stale gate)
  2. DEFAULT_PERMISSIONLESS_RESOLVE_STALE_SLOTS = 90_000 minimal-payload default
  3. Funding ABI: e9 wire format, engine ceiling 10_000, dt ceiling 10_000_000
  4. MarketConfig.tvl_insurance_cap_mult deposit cap (admin opt-in)
  5. Instruction::WithdrawInsuranceLimited (tag 23) + SlabHeader.insurance_operator + AUTHORITY_INSURANCE_OPERATOR = 4

All candidates below discarded — either blocked by existing runtime check, out of stated trust model, or exploit test passed (i.e., attack did not work).

D61. Operator bypasses bps/cooldown bounds via tag 20

Hypothesis: An operator holding only insurance_operator calls WithdrawInsurance (tag 20) for an unbounded drain.

Why discarded:

  • tag 20 handler at src/percolator.rs:7673 gates on require_admin(header.insurance_authority, a_admin.key) — a disjoint field from header.insurance_operator.
  • Proof test: test_withdraw_limited_operator_cannot_call_tag_20 resolves market, closes all accounts (both tag-20 gates now passable for insurance_authority), rotates insurance_operator to a fresh key, then attempts tag 20 → rejected. Positive control confirms insurance_authority still succeeds in the same state.

D62. max_bps = 10_000 + cooldown = 0 neutralizes bounded-drain limits

Hypothesis: Admin misconfigures the policy so operator can drain 100% per tx with no inter-call delay, rendering the "bounded" claim cosmetic.

Why discarded:

  • Init-time validation at src/percolator.rs:4348 rejects max_bps > 10_000.
  • Init-time validation at src/percolator.rs:4353 rejects max_bps > 0 && cooldown == 0.
  • Both blocks at InitMarket; the combination is unreachable in production markets.

D63. Multi-ix tx cooldown bypass

Hypothesis: Operator packs N tag-23 instructions in a single tx. clock.slot is constant within a tx, so all N calls see the same slot and the cooldown check always returns 0 slot-gap.

Why discarded: First call in the tx updates config. last_insurance_withdraw_slot = clock.slot. Call 2 sees last = clock.slot, so slot_gap = 0. Since init enforces cooldown > 0, the check slot_gap < cooldown (0 < N) trips and call 2 is rejected. Only call 1 succeeds per tx.

D64. config.init_restart_slot tampering after InitMarket

Hypothesis: A post-init mutation resets init_restart_slot to a value ≥ current LastRestartSlot, defeating restart detection.

Why discarded: grep for writes to init_restart_slot shows exactly one site — the InitMarket handler at src/percolator.rs:4752. No UpdateConfig, UpdateAuthority, or other handler touches it. The field is immutable post-init.

D65. Cluster restart race during InitMarket

Hypothesis: LastRestartSlot::get() captures N. A restart bumps it to N+1 mid-tx. Admin's init commits with stale init_restart_slot = N but never sees the bump because it happened atomically.

Why discarded: Solana's execution model — a single transaction does not span a restart event. Restarts halt all in-flight state; the blockchain replays from the last confirmed slot with txs re-applied or dropped. The syscall and the commit both happen inside one atomic tx; there's no race window.

D66. Deposit-cap bypass via insurance decrement within the same tx

Hypothesis: Some other instruction decrements insurance mid-tx; the cap check in DepositCollateral uses a snapshot that doesn't reflect the new value.

Why discarded: Handler loads engine_ref(&data)?.insurance_fund. balance.get() immediately before the comparison. Single-threaded per-tx semantics mean no concurrent mutation. If a prior instruction in the same tx decremented insurance, the handler sees the post-decrement value.

D67. Tag 23 breaks conservation invariant V ≥ C_tot + I

Hypothesis: Tag 23 decrements insurance and vault by different amounts, creating a V < C_tot + I state.

Why discarded (manual trace):

  • Before: vault = V, insurance = I, c_tot = C. Conservation: V ≥ C + I + net_pnl.
  • Commit: engine.insurance_fund.balance = I - A, engine.vault = V.checked_sub(A). Both decremented by the same A = amount_units.
  • After: (V - A) ≥ C + (I - A) + net_pnlV ≥ C + I + net_pnl. Same invariant, preserved.
  • SPL transfer moves amount base units (= amount_units × unit_scale if scale > 0, else equal). Engine tracks units; SPL vault tracks base. Both sides of the equation stay consistent with unit_scale scaling.

D68. Zeno-paradox lockout traps last residual

Hypothesis: Operator drains insurance until insurance = 9. Cap formula: cap = max(bps_cap, 10) = 10. Clamp: cap = min(10, 9) = 9. Operator requests 10 → rejected (10 > 9). Request for 9 → ??

Why discarded: Handler logic if (amount_units as i128) > cap uses strict greater-than. At cap = 9, amount_units = 9 passes (9 > 9 is false). Operator drains the exact remaining balance. Insurance reaches zero.

D69. Tag 23 amount = 0 or dust-misaligned amount

Why discarded: Handler has if dust != 0 || amount_units == 0 { return InvalidArgument }. Both zero-unit withdrawals and unit-scale-misaligned amounts are rejected before any state change.

D70. Tag 23 spoofed accounts (vault, ATA, clock)

Why discarded:

  • verify_vault(a_vault, &auth, &mint, &Pubkey::new_from_array( config.vault_pubkey)) — cross-checks vault PDA, mint, and stored pubkey.
  • verify_token_account(a_operator_ata, a_operator.key, &mint) — cross-checks ATA owner and mint.
  • accounts::expect_key(a_vault_pda, &auth) — verifies the signing PDA account matches the derived authority.
  • Clock::from_account_info(a_clock) — runtime verifies the sysvar key matches sysvar::clock::ID.

All four spoof vectors blocked.

D71. Burned insurance_authority + bounded drain leaves residue that traps CloseSlab

Hypothesis: Operator drains via tag 23 but can't reach 0 (Zeno). Residue > 0 means tag 20 is needed to zero insurance. But insurance_authority is burned, so tag 20 is unreachable. Slab rent is trapped forever.

Why discarded: D68 already shows tag 23's final-call clamp lets the operator drain to exactly zero. No residue. CloseSlab proceeds normally.

D72. Stale last_insurance_withdraw_slot post-restart

Hypothesis: Pre-restart, operator's last call set last = 1000. Post-restart, clock.slot resumes at 800. The cooldown check does 800.saturating_sub(1000) = 0, which is < cooldown. The first post-restart tag-23 call would be rejected — but if the operator passes amount = 0 or exploits some other path, maybe they bypass.

Why discarded: Tag 23 handler calls permissionless_stale_matured(&config, clock.slot) before the cooldown check. Post-restart, LastRestartSlot::get() > init_restart_slot, so permissionless_stale_matured returns true, and the handler returns OracleStale immediately. The cooldown / stale-last-slot path is never reached post-restart — no tag-23 call can succeed after a cluster restart. The market is frozen; resolution goes through ResolvePermissionless.

D73. Deposit-cap and SPL-transfer ordering race

Hypothesis: The cap check succeeds, but the SPL transfer fails, and engine state is left inconsistent.

Why discarded: Handler ordering is cap check → collateral::deposit (SPL CPI) → engine.deposit_not_atomic. The cap check runs BEFORE any state mutation. If the SPL transfer fails, the handler returns Err, Solana's runtime reverts the entire tx atomically (including any vault balance change attempted by the SPL program). Engine state is never mutated.

D74. Operator drains below insurance_floor

Hypothesis: Admin sets insurance_floor = 1_000 as a "minimum insurance reserved against losses." Operator drains insurance to 0 via tag 23, undermining the floor contract.

Why discarded (with caveat):

  • insurance_floor is a policy parameter used by use_insurance_buffer(loss) at percolator/src/percolator.rs:2318 to compute available = ins_bal.saturating_sub(floor). It controls loss-consumption, not an enforced minimum balance.
  • The prior WithdrawInsurance (tag 20) also ignores insurance_floor — it zeros the balance entirely at teardown. Tag 23's behavior is consistent.
  • If operators want a hard minimum balance, they must either (a) configure max_bps such that the steady-state drain rate stays above the floor, or (b) rely on the deposit-cap interaction (tvl_insurance_cap_mult > 0) to throttle TVL growth as insurance shrinks.
  • Caveat: if insurance_floor is ever promoted to an invariant (spec change), tag 23 would need to grow a cap = min(cap, ins - floor) gate. For now the two paths are consistent in ignoring it.

D75. CloseAccount bypass of deposit cap

Hypothesis: A closed account reduces c_tot. If cap was blocking deposits, closing should unblock them. Attacker closes-and-reopens repeatedly to churn capital past the cap.

Why discarded: CloseAccount correctly decrements c_tot via engine.close_account → set_capital(0). A closed account frees cap room for new deposits. This is the correct behavior — closing reduces TVL, shrinking c_tot; cap denominator (insurance) is unchanged; new room appears. Churn is bounded by account-init costs and the cap itself. Not an exploit.

Summary — Session 2026-04-21

No ship-blocking findings. Authority split on insurance_operator vs insurance_authority is structurally enforced (confirmed by tightened bypass test). Cluster-restart detection correctly gates all price-taking / state-mutating paths. Deposit cap is fail-closed on enable-without-insurance (bootstrap caveat documented; not a protocol hole). The tag-23 rate limits are ultimately as strong as admin's max_bps + cooldown configuration at InitMarket; init-time bounds prevent the pathological combinations.

Tests: 674 pass across default, small, medium tiers. Kani: 83/83 pass. Proof harnesses cover the pure restart-detected comparison and all pre-existing authorization/matcher/oracle surfaces.

Session 2026-04-22 — oracle observation monotonicity

Threat model

clamp_external_price previously had no ordering guarantee on Pyth / Chainlink readings. Once the authority-fallback path was removed (commit 86ea41f), the wrapper's only price source for non-Hyperp markets is the caller-supplied Pyth/Chainlink account. Pyth Pull is permissionless: anyone can post a fresher PriceUpdateV2 for any feed at any time. That created a per-call cherry-pick:

  1. State: last_effective_price_e6 = 100, latest on-chain Pyth shows price = 102 clamped to 101 (cap = 1%/slot).
  2. A second, valid-but-older Pyth account exists on-chain showing price = 99. Both readings are within max_staleness_secs of the current clock.
  3. A caller chooses to submit the older account. clamp_external_ _price clamps 99 against baseline 101 → returns 100, advances baseline to 100.
  4. Subsequent ops now price against 100 instead of 101. The caller has effectively pulled the baseline backward by a full cap-step.

Repeated systematically, this is a bounded but real price-direction attack — the caller can keep the baseline pinned to whichever older observation they prefer, within oracle_price_cap_e2bps per step.

Fix

MarketConfig.last_oracle_publish_time: i64 is added (stored in the 8-byte slot formerly reserved for authority_timestamp, no layout change). On every accepted external observation, the wrapper writes config.last_oracle_publish_time = publish_time.

clamp_external_price now applies a one-way clock to the source-feed timestamp:

publish_time >= last_oracle_publish_time
    → clamp the submitted observation against the baseline,
      advance baseline + timestamp.

publish_time <  last_oracle_publish_time
    → return last_effective_price_e6 unchanged.
      Do NOT advance baseline or timestamp.
      Do NOT error — the caller's tx still succeeds.

Pyth's publish_time and Chainlink's timestamp are signed by the respective off-chain networks and cannot be forged client-side, so they're a sound ordering signal.

Why graceful (return stored), not strict (return error)

The strict variant — reject any older observation outright — was considered and rejected. It deadlocks legitimate callers whose signing path is asynchronous from the Solana tip:

  • Hardware wallets that take seconds to confirm.
  • Multi-sig flows that batch signatures across minutes.
  • Offline signers that ship pre-signed txs.
  • Any tx that lands a few seconds after a competing keeper submitted a fresher Pyth update.

These callers' txs would need an embedded oracle-account-version parameter to retry safely against tip movement. Forcing them to retry with a fresh Pyth account each round is a permanent loop under any contention.

The graceful variant gives the caller's tx the freshest known price the wrapper has on file. It cannot move state backward (the older-observation branch is purely read-only on baseline). The cap- step cherry-pick is closed because the caller can no longer pick "older observation processed against current baseline" — they only get "current baseline as-is."

What's preserved vs. surrendered

Preserved:

  • last_effective_price_e6 is monotonic with respect to the one-way clock: it only moves on observations newer than the last accepted one.
  • Caller cannot rewind baseline by replaying an old observation.
  • Circuit-breaker clamp still applies to all forward observations.
  • All pre-existing terminal-stale guarantees (permissionless_stale_matured) hold unchanged.

Surrendered:

  • Observation freshness is not enforced at the per-tx level. A caller submitting an older Pyth account is priced against the stored baseline, which may differ from the most recent on-chain Pyth reading by up to one cap-step. This is the same surface area as the wrapper had before the monotonicity field existed, with the explicit constraint that the baseline cannot rewind.

InitMarket bootstrap

InitMarket now seeds last_oracle_publish_time from the genesis Pyth read (non-Hyperp markets only; Hyperp leaves it at 0). This prevents an immediate post-init replay of an even-older observation from poisoning the baseline before any normal-path crank has run.

ResolveMarket

The non-Hyperp admin ResolveMarket path also performs a direct external read (it bypasses clamp_external_price because §9.8 deviation band wants the raw, un-clamped oracle). The same graceful fallback applies there: an older observation substitutes the stored last_effective_price_e6 as live_oracle_price, mirroring the live policy.

Tests

  • test_oracle_older_observation_uses_stored_price_and_does_not_rewind: asserts the graceful behavior — older observation succeeds, but baseline + timestamp don't move, even when the submitted price is wildly different.
  • test_oracle_publish_time_equal_observation_succeeds: regression guard that equal-timestamp re-reads (e.g., two txs in the same slot reading the same on-chain Pyth account) keep working.

There aren't any published security advisories