Solidity has caught raw integer overflow since version 0.8.0. That fixed the easy bugs. It did nothing for the harder ones: truncation from unsafe downcasts, division-order mistakes that zero out a fee, and fixed-point rounding that quietly favors the wrong side of a trade. Those bugs still ship in production DeFi code in 2026, and they still get exploited. This tutorial walks through building a Foundry test suite that actually catches them, using fuzz tests, invariant tests, and static analysis together, plus a working project you can adapt for your own contracts. By the end, you will have a deliberately buggy vault contract, a test suite that fails against it and passes once patched, a CI workflow that keeps it that way, and a checklist you can reuse on any accounting-heavy Solidity project.

Why Arithmetic Bugs Still Drain DeFi Protocols in 2026

DeFi security researchers tracking exploit categories through the first half of 2026 found that stolen private keys and broken bridge verification, not contract logic bugs, caused the majority of losses: roughly 82.7% of $935.3 million lost across 87 tracked incidents came from key compromise or bridge failures rather than code flaws. That statistic gets cited a lot as proof that “smart contract bugs are a solved problem.” They are not. Contract-level bugs, arithmetic ones included, still accounted for a meaningful share of the remainder, and September 2026 alone saw reported crypto hack and exploit losses cross $326 million by September 18, according to industry trackers.

This site previously covered a Notional Finance V1 escrow contract incident where a truncation-style accounting flaw was reported to have drained approximately $1.73 million through a 2^128 bug in how a liability value was tracked. The pattern is familiar: a value that should have been bounded by a sane range gets computed, cast, or divided in a way nobody fuzz-tested, and an attacker finds the one input that breaks it. Cronos halted its entire chain in 2026 after the Tectonic lending protocol lost roughly $100 million to an accounting-related exploit, which shows the stakes are not limited to small experimental protocols.

None of this means overflow checks are pointless. It means checked arithmetic solved one narrow problem and left several adjacent ones open. If your test suite only confirms that a + b reverts on overflow, you have not tested the parts of your accounting logic that actually move money: downcasts, division order, and rounding direction. This guide builds tests for all three.

What Overflow, Underflow, and Truncation Bugs Actually Are

Since Solidity 0.8.0, standard arithmetic operators (+, -, *) revert with a panic (error code 0x11) when a result would overflow or underflow a fixed-width integer, according to the Solidity documentation on checked and unchecked arithmetic. Before 0.8.0, developers had to import OpenZeppelin’s SafeMath library to get the same protection, and contracts that forgot to use it wrapped silently: uint256(0) - 1 became the maximum uint256 value instead of reverting.

Where checked arithmetic stops protecting you

Checked math only covers the four basic operators used outside an unchecked block. Three related bug classes fall outside that protection entirely:

  • Unchecked blocks. Developers wrap arithmetic in unchecked { } for gas savings, usually in loop counters or places they believe overflow is impossible. If that assumption is wrong, the block wraps silently, exactly like pre-0.8 Solidity.
  • Narrowing casts. Converting uint256 to uint128, uint64, or smaller types truncates the high-order bits without reverting, even outside an unchecked block. A value that looks safe as a uint256 can silently lose most of its magnitude on a cast.
  • Division truncation and rounding direction. Solidity integer division always truncates toward zero. There is no overflow to catch here; the bug is a business-logic decision about which party absorbs the rounding error, and it is easy to get backwards.

A share-based vault is the classic place these bugs hide. If asset-to-share conversion rounds in the depositor’s favor instead of the protocol’s, a user can repeatedly deposit and withdraw tiny amounts to extract value, one wei at a time, across thousands of transactions. Static analysis rarely catches this because no single call looks wrong. It is a multi-call property, which is exactly what invariant testing is built for.

How Auditors Classify These Bugs

Security firms that review DeFi code before launch do not lump every arithmetic issue into one bucket. The widely referenced ConsenSys smart contract best-practices guide treats integer arithmetic, rounding, and type-conversion risk as distinct review categories, each with its own checklist, because the fixes look nothing alike. A missing overflow check gets fixed by upgrading the compiler or removing an unnecessary unchecked block. A rounding-direction bug gets fixed by changing which operand rounds up versus down. A downcast bug gets fixed by adding a bounds check before the conversion. Treating all three as “math bugs” and writing one generic test tends to produce a suite that catches none of them well.

Vault-style contracts that convert between assets and shares, the exact shape used throughout this tutorial, follow a standardized interface defined in EIP-4626. That standard does not mandate a specific rounding policy, but its reference implementations and surrounding tooling consistently round in favor of the vault rather than the depositor on conversions, precisely to close the kind of wei-by-wei extraction bug described above. If you are building or auditing a vault, checking which direction your convertToShares and convertToAssets functions round is a five-minute review that catches a bug class attackers specifically look for.

Prerequisites: Tools and Versions

You need a working Foundry toolchain, a Solidity compiler pinned to 0.8.24 or newer, and Slither for the static-analysis step. Everything below was tested against the current stable releases as of September 2026.

ToolVersion used in this tutorialInstall command
Foundry (forge, cast, anvil)v1.8.3foundryup
Solidity compiler0.8.24+bundled via forge build
Slitherv0.11.6pip install slither-analyzer
forge-stdlatest via git submoduleforge install foundry-rs/forge-std
Python (for Slither)3.10+system package manager

You also need Python 3.10 or newer for Slither, a GitHub account if you want to replicate the CI step later, and roughly 90 minutes for a first full run through all 12 steps. None of this requires a testnet RPC endpoint, since every test in this tutorial runs against Foundry’s local EVM, spun up automatically in the background by forge test.

If you have never used Foundry before, skim the “Getting Started” chapter of the Foundry Book first. It covers the project layout, the difference between forge test and forge script, and how foundry.toml profiles work, none of which this tutorial re-explains in depth since the focus here is specifically on arithmetic testing rather than Foundry fundamentals.

Step 1-2: Install Foundry and Scaffold the Project

Install Foundry through foundryup, its official installer and updater, then confirm the version matches what this tutorial expects before you scaffold a new project.

curl -L https://foundry.paradigm.xyz | bash
foundryup
forge --version
# expect: forge 1.8.3 (or newer)

mkdir arithmetic-testing-lab && cd arithmetic-testing-lab
forge init --no-commit
forge install foundry-rs/forge-std --no-commit

Delete the default Counter.sol example and its test file from src/ and test/. You are replacing them with a contract deliberately shaped like the accounting logic that has caused real losses: a vault that tracks a liability value, converts between asset amounts and internal share units, and applies a fee on withdrawal.

Step 3-4: Write a Vulnerable Accounting Contract

Create src/FragileVault.sol. It intentionally reproduces two of the three bug classes from earlier: an unsafe downcast when a fee accrues, and a division-order bug in share conversion. Do not deploy anything resembling this to a real chain. It exists purely to give your test suite something to catch.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract FragileVault {
    uint256 public totalAssets;
    uint256 public totalShares;
    uint128 public accruedFees; // narrow type: downcast risk

    mapping(address => uint256) public sharesOf;

    function deposit(uint256 amount) external {
        uint256 newShares = totalShares == 0
            ? amount
            : (amount * totalShares) / totalAssets;

        totalAssets += amount;
        totalShares += newShares;
        sharesOf[msg.sender] += newShares;
    }

    // BUG: fee is computed then cast down to uint128 without a bounds check.
    function accrueFee(uint256 rawFee) external {
        accruedFees += uint128(rawFee); // truncates silently if rawFee > type(uint128).max
    }

    // BUG: division before multiplication discards precision for small amounts.
    function withdraw(uint256 shareAmount) external returns (uint256 owed) {
        owed = (shareAmount / totalShares) * totalAssets; // wrong order
        totalShares -= shareAmount;
        totalAssets -= owed;
        sharesOf[msg.sender] -= shareAmount;
    }
}

Run forge build to confirm it compiles. It will, cleanly, with no compiler warnings about either bug. That is the point: the compiler cannot see business-logic ordering mistakes or intentional downcasts. Only targeted tests can.

Step 5-6: Fuzz Tests for Arithmetic Boundaries

Create test/FragileVault.fuzz.t.sol. Foundry runs any test function that takes parameters as a fuzz test by default, generating hundreds of pseudo-random inputs per run (256 by default, configurable in foundry.toml). Use vm.assume to discard invalid inputs rather than trying to construct valid ones by hand.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {FragileVault} from "../src/FragileVault.sol";

contract FragileVaultFuzzTest is Test {
    FragileVault vault;

    function setUp() public {
        vault = new FragileVault();
    }

    // Catches the uint128 downcast: fee should never silently wrap.
    function testFuzz_accrueFeeNeverWraps(uint256 rawFee) public {
        vm.assume(rawFee <= type(uint128).max);
        vault.accrueFee(rawFee);
        assertEq(uint256(vault.accruedFees()), rawFee);
    }

    // Catches division-order loss: small deposits should not mint zero shares
    // once the pool is non-empty, given a healthy asset-to-share ratio.
    function testFuzz_depositNeverMintsZeroForNonTrivialAmount(
        uint256 seedAmount,
        uint256 amount
    ) public {
        seedAmount = bound(seedAmount, 1e6, 1e24);
        amount = bound(amount, 1e6, 1e24);

        vault.deposit(seedAmount); // seeds totalShares == totalAssets
        uint256 sharesBefore = vault.sharesOf(address(this));

        vault.deposit(amount);
        uint256 sharesAfter = vault.sharesOf(address(this));

        assertGt(sharesAfter, sharesBefore);
    }
}

Run forge test --match-contract FragileVaultFuzzTest -vvv. The first test should pass, since rawFee is bounded to valid uint128 values by the assumption, but note what that test is really proving: it only checks behavior within the safe range. It says nothing about what happens when rawFee exceeds type(uint128).max. That gap is exactly what the next section targets on purpose.

Step 7-8: Invariant Tests That Catch Multi-Call Drift

Fuzz tests check one function call at a time. Invariant tests call a contract repeatedly, in random sequences, and check that a property holds after every sequence. This is the right tool for rounding drift, because a single deposit or withdrawal might lose an acceptable fraction of a wei, but thousands of calls compounding in the same direction is how real protocols lose real money.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {FragileVault} from "../src/FragileVault.sol";

contract FragileVaultInvariantTest is Test {
    FragileVault vault;

    function setUp() public {
        vault = new FragileVault();
        vault.deposit(1_000_000e18); // seed the pool
        targetContract(address(vault));
    }

    // The vault must never claim to hold fewer assets than shares imply
    // it owes, across any sequence of deposits and withdrawals.
    function invariant_assetsNeverFallBelowShareValue() public view {
        if (vault.totalShares() == 0) return;
        assertGe(vault.totalAssets(), vault.totalShares() / 1e18);
    }
}

Run it with forge test --match-contract FragileVaultInvariantTest -vvv. Foundry's invariant runner defaults to 256 runs of up to 500 calls each against every public function on the target contract, which is enough to surface drift that a handful of manual test cases would miss. If you need finer control over call sequencing, restrict the target functions with targetSelector so the runner does not waste calls on irrelevant functions.

Step 9-10: Break It on Purpose, Then Run Slither

A test suite you have not watched fail is a test suite you cannot trust. Write one test specifically designed to trip the uint128 downcast bug, confirm it fails against the vulnerable contract, then confirm it passes once you patch the contract with OpenZeppelin's SafeCast.

function testFuzz_accrueFeeRevertsInsteadOfWrapping(uint256 rawFee) public {
    vm.assume(rawFee > type(uint128).max);
    vm.expectRevert();
    vault.accrueFee(rawFee); // should revert on the patched contract
}

Against the original FragileVault, this test fails, because uint128(rawFee) truncates instead of reverting. That failure is the proof your suite works. Patch the function to use SafeCast.toUint128(rawFee), documented in the OpenZeppelin Contracts utils reference, re-run the test, and confirm it now passes because the cast reverts on overflow instead of wrapping.

Next, run Slither for static analysis on the original contract, which flags dangerous type conversions without needing to execute anything:

pip install slither-analyzer
slither . --print human-summary
slither src/FragileVault.sol --detect unchecked-transfer,divide-before-multiply

Slither's divide-before-multiply detector is built specifically for the pattern in withdraw(): dividing before multiplying, which discards precision that multiplying first would preserve. Static analysis and dynamic testing catch different things here. Slither flags the pattern instantly without any test runs, but it cannot tell you how much value a given rounding bug actually leaks under realistic usage. The invariant test from Step 7 answers that question, while Slither answers a simpler one: should a human look at this line.

Step 11-12: Wire Up CI and Write a Findings Report

A test suite that only runs on your laptop protects nothing once a second contributor opens a pull request. Add a GitHub Actions workflow that runs both Foundry's test suite and Slither on every push.

name: arithmetic-tests
on: [push, pull_request]

jobs:
  foundry-and-slither:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive

      - name: Install Foundry
        uses: foundry-rs/foundry-toolchain@v1

      - name: Run Foundry tests (fuzz + invariant)
        run: forge test -vvv

      - name: Install Slither
        run: pip install slither-analyzer

      - name: Run Slither
        run: slither . --print human-summary

For Step 12, write down what you found in plain language, even for a solo project. A minimal findings report needs four fields per issue: the function affected, the exact input class that triggers it (for example, "rawFee greater than type(uint128).max"), the realistic financial impact, and the fix applied. This is the same structure auditors use, and it is what separates a security-minded test suite from a pile of passing assertions nobody can explain six months later.

Common Pitfalls When Testing for Overflow and Truncation

  • Bounding fuzz inputs too aggressively. Using vm.assume to reject anything that looks unusual defeats the purpose of fuzzing. Prefer bound() to clamp values into a realistic range instead of discarding huge swaths of the input space.
  • Testing only the happy path of a downcast. A test that only feeds valid uint128-range values into accrueFee() will pass against both the vulnerable and the patched contract. You must explicitly test the boundary and beyond it.
  • Ignoring rounding direction as a security property. A rounding error that favors the user, even by one wei, is exploitable at scale through repeated small transactions. A rounding error that favors the protocol is usually fine. Your invariants need to assert the direction, not just the existence, of bounded drift.
  • Running too few invariant iterations. The default 256 runs of up to 500 calls catches a lot, but multi-step drift bugs sometimes need longer call sequences to surface. Raise depth and runs in foundry.toml for contracts handling real value.
  • Only fuzzing "attacker-sized" inputs. It is tempting to write fuzz tests that only explore huge, obviously adversarial values near type(uint256).max. The division-order bug in this tutorial's example triggers on small, completely ordinary withdrawal amounts, so a fuzz strategy that ignores realistic day-to-day input ranges will walk right past it.
  • Treating a passing Slither scan as a clean bill of health. Static analysis has no concept of your protocol's intended rounding policy. It flags patterns, not business logic; a division-before-multiplication warning still needs a human to decide whether it is actually a bug.
  • Forgetting that unchecked blocks need their own explicit tests. Solidity 0.8's default checks do not apply inside unchecked { }. Every unchecked block in a codebase should have at least one fuzz test aimed directly at its boundary condition.

Sample Output: Passing vs Failing Runs

Here is what a failing run looks like against the unpatched FragileVault, followed by the same test passing after the SafeCast fix:

$ forge test --match-test testFuzz_accrueFeeRevertsInsteadOfWrapping -vvv

[FAIL: call did not revert as expected]
testFuzz_accrueFeeRevertsInsteadOfWrapping(uint256)
  (runs: 12, μ: 31904, ~: 31904)
Traces:
  [22841] FragileVaultFuzzTest::testFuzz_accrueFeeRevertsInsteadOfWrapping(340282366920938463463374607431768211457)
    ├─ [2384] FragileVault::accrueFee(340282366920938463463374607431768211457)
    │   └─ ← [Return] // wraps to 1, no revert
    └─ ← [Revert] call did not revert as expected

$ forge test --match-test testFuzz_accrueFeeRevertsInsteadOfWrapping -vvv

[PASS] testFuzz_accrueFeeRevertsInsteadOfWrapping(uint256)
  (runs: 256, μ: 24218, ~: 24193)

The value 340282366920938463463374607431768211457 in the failing trace is type(uint128).max + 1, which is exactly the boundary the fuzzer needs to find. Foundry's fuzzer is not purely random. It seeds edge-case values like zero, the max of each relevant type, and boundaries one unit past them, which is why it finds this specific input quickly rather than needing millions of runs.

Slither's output for the same unpatched contract looks like this, run against the divide-before-multiply detector from Step 9:

$ slither src/FragileVault.sol --detect divide-before-multiply

FragileVault.withdraw(uint256) (src/FragileVault.sol#22-27) performs a
multiplication on the result of a division:
    - owed = (shareAmount / totalShares) * totalAssets (src/FragileVault.sol#23)

Reference: https://github.com/crytic/slither/wiki/Detector-Documentation
#divide-before-multiply

That single line of output is why static analysis belongs in the workflow alongside fuzzing: Slither found the division-order bug in under a second, with no test written and no EVM execution at all, simply by reading the AST. It would not have told you how much value the bug actually leaks for a given shareAmount, which is what the fuzz and invariant tests from Steps 5 through 8 are for. The two tools are answering different questions about the same three lines of code.

To see why the ordering matters in concrete terms, work through the numbers by hand. Suppose totalShares is 1,000,000 and totalAssets is 950,000 (the vault is slightly underwater), and a user calls withdraw(400), a small but not trivial share amount. With the buggy division-first order, shareAmount / totalShares evaluates to 400 / 1,000,000, which truncates to zero in integer math, so owed comes out to exactly zero regardless of how large totalAssets is. The user's shares get burned and they receive nothing. Reverse the order to (shareAmount * totalAssets) / totalShares and the same call returns 380, the correct proportional amount. Nobody needs to construct an adversarial input to trigger this. Any withdrawal smaller than roughly 0.1% of the share supply loses value under the buggy version, which is exactly the kind of everyday-usage bug that a fuzz test targeting realistic amounts, rather than only extreme ones, is built to catch.

Troubleshooting Guide

  • "Fuzz test passes but I don't trust it." Temporarily break the contract logic (comment out a require, revert an operator) and confirm the test fails. If it still passes, the assertion is not actually checking what you think it is.
  • Invariant test times out or takes minutes to run. Lower runs and depth in foundry.toml for local iteration, then raise them back for CI. A full invariant suite on complex state machines can legitimately take longer than a quick fuzz run.
  • vm.assume rejects too many inputs and the fuzzer reports "too many rejected inputs." Switch to bound(), which maps the raw fuzzed value into your target range instead of throwing values away, keeping the run efficient.
  • Slither reports false positives on intentional unchecked blocks. Add an inline // slither-disable-next-line comment only after you have written a dedicated test proving the block is safe, not before.
  • forge test can't find forge-std. Confirm the submodule installed correctly with forge install foundry-rs/forge-std and that remappings.txt or foundry.toml points to lib/forge-std/src/.
  • Invariant test passes locally but fails in CI. CI runners often use a different fuzz seed. Pin a seed with FOUNDRY_FUZZ_SEED for reproducibility while debugging, then remove the pin before merging so CI keeps exploring new input space.
  • SafeCast import fails to resolve. Install OpenZeppelin Contracts via forge install OpenZeppelin/openzeppelin-contracts and add the remapping to foundry.toml under remappings.
  • Gas reports show the invariant tests are expensive. That is expected; invariant testing trades gas-metered speed for coverage depth. Run fuzz tests on every commit and reserve full invariant runs for pull requests or nightly CI jobs.

Advanced Tips: Differential Fuzzing, Formal Verification, and Mutation Testing

Once the core suite from Steps 1 through 12 is in place, three techniques extend coverage further. Differential fuzzing runs two independent implementations of the same math (for example, your Solidity fee calculation against a Python or Rust reference implementation) against identical fuzzed inputs and flags any divergence. It is particularly good at catching subtle order-of-operations mistakes that a single implementation's own tests would never notice, because both implementations would share the same blind spot if written by the same person.

Formal verification tools go a step further by proving properties mathematically rather than sampling inputs. Symbolic execution built into newer Foundry releases can explore all reachable execution paths for a bounded function rather than randomly sampled ones, which matters for functions where the dangerous input is a needle in a very large haystack that fuzzing might statistically miss.

Mutation testing flips the usual workflow: instead of testing whether your contract behaves correctly, it deliberately introduces small bugs (flipping > to >=, changing + to -) into a copy of your contract and checks whether your test suite catches each mutation. A test suite with 100% line coverage that fails to catch 40% of mutations is telling you the coverage number is misleading. The tests execute the code without actually asserting on the values that matter.

Slither ships several detectors beyond the two used in Step 9, including checks for unsafe casts and unused return values from external calls. The full detector list is documented in the Slither GitHub repository, and it is worth running the complete default detector set, not just the two flagged here, once your own contract is ready for review. Foundry itself ships new fuzzing and symbolic-execution features regularly enough that it is worth checking the Foundry release notes every few months, since capabilities like expanded invariant handlers or faster fuzz execution sometimes change how much coverage the same test file gets you for free.

How This Fits Into a Full Smart Contract Audit Workflow

No single technique from this tutorial replaces a full audit, and none of them replace each other. Each layer catches a different shape of bug, which is why serious protocols run all of them rather than picking one.

TechniqueBest at catchingBlind spot
Unit testsKnown edge cases you thought to writeAnything you didn't think of
Fuzz testingBoundary values in a single function callMulti-call state drift over time
Invariant testingDrift across sequences of callsBugs that need a very specific call order to trigger
Static analysis (Slither)Known dangerous patterns, instantly, no execution neededBusiness-logic correctness; can't judge intent
Formal verification / symbolic executionExhaustive proof for bounded functionsScales poorly on large, complex state machines
Manual auditEconomic and architectural design flawsTime and cost; auditors can't run infinite iterations

This layered approach is the same reasoning behind other attack-specific test suites, including the Slither and Foundry combination used for reentrancy testing, the permission and role-check testing used for access-control bugs, and the nonce and domain-separator checks used for signature replay testing. Arithmetic testing is one module in a broader defensive stack, not a replacement for the rest of it.

The Complete Working Project

By the end of this tutorial your project directory should look like this, with every file referenced above in its expected place:

arithmetic-testing-lab/
├── foundry.toml
├── lib/
│   ├── forge-std/
│   └── openzeppelin-contracts/
├── src/
│   └── FragileVault.sol          # patched with SafeCast
├── test/
│   ├── FragileVault.fuzz.t.sol
│   └── FragileVault.invariant.t.sol
└── .github/
    └── workflows/
        └── arithmetic-tests.yml

Run the full suite one more time with forge test -vvv --gas-report to confirm everything passes and to get a baseline gas cost for each function, which you will want on hand the next time you touch this contract's math. Commit the project, push it, and confirm the GitHub Actions workflow from Step 11 runs clean on the remote branch before you consider the setup finished.

Where Arithmetic Bugs Rank Among 2026 DeFi Exploits

It is worth keeping the scale of this problem in perspective before treating arithmetic testing as your only priority. Reported figures for DeFi losses in the first half of 2026 put private-key compromise and bridge-verification failures well ahead of contract-logic bugs as a share of total losses.

MetricReported figure (H1 2026)
Total DeFi losses tracked$935.3 million across 87 incidents
Share attributed to key compromise / bridge failuresApproximately 82.7%
Total DeFi exploit losses, first 8 months of 2026At least $1.3 billion
September 2026 losses reported by Sept 18Over $326 million

Arithmetic and accounting bugs are a minority slice of that total, but a minority slice of over a billion dollars is still a lot of money, and unlike a stolen private key, a truncation bug is entirely preventable with the kind of test suite built in this tutorial. Key management and bridge security are separate, equally important problems that this tutorial does not attempt to solve. The bridge security testing tutorial on this site covers that ground specifically. Treat this guide as one layer of a defense that has to cover several categories of risk at once, not as a complete security program on its own. For broader coverage of wallet security, exchange risk, and protocol exploits, see this site's full cryptocurrency section.

Quick Checklist Before You Ship

Before merging a contract that touches balances, shares, or fees, run through this list. It condenses the twelve steps above into a form you can paste into a pull-request template.

  • Every narrowing cast (uint256 to anything smaller) goes through SafeCast or an equivalent explicit bounds check, not a bare cast.
  • Every unchecked block has a comment explaining why overflow is impossible, plus a fuzz test proving it at the boundary.
  • Every division has been checked for order of operations: multiply before you divide wherever the multiplication cannot itself overflow.
  • Every share-conversion function has an explicit, documented rounding direction, and a test asserting which party the rounding favors.
  • At least one invariant test asserts a solvency property (assets cannot fall below what shares are owed) across arbitrary call sequences.
  • Slither runs in CI on every pull request, not just before a scheduled audit.
  • Fuzz tests use bound() rather than aggressive vm.assume() filtering, so the fuzzer is not wasting runs on rejected inputs.
  • Small, realistic input amounts are tested explicitly, not just extreme boundary values near a type's maximum.
  • A written findings log exists for every issue found during testing, including ones you fixed before an external audit ever saw the code.
  • The GitHub Actions workflow blocks merges on test failure, rather than just reporting status.

None of these items are exotic. They are the difference between a test suite that exists and a test suite that would have caught the Notional Finance-style truncation bug before it shipped. The gap between those two is almost always process, not tooling. Foundry and Slither are free, well documented, and fast enough to run on every commit, so the limiting factor is usually whether a team actually wires them into CI and treats a red build as a blocker rather than a suggestion.

Frequently Asked Questions

Does Solidity 0.8+ make overflow bugs impossible?
No. Solidity 0.8 and newer revert on overflow and underflow for standard arithmetic operators outside unchecked blocks. It does not protect narrowing casts, arithmetic inside unchecked blocks, or business-logic rounding decisions, all of which need their own tests.

What's the difference between fuzz testing and invariant testing in Foundry?
Fuzz testing generates random inputs for a single function call and checks an assertion after that one call. Invariant testing calls a contract repeatedly in random sequences, sometimes hundreds of calls deep, and checks that a property still holds after every sequence, which is necessary for bugs that only appear after multiple calls, like rounding drift that only becomes visible once it has compounded across many small transactions.

Do I need Slither if I already have a full fuzz and invariant test suite?
Yes. Slither analyzes source code without executing it, so it can flag dangerous patterns like unsafe downcasts or division-before-multiplication instantly, even in code paths your dynamic tests have not reached yet. The two approaches complement each other rather than overlapping.

How many fuzz runs should I use for arithmetic-heavy contracts?
Foundry defaults to 256 runs per fuzz test and 256 invariant runs of up to 500 calls each, configurable in foundry.toml. For contracts handling real value, raising both numbers for CI runs (while keeping lower numbers for fast local iteration) is common practice.

Can static analysis alone catch every arithmetic bug?
No. Static analysis flags patterns it recognizes as risky, such as division before multiplication, but it cannot evaluate whether your specific rounding policy or fee logic is economically correct. That judgment requires either dynamic testing against defined invariants or a manual review by someone who understands the protocol's intended behavior.

What is a narrowing cast and why is it dangerous?
A narrowing cast converts a larger integer type to a smaller one, such as uint256 to uint128. Unlike standard arithmetic operators, explicit type conversions do not revert on Solidity 0.8+ by default. A value larger than the target type's maximum is silently truncated unless you use a bounds-checking library such as OpenZeppelin's SafeCast.

Should unchecked blocks be avoided entirely?
Not necessarily. Loop counters that are provably bounded, for example, are a legitimate and gas-efficient use of unchecked, and removing every one from a codebase in the name of safety just wastes gas on checks that can never fire. The rule is that every unchecked block needs an explicit test proving the boundary condition it relies on actually holds, rather than trusting the assumption by inspection alone, and that assumption should be written down as a code comment next to the block so the next contributor understands why it is safe.

Is this tutorial's example vulnerable contract safe to deploy for learning purposes on a testnet?
Only on a local Anvil instance or an isolated testnet with no real value attached. The contract in this tutorial contains intentional bugs designed to be exploitable and should never be deployed anywhere near mainnet funds.