// Solidity security patterns, common vulnerabilities, and pre-deploy audit checklist. The specific code patterns that prevent real losses — not just warnings, but defensive implementations. Use before deploying any contract, when reviewing code, or when building anything that holds or moves value.
Solidity security patterns, common vulnerabilities, and pre-deploy audit checklist. The specific code patterns that prevent real losses — not just warnings, but defensive implementations. Use before deploying any contract, when reviewing code, or when building anything that holds or moves value.
Smart Contract Security
What You Probably Got Wrong
"Solidity 0.8+ prevents overflows, so I'm safe." Overflow is one of dozens of attack vectors. The big ones today: reentrancy, oracle manipulation, approval exploits, and decimal mishandling.
"I tested it and it works." Working correctly is not the same as being secure. Most exploits call functions in orders or with values the developer never considered.
"It's a small contract, it doesn't need an audit." The DAO hack was a simple reentrancy bug. The Euler exploit was a single missing check. Size doesn't correlate with safety.
Critical Vulnerabilities (With Defensive Code)
1. Token Decimals Vary
USDC has 6 decimals, not 18. This is the #1 source of "where did my money go?" bugs.
Always multiply before dividing. Division first = precision loss.
// ❌ WRONG — loses precision
uint256 result = a / b * c;
// ✅ CORRECT — multiply first
uint256 result = (a * c) / b;
For complex math, use fixed-point libraries like PRBMath or ABDKMath64x64.
3. Reentrancy
An external call can call back into your contract before the first call finishes. If you update state AFTER the external call, the attacker re-enters with stale state.
// ❌ VULNERABLE — state updated after external call
function withdraw() external {
uint256 bal = balances[msg.sender];
(bool success,) = msg.sender.call{value: bal}(""); // ← attacker re-enters here
require(success);
balances[msg.sender] = 0; // Too late — attacker already withdrew again
}
// ✅ SAFE — Checks-Effects-Interactions pattern + reentrancy guard
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
function withdraw() external nonReentrant {
uint256 bal = balances[msg.sender];
require(bal > 0, "Nothing to withdraw");
balances[msg.sender] = 0; // Effect BEFORE interaction
(bool success,) = msg.sender.call{value: bal}("");
require(success, "Transfer failed");
}
The pattern: Checks → Effects → Interactions (CEI)
Checks — validate inputs and conditions
Effects — update all state
Interactions — external calls last
Always use OpenZeppelin's ReentrancyGuard as a safety net on top of CEI.
4. SafeERC20
Some tokens (notably USDT) don't return bool on transfer() and approve(). Standard calls will revert even on success.
// ❌ WRONG — breaks with USDT and other non-standard tokens
token.transfer(to, amount);
token.approve(spender, amount);
// ✅ CORRECT — handles all token implementations
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
token.safeTransfer(to, amount);
token.safeApprove(spender, amount);
Other token quirks to watch for:
Fee-on-transfer tokens: Amount received < amount sent. Always check balance before and after.
Rebasing tokens (stETH): Balance changes without transfers. Use wrapped versions (wstETH).
Pausable tokens (USDC): Transfers can revert if the token is paused.
Blocklist tokens (USDC, USDT): Specific addresses can be blocked from transacting.
5. Never Use DEX Spot Prices as Oracles
A flash loan can manipulate any pool's spot price within a single transaction. This has caused hundreds of millions in losses.
The virtual offset makes the attack uneconomical — the attacker would need to donate enormous amounts to manipulate the ratio.
OpenZeppelin's ERC4626 implementation includes this mitigation by default since v5.
7. Infinite Approvals
Never use type(uint256).max as approval amount.
// ❌ DANGEROUS — if this contract is exploited, attacker drains your entire balance
token.approve(someContract, type(uint256).max);
// ✅ SAFE — approve only what's needed
token.approve(someContract, exactAmountNeeded);
// ✅ ACCEPTABLE — approve a small multiple for repeated interactions
token.approve(someContract, amountPerTx * 5); // 5 transactions worth
If a contract with infinite approval gets exploited (proxy upgrade bug, governance attack, undiscovered vulnerability), the attacker can drain every approved token from every user who granted unlimited access.
8. Access Control
Every state-changing function needs explicit access control. "Who should be able to call this?" is the first question.
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
// ❌ WRONG — anyone can drain the contract
function emergencyWithdraw() external {
token.transfer(msg.sender, token.balanceOf(address(this)));
}
// ✅ CORRECT — only owner
function emergencyWithdraw() external onlyOwner {
token.transfer(owner(), token.balanceOf(address(this)));
}
For complex permissions, use OpenZeppelin's AccessControl with role-based separation (ADMIN_ROLE, OPERATOR_ROLE, etc.).
Zero addresses (tokens sent to 0x0 are burned forever)
Zero amounts (wastes gas, can cause division by zero)
Array length mismatches in batch operations
Duplicate entries in arrays
Values exceeding reasonable bounds
MEV & Sandwich Attacks
MEV (Maximal Extractable Value): Validators and searchers can reorder, insert, or censor transactions within a block. They profit by frontrunning your transaction, backrunning it, or both.
Sandwich Attacks
The most common MEV attack on DeFi users:
1. You submit: swap 10 ETH → USDC on Uniswap (slippage 1%)
2. Attacker sees your tx in the mempool
3. Attacker frontruns: buys USDC before you → price rises
4. Your swap executes at a worse price (but within your 1% slippage)
5. Attacker backruns: sells USDC after you → profits from the price difference
6. You got fewer USDC than the true market price
Smart contracts are immutable by default. Proxies let you upgrade the logic while keeping the same address and state.
When to Use Proxies
Use proxies: Long-lived protocols that may need bug fixes or feature additions post-launch
Don't use proxies: MVPs, simple tokens, immutable-by-design contracts, contracts where "no one can change this" IS the value proposition
Proxies add complexity, attack surface, and trust assumptions. Users must trust that the admin won't upgrade to a malicious implementation. Don't use proxies just because you can.
UUPS vs Transparent Proxy
UUPS
Transparent
Upgrade logic location
In implementation contract
In proxy contract
Gas cost for users
Lower (no admin check per call)
Higher (checks msg.sender on every call)
Recommended
Yes (by OpenZeppelin)
Legacy pattern
Risk
Forgetting _authorizeUpgrade locks the contract
More gas overhead
Use UUPS. It's cheaper, simpler, and what OpenZeppelin recommends.
UUPS Implementation
// Implementation contract (the logic)
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract MyContractV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
uint256 public value;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // Prevent implementation from being initialized
}
function initialize(address owner) public initializer {
__Ownable_init(owner);
__UUPSUpgradeable_init();
value = 42;
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
Critical Rules
Use initializer instead of constructor — proxies don't run constructors
Never change storage layout — only append new variables at the end, never delete or reorder
Use OpenZeppelin's upgradeable contracts — @openzeppelin/contracts-upgradeable, not @openzeppelin/contracts
Disable initializers in constructor — prevents anyone from initializing the implementation directly
Transfer upgrade authority to a multisig — never leave upgrade power with a single EOA
// ❌ WRONG — reordering storage breaks everything
// V1: uint256 a; uint256 b;
// V2: uint256 b; uint256 a; ← Swapped! 'a' now reads 'b's value
// ✅ CORRECT — only append
// V1: uint256 a; uint256 b;
// V2: uint256 a; uint256 b; uint256 c; ← New variable at the end
EIP-712 Signatures & Delegatecall
EIP-712: Typed Structured Data Signing
EIP-712 lets users sign structured data (not just raw bytes) with domain separation and replay protection. Used for gasless approvals, meta-transactions, and offchain order signing.
Domain separator prevents replaying signatures on different contracts or chains
Nonce prevents replaying the same signature twice
Deadline prevents stale signatures from being used later
In practice, use OpenZeppelin's EIP712 and ERC20Permit — don't implement from scratch
Delegatecall
delegatecall executes another contract's code in the caller's storage context. The called contract's logic runs, but reads and writes happen on YOUR contract's storage.
This is extremely dangerous if the target is untrusted.
// ❌ CRITICAL VULNERABILITY — delegatecall to user-supplied address
function execute(address target, bytes calldata data) external {
target.delegatecall(data); // Attacker can overwrite ANY storage slot
}
// ✅ SAFE — delegatecall only to trusted, immutable implementation
address public immutable trustedImplementation;
function execute(bytes calldata data) external onlyOwner {
trustedImplementation.delegatecall(data);
}
Delegatecall rules:
Never delegatecall to a user-supplied address — allows arbitrary storage manipulation
Only delegatecall to contracts YOU control — and preferably immutable ones
Storage layouts must match — the calling contract and target contract must have identical storage variable ordering
This is how proxies work — the proxy delegatecalls to the implementation, so the implementation's code runs on the proxy's storage. That's why storage layout matters so much for upgradeable contracts.
Automated Security Tools
Run these before deployment:
# Static analysis
slither . # Detects common vulnerabilities
mythril analyze Contract.sol # Symbolic execution# Foundry fuzzing (built-in)
forge test --fuzz-runs 10000 # Fuzz all test functions with random inputs# Gas optimization (bonus)
forge test --gas-report # Identify expensive functions
Run through this for EVERY contract before deploying to production. No exceptions.
Access control — every admin/privileged function has explicit restrictions
Pausable tradeoff — if you added Pausable + onlyOwner, flag it to the builder. A single key that can freeze all users is a censorship vector. Suggest timelocks or multisig governance.
Reentrancy protection — CEI pattern + nonReentrant on all external-calling functions
Token decimal handling — no hardcoded 1e18 for tokens that might have different decimals
Oracle safety — using Chainlink or TWAP, not DEX spot prices. Staleness checks present
Integer math — multiply before divide. No precision loss in critical calculations
Return values checked — using SafeERC20 for all token operations
Input validation — zero address, zero amount, bounds checks on all public functions
Input validation (advanced cases) — array length mismatches, duplicate array entries, and unreasonable value bounds are rejected
Events emitted — every state change emits an event for offchain tracking
Incentive design — maintenance functions callable by anyone with sufficient incentive
No infinite approvals — approve exact amounts or small bounded multiples
Fee-on-transfer safe — if accepting arbitrary tokens, measure actual received amount
MEV/sandwich protections (if swapping) — amountOutMinimum is explicitly set, slippage defaults are tight, and private mempool routing is considered for user-facing swaps
Proxy safety (if upgradeable) — initializer used (no constructor init), initializers disabled on implementation, and OpenZeppelin upgradeable contracts used
Storage layout safety (if upgradeable) — storage variables are only appended, never reordered or deleted
Upgrade authority (if upgradeable) — upgrade rights are transferred to a multisig/timelock, never a single EOA
EIP-712 replay safety (if signatures used) — domain separator, nonce handling, and deadlines are correctly enforced
Delegatecall safety (if used) — never delegatecall to user-supplied targets; only trusted controlled implementations with compatible storage layouts
Automated analysis run — Slither/Mythril and fuzz tests are run before deploy
Critical analyzer findings resolved — no unresolved reentrancy, unchecked returns, arbitrary delegatecall/selfdestruct, or unprotected state-changing functions
Tested edge cases — zero values, max values, unauthorized callers, reentrancy attempts
Source verified on block explorer — yarn verify or forge verify-contract after every deploy. Unverified contracts can't be audited by users and look indistinguishable from scams
Before you finish: send a one-line note via feedback/SKILL.md — issue or praise.