VL003 — overflow-checks is not enabled

This is a question about your build profile, not about a line of code. VL003 asks it once per workspace, and the answer is one line in one Cargo.toml.

One question, asked once

Until 27 July 2026 this rule reported every unchecked arithmetic operation it could see. That produced 382 findings across twelve codebases, and not one of them was actionable — because the answer to all 382 was the same, and it did not live on any of those lines. Rewriting each expression with checked_add would have been a month of diffs solving a problem that one Cargo.toml line solves completely.

So the rule was inverted. VL003 now asks exactly one question, once per workspace: does the workspace root enable overflow-checks for the release profile?

  • Yes — VL003 is completely silent. No project finding, no arithmetic sites, nothing.
  • No — one Medium finding against the workspace root manifest, plus the arithmetic sites listed beneath it at Low as evidence of what will wrap.

The Low findings are not a to-do list. They are there so you can see the blast radius before deciding, and they disappear the moment the flag is set.

The fix, first

Add this to the workspace root Cargo.toml:

Cargo.toml (workspace root) — the whole fix
[profile.release]
overflow-checks = true

That is the entire remediation. Overflow now aborts the transaction instead of writing a wrapped value, for every arithmetic operation in every crate the workspace builds. Anchor's own anchor init template has shipped this line for years; the workspaces that lack it are usually ones that grew out of a plain cargo new.

The cost is a handful of compute units per checked operation and a slightly larger binary. Against silently wrapping a token balance, that is not a real trade-off.

Severity

The project-level finding is Medium. The arithmetic sites are Low. Neither breaks CI at the default --fail-on highVL002 is the only rule that can report High.

Medium rather than High because a missing flag is a missing guard rail, not a demonstrated hole: whether it is exploitable depends on whether any reachable arithmetic can be driven past its bounds. Low for the sites because, individually, they are evidence rather than defects.

Why this is dangerous

Solana programs are always deployed in release mode, and Rust's release profile has overflow-checks = false by default. In debug builds an overflow panics; in release it silently wraps. So the arithmetic you tested and the arithmetic you deployed behave differently, and the difference only shows at the boundary.

Concretely: a u64 balance at 0 from which 1 is subtracted becomes 18_446_744_073_709_551_615. A withdrawal guard that reads if vault.balance >= amount and then does vault.balance -= amount looks correct and is correct — until a rounding step, a fee deduction, or a second path lets the subtraction happen without the guard. Then the account holds the largest number the type can express, and it is persisted to chain.

The same wrap in the other direction turns an accounting total, a share count, or a reward accumulator into an arbitrary value. What makes this class distinctive on Solana is not that it is hard to write correctly — it is that the compiler will not tell you, the tests will not tell you, and the state change is permanent.

Vulnerable — wraps silently in a release build
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
    let vault = &mut ctx.accounts.vault;
    vault.balance = vault.balance - amount;   // 0 - 1 == u64::MAX
    vault.total_withdrawn += amount;
    Ok(())
}

With overflow-checks = true both lines abort the transaction on overflow, and the state is never written. If you also want the failure to carry a domain error rather than a panic, use the checked forms:

Fixed at the call site too — an explicit error
vault.balance = vault
    .balance
    .checked_sub(amount)
    .ok_or(ErrorCode::InsufficientFunds)?;

Both are worth doing, in that order: the profile flag protects everything including code you have not read, and checked_* turns a panic into a message. VL003 only asks for the first.

Which manifest counts

This is the part most worth knowing, because getting it wrong looks exactly like getting it right.

Cargo ignores [profile.*] in every manifest except the workspace root. A member crate that sets overflow-checks = true in its own Cargo.toml has changed nothing about how it is built. Cargo does not even error — recent versions emit a warning that is easy to miss in a build log, and the program ships with wrapping arithmetic while its manifest says otherwise.

programs/my-program/Cargo.toml — has no effect
[package]
name = "my-program"

[profile.release]
overflow-checks = true   # ignored: not the workspace root
./Cargo.toml — the workspace root, where it works
[workspace]
members = ["programs/*"]

[profile.release]
overflow-checks = true

A linter that read the nearest manifest would congratulate you on the first file. VaultLint resolves the root the way Cargo does: it walks up from the crate to the nearest ancestor declaring a [workspace] table, honours an explicit package.workspace pointer, and respects workspace.exclude — an excluded crate is its own root and is judged on its own manifest. Glob patterns in workspace.members are not expanded, which is safe in practice because a [workspace] that does not actually contain the crate below it makes Cargo refuse to build.

The finding is reported against the manifest Cargo would read, at the [profile.release] line if there is one and at line 1 if there is not — so the location tells you where to type.

The Low findings underneath

The evidence sites are deliberately narrow: an unchecked +, - or * — plain or compound (+=, -=, *=) — whose result is written into a struct field. That is where persisted account state lives, and persisted state is what makes a wrap matter after the transaction ends.

The narrowing is why the evidence sites number 26 where they used to number 382. The project finding is measured separately and does not depend on them: of fifteen unaudited Solana repositories, six are built without overflow checks, and only one of those six contains arithmetic in the shape above.

Is the code running on mainnet?

A missing build flag reads as theoretical until you know that the binary compiled without it is executing right now. Pass --mainnet and VaultLint collects every declare_id! in the tree, asks the cluster what is deployed at each address, and marks the findings that sit in code compiled into a live program.

vaultlint scan . --mainnet — me-foundation/m2, verbatim
→ on chain · 1 declared program id
  M2mx93ekt1fmXSVkTrUL9xVFHkmME8HTUi5Cyc5aF7K  upgradeable by 9GWPeu3cBfkGSEit6HMaAFKswoirxqgMqykMh7RVH2Bb — last deploy at slot 358179668

⚠ MED  overflow-checks is not enabled
        Cargo.toml:6
        This workspace does not set `overflow-checks = true` under `[profile.release]`. …
        live on mainnet at M2mx93ekt1fmXSVkTrUL9xVFHkmME8HTUi5Cyc5aF7K

That last line is a conjunction neither half can state alone. A block explorer will tell you the program is deployed and who holds its upgrade authority — it never read the manifest, so it cannot know how the code was built. A linter reads the manifest — it never asked the cluster, so it cannot know anything is deployed. Put together they say something specific: this workspace is built without overflow checks, and that code is executing at this address.

The mark never changes the severity or the exit code. It is context, not a rule. An exit code that depended on an RPC answer would let the same commit pass CI today and fail tomorrow because an endpoint was slow, so the severity of a live finding is identical to the severity of a dead one.

For an ordinary finding, the crate it sits in must be in the path-dependency closure below the deployed program's crate — a shared library declares no id of its own and its arithmetic still runs on chain. VL003's project-level finding is reported against a manifest, so it takes the whole workspace's ids instead: Cargo builds every crate under that root with the profile it is missing. The linkage is conservative — dev-dependencies, build-dependencies and optional dependencies are not followed — so an unmarked finding means "not shown to be live", not "not live".

Of the fifteen unaudited repositories in the measurement, me-foundation/m2 and m3 are both built without overflow checks and both have a live program behind their declared id. Across all rules, 21 of the 24 findings in that corpus are marked; the three that are not sit behind ids with nothing deployed at them.

Without the flag VaultLint makes no network calls at all, which stays the default. --rpc-url <URL> points the lookup at your own endpoint and implies --mainnet.

What the rule does not flag

  • Any workspace that already sets the flag. Total silence: no project finding, no arithmetic sites. The question has been answered.
  • Arithmetic whose result does not land in a struct field. A local total, a loop counter, an intermediate in a calculation that is later bounded — none are reported. This is a deliberate narrowing, and it means VL003 is not an exhaustive audit of your arithmetic.
  • checked_add, checked_sub, checked_mul, saturating_*, wrapping_* and the other explicit forms. They are method calls, not bare operators, and they say what they mean.
  • Division and remainder. Their failure mode is a panic on divide-by-zero, which overflow-checks does not govern.
  • as casts. A narrowing as u32 truncates regardless of the profile flag, so reporting it under a rule about that flag would be misleading. Review casts separately.
  • Test, benchmark and fuzz code, which is never scanned — and additionally, any function or module carrying #[cfg(test)] inline. See what gets scanned.
  • Workspaces with no on-chain code. The project finding is about a program built in release mode and deployed, so it is only raised for a tree that imports anchor_lang::prelude or solana_program::entrypoint. A pure client or SDK crate is left alone.
  • A workspace root above the directory you scanned. Running vaultlint scan programs/vault will not report a manifest two levels up that you did not ask about — though the arithmetic sites still reflect its setting. Scan the repository root to see it.

Suppressing a finding

Suppression works on the arithmetic sites:

Suppress with a reason
// vaultlint:allow VL003 — amount is bounded by the guard three lines up
vault.balance = vault.balance - amount;

The project-level Medium finding cannot be suppressed by a comment, and silencing every Low site does not withdraw it. The flag is either set or it is not, and most workspaces that lack it have no arithmetic shaped the way VL003 recognises — across fifteen unaudited Solana repositories, six were built without overflow checks and only one of the six had a Low site to hang the report on. Answer it by setting the flag.

The comment is looked for on the finding's own line first, then upward through the contiguous block of comment lines, #[...] attribute lines and blank lines above it. The rule id is matched on a word boundary, and a bare vaultlint:allow with no id is intentionally not honoured.

References

Run this check on every PR.

VaultLint is a security linter for Solana and Anchor programs. Five narrow rules, hand-written, no telemetry, no network calls unless you ask for one.

See all five rules