Best Practices

Writing Secure Solidity: A Developer's Guide to Defensive Smart Contracts

Kennedy OwiroNovember 2, 202510 min read

The difference between a smart contract developer and a secure smart contract developer isn't knowledge of more patterns — it's a mindset shift. In Web2, bugs cause errors. In Web3, bugs cause permanent fund loss. This guide covers the defensive programming practices that separate secure contracts from vulnerable ones.

The Web3 Security Mindset

  1. Assume all external inputs are malicious — Every parameter, every callback, every external contract
  2. Minimize trust surface — The less your contract trusts, the less can go wrong
  3. Fail closed, not open — When in doubt, revert. Silent failures are exploitable
  4. Make it correct first, then optimize — Gas optimization that introduces bugs is a net loss

Pattern: Checks-Effects-Interactions (CEI)

The single most important pattern in Solidity security. Check preconditions, update state, then interact with external contracts.

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

    // EFFECTS
    balances[msg.sender] -= amount;
    totalDeposits -= amount;

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

    emit Withdrawal(msg.sender, amount);
}

Pattern: Input Validation

function setParameters(
    address oracle, uint256 fee, uint256 maxBorrow
) external onlyAdmin {
    require(oracle != address(0), "Zero oracle");
    require(fee <= MAX_FEE, "Fee too high");         // Cap at 10%
    require(maxBorrow > MIN_BORROW, "Too low");      // Prevent zero-config
    require(maxBorrow <= MAX_BORROW, "Too high");    // Prevent overflow scenarios

    _oracle = oracle;
    _fee = fee;
    _maxBorrow = maxBorrow;

    emit ParametersUpdated(oracle, fee, maxBorrow);
}

Testing for Security

Invariant Testing (Foundry)

Define properties that should always be true and let the fuzzer try to break them:

function invariant_totalSupplyMatchesBalances() public {
    uint256 sum;
    for (uint i = 0; i < actors.length; i++) {
        sum += vault.balanceOf(actors[i]);
    }
    assertEq(vault.totalSupply(), sum);
}

function invariant_contractSolvency() public {
    assertGe(
        address(vault).balance,
        vault.totalDeposits()
    );
}

Security-First Development Workflow

  1. Write specification first (what should the contract do?)
  2. Implement with CEI pattern throughout
  3. Write unit tests including edge cases (0, 1, MAX)
  4. Write invariant tests (what should ALWAYS be true?)
  5. Run Slither locally before every commit
  6. Get an audit before deployment
  7. Set up monitoring and incident response for post-deployment

Secure code starts with secure habits. Validate your approach with a professional audit — we catch what testing misses.

Soliditysecure codingdeveloper guidedefensive programmingsmart contract
Share

Written by

Kennedy Owiro

Founder & CTO, Vultbase

14+ years building security and QA systems at scale. Background in fintech security and Web3 smart contract testing. Built Vultbase's Intelligence Engine with 1,200+ exploit patterns from $40B+ in historical DeFi losses.

Protect your protocol before launch.

Submit your smart contracts for automated security analysis powered by 1,200+ real exploit patterns.

Start Your Audit →