All posts Services Contact
Client login Get started

Writing a Solidity contract: the checklist we actually use

Solidity Security

Most Solidity we are asked to fix was not broken by a clever attacker. It was broken by a decimal assumption, a missing zero-address check, or an upgrade path nobody could actually use. The attackers are competent; the bugs are mundane. Mundane is fixable with a checklist.

This is the list we run before anything touches a testnet, followed by what each item is actually protecting you from.

Access control, and nothing implied

If a function changes state, it has an explicit visibility and an explicit access check. No modifier, no check = public. We have found production contracts with an onlyOwner comment and no modifier.

// No — the comment is not a guard.
function setFee(uint256 fee) external {
    // onlyOwner
    fee_ = fee;
}

// Yes — explicit, and onlyOwner itself checks the zero address.
function setFee(uint256 fee) external onlyOwner {
    require(fee <= MAX_FEE, "fee too high");
    fee_ = fee;
}

Arithmetic before types

Every amount is a uint256 scaled by a constant known at compile time. Any decimal that is not 18 has a comment stating the exponent and the reason, because the person reading it in two years will not remember. Rounding is explicit — mulDiv style full-precision math, never a * b / c where a * b can overflow.

External call ordering

Checks, effects, interactions. Every time. And where a contract calls out to an untrusted address, the reentrancy guard goes on the function, not as a hopeful comment. We also check return values from transfer/send/approve rather than ignoring them.

Token decimal traps

USDC is 6 decimals on Ethereum and 6 on most chains, but USDT is 6 on some and 18 on others, and plenty of assets are not what their ticker suggests. If a contract accepts arbitrary ERC-20s, read decimals on-chain and scale against that. Do not assume 18. Do not assume the ticker resolves to the asset you mean.

Fee-on-transfer tokens also break naive accounting: you receive less than you were sent. Where that matters, measure the balance before and after rather than trusting the input amount.

An upgrade path you can actually use

If it is upgradeable: the admin is a multisig with a timelock, the timelock delay is long enough to react, the implementation contract is verified, and — the one people skip — you have written down and tested the actual upgrade procedure against a fork. An upgrade path nobody has executed is not an upgrade path.

Events for everything off-chain

Every meaningful state change emits an event with the actor, the amount, and a nonces or id field. Indexers are your API. If something important happens and emits nothing, your monitoring will not see it and neither will your users.

The last four questions

  • What is the maximum this contract can hold, and what happens at that ceiling?
  • What happens if the owner key is lost or compromised?
  • Which function, if it were wrong, would be hardest to detect?
  • What does the revert message tell an integrator, and does it tell them what to do next?