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 rule reports 26 findings where it used to report 382. Across 24 workspace roots in the current measurement, 22 are silent entirely — most real Solana workspaces do set the flag — and the remainder produce 2 project-level Medium findings and 24 Low sites between them.

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.

Suppressing a finding

Suppression works on the arithmetic sites, and it cascades:

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 is only emitted for a workspace root that still has at least one surviving Low site. Silence the last one and the Medium goes with it — the question is withdrawn when the reason for asking it is withdrawn. That also means you cannot suppress the project finding directly; suppress the evidence, or set 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 an offline security linter for Solana and Anchor programs. Five narrow rules, hand-written, no telemetry.

See all five rules