Skip to content
Security

Smart Contract Security: A Pre-Mainnet Checklist for dApp Teams

Reentrancy, access control, oracle manipulation, upgrade pitfalls, fuzzing and audits: a complete pre-mainnet smart contract security checklist with Solidity examples.

CoodesCoodes Engineering Team 10 min read · 2,186 words
Smart Contract Security: A Pre-Mainnet Checklist for dApp Teams
Table of contents

Once your contracts hit mainnet, the code is public, the funds are real and there's usually no undo button. Attackers can read your bytecode, simulate transactions against a fork and strike within minutes of deployment. Security can't be a phase at the end of the project. It has to run through design, implementation, testing, deployment and operations.

This checklist is for dApp teams getting ready to launch: founders who want to know what "secure enough" means, and developers who want a concrete list to work through. It covers the vulnerability classes that keep showing up in real incidents, the tools that catch them, and the operational practices that limit the damage when something slips through anyway.

Start with a threat model

Before you look at code, write down what you're protecting and from whom. A one-page threat model should answer these questions:

  • What assets does the system hold or control? Tokens, NFTs, governance power, privileged roles, off-chain data.
  • Who are the actors? Users, admins, keepers or bots, oracles, integrating protocols, and anonymous attackers with access to flash loans.
  • What are the trust assumptions? Which addresses can pause, upgrade, mint or move funds? What happens if one of those keys is compromised?
  • What must always be true? These become your invariants, for example "the sum of user balances never exceeds total deposits".

Most of the rest of this checklist traces back to that document. Auditors will ask for it too, and a clear threat model makes an audit faster and more useful.

Common vulnerability classes (with fixes)

Reentrancy

Reentrancy happens when your contract makes an external call, such as sending ETH or calling a token or callback, before updating its own state. The callee can then call back in and act on stale state. The classic form is a withdraw function:

// VULNERABLE: external call before state update
function withdraw() external {
    uint256 amount = balances[msg.sender];
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "transfer failed");
    balances[msg.sender] = 0; // too late
}

The fix is the checks-effects-interactions pattern, ideally with a reentrancy guard on top:

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract Vault is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "nothing to withdraw");
        balances[msg.sender] = 0;                       // effects
        (bool ok, ) = msg.sender.call{value: amount}(""); // interaction
        require(ok, "transfer failed");
    }
}

Also watch for cross-function and read-only reentrancy. In those cases a different function, or a view function that another protocol relies on, returns inconsistent values during the callback. Token standards with hooks (ERC-777, ERC-721 safeTransferFrom, ERC-1155) open up callback paths that aren't obvious at first glance.

Access control

Missing or incorrect access control is among the most damaging bug classes, and one of the easiest to prevent. Typical mistakes are an unprotected initialize(), a setOracle() with no modifier, or tx.origin used for authorization.

// VULNERABLE: anyone can change the price source
function setOracle(address newOracle) external {
    oracle = newOracle;
}

// FIXED: role-based access control
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

bytes32 public constant CONFIG_ROLE = keccak256("CONFIG_ROLE");

function setOracle(address newOracle) external onlyRole(CONFIG_ROLE) {
    require(newOracle != address(0), "zero address");
    emit OracleUpdated(oracle, newOracle);
    oracle = newOracle;
}

Emit events for every privileged action so monitoring can pick them up. Prefer Ownable2Step or role-based access over a single owner, so ownership transfers need the new owner to accept.

Integer issues after Solidity 0.8

Since Solidity 0.8.0, arithmetic overflow and underflow revert by default, which removed a whole category of bugs. Some integer problems are still around, though:

  • unchecked blocks switch the checks off. Only use them where you've proven the bounds, such as loop counters.
  • Unsafe downcasting (uint128(x)) silently truncates. Use OpenZeppelin's SafeCast.
  • Precision loss from dividing before multiplying, or from rounding in the wrong direction. In vault-style contracts, round in the protocol's favour and be aware of first-depositor share inflation attacks on ERC-4626 vaults.
  • Mismatched token decimals when you combine values from tokens with 6, 8 and 18 decimals.

Oracle manipulation

If your protocol reads prices from an AMM's spot reserves, an attacker with a flash loan can move that price within a single transaction, borrow against the inflated value and repay the loan, all atomically. Defences:

  • Use robust decentralized oracle feeds or time-weighted average prices (TWAPs) over a sensible window, not spot prices.
  • Check feed freshness (updatedAt) and sanity bounds, and handle sequencer downtime on L2s where that applies.
  • Consider circuit breakers that pause sensitive actions when prices move beyond expected thresholds.

Front-running and MEV

Pending transactions are visible to searchers and builders, who can reorder, insert or sandwich them. Common mitigations:

  • User-specified slippage limits and deadlines on swaps.
  • Commit-reveal schemes for auctions, games and votes, where revealing intent early gives others an advantage.
  • Avoiding designs where the first caller of a public function captures value that was meant for someone else, such as unprotected initializers or reward claims.
  • Recommending private transaction relays to users for high-value operations.

Signature replay

Off-chain signatures (permits, meta-transactions, allowlists) can be replayed unless they're bound to a specific context.

// VULNERABLE: same signature can be used repeatedly, on any chain
function claim(uint256 amount, bytes calldata sig) external {
    bytes32 hash = keccak256(abi.encodePacked(msg.sender, amount));
    require(ECDSA.recover(hash, sig) == signer, "bad sig");
    token.transfer(msg.sender, amount);
}
// FIXED: EIP-712 domain (chainId + contract), nonce and deadline
using ECDSA for bytes32;

bytes32 private constant CLAIM_TYPEHASH =
    keccak256("Claim(address account,uint256 amount,uint256 nonce,uint256 deadline)");

function claim(uint256 amount, uint256 deadline, bytes calldata sig) external {
    require(block.timestamp <= deadline, "expired");
    bytes32 structHash = keccak256(
        abi.encode(CLAIM_TYPEHASH, msg.sender, amount, nonces[msg.sender]++, deadline)
    );
    require(_hashTypedDataV4(structHash).recover(sig) == signer, "bad sig");
    token.safeTransfer(msg.sender, amount);
}

The fixed version inherits OpenZeppelin's EIP712 contract, which provides _hashTypedDataV4 and binds the signature to the chain ID and contract address. Use the library's ECDSA helpers, which reject malleable signatures, rather than calling ecrecover directly.

Upgradeable proxy pitfalls

Upgradeability lets you fix bugs. It also adds attack surface and trust assumptions. If you use proxies:

  • Initialize correctly. Implementation contracts use initializers instead of constructors. Protect initialize() with the initializer modifier and call _disableInitializers() in the implementation's constructor so nobody can initialize the implementation directly.
  • Preserve storage layout. Never reorder, remove or change the type of existing state variables. Append new ones only, and use namespaced storage (ERC-7201) or storage gaps as your library version recommends. The OpenZeppelin Upgrades plugins for Hardhat and Foundry validate layouts automatically.
  • Pick the pattern deliberately. With UUPS, the upgrade logic lives in the implementation, so forgetting to protect _authorizeUpgrade, or deploying an implementation without it, can brick or expose the contract. Transparent proxies keep upgrade logic in the proxy at the cost of slightly more gas.
  • Avoid selfdestruct and unchecked delegatecall in implementations.
  • Govern upgrades. Put the upgrade role behind a multisig and a timelock so users have time to react to a proposed change.
Warning: An upgradeable contract is only as trustworthy as whoever holds the upgrade key. If a single externally owned account can upgrade your protocol, then for practical purposes that account owns every asset in it. Document the upgrade process publicly.

Use battle-tested libraries and static analysis

OpenZeppelin Contracts

Don't reimplement ERC-20, ERC-721, access control, reentrancy guards or signature verification. OpenZeppelin Contracts are widely used, well documented and have been reviewed many times. Pin an exact version, read the release notes for breaking changes between major versions, and use SafeERC20 when you interact with arbitrary tokens, because many don't return a boolean or behave in non-standard ways (fee-on-transfer, rebasing, blocklists).

Static analysis with Slither

Slither, from Trail of Bits, analyzes Solidity source and flags a long list of known issue patterns: reentrancy, uninitialized storage, dangerous delegatecall, shadowing, missing events and more. It runs in seconds, so put it in CI:

# Run Slither against a Foundry or Hardhat project
slither . --exclude-dependencies --fail-high

# Print a human-readable summary of contracts and functions
slither . --print human-summary

Triage every finding. Fix it, or suppress it with a comment explaining why it's a false positive. A CI job that everyone has learned to ignore protects nothing.

Fuzzing and invariant testing

Unit tests check the scenarios you thought of. Fuzzing checks the ones you didn't. Foundry makes property-based testing cheap: any test function that takes parameters gets fuzzed automatically.

function testFuzz_DepositWithdrawRoundTrip(uint96 amount) public {
    vm.assume(amount > 0);
    deal(address(token), alice, amount);

    vm.startPrank(alice);
    token.approve(address(vault), amount);
    uint256 shares = vault.deposit(amount, alice);
    uint256 assets = vault.redeem(shares, alice, alice);
    vm.stopPrank();

    assertLe(assets, amount, "user must not withdraw more than deposited");
}

Invariant tests go a step further. Foundry calls random sequences of functions on handler contracts and checks, after every call, that your system-wide properties still hold:

function invariant_SolventVault() public view {
    assertGe(
        token.balanceOf(address(vault)),
        vault.totalAssets(),
        "vault holds fewer tokens than it reports"
    );
}

Echidna, also from Trail of Bits, is a mature property-based fuzzer with its own campaign configuration. Medusa is another option in the same family. Running a second fuzzer with a different search strategy on your most important invariants is cheap relative to the value at risk.

Tip: Take the invariants from your threat model and turn each one into an invariant test. When an auditor asks "what must never happen?", you can point to executable answers, which makes the audit more focused.

Also run fork tests against a mainnet fork (forge test --fork-url ...) for any integration with external protocols, oracles or tokens. Mocks almost always behave more nicely than the real thing.

Audits and bug bounties

An audit is a time-boxed expert review. It isn't a guarantee. To get the most out of one:

  1. Freeze the code. Audit a specific commit hash and don't keep adding features during the engagement.
  2. Provide documentation. The threat model, architecture diagrams, NatSpec comments, a list of known issues and your test suite with coverage reports.
  3. Fix, then re-review. Fixes can introduce new bugs, so have the auditor check the remediation commit.
  4. Consider more than one review for high-value protocols. Different reviewers find different things, and competitive audit contests add breadth.

After launch, run a bug bounty with clear scope, severity definitions and rewards that make responsible disclosure more attractive than exploitation. Publish a security.txt or security contact so researchers know how to reach you.

Deployment keys, monitoring and incident response

Deployment hygiene

  • Deploy with scripted, reproducible deployments (Foundry scripts, Hardhat Ignition) and verify the source on block explorers.
  • Use a hardware wallet or dedicated deployer key, then transfer every privileged role to a multisig (for example a Safe with an appropriate signer threshold) straight after deployment.
  • Put a timelock in front of sensitive parameter changes and upgrades.
  • Revoke deployer permissions and confirm on-chain that no stray roles are left behind.
  • Run the whole deployment on a testnet and on a mainnet fork first, then compare the resulting state.

Monitoring

Watch for privileged-role events, large or unusual transfers, sudden TVL changes, oracle deviations and paused-state changes. Alerts should reach a human quickly, through more than one channel.

Incident response

Write the playbook before you need it:

  • Who can trigger a pause, and how quickly can multisig signers be reached across time zones?
  • Which functions stay open while paused, so users can still withdraw where that's safe?
  • Who handles communication with users, and through which channels?
  • How will you do a post-mortem and remediation, including a re-audit of any fix?
The best time to rehearse your incident response is on a quiet Tuesday, not in the middle of an exploit.

The pre-mainnet checklist

AreaCheckDone when
DesignThreat model and invariants writtenReviewed by the whole team and shared with auditors
ReentrancyCEI pattern and guards on external-call pathsSlither clean; callback tokens considered
Access controlEvery state-changing function has explicit permissionsRole matrix documented; events emitted
MathNo unsafe unchecked, casts or roundingSafeCast used; rounding direction tested
OraclesNo spot-price dependencies; staleness checksFork tests with manipulated prices pass
MEVSlippage, deadlines, commit-reveal where neededSandwich scenarios tested
SignaturesEIP-712 with nonce, deadline, chain IDReplay tests fail as expected
UpgradesInitializers locked; storage layout validatedUpgrade plugin checks pass; timelock set
TestingUnit, fuzz, invariant and fork testsHigh coverage on core logic; invariants hold
ReviewExternal audit and fixes re-reviewedReport published; open issues acknowledged
KeysPrivileged roles on multisigDeployer roles revoked and verified on-chain
OperationsMonitoring, bounty and incident playbookAlert tested end to end; drill completed
Note: This checklist is a baseline, not a certificate. Protocols with novel mechanisms (lending, derivatives, cross-chain bridges) need domain-specific reviews that go well beyond generic patterns.

Conclusion

Smart contract security is layered. Threat modeling tells you what to protect. Proven libraries and careful patterns prevent the common bugs. Static analysis, fuzzing and invariant tests find the less obvious ones. Audits and bounties add outside eyes, and multisigs, timelocks, monitoring and a rehearsed incident plan limit the damage if something still gets through. None of these layers is enough by itself, but together they make an exploit far less likely.

If you're building a dApp and want help hardening contracts, wiring them into a Next.js front end or preparing for an audit, see our Web3 smart contract integration service, or contact us to talk through your launch plan.

Written by the Coodes Engineering Team

We build premium Web2 & Web3 themes and help teams ship dApps, SaaS platforms and Flutter apps.

Talk to us

Related articles

Ready to Build Something Amazing?

Join thousands of developers who trust our themes for their projects. Professional designs, clean code, and ongoing support.