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:
uncheckedblocks 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'sSafeCast. - 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 theinitializermodifier 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
selfdestructand uncheckeddelegatecallin implementations. - Govern upgrades. Put the upgrade role behind a multisig and a timelock so users have time to react to a proposed change.
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.
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:
- Freeze the code. Audit a specific commit hash and don't keep adding features during the engagement.
- Provide documentation. The threat model, architecture diagrams, NatSpec comments, a list of known issues and your test suite with coverage reports.
- Fix, then re-review. Fixes can introduce new bugs, so have the auditor check the remediation commit.
- 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
| Area | Check | Done when |
|---|---|---|
| Design | Threat model and invariants written | Reviewed by the whole team and shared with auditors |
| Reentrancy | CEI pattern and guards on external-call paths | Slither clean; callback tokens considered |
| Access control | Every state-changing function has explicit permissions | Role matrix documented; events emitted |
| Math | No unsafe unchecked, casts or rounding | SafeCast used; rounding direction tested |
| Oracles | No spot-price dependencies; staleness checks | Fork tests with manipulated prices pass |
| MEV | Slippage, deadlines, commit-reveal where needed | Sandwich scenarios tested |
| Signatures | EIP-712 with nonce, deadline, chain ID | Replay tests fail as expected |
| Upgrades | Initializers locked; storage layout validated | Upgrade plugin checks pass; timelock set |
| Testing | Unit, fuzz, invariant and fork tests | High coverage on core logic; invariants hold |
| Review | External audit and fixes re-reviewed | Report published; open issues acknowledged |
| Keys | Privileged roles on multisig | Deployer roles revoked and verified on-chain |
| Operations | Monitoring, bounty and incident playbook | Alert tested end to end; drill completed |
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.
