← Back to Blog

Solidity Security Best Practices 2026: A Developer's Checklist

2026-08-18 · By Josh Kim (Lead Security Researcher, ContractScan) solidity security best practices smart contract checklist 2026

Smart contract security in 2026 requires discipline across eight critical areas. This post covers proven patterns, working code examples, and the mistakes that lead to millions in losses. Each section includes a checklist and a safe vs unsafe code comparison.


1. Access Control: Enforce Admin Restrictions Correctly

Access control failures remain the leading cause of smart contract exploits. The pattern is consistent: unprotected functions, wrong parties granting permissions, or keys in the wrong place.

Best Practice: Role-Based Access Control (RBAC)

Use OpenZeppelin's AccessControl instead of raw onlyOwner:

import "@openzeppelin/contracts/access/AccessControl.sol";

contract SecureVault is AccessControl {
    bytes32 public constant ADMIN_ROLE = DEFAULT_ADMIN_ROLE;
    bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

    constructor(address admin, address multisig) {
        _grantRole(ADMIN_ROLE, multisig);
        _grantRole(OPERATOR_ROLE, admin);
        _grantRole(PAUSER_ROLE, admin);
    }

    function setFeeRecipient(address recipient) 
        external 
        onlyRole(ADMIN_ROLE) 
    {
        require(recipient != address(0), "Zero address");
        feeRecipient = recipient;
        emit FeeRecipientUpdated(recipient);
    }

    function pause() external onlyRole(PAUSER_ROLE) {
        _pause();
    }
}

Key principles:
- DEFAULT_ADMIN_ROLE must be a multi-sig wallet (3-of-5 minimum), never an EOA
- Operational roles (OPERATOR_ROLE) can be a deployer or hot wallet for day-to-day functions
- Emergency roles (PAUSER_ROLE) should be accessible quickly but separate from fund-moving permissions

Access Control Checklist

Unsafe vs Safe

UNSAFE:

// Anti-pattern: raw onlyOwner with deployer EOA
import "@openzeppelin/contracts/access/Ownable.sol";

contract UnsafeVault is Ownable {
    constructor() Ownable(msg.sender) {}  // msg.sender is a developer's EOA

    function emergencyWithdraw() external onlyOwner {
        payable(owner()).transfer(address(this).balance);
    }
    // If the owner key is compromised → entire vault is drained
}

SAFE:

// Best practice: RBAC with multi-sig as admin
import "@openzeppelin/contracts/access/AccessControl.sol";

contract SafeVault is AccessControl {
    bytes32 public constant ADMIN_ROLE = DEFAULT_ADMIN_ROLE;

    address public multisigAdmin;

    constructor(address _multisig) {
        require(_multisig != address(0), "Zero address");
        multisigAdmin = _multisig;
        _grantRole(ADMIN_ROLE, _multisig);
    }

    function emergencyWithdraw(address recipient) 
        external 
        onlyRole(ADMIN_ROLE) 
    {
        require(recipient != address(0), "Zero address");
        uint256 balance = address(this).balance;
        (bool success, ) = payable(recipient).call{value: balance}("");
        require(success, "Transfer failed");
    }
    // Admin action requires multi-sig consensus
}

2. Reentrancy: Use CEI and nonReentrant Guards

Reentrancy exploits allow attackers to drain funds by re-entering a function before state is finalized. The fix is simple: enforce Checks-Effects-Interactions (CEI) ordering.

Best Practice: Checks-Effects-Interactions (CEI) Pattern

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

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

    function withdraw(uint256 amount) external nonReentrant {
        // CHECKS: validate inputs
        require(balances[msg.sender] >= amount, "Insufficient balance");

        // EFFECTS: update state
        balances[msg.sender] -= amount;

        // INTERACTIONS: external calls last
        (bool success, ) = payable(msg.sender).call{value: amount}("");
        require(success, "Transfer failed");
    }

    function deposit() external payable {
        // EFFECTS: update state immediately
        balances[msg.sender] += msg.value;
    }
}

Why CEI matters:
1. Checks — validate all inputs before touching state
2. Effects — modify balances, flags, counters
3. Interactions — make external calls (which can re-enter) last

By the time an external call happens, state is already updated. A re-entrant call sees the new balance and cannot exploit a stale value.

Reentrancy Checklist

Unsafe vs Safe

UNSAFE:

// Anti-pattern: state updated after external call
contract UnsafeWithdraw {
    mapping(address => uint256) public balances;

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "Insufficient");

        // INTERACTIONS before EFFECTS → vulnerable to reentrancy
        (bool success, ) = payable(msg.sender).call{value: amount}("");
        require(success, "Transfer failed");

        balances[msg.sender] -= amount;  // Updated AFTER call
        // Attacker's receive() can call withdraw() again with stale balance
    }
}

SAFE:

// Best practice: CEI + nonReentrant
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

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

    function withdraw(uint256 amount) external nonReentrant {
        // CHECKS
        require(balances[msg.sender] >= amount, "Insufficient");

        // EFFECTS: update state FIRST
        balances[msg.sender] -= amount;

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

3. Input Validation: Check Addresses, Bounds, and Safe Math

Input validation catches errors before they propagate. Most exploits start with unvalidated parameters.

Best Practice: Validate All Inputs

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract ValidatedTransfer {
    address public tokenAdmin;

    constructor(address admin) {
        require(admin != address(0), "Admin is zero address");
        tokenAdmin = admin;
    }

    function transferTokensWithValidation(
        address token,
        address recipient,
        uint256 amount
    ) external {
        // Check 1: token address is not zero
        require(token != address(0), "Token is zero address");

        // Check 2: recipient is not zero
        require(recipient != address(0), "Recipient is zero address");

        // Check 3: amount is positive
        require(amount > 0, "Amount must be positive");

        // Check 4: amount doesn't exceed reasonable bounds
        require(amount <= 1e30, "Amount exceeds max");

        // After validation, proceed
        bool success = IERC20(token).transferFrom(msg.sender, recipient, amount);
        require(success, "Transfer failed");
    }

    function updateAdmin(address newAdmin) external {
        require(msg.sender == tokenAdmin, "Unauthorized");
        require(newAdmin != address(0), "New admin is zero address");
        require(newAdmin != tokenAdmin, "Same admin");

        tokenAdmin = newAdmin;
        emit AdminUpdated(newAdmin);
    }
}

Validation checklist — always verify:
- Zero address checks for critical addresses (admin, recipient, token)
- Amount bounds (not zero, not exceeding max supply)
- No division before multiplication (causes precision loss)
- Safe math (Solidity 0.8+ has overflow checks; use SafeMath for earlier versions)

Input Validation Checklist

Unsafe vs Safe

UNSAFE:

// Anti-pattern: no validation
contract NoValidation {
    mapping(address => uint256) public balances;

    function transfer(address to, uint256 amount) external {
        // No checks: zero address allowed, zero amount allowed, amount unbounded
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }

    function setAdmin(address newAdmin) external {
        // No validation: could set admin to address(0)
        admin = newAdmin;
    }
}

SAFE:

// Best practice: comprehensive validation
contract FullValidation {
    address public admin;
    mapping(address => uint256) public balances;

    function transfer(address to, uint256 amount) external {
        // Input validation
        require(to != address(0), "Recipient is zero");
        require(amount > 0, "Amount must be positive");
        require(balances[msg.sender] >= amount, "Insufficient balance");

        balances[msg.sender] -= amount;
        balances[to] += amount;

        emit Transfer(msg.sender, to, amount);
    }

    function setAdmin(address newAdmin) external {
        require(msg.sender == admin, "Unauthorized");
        require(newAdmin != address(0), "New admin is zero");
        require(newAdmin != admin, "Same admin");

        admin = newAdmin;
        emit AdminUpdated(newAdmin);
    }
}

4. External Calls: Check Return Values and Use SafeERC20

External calls are unpredictable. They can fail, revert, or return unexpected values. Always verify return values.

Best Practice: SafeERC20 and Return Value Checks

import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract SafeExternalCalls {
    using SafeERC20 for IERC20;

    function swapWithValidation(
        address tokenIn,
        address tokenOut,
        uint256 amountIn,
        address dex
    ) external {
        require(tokenIn != address(0), "Invalid token in");
        require(tokenOut != address(0), "Invalid token out");
        require(amountIn > 0, "Invalid amount");
        require(dex != address(0), "Invalid dex");

        // SafeERC20 wraps transfer/transferFrom with checks
        IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn);

        // Approve with SafeERC20 (handles non-standard tokens)
        IERC20(tokenIn).safeApprove(dex, amountIn);

        // Call dex swap
        (bool success, bytes memory data) = dex.call(
            abi.encodeWithSignature("swap(address,uint256)", tokenOut, amountIn)
        );
        require(success, "Swap failed");

        // Transfer output tokens back to caller
        uint256 balanceOut = IERC20(tokenOut).balanceOf(address(this));
        require(balanceOut > 0, "No tokens received");

        IERC20(tokenOut).safeTransfer(msg.sender, balanceOut);
    }

    function pullTokens(address token, address from, uint256 amount) internal {
        // Pull pattern: user approves, contract pulls (not pushed to)
        IERC20(token).safeTransferFrom(from, address(this), amount);
    }

    function pushTokens(address token, address to, uint256 amount) internal {
        // Push pattern: contract initiates transfer (riskier for large distributions)
        IERC20(token).safeTransfer(to, amount);
    }
}

Key practices:
- Use SafeERC20 wrapper for all ERC-20 interactions
- Always check return values from .call() and external functions
- Use pull-over-push: users initiate claims, contract doesn't push to them
- Verify balance changes after transfers

External Call Checklist

Unsafe vs Safe

UNSAFE:

// Anti-pattern: unchecked external calls, ignored return values
contract UnsafeExternal {
    address dex;

    function swap(address tokenIn, uint256 amountIn) external {
        // No validation of return value
        IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);

        // Unchecked call — if it fails, function continues silently
        dex.call(abi.encodeWithSignature("swap(uint256)", amountIn));
        // Attacker could have deployed a fallback contract that does nothing
    }
}

SAFE:

// Best practice: SafeERC20 + return value checks
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract SafeExternal {
    using SafeERC20 for IERC20;
    address public dex;

    function swap(address tokenIn, uint256 amountIn) external {
        require(tokenIn != address(0), "Invalid token");
        require(amountIn > 0, "Invalid amount");

        // SafeERC20 will revert if transfer fails
        IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn);

        // Check return value and revert on failure
        (bool success, ) = dex.call(
            abi.encodeWithSignature("swap(uint256)", amountIn)
        );
        require(success, "Swap failed");
    }
}

5. Upgradeable Contracts: Initialize, Not Constructor

Upgradeable contracts (proxies) have unique patterns. Using constructors in proxy implementations breaks initialization.

Best Practice: Initialize Function with Initializer Modifier

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";

contract UpgradeableVault is Initializable, AccessControlUpgradeable {
    bytes32 public constant ADMIN_ROLE = DEFAULT_ADMIN_ROLE;

    // V1 state
    address public feeRecipient;
    uint256 public feePercentage;

    // V2 state (added later, MUST come after V1 storage)
    mapping(address => bool) public whitelisted;

    // Gap to reserve space for future upgrades
    uint256[50] private __gap;

    // Initialize called by proxy, not constructor
    function initialize(address admin, address recipient, uint256 fee)
        external
        initializer
    {
        require(admin != address(0), "Admin is zero");
        require(recipient != address(0), "Recipient is zero");
        require(fee <= 10000, "Fee too high");

        _grantRole(ADMIN_ROLE, admin);
        feeRecipient = recipient;
        feePercentage = fee;
    }

    // Subsequent upgrade: add new initializer
    function initializeV2(address[] calldata whitelistAddresses)
        external
        reinitializer(2)
    {
        for (uint256 i = 0; i < whitelistAddresses.length; i++) {
            whitelisted[whitelistAddresses[i]] = true;
        }
    }

    function setFeeRecipient(address newRecipient)
        external
        onlyRole(ADMIN_ROLE)
    {
        require(newRecipient != address(0), "Zero address");
        feeRecipient = newRecipient;
        emit FeeRecipientUpdated(newRecipient);
    }
}

Upgradeable contract rules:
1. Use initialize() function with @initializable modifier, NOT constructor
2. Call _disableInitializers() in implementation constructor (for proxy implementations)
3. Never add state variables before existing ones (storage layout collision)
4. Use __gap array to reserve space for future state variables
5. Use reinitializer(2) for upgrade-time initialization in later versions

Upgradeable Contract Checklist

Unsafe vs Safe

UNSAFE:

// Anti-pattern: constructor used in proxy implementation
contract UnsafeUpgradeable {
    address public admin;
    uint256 public count;

    constructor(address _admin) {
        admin = _admin;  // NOT called by proxy — reverts or does nothing
        count = 0;
    }

    function setAdmin(address newAdmin) external {
        require(msg.sender == admin, "Unauthorized");
        admin = newAdmin;
    }
    // admin is never set because constructor isn't called through proxy
}

SAFE:

// Best practice: initialize() function with initializer
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract SafeUpgradeable is Initializable {
    address public admin;
    uint256 public count;
    uint256[50] private __gap;

    // Constructor in implementation disables initializer
    constructor() {
        _disableInitializers();
    }

    function initialize(address _admin) external initializer {
        require(_admin != address(0), "Zero address");
        admin = _admin;
        count = 0;
    }

    function setAdmin(address newAdmin) external {
        require(msg.sender == admin, "Unauthorized");
        require(newAdmin != address(0), "Zero address");
        admin = newAdmin;
    }
}

6. Oracle Safety: Use TWAP, Validate Staleness

Oracles are critical infrastructure. Using spot prices from AMMs for critical decisions (like liquidations) enables flash loan attacks.

import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

contract OracleSafePrice {
    AggregatorV3Interface public priceFeed;
    uint256 public constant STALENESS_THRESHOLD = 1 hours;
    uint256 public constant MAX_PRICE_DEVIATION = 0.1e18; // 10%

    address public twapFallback; // Uniswap V3 or other TWAP source

    constructor(address feed, address fallback) {
        require(feed != address(0), "Feed is zero");
        require(fallback != address(0), "Fallback is zero");
        priceFeed = AggregatorV3Interface(feed);
        twapFallback = fallback;
    }

    function getLatestPrice() external view returns (uint256) {
        (
            uint80 roundId,
            int256 answer,
            uint256 startedAt,
            uint256 updatedAt,
            uint80 answeredInRound
        ) = priceFeed.latestRoundData();

        // Check 1: answer is positive
        require(answer > 0, "Oracle price invalid");

        // Check 2: price is recent (not stale)
        uint256 timeSinceUpdate = block.timestamp - updatedAt;
        require(timeSinceUpdate <= STALENESS_THRESHOLD, "Price is stale");

        // Check 3: round is complete
        require(answeredInRound >= roundId, "Incomplete round");

        // Check 4: price hasn't deviated wildly (sanity check)
        uint256 previousPrice = _getPreviousPrice();
        uint256 maxAllowedPrice = (previousPrice * (1e18 + MAX_PRICE_DEVIATION)) / 1e18;
        uint256 minAllowedPrice = (previousPrice * (1e18 - MAX_PRICE_DEVIATION)) / 1e18;
        require(
            uint256(answer) >= minAllowedPrice && uint256(answer) <= maxAllowedPrice,
            "Price deviation too high"
        );

        return uint256(answer);
    }

    function getUniswapTWAP(address tokenA, address tokenB, uint32 timeWindow)
        external
        view
        returns (uint256)
    {
        // Time-weighted average price: resistant to flash loan attacks
        return IUniswapV3Pool(twapFallback).observe(timeWindow);
    }

    function _getPreviousPrice() internal view returns (uint256) {
        // Implement logic to fetch previous price for deviation check
        // This prevents sudden price jumps
        return 0; // Placeholder
    }
}

Oracle safety rules:
1. Check price is non-zero and positive
2. Check updatedAt timestamp is recent (staleness check)
3. Verify round is complete (answeredInRound >= roundId)
4. Use TWAP (time-weighted average price) for critical decisions
5. Implement price deviation sanity checks to catch extreme jumps
6. Use oracle fallback if primary feed goes down

Oracle Safety Checklist

Unsafe vs Safe

UNSAFE:

// Anti-pattern: spot price, no staleness check
contract UnsafeOracle {
    IUniswapV2Pair pool;

    function getPrice() external view returns (uint256) {
        (uint112 reserve0, uint112 reserve1, ) = pool.getReserves();
        // Spot price from AMM: vulnerable to flash loan sandwich
        return (reserve0 * 1e18) / reserve1;
    }

    function liquidateIfNeeded(address user) external {
        uint256 price = getPrice();  // Can be manipulated in single transaction
        if (price < threshold) {
            // Liquidate — attacker just flash-loaned to crash price
            _liquidate(user);
        }
    }
}

SAFE:

// Best practice: Chainlink with staleness check + TWAP
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

contract SafeOracle {
    AggregatorV3Interface public priceFeed;
    uint256 public constant STALENESS_THRESHOLD = 1 hours;

    function getPrice() external view returns (uint256) {
        (
            ,
            int256 answer,
            ,
            uint256 updatedAt,
            uint80 answeredInRound
        ) = priceFeed.latestRoundData();

        // Check price is fresh
        require(block.timestamp - updatedAt <= STALENESS_THRESHOLD, "Price stale");
        require(answer > 0, "Invalid price");

        return uint256(answer);
    }

    function liquidateIfNeeded(address user) external {
        uint256 price = getPrice();  // Chainlink + staleness check
        if (price < threshold) {
            _liquidate(user);
        }
    }
}

7. Event Logging: Emit Events for All State Changes

Events enable off-chain monitoring and are often required for security audits. Missing events obscure what happened in a contract.

Best Practice: Emit Events for All State Changes

contract EventfulContract {
    address public admin;
    uint256 public feePercentage;
    mapping(address => uint256) public balances;

    event AdminChanged(address indexed oldAdmin, address indexed newAdmin, uint256 timestamp);
    event FeeUpdated(uint256 oldFee, uint256 newFee, uint256 timestamp);
    event Deposit(address indexed user, uint256 amount, uint256 newBalance, uint256 timestamp);
    event Withdrawal(address indexed user, uint256 amount, uint256 newBalance, uint256 timestamp);

    constructor(address initialAdmin) {
        require(initialAdmin != address(0), "Zero address");
        admin = initialAdmin;
        emit AdminChanged(address(0), initialAdmin, block.timestamp);
    }

    function setAdmin(address newAdmin) external {
        require(msg.sender == admin, "Unauthorized");
        require(newAdmin != address(0), "Zero address");

        address oldAdmin = admin;
        admin = newAdmin;

        emit AdminChanged(oldAdmin, newAdmin, block.timestamp);
    }

    function setFeePercentage(uint256 newFee) external {
        require(msg.sender == admin, "Unauthorized");
        require(newFee <= 10000, "Fee too high");

        uint256 oldFee = feePercentage;
        feePercentage = newFee;

        emit FeeUpdated(oldFee, newFee, block.timestamp);
    }

    function deposit(uint256 amount) external {
        require(amount > 0, "Amount must be positive");

        balances[msg.sender] += amount;

        emit Deposit(msg.sender, amount, balances[msg.sender], block.timestamp);
    }

    function withdraw(uint256 amount) external {
        require(amount > 0, "Amount must be positive");
        require(balances[msg.sender] >= amount, "Insufficient balance");

        balances[msg.sender] -= amount;

        emit Withdrawal(msg.sender, amount, balances[msg.sender], block.timestamp);
    }
}

Event design rules:
1. Emit on every state change (balances, permissions, fees, flags)
2. Include indexed fields for filtering (user, token, role)
3. Include both old and new values where relevant
4. Include timestamp for temporal analysis
5. Use descriptive names that match the action

Event Logging Checklist

Unsafe vs Safe

UNSAFE:

// Anti-pattern: no events
contract NoEvents {
    address admin;
    mapping(address => uint256) balances;

    function setAdmin(address newAdmin) external {
        admin = newAdmin;
        // No event — audit log is invisible
    }

    function deposit(uint256 amount) external {
        balances[msg.sender] += amount;
        // No event — no record of who deposited what
    }
}

SAFE:

// Best practice: comprehensive event logging
contract WithEvents {
    address admin;
    mapping(address => uint256) balances;

    event AdminUpdated(address indexed oldAdmin, address indexed newAdmin);
    event Deposited(address indexed user, uint256 amount, uint256 newBalance);

    function setAdmin(address newAdmin) external {
        address oldAdmin = admin;
        admin = newAdmin;
        emit AdminUpdated(oldAdmin, newAdmin);
    }

    function deposit(uint256 amount) external {
        balances[msg.sender] += amount;
        emit Deposited(msg.sender, amount, balances[msg.sender]);
    }
}

8. Testing and Static Analysis: Automate Security Checks

Testing prevents bugs from reaching production. Static analysis catches common vulnerabilities before deployment.

Best Practice: Foundry Fuzz Tests + Slither Pre-commit Hook

// FUZZ TESTS: test-Vault.sol
import "forge-std/Test.sol";
import "../src/Vault.sol";

contract VaultTest is Test {
    Vault vault;
    address admin = makeAddr("admin");
    address user = makeAddr("user");

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

    // Property-based fuzz test: deposit-withdraw always results in same balance
    function testFuzzDepositWithdraw(uint256 amount) public {
        vm.assume(amount > 0 && amount < 1e30); // Bounds
        vm.prank(user);
        vault.deposit{value: amount}();

        uint256 balanceBefore = user.balance;
        vm.prank(user);
        vault.withdraw(amount);
        uint256 balanceAfter = user.balance;

        assertEq(balanceAfter - balanceBefore, amount, "Deposit-withdraw mismatch");
    }

    // Invariant test: total supply always equals sum of balances
    function invariantTotalSupply() public {
        assertEq(vault.totalSupply(), address(vault).balance, "Supply mismatch");
    }

    // Fuzz test against reentrancy: cannot re-enter withdraw
    function testNoReentrancy() public {
        vm.prank(user);
        vault.deposit{value: 10 ether}();

        vm.prank(user);
        vault.withdraw(5 ether); // If reentrancy possible, would fail invariant
    }
}

Set up Slither pre-commit hook:

# Create .git/hooks/pre-commit
#!/bin/bash
slither . --fail-high
if [ $? -ne 0 ]; then
    echo "Slither found high-severity issues. Fix before committing."
    exit 1
fi

Testing checklist:
- Unit tests for each function (happy path + edge cases)
- Fuzz tests for arithmetic operations (bounds checking)
- Fork tests against mainnet state for DeFi integrations
- Invariant tests for protocol-level properties
- Coverage > 95% of contract code
- Slither pre-commit hook to catch issues before push

Testing & Analysis Checklist

Unsafe vs Safe

UNSAFE:

// Anti-pattern: no tests, no static analysis
contract Untested {
    function complexMath(uint256 a, uint256 b, uint256 c) external pure returns (uint256) {
        // No tests, no Slither check — overflow/underflow risk
        return (a * b) / c;
    }
}
// Deployed without testing or scanning

SAFE:

// Best practice: comprehensive tests + static analysis
contract Tested {
    function complexMath(uint256 a, uint256 b, uint256 c) external pure returns (uint256) {
        require(c != 0, "Division by zero");
        return (a * b) / c;
    }
}

// Test file (test-Tested.sol)
contract TestedTest is Test {
    Tested tested = new Tested();

    function testComplexMathBasic() public view {
        uint256 result = tested.complexMath(10, 20, 2);
        assertEq(result, 100);
    }

    function testFuzzComplexMath(uint256 a, uint256 b, uint256 c) public {
        vm.assume(c > 0);
        uint256 result = tested.complexMath(a, b, c);
        assertEq(result, (a * b) / c);
    }

    function testDivisionByZeroReverts() public {
        vm.expectRevert("Division by zero");
        tested.complexMath(10, 20, 0);
    }
}

// Slither runs on pre-commit hook before pushing to git

Master Checklist: Before Mainnet Deployment

Use this final checklist before going live:

Access Control
- [ ] Admin functions protected by AccessControl with roles
- [ ] No EOA holds DEFAULT_ADMIN_ROLE — is multi-sig
- [ ] Ownership transfer uses Ownable2Step
- [ ] Role separation documented (operators, pausers, upgraders)

Reentrancy & External Calls
- [ ] All ETH/token sends use CEI pattern
- [ ] Critical functions use nonReentrant guard
- [ ] All ERC-20 transfers use SafeERC20 or return value checks
- [ ] Pull-over-push pattern used for distributions

Input Validation
- [ ] Zero address checks on all address parameters
- [ ] Amount > 0 for transfers/mints
- [ ] No division before multiplication
- [ ] Array bounds checked before loops

Upgradeable Contracts
- [ ] Uses initialize() not constructor
- [ ] Implementation has _disableInitializers() in constructor
- [ ] Storage layout collision-free
- [ ] Upgrade function behind timelock + multi-sig

Oracle Safety
- [ ] Staleness checks on oracle prices
- [ ] TWAP used instead of spot price
- [ ] Fallback oracle configured
- [ ] Price deviation sanity checks

Events & Logging
- [ ] All state changes emit events
- [ ] Events include indexed fields
- [ ] Timestamp included in critical events

Testing & Analysis
- [ ] Unit tests cover all functions
- [ ] Fuzz tests on arithmetic
- [ ] Code coverage > 95%
- [ ] Slither with no high findings
- [ ] Pre-commit hook running

Deployment
- [ ] Governance/timelock deployed
- [ ] Multi-sig created and tested
- [ ] All admin roles transferred pre-mainnet
- [ ] Testnet rehearsal completed
- [ ] Incident response plan documented


Scanning Your Contract

The above patterns catch the most common and costly vulnerabilities. But manual review is error-prone. Automated scanning catches what humans miss.

Scan your Solidity contracts with ContractScan — AI-assisted analysis checks access control architecture, reentrancy paths, oracle safety, input validation, and testing coverage in minutes. Start with a free scan to identify high-risk patterns before they reach users.


Related: Solidity Access Control Patterns: onlyOwner, Roles, and Multi-sig — detailed deep dive on access control patterns.

Related: Gas Optimization vs Security Tradeoffs in Solidity — making the right choices when optimization conflicts with safety.

Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

🛡️
Written by Josh Kim
Lead Security Researcher at ContractScan. Specializing in EVM smart contract vulnerability research, DeFi protocol security, formal verification, and automated vulnerability detection.
Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →