IAMUVIN

Blockchain Security

Smart Contract Security Checklist: 25 Things to Check Before Mainnet

Uvin Vindula·April 8, 2024·13 min read
Share

TL;DR

Every smart contract deployed to mainnet is a bug bounty with your own money as the prize pool. I have audited over 30 smart contract systems through my blockchain security audit service and the same vulnerabilities keep appearing — reentrancy in unexpected places, broken access control, oracle manipulation surfaces, and flash loan attack vectors that nobody tested for. This smart contract security checklist covers the 25 things I check on every audit, organized by severity. Each item includes vulnerable code, the fix, and a Foundry fuzz test to catch it automatically. If you are deploying to mainnet without checking every item on this list, you are gambling with your users' funds.


Why You Need a Security Checklist (Not Just an Audit)

I charge between $10,000 and $25,000 for a full smart contract security audit depending on codebase size and protocol complexity. Most teams come to me after they have already written the code. That is backwards.

A checklist you run during development catches 80% of the vulnerabilities I find in audits. The remaining 20% — complex business logic flaws, cross-contract interaction bugs, economic attack vectors — that is where a professional audit earns its fee. But you should never be paying an auditor to find a reentrancy bug. That is a checklist item.

This list follows the OWASP Smart Contract Top 10 framework, reorganized by the severity classifications I use in my own audit reports. I built this system after reviewing every major DeFi exploit from 2022 through 2026, cross-referencing with reports on solodit.xyz and rekt.news.

Here is the full checklist. Run it before every deployment. No exceptions.


Critical Severity: Stop Deployment Until Fixed

These are the vulnerabilities that drain entire protocols overnight. If any of these exist in your code, do not deploy. Period.

1. Reentrancy on External Calls

The classic. Still the most exploited vulnerability in 2026 because developers keep making external calls before updating state.

Vulnerable:

solidity
// BAD: State update after external call
function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient");

    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");

    balances[msg.sender] -= amount; // Too late — attacker already re-entered
}

Fixed:

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

// GOOD: Checks-Effects-Interactions + nonReentrant
function withdraw(uint256 amount) external nonReentrant {
    require(balances[msg.sender] >= amount, "Insufficient");

    balances[msg.sender] -= amount; // Effect BEFORE interaction

    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}

Foundry fuzz test to catch it:

solidity
function testFuzz_withdrawCannotReenter(uint256 amount) public {
    amount = bound(amount, 1, 10 ether);
    deal(address(vault), 100 ether);
    vault.deposit{value: amount}();

    ReentrantAttacker attacker = new ReentrantAttacker(address(vault));
    deal(address(attacker), amount);
    attacker.deposit{value: amount}();

    vm.expectRevert();
    attacker.attack();
}

The Checks-Effects-Interactions pattern is non-negotiable, but I always add nonReentrant as a defense-in-depth measure. Belt and suspenders.

2. Cross-Function Reentrancy

Most developers check for reentrancy within a single function but miss cross-function attacks where the attacker re-enters through a different function that reads stale state.

Vulnerable:

solidity
// BAD: Two functions share state, only one is protected
function withdraw(uint256 amount) external nonReentrant {
    require(balances[msg.sender] >= amount);
    balances[msg.sender] -= amount;
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success);
}

// Attacker re-enters HERE during withdraw callback
function transfer(address to, uint256 amount) external {
    require(balances[msg.sender] >= amount);
    balances[msg.sender] -= amount;
    balances[to] += amount;
}

Fixed: Apply nonReentrant to every function that reads or writes shared state. There is no exception to this rule.

3. Read-Only Reentrancy

This one is newer and more subtle. The attacker re-enters a view function on a different contract that reads stale state from the contract mid-execution. Curve's pools were exploited through this exact vector.

Vulnerable:

solidity
// In PriceOracle.sol — reads pool balance mid-withdrawal
function getPrice() external view returns (uint256) {
    return pool.totalAssets() / pool.totalSupply(); // Stale during callback
}

Fixed: Use a reentrancy lock that view functions can also check, or use TWAP-based oracles that cannot be manipulated within a single transaction.

4. Missing Access Control

I find broken access control in roughly half of all audits. Functions that should be admin-only are left public, or the access control modifier exists but is applied inconsistently.

Vulnerable:

solidity
// BAD: Anyone can drain the treasury
function emergencyWithdraw(address to) external {
    uint256 balance = address(this).balance;
    (bool success, ) = to.call{value: balance}("");
    require(success);
}

Fixed:

solidity
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

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

function emergencyWithdraw(address to) external onlyRole(ADMIN_ROLE) {
    uint256 balance = address(this).balance;
    (bool success, ) = to.call{value: balance}("");
    require(success);

    emit EmergencyWithdraw(to, balance);
}

Use OpenZeppelin's AccessControl over Ownable for any protocol with more than one admin role. And never roll your own access control — I have seen custom implementations broken by inheritance order, proxy storage collisions, and initializer bugs.

5. Integer Overflow/Underflow in Unchecked Blocks

Solidity 0.8+ has built-in overflow protection, but developers use unchecked blocks for gas optimization and reintroduce the vulnerability.

Vulnerable:

solidity
// BAD: Unchecked arithmetic with user-controlled input
function calculateReward(uint256 stakedAmount, uint256 multiplier) external pure returns (uint256) {
    unchecked {
        return stakedAmount * multiplier; // Overflows silently
    }
}

Fixed:

solidity
// GOOD: Only use unchecked where overflow is mathematically impossible
function calculateReward(uint256 stakedAmount, uint256 multiplier) external pure returns (uint256) {
    return stakedAmount * multiplier; // Let Solidity 0.8+ revert on overflow
}

// Unchecked is safe HERE because i < array.length guarantees no overflow
for (uint256 i = 0; i < recipients.length;) {
    _transfer(recipients[i], amounts[i]);
    unchecked { ++i; }
}

My rule: unchecked is only acceptable for loop counter increments and operations where you have a mathematical proof that overflow cannot occur. If you cannot write the proof in a comment, do not use unchecked.

6. tx.origin Authentication

Using tx.origin for authentication lets any contract called by the user impersonate them.

Vulnerable:

solidity
// BAD: Phishing attack via malicious contract
function transferOwnership(address newOwner) external {
    require(tx.origin == owner, "Not owner"); // Attackable
    owner = newOwner;
}

Fixed:

solidity
function transferOwnership(address newOwner) external {
    require(msg.sender == owner, "Not owner"); // Correct
    owner = newOwner;
}

There is no legitimate use case for tx.origin in access control. None.

7. Unprotected Initialization

Proxy contracts that can be re-initialized let attackers take ownership of the implementation.

Vulnerable:

solidity
// BAD: Can be called again after deployment
function initialize(address _owner) external {
    owner = _owner;
}

Fixed:

solidity
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

function initialize(address _owner) external initializer {
    owner = _owner;
}

constructor() {
    _disableInitializers(); // Prevent init on implementation
}

High Severity: Likely to Be Exploited

These do not always lead to total fund loss, but they create attack surfaces that sophisticated actors will find.

8. Flash Loan Attack Surfaces

Any function that reads a contract's token balance in the same transaction it can be manipulated is a flash loan target.

Vulnerable:

solidity
// BAD: Price derived from spot balance — flash loanable
function getSharePrice() public view returns (uint256) {
    return totalAssets() / totalSupply(); // Manipulable in same tx
}

function deposit(uint256 assets) external {
    uint256 shares = assets * totalSupply() / totalAssets();
    _mint(msg.sender, shares);
    token.transferFrom(msg.sender, address(this), assets);
}

Fixed:

solidity
// GOOD: Use time-weighted values or multi-block checks
function getSharePrice() public view returns (uint256) {
    return _twapAssets() / totalSupply(); // TWAP not manipulable in 1 tx
}

In every audit, I simulate flash loan attacks against every function that derives value from on-chain state. If you use balanceOf(address(this)) anywhere in price calculation logic, you have a flash loan surface.

9. Oracle Manipulation

Relying on a single oracle source or a spot price is an invitation to manipulation.

Vulnerable:

solidity
// BAD: Single oracle, no staleness check
function getPrice(address token) external view returns (uint256) {
    (, int256 price, , , ) = priceFeed.latestRoundData();
    return uint256(price);
}

Fixed:

solidity
function getPrice(address token) external view returns (uint256) {
    (
        uint80 roundId,
        int256 price,
        ,
        uint256 updatedAt,
        uint80 answeredInRound
    ) = priceFeed.latestRoundData();

    require(price > 0, "Invalid price");
    require(updatedAt > block.timestamp - MAX_STALENESS, "Stale price");
    require(answeredInRound >= roundId, "Stale round");

    return uint256(price);
}

Always check for stale prices, negative prices, and round completeness. I have found stale oracle bugs in over 40% of the protocols I have audited.

10. Front-Running / Sandwich Attacks

Any transaction where the outcome depends on execution order is vulnerable to MEV extraction.

Vulnerable:

solidity
// BAD: No slippage protection
function swap(address tokenIn, address tokenOut, uint256 amountIn) external {
    uint256 amountOut = calculateOutput(tokenIn, tokenOut, amountIn);
    IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
    IERC20(tokenOut).transfer(msg.sender, amountOut);
}

Fixed:

solidity
function swap(
    address tokenIn,
    address tokenOut,
    uint256 amountIn,
    uint256 minAmountOut, // User-specified slippage protection
    uint256 deadline       // Prevent stale transactions
) external {
    require(block.timestamp <= deadline, "Expired");
    uint256 amountOut = calculateOutput(tokenIn, tokenOut, amountIn);
    require(amountOut >= minAmountOut, "Slippage exceeded");

    IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
    IERC20(tokenOut).transfer(msg.sender, amountOut);
}

Every swap, deposit, and liquidation function needs minAmountOut and deadline parameters. No exceptions.

11. Unchecked Return Values

Some ERC-20 tokens (USDT being the most notorious) do not return a boolean on transfer. If you do not check the return value, the transfer can silently fail.

Vulnerable:

solidity
// BAD: USDT transfer returns void, this silently succeeds
IERC20(token).transfer(recipient, amount);

Fixed:

solidity
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;

// GOOD: Handles non-standard ERC-20 implementations
IERC20(token).safeTransfer(recipient, amount);

Use SafeERC20 for every token interaction. Every single one.

12. Denial of Service via Gas Limits

Loops that iterate over unbounded arrays can exceed the block gas limit, permanently bricking a function.

Vulnerable:

solidity
// BAD: If holders grows past ~1500, this bricks permanently
function distributeRewards() external {
    for (uint256 i = 0; i < holders.length; i++) {
        token.transfer(holders[i], rewards[holders[i]]);
    }
}

Fixed:

solidity
// GOOD: Pull-based distribution — users claim their own rewards
mapping(address => uint256) public pendingRewards;

function claimReward() external {
    uint256 reward = pendingRewards[msg.sender];
    require(reward > 0, "Nothing to claim");
    pendingRewards[msg.sender] = 0;
    token.safeTransfer(msg.sender, reward);
}

Push-based token distribution is an anti-pattern. Always prefer pull-based (claim) patterns.

13. Signature Replay Attacks

Signed messages without chain ID and nonce protection can be replayed across chains or after the signer revokes permission.

Vulnerable:

solidity
// BAD: No nonce, no chain ID — replayable
function executeWithSignature(address to, uint256 amount, bytes memory sig) external {
    bytes32 hash = keccak256(abi.encodePacked(to, amount));
    address signer = ECDSA.recover(hash, sig);
    require(signer == owner, "Invalid");
    token.transfer(to, amount);
}

Fixed:

solidity
mapping(uint256 => bool) public usedNonces;

function executeWithSignature(
    address to,
    uint256 amount,
    uint256 nonce,
    bytes memory sig
) external {
    require(!usedNonces[nonce], "Nonce used");
    bytes32 hash = keccak256(abi.encodePacked(to, amount, nonce, block.chainid, address(this)));
    address signer = ECDSA.recover(hash.toEthSignedMessageHash(), sig);
    require(signer == owner, "Invalid");
    usedNonces[nonce] = true;
    token.transfer(to, amount);
}

Include nonce, block.chainid, and address(this) in every signed message hash. Better yet, use EIP-712 typed data signing.


Medium Severity: Should Fix Before Mainnet

These will not drain your protocol overnight, but they create technical debt, increase gas costs, and make future exploits easier.

14. Missing Event Emissions

Every state change should emit an event. Without events, off-chain monitoring and indexing systems (The Graph, custom indexers) cannot track protocol activity.

solidity
// GOOD: Emit events for every state mutation
event Deposit(address indexed user, uint256 amount, uint256 shares);
event Withdraw(address indexed user, uint256 amount, uint256 shares);
event ParameterUpdated(string indexed param, uint256 oldValue, uint256 newValue);

function setFee(uint256 newFee) external onlyRole(ADMIN_ROLE) {
    emit ParameterUpdated("fee", fee, newFee);
    fee = newFee;
}

I check for event emissions on every external and public function that modifies state. If it changes storage, it emits an event.

15. Gas Optimization: Storage vs Memory

Reading from storage costs 2,100 gas (cold) or 100 gas (warm). Every unnecessary SLOAD adds up.

Inefficient:

solidity
// BAD: 3 storage reads for the same variable
function process(uint256 id) external {
    require(orders[id].amount > 0);      // SLOAD 1
    require(orders[id].owner == msg.sender); // SLOAD 2
    uint256 amount = orders[id].amount;   // SLOAD 3
}

Optimized:

solidity
// GOOD: Cache in memory, 1 storage read
function process(uint256 id) external {
    Order memory order = orders[id]; // Single SLOAD
    require(order.amount > 0);
    require(order.owner == msg.sender);
    uint256 amount = order.amount;
}

16. Storage Slot Packing

Solidity packs variables that fit within a single 32-byte slot. Ordering your struct fields correctly saves significant gas.

solidity
// BAD: 3 storage slots (128 bytes wasted)
struct UserInfo {
    uint256 amount;    // slot 0 (32 bytes)
    bool isActive;     // slot 1 (1 byte + 31 wasted)
    uint256 reward;    // slot 2 (32 bytes)
}

// GOOD: 2 storage slots — bool packs with a uint
struct UserInfo {
    uint256 amount;    // slot 0
    uint256 reward;    // slot 1
    bool isActive;     // slot 1 (packs into remaining space... actually slot 2)
}

// BEST: Downsize when possible
struct UserInfo {
    uint128 amount;    // slot 0 (16 bytes)
    uint128 reward;    // slot 0 (16 bytes) — same slot!
    bool isActive;     // slot 1
}

I run forge inspect Contract storage-layout on every contract to verify packing. If you are wasting slots, you are wasting your users' gas.

17. Upgradeability Storage Collisions

Proxy-based upgradeable contracts can corrupt state if the new implementation changes the storage layout.

Vulnerable:

solidity
// V1
contract VaultV1 {
    uint256 public totalDeposits; // slot 0
    address public owner;         // slot 1
}

// V2 — WRONG: inserted variable shifts everything
contract VaultV2 {
    uint256 public totalDeposits; // slot 0
    uint256 public totalRewards;  // slot 1 — COLLISION with owner!
    address public owner;         // slot 2
}

Fixed: Always append new variables at the end. Never insert, reorder, or remove existing storage variables. Use storage gaps for future-proofing:

solidity
contract VaultV1 {
    uint256 public totalDeposits;
    address public owner;
    uint256[48] private __gap; // Reserve 48 slots for future use
}

18. Centralization Risks

Single admin keys controlling critical functions are a rug-pull vector and a regulatory red flag.

solidity
// GOOD: Time-lock + multi-sig for critical changes
uint256 public constant TIMELOCK_DELAY = 2 days;

mapping(bytes32 => uint256) public pendingChanges;

function proposeFeeChange(uint256 newFee) external onlyRole(ADMIN_ROLE) {
    bytes32 id = keccak256(abi.encodePacked("fee", newFee));
    pendingChanges[id] = block.timestamp + TIMELOCK_DELAY;
    emit FeeChangeProposed(newFee, block.timestamp + TIMELOCK_DELAY);
}

function executeFeeChange(uint256 newFee) external onlyRole(ADMIN_ROLE) {
    bytes32 id = keccak256(abi.encodePacked("fee", newFee));
    require(pendingChanges[id] != 0 && block.timestamp >= pendingChanges[id], "Not ready");
    delete pendingChanges[id];
    fee = newFee;
    emit FeeChanged(newFee);
}

Time-locks on every admin function. Multi-sig (Gnosis Safe) for the admin role. These are table stakes for mainnet.

19. Missing Circuit Breaker

Every production protocol needs an emergency pause mechanism.

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

function deposit(uint256 amount) external whenNotPaused nonReentrant {
    // ...
}

function emergencyPause() external onlyRole(GUARDIAN_ROLE) {
    _pause();
    emit EmergencyPaused(msg.sender);
}

Separate the GUARDIAN_ROLE (can pause) from the ADMIN_ROLE (can change parameters). Your guardian should be a hot wallet that can respond in minutes.

20. Rounding Errors in Share Calculations

Vault-style contracts that convert between assets and shares are vulnerable to precision loss and first-depositor attacks.

Vulnerable:

solidity
// BAD: First depositor can manipulate share price
function deposit(uint256 assets) external returns (uint256 shares) {
    shares = totalSupply() == 0 ? assets : (assets * totalSupply()) / totalAssets();
    _mint(msg.sender, shares);
    token.transferFrom(msg.sender, address(this), assets);
}

Fixed:

solidity
// GOOD: Virtual offset prevents first-depositor manipulation
uint256 private constant OFFSET = 1e3;

function deposit(uint256 assets) external returns (uint256 shares) {
    shares = (assets * (totalSupply() + OFFSET)) / (totalAssets() + 1);
    _mint(msg.sender, shares);
    token.transferFrom(msg.sender, address(this), assets);
}

The virtual offset pattern (popularized by ERC-4626 implementations) eliminates first-depositor attacks by making share price manipulation economically infeasible.


Low Severity: Code Quality and Hardening

These will not get you exploited, but they separate amateur contracts from production-grade ones.

21. Floating Pragma

solidity
// BAD: Could compile with a buggy compiler version
pragma solidity ^0.8.0;

// GOOD: Pinned to exact version
pragma solidity 0.8.24;

Pin your Solidity version. Different compiler versions have different optimizer bugs. You want to deploy with the exact version you tested with.

22. Missing NatSpec Documentation

solidity
/// @notice Deposits assets into the vault and mints shares
/// @param assets The amount of underlying tokens to deposit
/// @return shares The amount of vault shares minted
/// @dev Uses virtual offset to prevent first-depositor attack
function deposit(uint256 assets) external returns (uint256 shares) {

Every external and public function needs @notice, @param, and @return NatSpec. This is not optional — it generates your contract's documentation and helps auditors understand intent.

23. Lack of Input Validation

solidity
// GOOD: Validate everything
function setFee(uint256 newFee) external onlyRole(ADMIN_ROLE) {
    require(newFee <= MAX_FEE, "Fee exceeds maximum");
    require(newFee != fee, "No change");
    emit FeeUpdated(fee, newFee);
    fee = newFee;
}

function deposit(uint256 amount) external {
    require(amount > 0, "Zero amount");
    require(amount >= MIN_DEPOSIT, "Below minimum");
    // ...
}

Validate every parameter. Check for zero addresses, zero amounts, bounds, and nonsensical inputs. The gas cost is negligible compared to the bugs it prevents.

24. No Invariant Tests

Unit tests check individual functions. Fuzz tests check random inputs. Invariant tests check that your protocol's core properties hold across any sequence of actions.

solidity
// Foundry invariant test
function invariant_totalSharesMatchDeposits() public {
    assertGe(
        vault.totalAssets(),
        vault.totalSupply(),
        "Total assets must be >= total shares"
    );
}

function invariant_noUserOwnsMoreThanTotal() public {
    for (uint256 i = 0; i < actors.length; i++) {
        assertLe(
            vault.balanceOf(actors[i]),
            vault.totalSupply(),
            "User balance exceeds total supply"
        );
    }
}

I run invariant tests with at least 10,000 call sequences and 256 depth per sequence. If your invariants break under random action sequences, they will break under adversarial ones.

25. Missing Emergency Recovery for Stuck Tokens

Users accidentally send tokens directly to your contract address. Without a recovery function, those tokens are permanently lost.

solidity
function recoverERC20(address token, uint256 amount) external onlyRole(ADMIN_ROLE) {
    require(token != address(stakingToken), "Cannot recover staking token");
    IERC20(token).safeTransfer(msg.sender, amount);
    emit TokenRecovered(token, amount);
}

Exclude your protocol's core tokens from the recovery function to prevent admin rug pulls, but allow recovery of any accidentally sent token.


The Audit Process: How I Run This Checklist

When a team hires me for a blockchain security audit, this checklist is just the starting point. Here is the full process:

Phase 1 — Automated Analysis (Day 1)

  • Run Slither static analysis for known vulnerability patterns
  • Run forge test --fuzz-runs 10000 for all fuzz tests
  • Run invariant tests with 256 depth, 10,000 sequences
  • Gas benchmarking with forge snapshot

Phase 2 — Manual Review (Days 2-5)

  • Line-by-line review of every external function
  • Cross-contract interaction analysis
  • Economic attack modeling (flash loans, sandwich, oracle manipulation)
  • Access control mapping — who can call what and when

Phase 3 — Attack Simulation (Days 6-7)

  • Write proof-of-concept exploits for every finding
  • Test on a mainnet fork using forge test --fork-url
  • Simulate multi-block MEV attacks
  • Flash loan attack simulation against every price-dependent function

Phase 4 — Report and Remediation (Days 8-10)

  • Severity-classified findings with PoC code
  • Recommended fixes with gas impact analysis
  • Re-audit of applied fixes
  • Final sign-off with deployment recommendations

Key Takeaways

  1. Critical items are non-negotiable. Reentrancy, access control, integer safety, and initialization protection must be verified before any testnet deployment.
  1. Flash loan and oracle attacks are the 2026 meta. Every function that derives value from on-chain state is a potential flash loan target. Test for it explicitly.
  1. Foundry fuzz and invariant testing catches what unit tests miss. If you are not running fuzz tests with at least 10,000 runs, your test suite is decorative.
  1. A checklist is not a replacement for an audit. It catches the obvious issues. A professional audit catches the business logic flaws, cross-contract interactions, and economic attack vectors that no checklist can cover.
  1. Security is a spectrum, not a checkbox. Every item on this list reduces your attack surface. The goal is not perfection — it is making exploitation economically irrational.

Run this checklist on every contract before deployment. Automate what you can with Foundry. And when the stakes are high enough — when you are handling real user funds — get a professional audit.


About the Author

I am Uvin Vindula (@IAMUVIN), a Web3 and AI engineer based between Sri Lanka and the UK. I build production DeFi protocols, audit smart contracts, and write about blockchain security. I have been working with Solidity since 2022, spoken about Web3 security at Token2049, and built DeFi components through Terra Labz for clients across Asia.

I offer comprehensive blockchain security audits for smart contract systems ranging from single vaults to full protocol suites. Audits start at $10,000 for simple contracts and scale to $25,000+ for complex multi-contract DeFi protocols.

Need a security audit before mainnet? View my audit services and book a consultation.

Working on a Web3 or AI project?

Share
Uvin Vindula

Uvin Vindula

Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.