An audited Anchor program is mostly clean. What breaks it later is a pull
request that drops a check: an Account<'info, Vault> loosened to
UncheckedAccount to get a test passing, a Signer turned into a plain
account, a has_one or seeds deleted in a refactor. Anchor enforces
the declaration, so when the declaration goes the check goes too, and
nothing else fails. solinv diff compares two versions of a program's
source, slot by slot, and names every check that is gone.
A native or Pinocchio program has no declaration to compare. For one,
solinv diff reads the checks in its code instead and warns on each one
a change drops: native-diff.md.
solinv diff --base-src <before> --head-src <after> # text; exit 1 if a check is removed
solinv diff --base-src <before> --head-src <after> --format github # Actions annotationsIt reads source only — no build, no IDL, no Solana toolchain — so it runs
on any CI runner. action.yml wraps it for pull requests; see the
README. Live example:
a demo pull request that makes
the change in examples/escrow-demo/diff/drop-a-check
and fails with the finding on the line.
A slot is one field of a #[derive(Accounts)] struct, named by the
instruction whose handler takes that struct as its Context<..>
(composites by dotted path).
| Change | Reported as |
|---|---|
typed account (Account, AccountLoader, InterfaceAccount, …) → UncheckedAccount / AccountInfo |
removed: owner and type no longer checked |
Signer or #[account(signer)] → neither, or made Option<Signer> |
removed: no longer required to sign |
seeds, address or owner constraint deleted |
removed |
has_one = x deleted |
removed — or moved (warning) if another slot of the instruction now has it |
constraint = <expr> deleted or replaced |
removed, naming the expression — or moved (warning) |
constraint = <expr> (or has_one = x) kept as one side of a new top-level || (is_owner(signer.key()) || (signer.key() == JANITOR && active()?)) |
removed, said as widened: still checked, unless the added side — the part a reviewer has to judge |
a constraint or handler check rewritten as the same call with other arguments (is_signer_authorized(&a, admin, authority.key(), false, false, true) → …, ALLOW_REBALANCE), is_supported_mint(&m, init) → is_supported_mint(&m, init, hook)?) |
removed, said as the same call with other arguments, quoting both. Still a removal: an argument can switch a check off (a limit set to 0, the account swapped through a local, | true) in ways the text does not tell apart from a refactor. Not said so when a change is outside a call's parentheses or inside a macro's (require_keys_eq!(a, b)) |
token::mint / token::authority deleted |
removed: any mint / owner is accepted |
address, token::mint or token::authority value changed |
warning: confirm it names the account it should |
a has_one, address, constraint or token::* deleted while the instruction still declares the same key equality another way — has_one = f on x, address = x.f on f, constraint = x.load()?.f == f.key(), token::mint = m and constraint = t.mint == m.key() are one check |
not reported. Each name is read as the account it is in its own struct, so state in one composite is not state in another and an instruction argument is not the account of that name; declarations on an Option account, or inside an Option composite, do not count, since they do not run when it is left out. .load()? and spaces do not matter |
| …the same, but only once a typed account gone from the instruction is read as the one new account of the same type | warning naming the rename (marginfi_group → group): confirm it is the same account |
a check at the top level of the handler body deleted or changed: require!, require_eq! / _neq / _gt / _gte, require_keys_eq! / _neq, or if <cond> { return Err(..) } with no else |
removed, naming it (if !authority.is_signer removed from the handler) — on the first account it names, or the instruction when it names only locals |
…and an account it names now declares a check of the same kind instead — a signer check a Signer; a key check a has_one, constraint, seeds, address or Program/Sysvar type; an owner or discriminator check a typed account; a bump check seeds |
warning: moved to a declaration — confirm it covers the same thing. Any other check (an amount, a slot) has no declared form and stays removed |
vipers assert_keys_eq! / assert_keys_neq! deleted from an impl Validate the handler's #[access_control(ctx.accounts.validate())] runs (composites' self.x.validate() followed) |
removed, naming the check — or moved (warning) |
close = <target> deleted |
removed: no longer emptied and handed back to the system program |
close target changed |
warning: that is where its lamports go |
typed → a different typed type (Account<Config> → Signer) |
warning: type changed |
a slot wrapped in Option<…> |
warning: now optional |
a slot kept as a placeholder: now UncheckedAccount / AccountInfo with a /// CHECK: comment that says deprecated |
its removals merged into one that says it is a placeholder and quotes each (warnings and a lost signature stay as they are). Still a removal: a retired account's checks may have pinned another still in use (a has_one, an allowlist PDA seeded by another account), which the source cannot tell from bookkeeping |
| a slot with checks gone from an instruction that remains | warning: renamed or removed, listing its checks |
an instruction renamed — gone, with a new instruction sharing at least half of the two's slot names (programs, sysvars, authority and payer not counted), best match first, ties left unpaired |
warning naming the new name; the two are compared slot by slot, so a check the rename dropped is still removed |
| an instruction gone with nothing that matches, and checks | warning: not compared, with how many checks it had |
| a new instruction with no signer that writes an account | warning: permissionless by design, or a missing authority check? |
an instruction whose handler now only fails — every fn read as it is nothing but err!(..) / Err(..) (after any msg!), or a call to such a fn taking the same Context — so a cfg variant, mock or test fn that fails does not count |
its removals replaced by one warning: it always fails, so its checks guard nothing that runs |
Removals exit 1 (unless --no-fail); warnings do not. Annotations sit on
the line that declares the slot in the new version.
Constraints are compared by expression, with whitespace collapsed and the
@ Error dropped, so reformatting or renaming an error is not a change,
and swapping one check for another is. String literals in an expression
are kept, so role == "admin" → role == "attacker" is a change.
Handler checks are compared with spaces dropped and the two sides of a
lone == / != in order, so a == b and b == a are one check.
vipers key checks are compared with their arguments sorted and written
from the instruction's root (assert_keys_eq!(common.bank.crate_mint, common.crate_mint)), so swapping the arguments is not a change; removing
the #[access_control] that runs them removes every one.
- The demo change above: both findings, at the line, exit 1 — locally and on the demo pull request.
- Jito tip-payment, tip-distribution and Raydium cp-swap compared with themselves: nothing reported.
- escrow-clean against the planted-bug escrow: three warnings, on exactly the three planted instructions that write state with no signer.
- sealevel-attacks: every fixed program turned
into its insecure version — 11 of 11 classes where the check was
declared, 9 of 11 where it was written in the handler; 0 removed
checks in the 22 pairs the other way round, nor between the two fixed
versions (
secure→recommended: the handler checks become declarations, reported as moved). - Cashio's real source, fixed (
7df6581) against the version before the fix:assert_keys_eq!(common.bank.crate_mint, common.crate_mint)removed fromvalidate(), inprint_cashandburn_cash— the check its fix added. Cashio's code never had that check, so no pull request removed it; this shows that one written and later deleted would be named.
It reads text, not types, line by line as rustfmt lays fields out:
- A check moved from an attribute into the handler body
(
require_keys_eq!) reads as removed. That is deliberate: a reviewer should look. Of the handler body only the top level is read: a check inside a loop, amatch, anif … elsechain, or a function the handler calls under another name (a helperauth(&ctx)) is not; a function of the same name taking the sameContext(lib.rs delegating toinstructions::swap(ctx, …)) is read as part of the instruction, and so is a fn the handler handsctx.accountsitself to and whose result it returns or?s (swap_with_fee(ctx.accounts, ..),ctx.accounts.process(..)), one call deep, with its parameter read asctx.accounts— only when the call runs on every path (top level, not after areturn Ok, not under#[cfg], not in a closure), the fn has no early success of its own and does not rebind the parameter, and a bare name is the top-level fn of the same file with nousefrom outside the source that could mean another. A top-levellet x = &ctx.accounts.y;is followed; a local built from an account's data (let token = unpack(..)) is not, so a check on it counts for the instruction, not the account.if !x.is_signer { return Err }andrequire!(x.is_signer)are different texts, so rewriting one as the other reads as a removal. The other code read is vipers'assert_keys_eq!/assert_keys_neq!in animpl Validaterun by#[access_control]. A check inside anifcounts as always run; a secondimpl Validatefor the same struct anywhere in the source (a test module's included) leaves both unread. Rarer spellings —self.x.to_account_info().key,<X as Validate>::validate(&self.x)— are not normalised, so switching to one reads as a removal. - Each module's
pub fn handler(ctx: Context<X>)is read as the instruction whose other fn takesX(the program's wrapper calling it); when no one fn does, ashandler(X). A handler check written in two fns one instruction runs counts twice: removing one copy is a removal unless the instruction keeps as many. - A field may carry its attribute on its own line
(
#[account(mut)] pub vault: …). - A field typed by a type alias, a struct name defined twice, or a field type split over lines is not placed, so changes to it are not compared.
- A renamed instruction is paired by slot names alone: a rename that also renames most slots reads as one instruction gone (a warning) and one new, and an unrelated new instruction that happens to reuse half of a removed one's names is compared with it.
- Checks the IDL carries but the source does not spell (none in Anchor today) are out of reach; it compares source with source.