# Smart Contract Audit Checklist — 20 Things to Check Before Deployment
Deploying a smart contract to mainnet is permanent. There's no "undo" button, no hotfixes, no patches. One vulnerability can drain millions.
This checklist covers 20 critical security checks every developer should perform before deployment. Whether you're self-auditing or preparing for a professional audit, these points will help you catch dangerous issues early.
Access Control (1-3)
1. ✅ All Privileged Functions Protected
Every function that modifies critical state must have access control.
Check:
// BAD: Anyone can withdraw
function withdraw(uint256 amount) external {
payable(msg.sender).transfer(amount);
}
// GOOD: Only owner can withdraw
function withdraw(uint256 amount) external onlyOwner {
payable(msg.sender).transfer(amount);
}
Tools: Slither detects unprotected functions with --detect unprotected-upgrade
2. ✅ Owner Set Correctly in Constructor
Ensure ownership is properly initialized.
// BAD: Owner defaults to zero address
address public owner;
// GOOD: Owner set in constructor
constructor() {
owner = msg.sender;
}
Test: Verify owner is set correctly after deployment.
3. ✅ Two-Step Ownership Transfer
Prevent accidental ownership loss.
// BAD: Single-step, irreversible
function transferOwnership(address newOwner) external onlyOwner {
owner = newOwner; // Typo = permanent loss
}
// GOOD: Two-step process
address public pendingOwner;
function transferOwnership(address newOwner) external onlyOwner {
pendingOwner = newOwner;
}
function acceptOwnership() external {
require(msg.sender == pendingOwner);
owner = pendingOwner;
pendingOwner = address(0);
}
Use: OpenZeppelin's Ownable2Step
Reentrancy (4-5)
4. ✅ Checks-Effects-Interactions Pattern
State updates before external calls.
// BAD: External call before state update
function withdraw() external {
uint256 amount = balances[msg.sender];
(bool success,) = msg.sender.call{value: amount}("");
require(success);
balances[msg.sender] = 0; // TOO LATE
}
// GOOD: State update first
function withdraw() external {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0; // Update BEFORE call
(bool success,) = msg.sender.call{value: amount}("");
require(success);
}
5. ✅ ReentrancyGuard on External Calls
Defense in depth.
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract Safe is ReentrancyGuard {
function withdraw() external nonReentrant {
// Protected
}
}
Test: Write reentrancy attack tests to verify protection.
Integer Operations (6-7)
6. ✅ No Unchecked Math (Unless Intentional)
Solidity 0.8+ has built-in overflow protection. Don't disable it without reason.
// BAD: Unchecked without justification
unchecked {
balance += amount; // Can overflow
}
// GOOD: Use unchecked only when safe
unchecked {
// Loop counter won't realistically overflow
for (uint256 i = 0; i < items.length; ++i) {
// ...
}
}
7. ✅ Division Before Multiplication
Minimize rounding errors.
// BAD: Division first (loses precision)
uint256 result = (value / 100) * 25; // 0.25% of value
// GOOD: Multiplication first
uint256 result = (value * 25) / 100;
Input Validation (8-10)
8. ✅ All Inputs Validated
Never trust user input.
function setFee(uint256 _fee) external onlyOwner {
require(_fee <= 1000, "Fee too high"); // Max 10%
fee = _fee;
}
function transfer(address to, uint256 amount) external {
require(to != address(0), "Invalid address");
require(amount > 0, "Invalid amount");
// ...
}
9. ✅ Array Length Checks
Prevent out-of-gas errors.
function batchTransfer(address[] calldata recipients, uint256 amount) external {
require(recipients.length <= 100, "Too many recipients");
for (uint256 i = 0; i < recipients.length; i++) {
_transfer(msg.sender, recipients[i], amount);
}
}
10. ✅ Zero Address Checks
Prevent burning tokens/ETH accidentally.
function setTreasury(address _treasury) external onlyOwner {
require(_treasury != address(0), "Zero address");
treasury = _treasury;
}
Gas Optimization (11-13)
11. ✅ Storage Access Minimized
Cache storage reads in memory.
// BAD: Multiple storage reads
function calculateReward() external view returns (uint256) {
return userBalance[msg.sender] * rewardRate * stakingPeriod / 1e18;
// Each variable read from storage costs 2100 gas
}
// GOOD: Cache in memory
function calculateReward() external view returns (uint256) {
uint256 balance = userBalance[msg.sender]; // One storage read
uint256 rate = rewardRate;
uint256 period = stakingPeriod;
return balance * rate * period / 1e18;
}
12. ✅ Loop Gas Limits Considered
Unbounded loops can run out of gas.
// BAD: Unbounded loop
function distributeRewards() external {
for (uint256 i = 0; i < users.length; i++) {
// If users.length = 10000, this will fail
_transfer(users[i], rewardAmount);
}
}
// GOOD: Batch processing
function distributeRewards(uint256 start, uint256 end) external {
require(end <= users.length);
require(end - start <= 100, "Batch too large");
for (uint256 i = start; i < end; i++) {
_transfer(users[i], rewardAmount);
}
}
13. ✅ Efficient Data Types
Use appropriate sizes.
// BAD: Wasteful storage
struct User {
uint256 id; // Could be uint32
uint256 timestamp; // Could be uint32
bool active; // 1 byte but takes full slot
}
// GOOD: Packed storage
struct User {
uint32 id; // 4 bytes
uint32 timestamp; // 4 bytes
bool active; // 1 byte
// All fit in one 32-byte slot
}
Events (14-15)
14. ✅ Critical Actions Emit Events
Essential for off-chain tracking.
event Transfer(address indexed from, address indexed to, uint256 amount);
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
event Paused(address account);
function transfer(address to, uint256 amount) external {
// ...
emit Transfer(msg.sender, to, amount);
}
15. ✅ Indexed Parameters for Filtering
Up to 3 indexed parameters per event.
// GOOD: Key fields indexed
event Deposit(
address indexed user,
address indexed token,
uint256 amount,
uint256 timestamp
);
External Interactions (16-17)
16. ✅ External Calls Handled Safely
Always check return values.
// BAD: Ignoring return value
token.transfer(recipient, amount);
// GOOD: Check return value
bool success = token.transfer(recipient, amount);
require(success, "Transfer failed");
// BETTER: Use SafeERC20
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
token.safeTransfer(recipient, amount); // Reverts on failure
17. ✅ msg.sender Used (Not tx.origin)
See our dedicated article on this.
// BAD: Phishing vulnerable
require(tx.origin == owner);
// GOOD: Secure
require(msg.sender == owner);
Upgradability (18-19)
18. ✅ Storage Layout Preserved (Upgradeable Contracts)
Don't rearrange variables in upgrades.
// V1
contract TokenV1 {
address public owner;
uint256 public totalSupply;
}
// BAD V2: Inserted variable breaks storage
contract TokenV2 {
address public owner;
uint256 public newVariable; // BREAKS totalSupply location
uint256 public totalSupply;
}
// GOOD V2: Append only
contract TokenV2 {
address public owner;
uint256 public totalSupply;
uint256 public newVariable; // Appended at end
}
19. ✅ Initialization (Not Constructor) for Proxies
Proxies don't execute constructors.
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract TokenUpgradeable is Initializable {
address public owner;
// BAD: Constructor won't run
constructor() {
owner = msg.sender;
}
// GOOD: Initialize function
function initialize() public initializer {
owner = msg.sender;
}
}
Testing (20)
20. ✅ Comprehensive Test Coverage
Aim for 100% line coverage, but also test:
Unit Tests:
function testOwnerCanWithdraw() public {
vm.prank(owner);
contract.withdraw(100);
assertEq(owner.balance, 100);
}
Negative Tests:
function testNonOwnerCannotWithdraw() public {
vm.prank(user);
vm.expectRevert("Not owner");
contract.withdraw(100);
}
Edge Cases:
function testWithdrawZero() public {
vm.prank(owner);
vm.expectRevert("Amount must be > 0");
contract.withdraw(0);
}
Fuzzing:
function testWithdrawFuzz(uint256 amount) public {
vm.assume(amount > 0 && amount <= address(contract).balance);
vm.prank(owner);
contract.withdraw(amount);
}
Tools to Run
Before deployment, run these automated tools:
# Static analysis
slither .
# Formal verification (if applicable)
certora-cli
# Test coverage
forge coverage
# Gas optimization
forge test --gas-report
Professional Audit
This checklist catches common issues, but professional audits are irreplaceable for high-value contracts.
When to get audited:
- Managing >$100k in TVL
- Complex DeFi logic (DEX, lending, derivatives)
- Upgradeable contracts
- Multi-signature or DAO governance
- Before mainnet launch of any public protocol
Top audit firms:
- Trail of Bits
- OpenZeppelin
- ConsenSys Diligence
- Certora
- ChainSecurity
Final Checklist
Before deploying to mainnet:
✅ All 20 points from this article addressed
✅ Slither run with no high/medium issues
✅ 100% test coverage achieved
✅ Fuzzing tests passed
✅ Gas optimizations applied
✅ Professional audit completed (if high-value)
✅ Deployment tested on testnet
✅ Emergency pause mechanism in place
✅ Bug bounty program prepared
✅ Documentation complete
Conclusion
Security isn't a checkbox — it's a mindset. This checklist provides a solid foundation, but staying secure requires:
- Continuous learning (attacks evolve)
- Conservative coding (simplicity > cleverness)
- Defense in depth (multiple protections)
- Humility (assume you missed something)
The stakes are real. A single vulnerability can cost millions. Take the time to audit thoroughly.
Practice secure development on Solingo — our platform includes automated security checks, real-world vulnerability simulations, and step-by-step remediation guides for all items on this checklist.