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.
(none validated so far in this audit pass)
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.
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_PROGRESSfires on the inner call and rejects. - The outer
slab_data_mutborrow is released before the CPI, so the matcher can read (not write) the slab. Reads alone can't steal funds or corrupt state.
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.
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.
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.
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.
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).
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.
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.
For each hypothesis above, I:
- Located the code path supposedly enabling the exploit.
- Drafted a concrete test sequence.
- Either ran the test (if it was small enough to execute) or walked the code mechanically to prove the exploit is blocked.
- Discarded the finding when the proof held.
- 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.
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.
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.
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:
- User deposits exactly min_initial_deposit (e.g., 100). Opens and closes a position. Leaves 100 capital with no open position.
- Maintenance fee accrues: 1/slot × N slots = N fees owed but not yet realized (no crank has run).
- Keeper calls ReclaimEmptyAccount. My added sync realizes N fees, capital drops to 100−N. If 100−N < 100, reclaim eligibility passes.
- 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.
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.
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).
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.
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 != 0sched_present != 0pending_present != 0All three warmup signals must be clean before close. Early close with pending warmup PnL is not possible.
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).
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.
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.
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.
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.
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.
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.
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).
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.
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).
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_accountsis 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.
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).
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.
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.
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.
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
-
- % 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
Threat model: Compromised/malicious admin extracts user capital via adversarial funding rate + mark manipulation. Classic centralized-protocol rug vector.
Exploit path:
- Attacker is admin AND oracle_authority on a live market with open user positions.
- Attacker opens a large LP on the opposite side of users' aggregate exposure (e.g., SHORT if users are net LONG).
- Attacker calls PushOraclePrice to push the mark significantly away from the index, creating a large premium.
- Attacker calls UpdateConfig to raise
funding_max_bps_per_slotto the engine-enforced max of 10 bps/slot (21,600% per day at max rate). - Funding accrues against users' positions; their capital shrinks each slot, transferring to attacker's LP.
- Users who don't close positions fast enough lose up to 100% of their capital over hours.
- 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^6is 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:
- Admin can be burned (rug-proof markets)
- Per-market cap (operators can lower the effective cap below engine max when they deploy)
- 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
Thinking like a professional DeFi hacker, the high-value attacks typically target one of these surfaces:
-
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.
-
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_maturedforces resolution after sustained staleness. Bounded attack surface. -
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.
-
Reentrancy —
FLAG_CPI_IN_PROGRESSblocks reentry on the same slab. Cross-slab reentry (D21) can't elevate privileges beyond what attacker already has. -
Token transfer / mint tricks — verify_token_program checks legacy
spl_token::ID(rejects Token-2022 fee extensions).verify_vault_emptyat init rejects pre-loaded vaults.verify_token_accountchecks mint + owner on every transfer. -
State initialization / layout — D56/D45/D55 covered.
slab_guardchecks owner + length + reentrancy. Instruction decoder uses checked reads. No uninitialized-data exploits. -
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.
-
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.
-
MEV / sandwich — limit_price_e6 on TradeCpi bounds slippage. User can refuse to execute outside their limit. Standard perp protection.
-
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.
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.
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.
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.
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.
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 viacharge_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_okexplicitly rejects both zero- admin-header and zero-signer cases. Confirmed via direct function test.
The discards remain valid. D12's wording was tightened.
54 concrete attack hypotheses probed across three rounds. Every candidate discarded under inspection. No ship-blocking findings.
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.
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.
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.
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.
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.
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.
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."
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.
Focused probe against the 5 surfaces introduced this session:
LastRestartSlotsysvar integration (cluster-restart stale gate)DEFAULT_PERMISSIONLESS_RESOLVE_STALE_SLOTS = 90_000minimal-payload default- Funding ABI: e9 wire format, engine ceiling 10_000, dt ceiling 10_000_000
MarketConfig.tvl_insurance_cap_multdeposit cap (admin opt-in)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).
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 fromheader.insurance_operator. - Proof test:
test_withdraw_limited_operator_cannot_call_tag_20resolves market, closes all accounts (both tag-20 gates now passable forinsurance_authority), rotatesinsurance_operatorto a fresh key, then attempts tag 20 → rejected. Positive control confirmsinsurance_authoritystill succeeds in the same state.
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.
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.
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.
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.
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.
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 sameA = amount_units. - After:
(V - A) ≥ C + (I - A) + net_pnl⟺V ≥ C + I + net_pnl. Same invariant, preserved. - SPL transfer moves
amountbase units (=amount_units × unit_scaleif scale > 0, else equal). Engine tracks units; SPL vault tracks base. Both sides of the equation stay consistent withunit_scalescaling.
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.
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.
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 matchessysvar::clock::ID.
All four spoof vectors blocked.
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.
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.
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.
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_flooris a policy parameter used byuse_insurance_buffer(loss)at percolator/src/percolator.rs:2318 to computeavailable = ins_bal.saturating_sub(floor). It controls loss-consumption, not an enforced minimum balance.- The prior
WithdrawInsurance(tag 20) also ignoresinsurance_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_bpssuch 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_flooris ever promoted to an invariant (spec change), tag 23 would need to grow acap = min(cap, ins - floor)gate. For now the two paths are consistent in ignoring it.
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.
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.
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:
- State:
last_effective_price_e6 = 100, latest on-chain Pyth showsprice = 102clamped to101(cap = 1%/slot). - A second, valid-but-older Pyth account exists on-chain showing
price = 99. Both readings are withinmax_staleness_secsof the current clock. - A caller chooses to submit the older account.
clamp_external_ _priceclamps99against baseline101→ returns100, advances baseline to100. - Subsequent ops now price against
100instead of101. 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.
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.
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."
Preserved:
last_effective_price_e6is 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 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.
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.
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.