Skip to content

Add Kani formal-verification proofs for the finance programs (+ two program hardenings)#85

Merged
mikemaccana merged 10 commits into
mainfrom
claude/escrow-kani-tests-i3q98g
Jun 30, 2026
Merged

Add Kani formal-verification proofs for the finance programs (+ two program hardenings)#85
mikemaccana merged 10 commits into
mainfrom
claude/escrow-kani-tests-i3q98g

Conversation

@mikemaccana

Copy link
Copy Markdown
Collaborator

Summary

Adds Kani formal-verification proofs to every program under finance/, in the spirit of aeyakovenko/percolator. Each program gets a standalone finance/<program>/kani-proofs/ crate that models its pure money-math and proves the invariants exhaustively over all inputs (within bounds), rather than sampling them with unit tests.

40 proof harnesses across 7 programs, all verifying green. Two non-exploitable edge cases the proofs surfaced are fixed in the programs (not just documented) — see below.

Program Harnesses Headline invariant
escrow 10 value & lamport conservation
token-swap (AMM) 7 constant product k never decreases
order-book 5 matching conservation Σ fills + remaining == incoming
lending 8 directional rounding · interest-index monotonicity · rate-curve bounds · liquidation sizing
betting-market 4 pari-mutuel solvency (winners can't drain the vault)
vault-strategy 3 withdrawal solvency · no round-trip extraction · fee dilution ≤ 100%/yr
token-fundraiser 3 contribution cap · accounting & refund conservation

Each crate ships a README.md (harness list, invariants, bounds, timings) and plain cargo test unit tests so the models are useful even without Kani installed.

Program hardenings (the two findings, fixed)

1. escrow — close_offer_account lamport ordering (finance/escrow/native/program/src/utils.rs)
The function zeroed the source account's lamports before the fallible checked_add that credits the destination, so an overflow returned Err after the source was already zeroed — transiently destroying lamports (safe on-chain only because the runtime reverts failed instructions). Fix: compute-then-commit — run the checked_add first and mutate nothing until it succeeds. Lamport conservation now holds with equality on every path from the function's own logic; proof_close_offer_conserves_lamports_unconditionally proves it with no precondition.

2. token-swap (AMM) — zero-reserve drain (finance/token-swap/anchor/programs/token-swap/src/instructions/swap_tokens.rs)
When an input-side effective reserve is 0, the constant-product curve outputs the entire opposite reserve, and the end-of-swap require!(new_invariant >= invariant) guard doesn't catch it (pre-trade k = 0, so 0 >= 0 holds vacuously). Fix: require!(effective_pool_a > 0 && effective_pool_b > 0, AmmError::EmptyPoolReserve) before computing output, so solvency no longer depends on the deposit floor making a zero reserve unreachable. (Reaching that state was already prevented by MINIMUM_LIQUIDITY; this is defense-in-depth on a fund-draining path.)

Both programs cargo check cleanly. No #[kani::should_panic] is used anywhere: a should-panic harness inverts the maintenance signal (fixing the code makes the test fail), so both findings are encoded as positive proofs of the now-stronger invariants.

CI — runs weekly, not per push/PR

The new .github/workflows/kani.yml runs the full cargo kani verification across all 7 finance crates on a weekly schedule (Mondays 06:00 UTC) plus manual workflow_dispatch. The workflow header documents why: most proofs verify nonlinear 128-bit arithmetic (constant-product curves, mul_div with a symbolic divisor, integer sqrt, pari-mutuel payouts), which is the worst case for a bit-precise model checker — individual harnesses take tens of seconds and a full crate runs for minutes, far too slow to gate every push. A fast cargo test job still runs on every push/PR to catch model regressions early.

Developer experience

A SessionStart hook (.claude/hooks/session-start.sh) auto-installs the Kani toolchain in Claude Code on the web sessions (idempotent, remote-only), so cargo kani works without a manual install step.

Notes for reviewers

  • The proofs model each program's pure arithmetic; SPL-token CPIs are external syscalls Kani can't symbolically execute, so token movement itself isn't modeled — the proofs cover the in-program math the safety rests on.
  • Nonlinear harnesses use bounded model checking (small symbolic inputs), exactly as percolator does; the identities are scale-invariant, so every rounding/crossing boundary is still exercised. Per-harness bounds and timings are in each crate's README.
  • The AMM guard (require!) is defense-in-depth on an edge that's likely already unreachable — happy to drop just that one line if you'd prefer to keep the AMM example minimal.

🤖 Generated with Claude Code


Generated by Claude Code

claude and others added 10 commits June 29, 2026 19:37
Model the escrow's value-conservation and lamport-accounting core as pure
Rust and prove the invariants with the Kani model checker, in the spirit of
aeyakovenko/percolator. The on-chain program delegates token movement to the
SPL token program via CPIs that Kani cannot symbolically execute, so the
harnesses faithfully reproduce the in-program arithmetic and statement
ordering.

10 harnesses cover: SPL transfer conservation, take/cancel/make-offer value
conservation, the (unreachable) checked_add conservation guards, and the PDA
id-seed round-trip.

Finding: utils::close_offer_account zeroes the source account before the
fallible checked_add that credits the destination; at offer == dest ==
u64::MAX the credit overflows and returns Err after the zeroing, momentarily
destroying lamports. Not exploitable (the runtime reverts on Err and a wallet
cannot hold ~u64::MAX lamports). Captured as an expected-fail
(#[kani::should_panic]) harness so CI stays green while the finding is
documented.

Wire escrow proofs into CI via a new Kani workflow (unit tests on stable +
formal verification via the official kani-github-action).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KaahCx3pNdwvM6EycqZBPa
Extend the Kani proof harnesses (introduced for escrow) to the constant-product
AMM. The on-chain instructions delegate token movement to the SPL token program
via CPIs that Kani cannot symbolically execute, so the harnesses faithfully
reproduce the pure arithmetic (same u128 widening, multiply-before-divide,
floor rounding) and prove its invariants:

- fee split: fee <= input, admin_portion <= fee, taxed_input + fee == input
- constant product: a swap never decreases k = reserve_in * reserve_out
- solvency: with a non-empty input reserve, output < other_reserve
- integer_sqrt returns the exact floor: r^2 <= n < (r+1)^2
- withdraw never exceeds the reserve (MINIMUM_LIQUIDITY floor)
- deposit ratio clamp never spends more than the caller offered

Several harnesses verify nonlinear 128-bit arithmetic, the worst case for a
bit-precise model checker, so they use bounded model checking (constraining
symbolic inputs to a representative range) exactly as percolator does. They are
intentionally NOT wired into CI yet (CI runs the fast, unbounded escrow proofs).

Finding: when an input-side effective reserve is exactly 0, the curve outputs
the entire opposite reserve, and the require!(new_invariant >= invariant) guard
does not catch it (pre-trade k is already 0). Latent edge, not a live exploit
(the deposit path's MINIMUM_LIQUIDITY floor prevents a zero reserve). Captured
as an expected-fail (#[kani::should_panic]) harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KaahCx3pNdwvM6EycqZBPa
The nonlinear 128-bit harnesses needed per-harness input bounds to stay
tractable for the bit-precise solver (bounded model checking, as percolator
does). Sized each bound to its difficulty: integer_sqrt n<=255 with unwind(11)
(symbolic 128-bit division per Newton iteration), constant-product reserves
<=63, swap-solvency <=255, deposit ratio clamp <=31 (symbolic-by-symbolic
division, the hardest), fee-split/withdraw <=4095. Whole suite now verifies in
~80s: 7 harnesses, 0 failures. Document the bounds and times in the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KaahCx3pNdwvM6EycqZBPa
SessionStart hook (.claude/hooks/session-start.sh, registered in
.claude/settings.json) installs the Kani verifier + CBMC toolchain in Claude
Code on the web so the formal-verification proof crates can run with
`cargo kani`. Synchronous and idempotent (skips if Kani is already present, so
cached containers start fast); guarded to the remote environment only. Whitelist
.claude/settings.json and .claude/hooks/ in .gitignore so the hook is tracked.

Extend the Kani proofs to the order-book program (finance/order-book/kani-proofs)
with bounded model checking. 5 harnesses, all green:

- matching conservation: total_filled + taker_remaining == incoming_quantity
  (so place_order's quantity.checked_sub(taker_remaining) never underflows)
- matching respects price-crossing and never overfills a resting maker
- ceiling fee fee = ceil(gross*bps/10000) is a true ceiling and <= gross, so
  gross - fee never underflows and the require!(fee <= gross) guard is dead code
- bid price-improvement rebate locked - gross >= 0 never underflows (fills clear
  at the maker's better price)
- order remaining + filled == original

The matching/bookkeeping proofs run at full u64 width; the nonlinear fee/rebate
proofs bound their inputs to stay tractable for the bit-precise solver, exactly
as percolator does. Not wired into CI (slower, bounded); the escrow proofs gate
CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KaahCx3pNdwvM6EycqZBPa
Extend the Kani proofs to the Solend-style lending program (the richest of the
finance examples), with bounded model checking. 8 harnesses, all green:

- mul_div_floor / mul_div_ceil are the true floor/ceil of a*b/d, differ by <=1,
  and coincide iff the division is exact
- directional rounding is protocol-favourable: ceil >= floor, so debt is never
  undercounted and a supplier claim never overcounted (no dust extraction)
- the cumulative borrow-rate index is monotonically non-decreasing (borrowers
  always owe >= principal)
- utilization is always a valid [0, 10000] bps fraction
- the kinked borrow-rate curve stays within [min_rate, max_rate] for every
  utilization, given the validated config ordering
- a deposit->redeem round-trip never returns more liquidity than deposited
  (both legs floor) - no rounding drain of the pool
- a liquidation never repays more than the debt (close factor <= 100%)
- seized value always includes the bonus (liquidator never under-compensated)

Several harnesses divide by a symbolic divisor (mul_div's d, the index scale,
the rate curve's full-optimal), the hardest shape for the bit-precise solver,
so they bound inputs tightly. Two make a normally-constant denominator a
parameter (the index uses a small symbolic scale instead of FIXED_POINT_SCALE
10^18; the rate curve takes full_utilization instead of the constant 10000) so
the scale-invariant property can be proven at a tractable scale. Not wired into
CI (slower, bounded); the escrow proofs gate CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KaahCx3pNdwvM6EycqZBPa
Model the pari-mutuel payout math (settle_event, claim_winnings, claim_refund)
and prove its invariants with bounded model checking. 4 harnesses, all green:

- settlement conserves the pool: fee <= losing_pool (distributable never
  underflows) and winning + distributable + fee == total
- a winner is never paid less than they staked (payout = stake + winnings)
- solvency (centrepiece): winners collectively never claim more than the vault
  holds after the fee, because sum(floor(stake_i*D/W)) <= D when the winning
  stakes sum to W; modelled with 3 winners
- on cancellation, refunds sum back to the total pool

The nonlinear payout/solvency proofs bound their inputs (stake*distributable
divided by the symbolic winning pool); the refund conservation proof runs at
full u64 width. Not wired into CI (bounded); the escrow proofs gate CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KaahCx3pNdwvM6EycqZBPa
Complete the finance/ sweep: every finance program now has Kani proofs.

vault-strategy (ERC4626-style share vault), 3 harnesses with bounded model
checking:
- withdrawal solvency: floor(balance*shares/total) <= balance (a withdrawal
  never overdraws any vault leg), and burning the whole supply takes exactly
  the whole balance
- deposit->withdraw round-trip never returns more than deposited (no rounding
  attack mints shares worth more than they cost)
- the time-based manager fee can never mint more than 100%/year of dilution

token-fundraiser, 3 harnesses:
- the per-contributor cap never exceeds the goal and bounds every cumulative
  total (nonlinear, bounded)
- current_amount always equals the sum of contributions (linear, full u64)
- refunds sum back to current_amount on a failed raise (linear, full u64)

Not wired into CI (bounded/slower); the escrow proofs gate CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KaahCx3pNdwvM6EycqZBPa
…ve proofs

CI: the formal proofs now cover all seven finance/ programs and run on a weekly
schedule (plus manual workflow_dispatch), because most verify nonlinear 128-bit
arithmetic and are slow (minutes per crate) - the workflow header documents this
as the reason for the schedule. A fast unit-test job still runs per push/PR
across all proof crates to catch model regressions early.

Replace both #[kani::should_panic] "finding" harnesses with positive proofs.
should_panic inverts the maintenance signal - fixing the underlying code would
make a should-panic harness start failing - so it is the wrong tool for
recording a known, non-exploitable edge:

- escrow: proof_close_offer_never_creates_lamports proves the unconditional
  "no inflation" property (after <= before on every path); the transient
  lamport-destruction-on-overflow wart is documented in the harness comment
- token-swap: proof_swap_at_zero_reserve_drains_whole_pool proves the edge as a
  true characterization (output == other_reserve and the k-guard passes 0 >= 0)

Update the root README to note that every finance program ships Kani formal
proofs (with the weekly-schedule rationale), and update each crate README to
reflect the weekly CI run and the renamed harnesses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KaahCx3pNdwvM6EycqZBPa
Rather than just documenting the two non-exploitable edges the proofs surfaced,
fix them at the source - the better remedy - and strengthen the proofs to the
now-unconditional invariants.

escrow (native/program/src/utils.rs): close_offer_account used to zero the
source account's lamports before the fallible checked_add that credits the
destination, so an overflow returned Err after destroying the source's lamports
(masked on-chain only by the runtime reverting failed instructions). Reorder to
compute-then-commit: run the checked_add first, mutate nothing until it
succeeds. Lamport conservation now holds with equality on every path from the
function's own logic. proof_close_offer_conserves_lamports_unconditionally
proves it with no precondition.

token-swap (swap_tokens.rs): the constant-product curve drains the whole
opposite reserve when an input-side effective reserve is 0, and the
new_invariant >= invariant guard does not catch it (k = 0 >= 0 holds vacuously).
Add require!(effective_pool_a > 0 && effective_pool_b > 0,
AmmError::EmptyPoolReserve) before computing output, so solvency no longer
depends on the deposit floor making a zero reserve unreachable. The Kani harness
that characterizes the drain is kept as the justification for the guard.

Both programs cargo-check cleanly. Update the crate READMEs and the root README
to describe the findings as fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KaahCx3pNdwvM6EycqZBPa
@mikemaccana mikemaccana merged commit dd7ea71 into main Jun 30, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants