Some of Solana's largest losses came down to one account nobody checked:
Wormhole (Feb 2022,
~$320M) never verified that the instructions sysvar was the real one;
Cashio (Mar 2022, ~$48M) never
validated a mint field, so a chain of fake accounts passed;
Crema (Jul 2022, ~$8.8M) let a
fake tick account past its owner check. Those checks were missing from the start, and spotting a
check that was never written is a reviewer's job: solinv would not have caught them (we checked
Cashio's real source, see #8). solinv covers the other
half: a check that was written down and a later change removes. In Anchor that is one type or one
#[account(...)] line, enforced only while it is there — and a refactor that loosens
Account<'info, Vault> to UncheckedAccount or deletes a has_one fails no test. In a native or
Pinocchio program it is one if !a.is_signer() { return Err(..) }, and deleting it fails no test either. As more
programs are written and refactored by AI coding agents, that kind of change gets more common and
easier to miss: it compiles, the tests the agent wrote pass, and a one-line deletion is easy to skim
past in a large generated diff.
solinv tells you when that happens, on the pull request, before it merges — and maps where a program's hand-written account checks are, and fuzzes them with the attack each check exists to stop. Apache-2.0.
Try it without installing: paste a pull request or a program address into
solinv check. It runs solinv diff in the browser
(solinv-static built to WebAssembly, at dec5547, v0.1.3; Anchor programs only for now), and for a deployed program it reads its IDL
(Anchor's IDL account, or the Program Metadata program) and, for an Anchor IDL account, diffs every
upgrade in its history. To fail every pull request in CI, or to fuzz the checks, install it (below).
| I want to… | Use |
|---|---|
| fail a pull request that removes an account check, with a note on the line | the psyto/solinv GitHub Action, or solinv diff --base-src <before> --head-src <after> |
| …in a native or Pinocchio program (a warning: what its code no longer checks) | the same; what it reads |
| see which account checks a deployed program's upgrade dropped, without its source | solinv diff --base-idl <audited.json> --head-idl <onchain.json> (anchor idl fetch), or every upgrade at once in solinv check; how it decides |
| see which instructions need a hand-checked account, before writing any harness | solinv idl target/idl/<program>.json --src programs/<program>/src |
catch a PDA signature whose seeds have no bump, a transfer hook execute Token-2022 can never reach, or a whitelist-style entry whose seeds leave out the registry that gates it |
the same two commands: solinv idl --src lists them under "program-wide findings", and solinv diff reports one a change adds |
| get a paste-ready spec for one instruction | solinv idl <idl> --rust <ix> |
fill an InstructionSpec from the IDL and fixture state instead of by hand |
solinv_fuzz::Idl → .spec() → .infer_from_state() / .infer_candidates() |
| check a signer is really required | solinv_core::invariants::signer_skip::check |
| check an account's owner program / type tag is really checked | owner_skip::check / discriminator_skip::check |
| check a PDA can't be forged, or swapped for another user's | pda_forge::check / account_swap::check (check_sets for slots swapped together) / bump_seed_canonicalization::check |
check a require! guard can't be skipped |
precondition_skip::check + InstructionSpec.guards |
| catch wrapped arithmetic on named fields | unchecked_math::check + AccountSchema |
cap compute units, or catch re-entry / under-funded realloc |
cu_dos / cpi_reentrancy / realloc_race |
| measure whether the detectors would catch a bug you plant | solinv mutate plan → apply → score |
| check two programs behave the same (Anchor → Pinocchio rewrite) | solinv_fuzz::check_pair_equivalent |
From a fresh clone, no Solana toolchain. First, a change that drops a check — the demo vault
loosened to UncheckedAccount and its has_one deleted
(the file):
git clone https://github.com/psyto/solinv && cd solinv
cargo run -q -p solinv-cli -- diff \
--base-src examples/escrow-demo/programs/escrow-clean/src \
--head-src examples/escrow-demo/diff/drop-a-checksolinv diff: 1 removed check(s), 0 warning(s)
REMOVED withdraw.vault (examples/escrow-demo/diff/drop-a-check/lib.rs:153): was Account, now UncheckedAccount: Anchor no longer checks its owner and type; and the checks on its data no longer read it where solinv sees them: `has_one = depositor` removed: it no longer has to match `depositor`. If the handler now loads `vault` itself, confirm each still runs, on every path
The two losses are one finding: once the vault is unchecked, its has_one goes with the type, and
whether the handler now checks it by hand is the question to answer.
It exits 1, so CI fails. In a pull request the same finding appears as an annotation on the changed line — the demo pull request makes this change to the real program and fails with the finding (two annotations in runs before v0.1.9), on line 146.
Second, the map of a whole program — which instructions have hand-checked accounts:
cargo run -q -p solinv-cli -- idl examples/escrow-demo/idl/escrow.json \
--src examples/escrow-demo/programs/escrow/srcprogram Esrcw11111111111111111111111111111111111111 — 12 instructions, account types: Vault
S signer W writable P pda seeds A fixed address · nothing declared U unchecked in source T typed in source t typed, identity not fixed by a constraint
…
unsafe_set_amount_from_source ! no signer, writes an unconstrained slot
0 source U
1 target W t
…
unsafe_withdraw ! no signer, writes an unconstrained slot
0 vault W U
1 depositor W U
7 slots are UncheckedAccount / AccountInfo in source: their checks are hand-written, which is where the Critical 5 can find something.
! 1 writable typed accounts have no declared constraint fixing which account they are (no seeds, address, has_one, associated_token:: or .key() comparison, in the attributes or at the top of the handler). Unless a function the handler calls checks the key, a caller may pass any account of the type: expected for their own token accounts, a hole for a vault, escrow or pool the program owns. account-swap candidates: unsafe_set_amount_from_source.target
3 instruction(s) with no signer write an unconstrained slot: unsafe_set_amount_from_source, unsafe_withdraw_with_bump, unsafe_withdraw
Start there: either they are meant to be permissionless, or an authority check is missing.
U marks an UncheckedAccount / AccountInfo, whose owner and type checks are hand-written; T
a typed account Anchor checks. t is a typed account whose identity no declared constraint fixes:
Anchor checks that it is a Vault, not which one, so unless the handler compares its key, a
caller can pass their own. Without --src the IDL alone cannot tell these apart. The three
flagged instructions are the demo program's three planted account-validation bugs. To fuzz them, see
fuzz the planted-bug demo (needs the Crucible CLI and cargo build-sbf).
| Claim | Evidence |
|---|---|
| A removed check fails the PR | The demo above; the demo pull request |
A check in vipers' validate() counts too |
Cashio's real source, fixed → before the fix: the check its fix added is named, in both instructions (details); the map marks 2 of its 26 typed accounts t, both validated by the program they are passed to (26 before vipers was read, #8) |
| …on the 11 classes of sealevel-attacks | Turning each fixed program into its insecure version: 11 of 11 caught where the check was declared, 9 of 11 where it was written in the handler (the other two change CPI seeds and zeroing code, not a check); 0 removed checks reported in the other direction (details) |
| …on real merged pull requests | Over 1,133 merged PRs of ten public Anchor programs: two mango-v4 PRs dropped a has_one that an audit restored 7 and 17 days later, and solinv names each on the PR that dropped it. On a random sample of 40 removals it reported there, 5 were real loosenings and 35 the same check written another way or moved. solinv now reads has_one / address / constraint == as one check and a yes/no gate swapped on the same account as a warning, which takes it to 25 reports with the same 5 real (about 1 in 5) — still expect a reviewer to clear most failures as "moved" (details) |
| …on open pull requests | Over 121 open PRs of 13 public Anchor programs (marginfi, Orca, Squads, Meteora, OpenBook, Raydium and others), every report read against its diff: no dropped check, which is the expected answer for well-reviewed teams. Of the 48 removals, 37 were deliberate changes of meaning that a reviewer has to clear: marginfi's new borrow-order signer, openbook's || janitor. The other 11 were checks moved into a helper, a call given one more argument, or an instruction retired. Since v0.1.9, 43 are left, 38 of them saying what the check became ("the same call with other arguments", "widened") (details) |
| …and in native and Pinocchio code | Over 1,164 commits of percolator-prog, SPL Token / p-token, princeps-solana and openhl-solana: 43 warnings, 10 of them real loosenings (owner checks commented out, a signer requirement dropped, an oracle error swallowed), about 1 in 4. Checks deleted by hand from princeps-solana's vault and openhl-solana's core: 4 of 4 named (details) |
| No noise on code that keeps its checks | Comparing Jito tip-payment, tip-distribution and Raydium cp-swap with themselves: 0 reports. Reformatting a constraint or renaming its error: 0 reports (tested) |
| The fuzz detectors catch a check that is removed | Mutation test on the clean escrow: of the removed checks the harness reached, 5 of 6 were caught, each by the detector predicted for it — after setting aside 5 mutations that cannot change behaviour, each argued in the report (5 of 11 before) |
| …but not every kind yet | On Raydium's native AMM, 0 of 18: its guards compare values computed inside the handler, which a declaration cannot name (why) |
| It is quiet on audited code | Three production programs fuzzed to a first result: 0 findings, the expected answer (details) |
On every pull request and every push to main (.github/workflows/solinv.yml; a push covers
changes made without a pull request, and re-checks a merged one):
on:
pull_request:
push:
branches: [main]
jobs:
account-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: psyto/solinv@v0That is all (on the GitHub Marketplace). It finds every Anchor program in the repository (each programs/<name>/src with a
#[derive(Accounts)], at any depth) and every native or Pinocchio one (a src with an
entrypoint!; what its code no longer checks is a warning, never a failure), compares each with the pull request's base (on a push, with
the commit main was at before it, so a push of several commits is compared as one change), and
fails the job when a check is removed, with an annotation on the line. No Solana toolchain, no
build of your program: the job took 12 seconds on the demo program, downloading the release
binary of solinv built for that tag. The action uses that binary only after
gh attestation verify confirms solinv's own release workflow built it from that exact commit;
otherwise (another runner OS, or a commit without a release) it compiles solinv, about 2 minutes.
@v0follows the latestv0.xrelease. To pin, use a release tag (@v0.1.9) or its commit.program-src:lists the programs to compare instead, one path per line (one in a directory not namedprograms/, or only some of them). A path that is gone fails.- An Anchor program deleted by the change, or one left with no account struct, fails the job: every
check it had is gone. A native program deleted is a warning.
fail-on-removed: falseturns every failure into a warning. - In a merge queue, add
merge_group:toon:; it compares with the queue's base.
The CLI (not on crates.io yet; pin a release tag):
cargo install --git https://github.com/psyto/solinv --tag v0.1.9 solinv-cliIn your Crucible fuzz harness:
[dependencies]
solinv-fuzz = { git = "https://github.com/psyto/solinv", tag = "v0.1.9" }
solinv-core = { git = "https://github.com/psyto/solinv", tag = "v0.1.9" }
crucible-test-context = { git = "https://github.com/asymmetric-research/crucible", tag = "v0.1.0" }use solinv_fuzz::{Idl, IdlSpecExt}; // `spec()` comes from IdlSpecExt (or `use solinv_fuzz::prelude::*`)
let spec = Idl::from_json(include_str!("../idl/my_program.json"))?
.spec("deposit", program_id)?
.account("vault", vault)
.signer("depositor", Arc::clone(&depositor))
.args(&50_000u64.to_le_bytes())
.build()?
.infer_from_state(|pk| ctx.get_account(pk).ok()) // facts
.infer_candidates(|pk| ctx.get_account(pk).ok()) // leads, logged
.into_spec();Return the specs from HasInstructionSet::instructions() and call the detectors from an
#[invariant_test], e.g. solinv_core::invariants::signer_skip::check(fixture). Full walk-through:
start from your IDL and integration.
To fail CI on a finding, read the log, not the exit code: crucible run exits 0 when it reports one
(crucible#26), and checks --timeout only
every 1000 iterations (crucible#25).
timeout -s INT -k 30 180 crucible run <program> invariant_all \
--binary-in target/release/invariant_test --timeout 180 > fuzz.log 2>&1 || true
if grep '^\[FUZZ_FINDING\] \[' fuzz.log; then exit 1; fi # solinv's findings start `[<detector>:…]`princeps-solana's workflow
is a complete example, separating a crash of crucible itself from the timeout.
Both install lines above were checked from GitHub at that rev, not from a local clone.
Trying it on a program of your own for the first time? docs/try-solinv.md is a
one-page walk-through, with what to time and what to send back.
All installed from the pins above, the same way as for anyone else:
| Repo | What runs | Measured |
|---|---|---|
| fabrknt/veil | the action at @v0, two lines, on pull requests and on pushes to main; it finds the 5 Anchor 0.30 programs itself |
10 seconds a run with the attested release binary. Earlier, a throwaway pull request that dropped a has_one failed with the annotation on the line (fabrknt/veil#2) |
| psyto/soldojo | the action at @v0, two lines, on pull requests and on pushes to main — changes there go straight to main, which is why the action compares pushes |
9 seconds a run (0 removed). The same two lines on a throwaway branch of this repository found its 7 Anchor programs; a push that dropped a has_one failed with both annotations on the line, and its revert passed |
| psyto/princeps-solana | the action at @v0, two lines; it finds the 6 native (Pinocchio) programs itself and warns on a dropped check |
A borrower key check deleted from repay_loan on a pull request: warned on, job passed (#4) |
| psyto/openhl-solana | the same, on the course's native openhl-core |
Deleting Deposit's signer check, which logs before it fails, was not seen at first: 78 of its 192 checks were not read (fixed in v0.1.5); now warned on (#1) |
| psyto/princeps-solana | a fuzz harness for a native (Pinocchio) vault: signer-skip, owner-skip, account-swap, 3 minutes per pull request | Deleting one check at a time: 6 of 6 caught; 0 findings on the current code. Two of them — the compliance-program pin in deposit and withdraw — need two accounts swapped together, which account_swap::check_sets does (#4, psyto/princeps-solana#3) |
| psyto/openhl-solana | a fuzz harness for the native trading vault of the Solana Internals course, Chapter 12, which ends with it as an exercise (§12.7) | Its first run found a real hole: withdraw did not check that the share belonged to the vault, so shares minted cheaply in one vault drained another. Deleting the 9 account checks one at a time: 5 caught; the other 4 are refused anyway by the runtime's owner rule or by SPL Token, and the chapter explains each |
| psyto/openhl-solana | a second harness for Chapters 3, 4 and 6 of the same course (CreateMarket, Bench, CreateVault, Deposit), each chapter ending with it as an exercise | Deleting 14 account checks one at a time: 1 caught (CreateVault's vault-authority PDA, the one nothing else backs); the chapters trace what refused each of the other 13 (the runtime's signer and owner rules, a missing callee account, SPL Token, another check). cu-dos on Bench with a 50,000 CU cap: a finding within seconds. Writing it turned up #15 |
| psyto/openhl-solana | a third harness, for the course's Chapter 13 builder fees: a Relation (builders' accumulated_fees ≤ the fee vault's balance) and a guard (a builder is credited only in its fee mint), on three markets including one at a non-PDA address |
0 findings on the current program, which answers whether a second market can owe builders more than its users paid (no). Of 12 changes, 8 caught, including two arithmetic ones no account check sees (a claim that does not zero the debt, a share of the notional instead of the fee), caught only by the relation. Writing it turned up #16 |
| psyto/openhl-solana | a fourth harness, for the course's Chapter 11 position handlers (open, close, liquidate) across two markets in one quote mint | Its first run found a real hole: the handlers took any market's oracle and funding state, so a long opened at one market's price closed at another's (200 in, 1,200 out); fixed. Of 17 deleted checks, 12 caught; the two margin rules only tests catch, and the chapter traces the rest |
| psyto/openhl-solana | cu-dos on the course's Chapter 8 matcher, with a 50,000 CU cap per match | A finding within seconds (58,148 CU on a full book); capping max_fills at a bound derived from the measurements removes it. Measuring for the exercise showed the chapter's CU table was from an older toolchain (about 3,500 CU per fill, measured 1,500) and that its "the budget breaks" exercises could not break at the book's 32 slots; the chapter now says so |
| psyto/openhl-solana | cu-dos on the course's Chapter 15 slab (critbit) matcher, with a 100,000 CU cap | The chapter's cost table, written before measuring, had the slab dearer per place and the flat book cheaper below 8 orders; neither held. A first cap of 69 fills, derived from contiguous prices, was wrong: makers can pick prices that make the critbit deep (u64::MAX - 2^k), and the fuzzer then reported 157,245 CU; the cap that fits that shape is 31 |
| solinv check | solinv-static (source, diff, idl, idl_diff, legacy) built to WebAssembly from the 3eb3922 (v0.1.9) pin; diff_idls on every upgrade of a program's on-chain IDL |
marginfi's IDL (10 versions since 2024-03): 3 of 9 upgrades drop a declared has_one (2, 8 and 2 findings); in the source the check moved into a constraint that also admits delegated and receivership signers |
What using it ourselves turned up: the t mark for typed accounts not tied to the instruction
(#3; in veil it surrounds four real holes the earlier map showed as T),
a signer-skip false positive (#5), matrix jobs that
raced on the action's cache so each one recompiled (#6; the
action now has no cache and downloads an attested binary instead), that the action ran only on pull
requests while our repositories mostly push to main (it now compares pushes too), two misreads found
by running solinv idl --src over 105 of our own Anchor programs (a field with its attribute on the
same line was dropped; every module's fn handler merged into one phantom instruction; both fixed in
577ca4f), that putting the action on a native
program failed with an error that did not say why (it now reads native programs), that once it did, it
found none on GitHub's runners (GNU find rejects a {} in the detector that macOS find lets
through; fixed in v0.1.6, with a job that runs the detector on ubuntu),
two setup gaps in the docs (#7), four kinds of report
that read worse than they were on other teams' open pull requests: a check rewritten with other
arguments, one widened with ||, a handler's body moved into a fn handed ctx.accounts, and an
instruction retired to err!(..). Each was a "removed" with nothing more said
(#17, #20–#22;
the first two stay removals, because a review broke every rule that made them warnings), and check_sets
skipping a set whose accounts are missing without saying so (#13). Two
shapes that review, not solinv, found in our own programs are now read too: signer seeds with no bump
(the call cannot sign), and a transfer hook whose execute has neither the SPL Execute discriminator
nor a fallback that runs it; and an entry PDA (a whitelist, say) written under a registry's authority
whose seeds name the wallet but not the registry, so any registry's authority writes every registry's
entries (#18). Building
solinv check turned up two bugs in anchor idl convert's legacy-IDL conversion — string seeds keep
their quotes (otter-sec/anchor#5255), and
has_one relations land on the wrong account (#5256)
— and Codex reviewing idl_diff against real IDLs found five ways a loosened check could go silent
or read as a false REMOVED (fixed in ef3bb11). Upstream:
crucible's --timeout overran by 18 minutes in CI (crucible#25),
and it exits 0 on findings, so CI has to look for [FUZZ_FINDING] itself (crucible#26).
- Anchor's own checks enforce what is declared. solinv watches the declarations: it notices when one disappears, and maps where nothing is declared and the check is hand-written.
- Linters and static analyzers flag patterns in one version.
solinv diffcompares two versions and names what changed; the fuzz detectors send the attack and report only if it lands. - An audit checks the code once. solinv keeps the audited checks in place afterwards; it does not replace one.
- One dependency in your harness. solinv runs inside Crucible and inherits its sBPF execution, coverage and scheduling; it adds detectors and spec-building, not an engine. No validator, no RPC.
- The IDL writes the account half of the spec, and the source narrows what to read. On the
production programs below,
--srccut the slots to review from 29 to 5 (tip-distribution), 34 to 14 (tip-payment) and 108 to 30 (cp-swap). What you still write is the fixture: the accounts that must exist before an instruction can run, and one action per instruction. - Measured on three production Anchor programs (2026-11-03, fixture written by someone who
knows solinv): Jito tip-payment 6 min and tip-distribution 6 min to a first Crucible result,
Raydium cp-swap 11 min (its admin key is hard-coded, so setup writes the config account directly).
All three came back clean — the expected answer for audited code; the use is catching a check that a
later change drops, in CI. What slowed each one down is in
docs/idl-specs.md.
Sibling to pinocchio-bench — the public CU-measurement leaderboard comparing Anchor vs Pinocchio across 13 DeFi hot paths.
Existing Solana fuzzers (Crucible, Trident) provide coverage-guided execution and snapshot infrastructure. They ask the user to declare what counts as a bug. Solinv ships that declaration as a library:
- Pre-built Solana invariants — 11 in the catalog, each tested end-to-end against planted bugs and clean against hardened production code: Critical 5 (signer-skip, owner-skip, discriminator-skip, pda-forge, account-swap) + High 6 (unchecked-math, cu-dos, cpi-reentrancy, realloc-race, bump-seed-canonicalization, precondition-skip). Each ships with a 10-section spec doc, Gate 1 planted-bug acceptance, and Gate 2 production-target calibration evidence.
- Honest calibration data — production code is mostly clean. Solinv publishes the null results alongside the catalog so the detectors' sensitivity is auditable rather than implied. As of the 5-invariant calibration sweep on Raydium AMM SwapV2: 168,531 executions / 0 violations across 5 distinct detection mechanisms.
- Pre-commit kill-criterion methodology — every invariant ships with a §9 pre-committed experiment design (Gate 1 + Gate 2 binary outcomes) and a §10 honest framing of any decision overrides. The methodology has been empirically tested across 5 invariants × Day 34 / 38 / 58 / 59 / 60 result docs.
- Layer-of-responsibility framing — each bug class is mapped to what Solana's runtime defends against vs what the protocol must own. See the catalog summary table below.
- Bytepoke helper API —
solinv-fuzz::bytepokeprovides reusable Anchor-discriminator + sighash + byte-offset writers +AnchorAccountBuilder, for pre-writing deep account state directly instead of reaching it through the program's own instructions. - Disclosure templates — Immunefi / Sherlock / direct-to-protocol markdown templates with field-by-field guidance + a worked example from solinv's own planted-bug detection.
- Field schemas + relation invariants — state invariants name
pool.total_depositsinstead of byte offset 40, and can assert relations across accounts (liquidity + borrowed == total). A wrong offset used to fail silently; it now fails at declaration time. See field schemas. - Mutation testing —
solinv-mutateplants known Solana bug classes in a program's source and measures which detectors kill them, with a denominator that excludes mutants nothing ever reached. See mutation testing. - Differential equivalence + checkable findings — a three-surface oracle for "do these two programs behave the same?" (written state, accept/reject and reason, compute units), and a finding format that identifies every input by digest and states plainly whether it carries them. See differential equivalence below.
- Specs from an Anchor IDL —
solinv-fuzz::idlbuilds the account-validation half of anInstructionSpecfrom the IDL plus the fixture's post-setup state, andsolinv idlmaps a program's unsigned writers before any fixture exists — with--src, also which slots areUncheckedAccount/AccountInfoin source. Expectations the IDL does not make are kept apart as candidates: leads to confirm, not facts. See start from your IDL. - The checks a change removes —
solinv diffcompares two versions of a program's source slot by slot (no build): a typed account loosened toUncheckedAccount, a signer no longer required, aseeds/address/owner/has_one/constraintdeleted (constraints compared by expression; one kept as a side of a new||said as widened, one rewritten as the same call with other arguments said so), plus warnings for moved checks, type changes, optional accounts, new unsigned writers and an instruction retired toerr!(..). A fn the handler handsctx.accountsto is read with it.action.ymlruns it on pull requests with annotations on the line. Design and limits:docs/diff.md.
Engine performance is not the wedge — solinv runs on Crucible and inherits its sBPF coverage and libafl scheduling.
| Invariant | Solana runtime defense | Protocol responsibility |
|---|---|---|
| unchecked-math | none | total |
| cu-dos | per-tx 200K CU cap | total within cap |
| cpi-reentrancy | writable-account locks (same-account only) | partial (different-account / proxy paths) |
| realloc-race | near-total (rent check at tx commit) | intent only |
| bump-seed-canonicalization | none | total (use find_program_address + store canonical bump) |
| signer-skip | none (Anchor framework helps for Signer<'info>) |
total for raw AccountInfo |
| owner-skip | none (Anchor framework helps for typed Account<'info, T>) |
total for UncheckedAccount |
| discriminator-skip | none | total |
| pda-forge | none at read-time (runtime checks invoke_signed only) |
total |
| account-swap | none | total |
| precondition-skip | none | total (the runtime has no notion of a protocol's entry conditions) |
The scale anchors solinv's value-add: detectors target precisely the layer Solana's runtime doesn't own, where the protocol's responsibility is total or near-total.
psyto/pinocchio-bench is
the public CU-measurement leaderboard comparing Anchor 0.32.1 vs
Pinocchio 0.11.1 across 13 representative DeFi hot paths (W0-W12:
no-op through perp open_position). Two scaling laws now hold across
the dataset:
| Dimension | Marginal Anchor overhead |
|---|---|
| Per additional mutable zero-copy account | ~329 CU |
| Per additional CPI hop | ~1,968 CU |
Pinocchio-bench answers "how much CU is on the table?". Solinv answers "can a Pinocchio rewrite preserve the original program's behavior?" — see differential equivalence below. The leaderboard lists 5-6 invariants per workload that a fuzzer "would attach" for each surface (orderbook tick insert, AMM constant-product, lending refresh, vault NAV, oracle publish, perp margin); solinv is the framework that actually attaches them.
Invariants ask whether a program violates a property. A rewrite, a refactor or an optimization asks something else: whether the new version still does what the old one did.
On Ethereum a differential runner can diff against a commitment — the state root, the receipts trie. Solana publishes no equivalent, so there is nothing to compare except the other run, and the definition of "same outcome" is a choice the tool makes rather than something the chain hands you. Get it wrong and you don't get an error, you get silent false confidence.
Solinv spells the oracle out as three surfaces — chosen because they are where a rewrite is most likely to break, not because they exhaust what a caller can observe:
| Surface | Type | Fault when |
|---|---|---|
| Written account state | BodyDivergence |
any byte of the compared body differs |
| Accept/reject and the reason | ParityDivergence |
decisions differ, or both reject for different reasons |
| Compute units consumed | CuComparison |
the rewrite consumed more than the original |
Two of these are easy to omit and expensive to omit:
- Rejection reasons are ABI. A caller branching on
6001behaves differently against a rewrite returning6002— yet a boolean accept/reject check calls that agreement, because both sides "failed". Comparing the custom code alone is still not enough: every builtinProgramErrorreports no code, so two different builtins compare equal.ParityDivergence::check_outcomescompares a normalized reason (custom:6001,builtin:InvalidArgument). - CU is consensus-relevant. Two runs writing byte-identical state at
different CU are not equivalent: the dearer one can exceed the
transaction limit and fail where the other succeeds. Only a
regression is a fault — for a rewrite the two sides are supposed to
disagree, in one direction. (
CuComparison::from_outcomescan record the figure per action; the harnesses callregressiononly, so what they retain is the verdict, not a CU series.)
A harness that checks written state alone will pass a rewrite that changed its rejection reasons or got slower.
What these three do not cover, stated plainly because a differential that quietly under-checks is worse than one that admits its scope: account lamports / owner / executable flags, accounts written but not declared as a pair, instruction return data, and emitted logs. Passing all three narrows where a divergence can hide; it does not prove equivalence.
A fourth condition is a precondition rather than a surface: a probe
whose send() failed never reached the program. SendFailure reports
it — including the case where both sides failed, since a symmetric
failure still produced no evidence. Nothing diverges when nothing
executes, so a silently skipped send failure turns a broken harness
into a clean run, which is the worst result a differential can give.
A finding that only says what diverged is an assertion.
solinv-disclose packages it with its inputs — identified by digest,
and carried outright when the producer supplies them:
# Divergence report — amm-diff (raydium-amm swap)
- **Sides**: `anchor` (left) vs `pinocchio` (right)
- **Verdict**: DIVERGED
- **Claim digest**: `9fbd48ca…` (schema v2, build-scoped — contains a
builtin rejection token whose text depends on the producing toolchain)
- **Reproducible**: NOT FROM THIS REPORT ALONE — the inputs are
identified by digest, not carried. Obtain them independently, verify
them against the hashes below, then re-execute.
- Not carried: anchor program bytes, pinocchio program bytes,
`pool` account data, encoding for action `swap(100, 0)`,
the execution context and signer policy
## Where they parted
- [recorded] `swap` CU: 12400 → 3900 (-8500)
- **[fault]** `swap(100, 0)` outcome diverged: left rejected (custom:6001),
right rejected (builtin:InvalidArgument)
…followed by tables for each side's .so and every account's pre-state
and metadata (owner, lamports, executable, rent epoch), the activated
feature set, the execution context — runtime, clock, and how signatures
are to be satisfied — and the action sequence with its account metas.
Program IDs are upgradeable, so an ID names a slot in time rather than a binary; the feature set matters because Solana's runtime semantics are gated by flags that activate at epoch boundaries, which makes "same instruction, same accounts" two different executions on two different days.
A digest identifies; it does not deliver. A hash lets a reader
confirm that bytes they already hold are the right ones — it cannot
hand them those bytes. So the bundle is graded four ways:
InputsCarried (every input the bundle models travels with it, intact,
with a stated execution context), IdentifiedOnly (inputs are named by
digest and must be obtained elsewhere), Incomplete (the comparison is
not even defined), and Corrupt (carried bytes contradict their own
digest). Every report this repository can produce today is
IdentifiedOnly, and says so in its header rather than inviting a
reproduction it cannot support.
The top verdict is deliberately not called reproducible. A published report can never carry signing material, and it states rather than carries the runtime conditions — so what it promises is that nothing the model covers is missing or corrupt, and that the reader knows which environment to reconstruct.
The claim digest pins the finding — excluding tool provenance, so a crate release does not change it — and is bound to a schema version carried inside the hashed payload. It does not attest that the claim is true; no hash can. Confirming a finding means re-running the inputs. The digest makes the claim immutable; re-execution makes it true or false.
solinv-disclose depends on serde and sha2 and nothing else: finding a
divergence needs Crucible, LiteSVM and the Solana crates, but checking
a report about one should not.
Design rationale, API walkthrough and current gaps:
docs/differential-equivalence.md.
All six differential harnesses under
examples/pinocchio-bench-fuzz/fuzz/ (amm-diff, matching-diff,
oracle-diff, perp-diff, refresh-diff, vault-diff) check all
three surfaces and report probes that produced no evidence, with CU
non-regression as its own *_cu_no_regression_only variant so a
performance finding is never reported as a behavioural one. Each
invariant runs its probes once and hands the result to the evaluators,
since the probes mutate fixture state.
amm-diff emits its findings as reports: SOLINV_REPORT=<prefix>
writes the canonical JSON and the Markdown view on the first
divergence, and SOLINV_REPORT_INLINE=1 carries the program bytes and
account pre-state rather than only naming them. The other five
harnesses still leave a finding as a log line.
A state invariant reads a little-endian integer at a byte offset. Declaring that offset by hand is the most error-prone part of adopting solinv, and it fails in the worst possible way: silently. The detector reads eight bytes belonging to some other field, they move plausibly, nothing fires, and the campaign reports a clean run. A harness checking the wrong bytes is indistinguishable from one that found no bug — which matters more here than elsewhere, because solinv's headline result is a null result.
AccountSchema moves the layout into a single checkable declaration:
let pool = AccountSchema::anchor("Pool") // cursor starts past the 8-byte disc
.field("authority", 32) // declared for its offset only
.field("total_deposits", 8)
.field("total_borrows", 8);
pool.validate_len(account.data.len())?; // check the claim against reality
let inv = pool.monotonic(
"deposits grow", vec![0], "total_deposits", MonotonicDir::NonDecreasing,
)?;| Mistake | Before | After |
|---|---|---|
| Offset off by one field | silent wrong read | UnknownField, listing the real names |
| Asserting on a 32-byte pubkey | silent truncation | UnreadableWidth |
| Layout drifted from the program | silent wrong read | ShortAccount from validate_len |
StateInvariantKind::Relation adds the shape the other three kinds
cannot express — two sides, each a sum of terms, compared with ==,
<=, <, >= or >:
relation(
"liquidity + borrowed == total",
vec![reserve.term(0, "liquidity")?, reserve.term(0, "borrowed")?],
RelOp::Eq,
vec![reserve.term(0, "total")?],
0,
)?A relation fires only when it held before the ix and failed after: one that was already false indicts the declaration, not the instruction. Because a relation that never holds also never fires, that case is reported rather than dropped — as are unreadable fields, once per site, un-gated by any env var. A harness that is silently checking nothing has to say so by default.
The grammar stays deliberately closed (integers plus an operator), so a
candidate invariant can be validated before it ever executes. Ratios
are not expressible in v1; that is a stated limit, not an oversight.
Design rationale: docs/invariants/field-schema.md.
solinv-mutate plants a known bug class in a program's source, runs
the catalog against it, and records whether the detector that was
supposed to catch it did. It does not find bugs — every bug in a
campaign is one the tool planted, and nothing it produces is
submittable anywhere.
What it produces is the missing half of the calibration argument. The
sensitivity claim behind "0 violations on production code" currently
rests on examples/escrow-demo, and escrow-demo is not Raydium. A
silent run is evidence only if the same harness on that same program
demonstrably kills planted bugs.
The denominator is the design:
score = killed / (killed + survived) # reached mutants only
not_reached and build_failed are excluded and reported next to the
headline, never folded in. solinv already measured why this matters
from the other direction — bump-seed-canonicalization detects its
planted bug at ~2% because reachability, not sensitivity, sets the
rate. Two consequences: a campaign that reached nothing has no
score rather than 0%, and a run below its committed min_reached
reports INCONCLUSIVE rather than FAIL.
Identifying unreached mutants needs evidence, since a silent run and an
unasked detector look identical. Plans record each mutant's
enclosing_fn; the fixture's action_* methods emit
[solinv][reached] ix=<name> under SOLINV_REACHABILITY=1 when a
canonical instruction lands; the campaign script joins them. The join is
sound in one direction only — an instruction that never ran proves the
mutation never ran, but an instruction that ran does not prove the
mutated branch did — so every ambiguity resolves to survived,
erring against the detector.
Three findings shaped the runner, each of them a wrong number it produced first:
- A run with no evidence is
harness_error, notsurvived. The first campaign scored 0.0% over 17 reached mutants;cruciblehad in fact exited immediately every time, and an empty log has no violations in it. A baseline build-and-run now gates the campaign. - A kill has to be caused by the mutation. The next campaign scored 17 of 17, gate PASS — with 0 killed by the predicted invariant. The unmutated program was already producing 7202 violations. The runner now measures the baseline's detector set and subtracts it.
- The target has to be correct before you mutate it. Which is why
escrow-cleanexists: escrow with the timelock fixed and everyunsafe_*handler removed, verified to produce no violations at all before mutation.
solinv mutate operators # 9 operators, 6 of 10 invariants
solinv mutate plan --target escrow-clean \
--src examples/escrow-demo/programs/escrow-clean/src/lib.rs \
--out plan.json --statement "..." --min-score 0.8 --min-reached 8
./scripts/run_mutation_campaign.sh plan.json \
examples/escrow-demo/programs/escrow-clean/Cargo.toml \
escrow_clean examples/escrow-demo results.json
solinv mutate score --plan plan.json --results results.jsonThe kill criterion is committed at plan time, before any mutant runs
— the §9 pre-commit discipline applied to the campaign itself.
Campaigns on escrow-clean, before and after the invariant the first one located:
| Run | Raw | After exclusions | Gate |
|---|---|---|---|
Before precondition-skip |
18.2% (2/11) | 28.6% (2/7) | INCONCLUSIVE |
| After | 45.5% (5/11) | 83.3% (5/6) | PASS |
Every kill in both runs was by the invariant its operator predicted.
Read the PASS sceptically: it rests on six mutants, and min_reached
was set to 6 after the first run had shown how many exclusions to
expect — choosing the bar with that knowledge is what §9 pre-commitment
exists to prevent. This is calibration of the instrument, not a
detector clearing a gate.
The first run's nine survivors are what made it worth running. Four
were equivalent mutants — an Anchor Signer constraint is redundant on
a handler whose body invokes a CPI that requires the signature anyway,
and the same mutation is killed on the two handlers that move
lamports directly. The other five were require! guards on timelocks
and amount bounds, which no invariant in the catalog modelled. That
bucket became precondition-skip,
the eleventh invariant — the first solinv located with its own tooling
rather than taking from a write-up. Three of the five now die,
including the timelock race escrow-demo plants as its flagship bug,
which until now read as detected only because that harness carries a
hand-written assertion.
One honest survivor remains: deposit's amount > 0. The guard is
real and the mutation removes it, but the detector sends a fixed
data_sample and never varies the argument to zero. That is a
reachability limit, not a blind spot, and the distinction decides what
work would fix it.
First campaign on production code — raydium-amm: 21 mutants inside the two swap handlers, baseline silent. 0 killed of 18 reached — FAIL, conclusively. Three causes, and only one of them is a blind spot:
- The operator set was Anchor-only. raydium-amm has zero
require!and zeroSigner<'info>; its 212 error returns are allif cond { return Err(..) }. The catalog could not mutate a native program at all, so every score before this one measured Anchor code specifically.native-guard-neutralizefixes that — 129 matches in raydium's processor.rs. - Most survivors are guards the invariant cannot express.
precondition-skipcompares pre-state values; a production AMM guards on computed intermediates (swap_amount_out < minimum_amount_out), which no pre-state declaration can name. Real limit of the design, invisible on a four-handler escrow. - The campaign was mis-specified. 15 of 18 mutants predicted
precondition-skipand the raydium harness declares no guards — the detector was switched off on the target it was being scored against.
One finding replicated: neutralizing raydium's !is_signer check opens
nothing, because that account is the authority of the token_transfer
CPI. Same redundancy as escrow-clean's deposit, found independently
on production code.
v1 operators reach 7 of the 11 catalog invariants; cu-dos,
cpi-reentrancy, realloc-race and account-swap need structural
rather than textual mutation and are named in every campaign summary so
a score is never mistaken for a verdict on all ten. This campaign
exercised two of them — a four-handler escrow does not contain the
shapes the rest detect. Design note:
docs/mutation-testing.md.
Fuzz the planted-bug demo end to end.
# Install Crucible CLI (one-time)
git clone https://github.com/asymmetric-research/crucible ~/src/crucible
cargo install --path ~/src/crucible/crates/crucible-fuzz-cli
# Build the planted-bug program (one planted bug per High-tier invariant
# + the Critical-5 acceptance fixture)
cd examples/escrow-demo
cargo build-sbf --tools-version v1.52 --manifest-path programs/escrow/Cargo.toml
# And the clean twin, which mutation campaigns target. It must produce
# NO violations before mutation — that is the property being relied on.
cargo build-sbf --tools-version v1.52 \
--manifest-path programs/escrow-clean/Cargo.toml
crucible run escrow_clean invariant_all --release --timeout 30
# Run each invariant in isolation
for inv in signer_skip owner_skip discriminator_skip pda_forge account_swap \
unchecked_math cu_dos cpi_reentrancy realloc_race \
bump_seed_canonicalization; do
crucible run escrow invariant_${inv}_only --release --timeout 30
doneEach campaign reports its respective invariant's violation against the
planted bug. Detection rates range from ~2% (bump-seed-canonicalization's
substitution attack requires the canonical vault to exist first; state
buildup gates reachability) to ~99% (deterministic mechanisms like
unchecked-math / cu-dos / realloc-race). See
examples/escrow-demo/README.md
for per-variant detection numbers and the combined acceptance variant.
The 5 High-tier invariants tested under apples-to-apples Gate 2 conditions against Raydium AMM SwapV2:
| Invariant | Mechanism | Executions | Violations |
|---|---|---|---|
| unchecked-math | state mutation (Bounded) | 15,380 | 0 |
| cu-dos | per-ix CU consumption | 25,650 | 0 |
| cpi-reentrancy | CPI call-tree logs | 27,573 | 0 |
| realloc-race | runtime err + post-state | 24,381 | 0 |
| bump-seed-canonicalization | alt-PDA substitution | 75,547 | 0 |
| Total | 168,531 | 0 |
5 distinct detection mechanisms × 1 hardened production target × 0
violations. Edge saturation 629/14,696 identical across all 5 runs —
additional time would not have changed the outcome. The dataset is
the empirical backbone of solinv's "honest tested-and-found-nothing"
framing. Full per-Gate methodology in
docs/phase5-day60-bump-seed-canonicalization-gates.md.
The catalog targets Solana's distinctive account model and runtime-specific bug surfaces.
| # | Tier | Name | Bug class | Spec |
|---|---|---|---|---|
| 1 | Critical | signer-skip | missing is_signer on authorization-required account |
docs/invariants/signer-skip.md |
| 2 | Critical | owner-skip | missing account.owner == expected_program check |
docs/invariants/owner-skip.md |
| 3 | Critical | discriminator-skip | missing Anchor account discriminator check | docs/invariants/discriminator-skip.md |
| 4 | Critical | pda-forge | PDA seed not verified at read time, attacker forges arbitrary PDA | docs/invariants/pda-forge.md |
| 5 | Critical | account-swap | wrong-context PDA — missing context-binding check | docs/invariants/account-swap.md |
| 6 | High | unchecked-math | arithmetic overflow / wrap reaching protocol state | docs/invariants/unchecked-math.md |
| 7 | High | cu-dos | single ix consumes >limit CU → permanent DoS | docs/invariants/cu-dos.md |
| 8 | High | cpi-reentrancy | CPI cycle through caller program → state-coherence break (Mango v3 class) | docs/invariants/cpi-reentrancy.md |
| 9 | High | realloc-race | data buffer grown past rent-exempt threshold without lamport top-up | docs/invariants/realloc-race.md |
| 10 | High | bump-seed-canonicalization | non-canonical PDA bump accepted; alt-PDA substitution attack | docs/invariants/bump-seed-canonicalization.md |
| 11 | High | precondition-skip | ix succeeds while a declared entry guard was false — dropped timelock or bound | docs/invariants/precondition-skip.md |
Invariant 11 was located by a mutation campaign rather than proposed:
five survivors on escrow-clean were dropped require! guards that
nothing in the catalog modelled, one of which reproduces the timelock
race escrow-demo plants as its flagship bug — a bug that read as
"detected" only because that harness carries a hand-written assertion.
See mutation testing.
Medium tier (roadmap, not yet specced): close-reopen, sysvar-manipulation, permissionless-misuse, rent-exemption, account-init-race.
Each implemented invariant ships with:
- A spec doc under
docs/invariants/(8 sections + a §9 pre-committed kill criterion for the gated experiments) - Reference implementation under
crates/solinv-core/src/invariants/ - Planted-bug fixture in
examples/escrow-demo/ - Regression tests covering both detect-pair (when in-tree) and non-detect baseline
| Target | Class | Result |
|---|---|---|
examples/escrow-demo |
planted bugs across 5 handlers, 7 invariants | 7/7 planted bugs detected end-to-end (Day 13 Critical 5/5, Day 33 unchecked-math + Day 37 cu-dos Gate 1) |
examples/raydium-amm-fuzz |
Native production AMM, 5 invariants × SwapBaseInV2 + OutV2 | 25,383 attacks → 0 violations (Day 21) |
examples/slumlord-fuzz |
Native flash-loan program (Igneous Labs) | 86,391 exec × 5 invariants → 0 violations (Day 43) |
| Raydium SwapV2 + unchecked-math | High-tier, Gate 2 | 0 violations / 15K exec (Day 34) |
| Raydium SwapV2 + cu-dos | High-tier, Gate 2 | 0 violations / 25K exec (Day 38) |
On hardened production code: 0 violations across 3 invariant classes × 3 protocols × ~150K total executions. This is the empirical calibration result: solinv's invariants are sensitive to planted bugs and silent against well-engineered production code. The dataset is preserved as the project's published-honest baseline.
Solinv inherits Crucible's LiteSVM (currently v0.9.1) for in-process program execution. Different program classes have different reachability:
| Program class | Reachability | Examples |
|---|---|---|
Native (any solana-program 1.16+) |
✅ works | Raydium AMM, Slumlord, Save (ex-Solend), Jito restaking |
| Anchor 1.0+ | ✅ works | escrow-demo |
| Anchor 0.31 + Solana 2.x | Meteora DAMM v2, Jito workspace SDK | |
| Anchor 0.28 | ✅ for Sanctum unstake's deployed binary (solana program dump): init and later instructions run. Our local build of it (solana-program 1.14.20, platform-tools v1.37/v1.39) faulted on the init CPI, on a real validator too. Other 0.28 programs untested |
Sanctum unstake |
| Anchor 0.27, 0.29 | klend | |
Pre-Anchor / solana-program 1.10 |
Wormhole bridge |
Fuzz the binary the protocol deployed. solana program dump -u m <program id> out.so
gets it. An earlier diagnosis blamed LiteSVM for Sanctum unstake's (Anchor 0.28)
init failure; the real cause was our rebuild of its solana-program 1.14 source
with new platform-tools
(see docs/phase5-day61-h1-was-the-build.md).
The solinv-fuzz::bytepoke module (Anchor discriminator + sighash helpers,
byte-offset writers, AnchorAccountBuilder) is still there for pre-writing deep
state directly instead of reaching it through the program's own instructions.
The klend example uses it.
See docs/phase5-day57-bytepoke-helper.md
for the helper API.
solinv/
├── crates/
│ ├── solinv-core/ # 7 implemented invariants + regression tests
│ ├── solinv-fuzz/ # Crucible re-exports + HasContext/HasInstructionSet traits
│ │ # + bytepoke + differential equivalence oracle
│ │ # + AccountSchema (named fields) + Relation
│ │ # + idl (Anchor IDL → InstructionSpec, candidates)
│ ├── solinv-mutate/ # Mutation operators, campaign planning, and
│ │ # reachability-aware scoring (serde+regex only,
│ │ # no Crucible/Solana)
│ ├── solinv-cheat/ # LiteSVM cheatcode wrappers
│ ├── solinv-corpus/ # Yellowstone gRPC seeder (scaffold)
│ ├── solinv-static/ # no Solana deps (builds to WASM): the IDL report,
│ │ # source.rs (declared account types, #[account]
│ │ # constraints, vipers validate()), diff.rs (what a
│ │ # change removes), idl_diff.rs (the same from IDLs)
│ ├── solinv-disclose/ # Divergence findings as checkable evidence
│ │ # (serde+sha2 only, no Crucible/Solana);
│ │ # platform formatters pending
│ └── solinv-cli/ # `solinv` binary — idl, diff (on solinv-static),
│ # mutate, score (+ scaffolds: init, check, fuzz,
│ # corpus, disclose)
├── examples/
│ ├── escrow-demo/ # planted-bug acceptance fixture (7 invariants)
│ │ ├── diff/drop-a-check/ # escrow-clean with one check dropped (diff quickstart)
│ │ └── idl/ # IDLs of both programs, for `--features idl_specs`
│ ├── raydium-amm-fuzz/ # Native production AMM (validated clean)
│ ├── slumlord-fuzz/ # Native flash-loan (validated clean)
│ ├── klend-fuzz/ # Anchor 0.29 (source build; ReserveConfig setup incomplete)
│ ├── sanctum-unstake-fuzz/ # Anchor 0.28 (mainnet binary, init reachable)
│ ├── targets.phase4.toml # target-scoring config (10 protocols)
│ └── target-scoring.example.toml # config schema reference
├── docs/
│ ├── invariants/ # per-invariant 8-section specs
│ │ └── field-schema.md # named fields + relation invariants
│ ├── idl-specs.md # IDL → spec: facts vs candidates, limits
│ ├── diff.md # what `solinv diff` reports, and its limits
│ ├── history-scan.md # solinv diff over 1,133 merged PRs of 10 programs
│ ├── open-pr-scan.md # …and over 121 open PRs of 13
│ ├── mutation-testing.md # operator catalog + scoring rationale
│ ├── differential-equivalence.md # oracle rationale + report format
│ ├── disclosure-template-*.md # Immunefi / Sherlock / generic templates
│ ├── research-*.md # research deliverables
│ ├── harness-pattern-raw-call.md # reusable Anchor-version-independent harness
│ └── (implementation logs) # Day-by-day audit trail of engineering decisions
├── action.yml # GitHub Action: solinv diff on pull requests
├── scripts/ # bandit allocation, metrics, mutation campaigns,
│ # idl-specs-parity.sh (hand vs IDL specs)
└── logs/ # per-campaign fuzz logs (gitignored)
anchor build # writes target/idl/<program>.json
solinv idl target/idl/my_program.json # where to point the Critical 5
solinv idl target/idl/my_program.json --src programs/my_program/src # + declared types
solinv idl target/idl/my_program.json --rust deposit # binding stub for one ixReads the Anchor 0.30+ IDL format, and a legacy (pre-0.30) IDL directly:
solinv idl and solinv diff --base-idl convert it the way
anchor idl convert does, minus two bugs in that converter (string const
seeds keep their JSON quotes,
anchor#5255; has_one
relations land on the wrong account,
anchor#5256), so do
not pre-convert with it. In a harness, Idl::from_json_any does the
same; Idl::from_json takes 0.30+ only. Shank IDLs from native programs
are not supported. Building an IDL on Anchor 0.30
with current Rust needs a proc-macro2 pin
(anchor#3661); the
steps are in docs/try-solinv.md.
solinv idl needs no fixture. It lists every
instruction's slots as the IDL declares them and names the instructions
with no signer that write a slot the IDL leaves unconstrained —
either deliberately permissionless, or missing an authority check. On
escrow-demo's planted program those are exactly its three planted
account-validation bugs.
The IDL cannot tell a typed Account<'info, T> from an
UncheckedAccount (Anchor strips the /// CHECK: line from docs), so
without source every unconstrained slot counts the same. --src reads
each field's declared type from the #[derive(Accounts)] structs and
marks slots U (UncheckedAccount / AccountInfo: owner and type
checks are hand-written) or T (one of Anchor's typed wrappers). A typed
data account (Account, AccountLoader, InterfaceAccount) that no
seeds, address, has_one, associated_token::* or .key()
comparison identifies — on it, or on
another slot naming it — and that init does not create, is marked
t: Anchor checks its owner and type, not which account it is, so it
is where account-swap should look. It is a candidate list, not a
verdict: seeds, address, has_one, associated_token::authority, a
vipers assert_keys_eq! in an impl Validate that #[access_control]
runs, and each
&& clause of a constraint comparing a .key() (not an ||, where
one branch can pass alone) count when they
name another account of the instruction or an upper-case constant
(seeds of literals alone too), not an instruction argument the caller
picks, and what a comparison says is not checked. A key comparison at
the top level of the handler body (require_keys_eq!, if a.key() != b.key() { return Err(..) }) counts. So does one in a function the handler
hands ctx.accounts itself to, on every path (f(ctx.accounts, ..),
ctx.accounts.f(..), one call deep). One inside any other function the
handler calls is not read (Jito tip-distribution's upload_merkle_root
checks in such a helper), nor is the fact
that a type only ever exists at one PDA. A tie to another account
counts even when that account is itself the caller's choice (the pool
or sale the instruction acts on): whether a caller can create a matching
pair depends on who can create the type, which the map does not read —
account_swap::check_sets with both slots swapped is the test for that. The reader is a line reader,
not a parser: a slot typed by a type alias, a struct name
defined twice, or a field type split over lines is left unplaced and
counted as not found — never guessed. On the production programs
measured that narrows the review a lot: Jito tip-distribution 29
unconstrained slots → 5 unchecked, tip-payment 34 → 14, Raydium cp-swap
108 → 30, with every slot placed. Writable t slots: tip-distribution 4
(a Config that initialize creates at a PDA, and tip-distribution
accounts the handler checks or any caller may migrate by design),
cp-swap 22 (admin-chosen configs and the
caller's own token accounts — its vaults are tied by constraint), and
20 on an Anchor 0.30 program of our own, among them the vaults behind
four real holes (#3).
Building a spec, system_program and associated_token_program are
bound by name when the IDL gives no address, and listed in
IdlSpec::defaulted(). Nothing else is guessed: a program's own account
may be called clock, and token_program may be SPL Token or
Token-2022. A spec whose program id differs from the
IDL's address gets an unresolved() entry: the loaded .so must
declare that id, or Anchor rejects every instruction with
DeclaredProgramIdMismatch — usually a .so built with different
features than the IDL.
In the fixture, the stub becomes the spec:
use solinv_fuzz::IdlSpecExt; // brings `spec()` into scope
let spec = idl.spec("deposit", program_id)?
.account("vault", vault_pda)
.signer("depositor", Arc::clone(&depositor))
.args(&50_000u64.to_le_bytes())
.build()?
.infer_from_state(|pk| ctx.get_account(pk).ok()) // after setup()
.infer_candidates(|pk| ctx.get_account(pk).ok())
.into_spec();The IDL supplies the account list, signers, PDA seeds and instruction
data. infer_from_state adds facts: owner and discriminator of each
seeded slot, and which seeded accounts the instruction creates.
That alone finds nothing on Anchor. What the IDL declares, Anchor
already enforces (signer: true is a Signer<'info>, pda.seeds is a
seeds = [...] constraint), so the spec only asks the program to
honour checks its own macros generated. Bugs live where the IDL is
silent. infer_candidates proposes checks for those slots from what
the fixture put there — a program-owned account with an IDL
discriminator (owner + discriminator); a seed pattern from elsewhere in
the IDL that re-derives the slot's address (pda seeds); on an
instruction with no signer that writes program state, a slot whose
pubkey that state stores (signer) — and lists each in
IdlSpec::candidates().
Candidates are leads, not facts. They describe what the fixture
passed, not what the instruction must require: a correct permissionless
crank that pays a stored beneficiary matches the signer rule. Confirm
a finding on a candidate slot against the program's intent before
reporting it. Findings do not carry that provenance themselves, so log
IdlSpec::candidate_log() when building the spec (the escrow-demo
harnesses do): a finding whose instruction and account index match a
[SOLINV_CANDIDATES] line is a lead. Account-data seeds, seeds under
another program, non-8-byte discriminators, and seed patterns that
could not be evaluated for a typed slot are listed in
IdlSpec::unresolved().
Still hand-written: swap_alternates, state invariants and guards —
semantic, not in any IDL. Composite accounts are named by dotted path
("group_a.authority"), or by leaf name when it is unique.
scripts/idl-specs-parity.sh runs escrow-demo's detectors with the
hand-written specs and with --features idl_specs, and exits non-zero
unless every planted row matches across modes and the clean twin
reports nothing. Last run (seed 1,
20 s per planted run, 60 s clean): the same detector fired on the same
instruction in both modes for all five of the Critical 5, and the clean
twin stayed at 0 under invariant_all.
Implement two traits on a fixture that already works under Crucible.
For an Anchor program, build the InstructionSpecs from the IDL
(start from your IDL) and hand-write only the
semantic fields; the full literal below is what that fills in, and is
still the route for native and Pinocchio programs:
use solinv_fuzz::{HasContext, HasInstructionSet, InstructionSpec};
impl HasContext for MyFixture {
fn ctx(&self) -> &TestContext { &self.ctx }
fn ctx_mut(&mut self) -> &mut TestContext { &mut self.ctx }
fn program_ids(&self) -> Vec<Pubkey> { vec![self.program_id] }
fn fee_payer(&self) -> Arc<Keypair> { Arc::clone(&self.fee_payer) }
}
impl HasInstructionSet for MyFixture {
fn instructions(&self) -> Vec<InstructionSpec> {
vec![InstructionSpec {
program_id: self.program_id,
name: "my_ix".into(),
accounts: vec![/* AccountMeta list */],
signer_indices: vec![/* indices that must be signers */],
expected_owners: vec![/* per-account expected owner (or None) */],
expected_discriminators: vec![/* Anchor disc bytes (or None) */],
expected_pda_seeds: vec![/* PDA seeds (or None) */],
// ... see InstructionSpec docs for all 14 fields ...
}]
}
}Then call invariants from a single #[invariant_test]:
#[invariant_test]
fn invariant_all(fixture: &mut MyFixture) {
solinv_core::invariants::signer_skip::check(fixture);
solinv_core::invariants::owner_skip::check(fixture);
solinv_core::invariants::discriminator_skip::check(fixture);
solinv_core::invariants::pda_forge::check(fixture);
solinv_core::invariants::account_swap::check(fixture);
solinv_core::invariants::unchecked_math::check(fixture);
solinv_core::invariants::cu_dos::check(fixture);
}Anchor IDLs auto-fill four of the five Critical-tier fields
(signer_indices, expected_owners, expected_discriminators,
expected_pda_seeds); only swap_alternates requires manual
context-binding declaration. Native programs declare manually.
Per-invariant attack vectors and false-positive risks are documented
under docs/invariants/<name>.md §3 + §5.
The CLI ranks bug-bounty / audit targets via a TOML scoring config:
./target/release/solinv score --config examples/targets.phase4.toml --top 10
./target/release/solinv score --config examples/targets.phase4.toml --top 10 --reachable-onlyEach target carries a status field (ready / exhausted / gated
/ unverified) + free-form note, so the rank output reflects what's
actually fuzzable today, not just what scores high in the abstract.
For per-invariant signal-rate tracking across campaigns, solinv emits machine-readable bandit metrics:
SOLINV_BANDIT_METRICS=1 crucible run <target> <invariant> --release --timeout 30
# emits: [solinv][bandit] invariant=... dt_sec=... delta_fp=... fp_per_sec=...The scripts/recommend_bandit_allocation.sh and
scripts/bandit_decide.sh helpers convert log lines into deterministic
allocation decisions (70/30, 50/50, or early-stop). Policy:
docs/day3-bandit-allocation-policy.md.
docs/disclosure-template-{immunefi,sherlock,generic}.md each pair a
field-by-field submission template (matching the platform's expected
form shape) with a worked example built from solinv's own
escrow-demo planted-bug detection.
When a real finding does emerge during dogfooding, the templates make it directly submittable without a from-scratch write-up.
These are human-authored submissions for invariant findings —
severity argument, impact narrative, remediation. The machine-generated
evidence for differential findings is solinv-disclose's
DivergenceReport (above). They are complementary: the template is what
a triage team reads, the report is what they re-run. Wiring the report
into the platform-specific templates is not implemented yet.
Every gated invariant ships with a §9 in its spec doc that pre-commits the experiment's binary outcome before any code runs. Two such gates have already fired in practice:
- Day 34 (unchecked-math) — Gate 2 FAIL on Raydium, binding pivot
- Day 38 (cu-dos) — Gate 2 FAIL on Raydium, binding pivot across High tier
This is the methodology contribution as much as the catalog: gated experiments with binary outcomes, documented in the spec at write-time, honored at result-time. The pre-commit prevents "let me try one more thing" drift after a negative result.
cargo check --workspace # verify scaffold (~5s)
cargo test --workspace # all crates (273 tests)
cargo test -p solinv-core # invariant regression tests
cargo test -p solinv-fuzz # bytepoke + differential oracle + schema + idl
cargo test -p solinv-mutate # operators + mutants + scoring (no Solana deps)
cargo test -p solinv-disclose # divergence reports (no Solana deps)
cargo build --release # build CLI binary
./target/release/solinv --help
./scripts/idl-specs-parity.sh # needs crucible + escrow-demo .so filesCLI surface:
solinv idl <idl.json> # account-validation map + unsigned writers
solinv idl --src <program src> # the map from source alone (no IDL: older Anchor);
# A also marks a non-constant `address =`
solinv idl <idl.json> --src <program src> # + U/T/t: unchecked, typed, typed but untied
solinv diff --base-src <before> --head-src <after> # checks a change removes; exit 1 if any
solinv diff … --format github # the same, as Actions annotations
solinv diff --base-idl <a.json> --head-idl <b.json> # from two IDLs, e.g. the audited build
# and `anchor idl fetch`: signer, seeds,
# address, has_one only
solinv idl <idl.json> --rust <ix|all> # SpecBuilder stub to paste into a fixture
solinv init # scaffold a solinv harness (not yet implemented)
solinv check <target> # run invariant checks
solinv fuzz <target> # run coverage-guided fuzzer
solinv score --config targets.toml # rank targets by ROI proxy score
solinv corpus fetch --rpc URL --program-id # ingest mainnet corpus (scaffold)
solinv disclose <bug-id> # generate disclosure report (scaffold)
solinv mutate operators # list the mutation operator catalog
solinv mutate plan --target T --src F ... # plan a campaign + commit its kill criterion
solinv mutate apply --plan P --mutant ID # write one mutant's patched source
solinv mutate score --plan P --results R # score a completed campaign
PRs welcome. See CONTRIBUTING.md for the
detector / harness / calibration contribution shapes, code style,
and PR process. Security vulnerabilities go via
SECURITY.md (not regular issues).
Sibling project pinocchio-bench is the public CU leaderboard — adopt solinv on top of a Pinocchio rewrite to verify behavioral equivalence with the original Anchor program.
Apache-2.0 — see LICENSE-APACHE.
Dependency stack stays MIT/Apache-2.0 clean (Crucible MIT, LiteSVM Apache-2.0); AGPL deps are avoided so downstream commercial users aren't polluted.