Blockchain 12 min read

What We Check Before Any Smart Contract Goes to Mainnet

An audit from a specialist firm is necessary but not sufficient. This is the internal checklist our engineers run before a contract is considered audit-ready - the things static analysis tools miss and auditors expect you to have already found.

Solidity Security Audit Smart Contracts EVM Web3
S
Sequere
ADMIN · Sequere

We've sent contracts to four different audit firms over the past two years - Trail of Bits, Halborn, Code4rena, and a boutique firm that specialises in DeFi protocols. Every one of them has said some version of the same thing at kickoff: the quality of the pre-audit matters more than most teams realise. The firms that get clean reports aren't just lucky. They've done a specific set of things before the auditors ever open the codebase.

What an audit actually covers - and what it doesn't

A smart contract audit is a time-boxed, expert review of your codebase against known vulnerability classes. Good auditors will find things you missed. They'll also write up architectural concerns, gas optimisations, and code quality observations. What they won't do is fix your test coverage, clean up your access control design, or run your static analysis tools for you.

The practical consequence: an audit that lands on a poorly prepared codebase wastes a disproportionate amount of audit time on findings that your own tooling would have caught. Slither flags reentrancy paths. Mythril identifies integer overflow risks. If your auditors are spending their first two days on Slither-level findings, you've paid audit rates for work that costs a fraction of that if you run it yourself before they start.

This checklist is what we gate-check before telling an auditor a contract is ready. It's not comprehensive - no pre-audit checklist can replace an expert review - but everything on it is something you should have confirmed before someone else needs to find it.

Scope

This checklist targets Solidity contracts on EVM-compatible chains (Ethereum, Arbitrum, Optimism, Polygon, Base). The security patterns apply broadly to EVM development, though some tooling and specific vulnerability mechanics vary by chain. We don't cover Rust/Solana or Move-based chains here.

Reentrancy and call ordering

Reentrancy is the oldest exploit class in Solidity and still one of the most common findings in audit reports. The DAO hack in 2016 was reentrancy. Multiple protocol exploits since then have been reentrancy variants. It remains common not because developers don't know about it, but because the patterns that cause it look innocuous in isolation.

The root cause is always the same: a contract makes an external call to an untrusted address before it has finished updating its own state. If that external call can reenter the calling contract, it will observe stale state - balances that haven't been decremented, flags that haven't been set.

SOLIDITY - Vulnerable vs. safe withdrawal pattern Copy
// ❌ VULNERABLE: state update happens after external call
function withdraw() external {
    uint256 amount = balances[msg.sender];
    (bool ok,) = msg.sender.call{value: amount}("");
    require(ok);
    balances[msg.sender] = 0;  // too late - attacker already reentered
}

// ✅ SAFE: checks-effects-interactions pattern
function withdraw() external {
    uint256 amount = balances[msg.sender];
    balances[msg.sender] = 0;         // effect first
    (bool ok,) = msg.sender.call{value: amount}("");  // interaction last
    require(ok);
}

// ✅ ALSO SAFE: ReentrancyGuard from OpenZeppelin
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract Vault is ReentrancyGuard {
    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        balances[msg.sender] = 0;
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok);
    }
}

What to verify before audit:

  • Every function that makes an external call (.call(), .transfer(), token transfers, interface interactions) follows checks-effects-interactions ordering.
  • Any function that can't follow CEI strictly uses ReentrancyGuard or equivalent mutex.
  • Cross-function reentrancy is considered - an attacker reentering via a different function than the one they called. nonReentrant on one function doesn't protect state shared with another unprotected function.
  • Read-only reentrancy is considered if your contract's state is read by third-party protocols. A view function that returns stale state mid-transaction can be exploited by protocols that rely on it for pricing.

Access control: who can call what, and can that change

Access control findings are consistently in the top three categories across audit reports we've seen. The vulnerability class isn't exotic - it's functions that should be restricted that aren't, or restriction logic that has an edge case that bypasses it.

The checklist we run:

  • Every state-changing external function has an explicit access modifier or an explicit comment explaining why it's intentionally public. No function should be public by accident.
  • Ownership transfer is two-step. Single-step transferOwnership(newAddress) has a well-known footgun: if newAddress is wrong (typo, wrong network, contract that can't call back), you've permanently lost ownership. OpenZeppelin's Ownable2Step requires the new owner to explicitly accept.
  • Constructor arguments are validated. A constructor that accepts an admin address without checking it's non-zero has bricked protocols when deployed with a missing argument.
  • Role separation exists for high-impact actions. A single owner role that can pause the contract, update fee parameters, and drain an emergency fund is a single point of compromise. Timelock-controlled admin functions and separate pause authority reduce the blast radius of a compromised key.
SOLIDITY - Two-step ownership transfer with zero-address guard Copy
import "@openzeppelin/contracts/access/Ownable2Step.sol";

contract Protocol is Ownable2Step {
    address public feeRecipient;

    constructor(address _feeRecipient) {
        // Validate before storing - zero address means fees are burned forever
        require(_feeRecipient != address(0), "zero address");
        feeRecipient = _feeRecipient;
    }

    function setFeeRecipient(address _new) external onlyOwner {
        require(_new != address(0), "zero address");
        feeRecipient = _new;
    }
}
Frequently missed

Initializer functions in upgradeable contracts need the same access control discipline as constructors. An uninitialised upgradeable contract where the initialize() function is callable by anyone is a critical vulnerability. OpenZeppelin's Initializable modifier handles this, but only if you use it consistently - including on inherited initializer functions.

Arithmetic safety and integer edge cases

Solidity 0.8.x made overflow and underflow revert by default, which eliminated an entire class of arithmetic bugs that plagued earlier contracts. If you're on 0.8+, unchecked blocks are the primary concern - they disable the overflow protection explicitly, and any arithmetic inside unchecked { } needs manual verification.

Beyond overflow, the arithmetic issues that still appear in audit reports:

  • Division precision loss. Solidity integer division truncates. 1 / 3 = 0. For any calculation involving fee percentages, share ratios, or reward distributions, division order matters. Always multiply before dividing. For financial contracts, consider whether fixed-point arithmetic (via a library like PRBMath or ABDKMath) is appropriate.
  • Rounding direction. In fee calculations, rounding should always favour the protocol, not the user. A rounding error of 1 wei per transaction is negligible. Aggregated across millions of transactions, it represents real funds that leave the protocol.
  • Type casting. Casting a uint256 to uint128 silently truncates in Solidity. SafeCast from OpenZeppelin adds the missing revert on overflow.
  • Block timestamp manipulation. Miners can influence block.timestamp by a few seconds. Any time-locked logic that uses it for financial decisions should have a tolerance range, and should not use it as a source of randomness.

Oracle and price feed risks

DeFi protocols that rely on external price data are only as secure as their price feeds. Oracle manipulation has been the root cause of more than $1 billion in protocol losses. The attack surface is larger than most teams appreciate when they first integrate a price feed.

Spot price vs TWAP

Using a DEX spot price (the current pool ratio) as an oracle is almost always wrong for any security-critical decision. Spot prices can be manipulated within a single transaction via flash loans. A TWAP (time-weighted average price) over even a 15-minute window requires sustained manipulation that is significantly more expensive. Uniswap V3 provides an on-chain TWAP oracle; Chainlink provides off-chain price feeds with deviation thresholds and heartbeat checks.

SOLIDITY - Chainlink price feed with staleness check Copy
interface AggregatorV3Interface {
    function latestRoundData() external view returns (
        uint80 roundId, int256 answer,
        uint256 startedAt, uint256 updatedAt, uint80 answeredInRound
    );
}

function getPrice() internal view returns (int256) {
    (, int256 price,, uint256 updatedAt,) = priceFeed.latestRoundData();

    // Staleness check: reject if price hasn't updated in 1 hour
    require(block.timestamp - updatedAt <= 3600, "stale price");

    // Sanity check: reject zero or negative prices
    require(price > 0, "invalid price");

    return price;
}

Three checks every Chainlink integration needs: a staleness check (the feed's heartbeat interval plus a tolerance), a zero/negative price check, and a circuit breaker for when the reported price deviates beyond a reasonable range from a secondary source. Chainlink feeds have circuit breakers, but they're set at the aggregator level - your contract should have its own.

Critical - verify before audit

Chainlink price feeds have a minAnswer and maxAnswer set in the aggregator that limits the range of prices the feed will report. During the LUNA collapse, Chainlink correctly stopped reporting because the price fell below minAnswer - but some protocols didn't handle this case and continued using the minAnswer floor as if it were the real price. Always verify your contract handles a feed returning its floor or ceiling price correctly.

Upgradeability and storage layout

Upgradeable contracts - using the Transparent Proxy or UUPS pattern - introduce a class of bugs that non-upgradeable contracts don't have. The most dangerous is storage collision: if an upgrade changes the storage layout in a way that shifts where variables are stored, you corrupt the contract's state silently.

SOLIDITY - Storage layout: safe vs. unsafe upgrade Copy
// V1 storage layout
contract ProtocolV1 {
    uint256 public totalSupply;   // slot 0
    address public owner;         // slot 1
}

// ❌ UNSAFE V2: inserted variable shifts owner to slot 2
contract ProtocolV2_Bad {
    uint256 public totalSupply;   // slot 0
    uint256 public newVariable;   // slot 1 ← inserted, breaks owner
    address public owner;         // slot 2 ← was slot 1, now corrupted
}

// ✅ SAFE V2: new variables appended only
contract ProtocolV2_Safe {
    uint256 public totalSupply;   // slot 0 - unchanged
    address public owner;         // slot 1 - unchanged
    uint256 public newVariable;   // slot 2 - appended safely
}

Additional upgradeability checks:

  • The upgrade function itself is access-controlled. An unprotected upgradeTo() function is an immediate critical finding.
  • Storage gaps exist in base contracts. If an upgradeable base contract doesn't have a storage gap (uint256[50] private __gap;), adding state variables in a future version shifts all derived contract storage.
  • The initialiser cannot be called twice. The initializer modifier from OpenZeppelin handles this, but only on the top-level call - functions marked onlyInitializing in inherited contracts can still be called directly if you're not careful.
  • Validate storage layout compatibility with hardhat-upgrades or Foundry's upgrade safety checks before every new implementation deployment.

Static analysis: what to run and what it misses

Static analysis tools are not optional. They're the floor, not the ceiling. Running them before audit ensures auditors aren't spending paid time on findings that a free tool catches in under five minutes.

Tool What it finds well What it misses When to run
Slither Reentrancy, unprotected functions, dangerous patterns, shadowed variables, unused returns Business logic flaws, multi-contract interactions, economic exploits Every PR, in CI
Mythril Integer overflow, reentrancy (symbolic), arbitrary storage writes Slow on complex contracts; many false positives; misses access control design flaws Pre-audit, not CI
4naly3er Gas optimisation opportunities, best practice violations, QA findings Security-critical issues - it's a gas/QA tool, not a security scanner Before audit submission
Aderyn Common vulnerability patterns, fast Rust-based analysis Newer tool - pattern library still growing vs Slither Alongside Slither

The checklist: run Slither with the full detector suite and address or explicitly acknowledge every finding before audit. Run Mythril on the contracts that handle user funds. Submit the output of both alongside your audit request - every decent firm will ask for it anyway.

BASH - Slither with JSON output for CI integration Copy
# Install
pip3 install slither-analyzer

# Run against a Foundry project with JSON output
slither . \
  --config-file slither.config.json \
  --json slither-report.json \
  --checklist \
  --show-ignored-findings

# slither.config.json - filter known false positives
# {
#   "filter_paths": "lib,test",
#   "detectors_to_exclude": "naming-convention"
# }

# Fail CI if any high/critical findings exist
slither . --fail-high

Test coverage: what 100% line coverage doesn't tell you

100% line coverage is necessary but not sufficient. A test that calls every function once with happy-path inputs will achieve 100% line coverage and miss every edge case. What auditors are actually looking for - and what matters for mainnet - is scenario coverage.

Our coverage requirements before audit:

  • 100% line and branch coverage on all contracts in scope. Foundry's forge coverage makes this easy to measure. Any uncovered branch is a code path that hasn't been tested.
  • Every revert path is tested explicitly. If a function has five require statements, there should be five tests that each trigger exactly one of them.
  • Fuzz tests on every function that accepts numeric inputs. Foundry's built-in fuzzer runs thousands of randomised inputs automatically. A fuzz test that runs for 10,000 iterations takes seconds and finds edge cases that targeted unit tests don't.
  • Invariant tests for core protocol properties. Total supply should equal the sum of all balances. Collateral ratio should never fall below the liquidation threshold during normal operations. Invariant tests encode these as properties the fuzzer tries to break.
  • Fork tests against mainnet state for any integration with external protocols. A test that deploys mock contracts for Uniswap, Aave, or Chainlink is not the same as testing against the real deployed contracts. Fork tests in Foundry pull actual mainnet state.
SOLIDITY - Foundry fuzz + invariant test examples Copy
contract VaultTest is Test {
    Vault vault;

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

    // Fuzz test: deposit any amount and verify accounting
    function testFuzz_Deposit(uint256 amount) public {
        vm.assume(amount > 0 && amount <= 1000 ether);
        vm.deal(address(this), amount);

        vault.deposit{value: amount}();

        assertEq(vault.balanceOf(address(this)), amount);
        assertEq(address(vault).balance, amount);
    }

    // Invariant: vault balance always covers all user balances
    function invariant_SolvencyCheck() public {
        assertGe(
            address(vault).balance,
            vault.totalDeposits(),
            "vault insolvent"
        );
    }
}

Full pre-audit checklist

This is the literal gate-check we run. Everything below should be confirmed - or explicitly documented as a known, accepted risk - before a contract goes to external audit.

Reentrancy & call safety Critical

  • All external calls follow checks-effects-interactions order
  • Functions that can't follow CEI use nonReentrant modifier
  • Cross-function reentrancy paths mapped and protected
  • Read-only reentrancy risk assessed for any state read by external protocols

Access control Critical

  • Every public/external function has intentional visibility
  • Ownership transfer uses two-step pattern
  • Constructor validates all address arguments are non-zero
  • Privileged roles are separated (admin, pauser, upgrader)
  • Time-sensitive admin actions go through a timelock

Arithmetic High

  • All unchecked blocks have explicit justification
  • Division always follows multiplication
  • Rounding direction favours protocol in fee calculations
  • Type casts use SafeCast where overflow is possible

Oracle & price feeds Critical

  • No spot price used for security-critical decisions
  • Chainlink feeds check updatedAt staleness against heartbeat
  • Zero and negative price answers handled explicitly
  • minAnswer/maxAnswer circuit breaker case handled

Upgradeability High

  • Storage layout validated with hardhat-upgrades or Foundry checker
  • Storage gaps present in all upgradeable base contracts
  • initialize() cannot be called after deployment
  • Upgrade function is access-controlled and timelocked

Tooling & coverage Medium

  • Slither run with full detector suite - all high/critical addressed
  • Mythril run on all fund-handling contracts
  • 100% line and branch coverage on in-scope contracts
  • All revert paths covered by dedicated tests
  • Fuzz tests on all functions accepting numeric inputs
  • Invariant tests for core protocol solvency properties
  • Fork tests for all external protocol integrations

The contracts that come out of audit with a clean report aren't the ones where the auditors found nothing. They're the ones where the team found everything first, and the auditors could spend their time on the genuinely hard architectural questions.

— Sequere, ADMIN, Sequere

This checklist won't replace an audit - and nothing should. But it sets a floor that's significantly higher than most teams have when their first external audit starts. Every item on it is something that's shown up as a finding in a real audit report at some point.

If you're preparing a contract for mainnet and want a pre-audit review before engaging an external firm, we offer a focused pre-audit assessment - a two to three day review that works through this checklist and your specific protocol's risk surface before an external auditor does.