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 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.
USDC has 6 decimals, not 18. This is the #1 source of "where did my money go?" bugs.
// ❌ WRONG — assumes 18 decimals. Transfers 1 TRILLION USDC.
uint256 oneToken = 1e18;
// ✅ CORRECT — check decimals
uint256 oneToken = 10 ** IERC20Metadata(token).decimals();
Common decimals:
| Token | Decimals |
|---|---|
| USDC, USDT | 6 |
| WBTC | 8 |
| DAI, WETH, most tokens | 18 |
When doing math across tokens with different decimals, normalize first:
// Converting USDC amount to 18-decimal internal accounting
uint256 normalized = usdcAmount * 1e12; // 6 + 12 = 18 decimals
Solidity has no float or double. Division truncates to zero.
// ❌ WRONG — this equals 0
uint256 fivePercent = 5 / 100;
// ✅ CORRECT — basis points (1 bp = 0.01%)
uint256 FEE_BPS = 500; // 5% = 500 basis points
uint256 fee = (amount * FEE_BPS) / 10_000;
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.
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)
Always use OpenZeppelin's ReentrancyGuard as a safety net on top of CEI.
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:
A flash loan can manipulate any pool's spot price within a single transaction. This has caused hundreds of millions in losses.
// ❌ DANGEROUS — manipulable in one transaction
function getPrice() internal view returns (uint256) {
(uint112 reserve0, uint112 reserve1,) = uniswapPair.getReserves();
return (reserve1 * 1e18) / reserve0; // Spot price — easily manipulated
}
// ✅ SAFE — Chainlink with staleness + sanity checks
function getPrice() internal view returns (uint256) {
(, int256 price,, uint256 updatedAt,) = priceFeed.latestRoundData();
require(block.timestamp - updatedAt < 3600, "Stale price");
require(price > 0, "Invalid price");
return uint256(price);
}
If you must use onchain price data:
observe()The first depositor in an ERC-4626 vault can manipulate the share price to steal from subsequent depositors.
The attack:
1999 * 1 / 2000 = 0 shares (rounds down)The fix — virtual offset:
function convertToShares(uint256 assets) public view returns (uint256) {
return assets.mulDiv(
totalSupply() + 1e3, // Virtual shares
totalAssets() + 1 // Virtual assets
);
}
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.
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.
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.).
Never trust inputs. Validate everything.
function deposit(uint256 amount, address recipient) external {
require(amount > 0, "Zero amount");
require(recipient != address(0), "Zero address");
require(amount <= maxDeposit, "Exceeds max");
// Now proceed
}
Common missed validations:
Run through this for EVERY contract before deploying to production. No exceptions.
nonReentrant on all external-calling functions1e18 for tokens that might have different decimalsMEV (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.
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
// ✅ Set explicit minimum output — don't set amountOutMinimum to 0
ISwapRouter.ExactInputSingleParams memory params = ISwapRouter
.ExactInputSingleParams({
tokenIn: WETH,
tokenOut: USDC,
fee: 3000,
recipient: msg.sender,
amountIn: 1 ether,
amountOutMinimum: 1900e6, // ← Minimum acceptable USDC (protects against sandwich)
sqrtPriceLimitX96: 0
});
For users/frontends:
https://rpc.flashbots.net) — sends transactions to a private mempool, invisible to sandwich botsWhen MEV matters:
When MEV doesn't matter:
Smart contracts are immutable by default. Proxies let you upgrade the logic while keeping the same address and state.
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 | 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.
// 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 {}
}
initializer instead of constructor — proxies don't run constructors@openzeppelin/contracts-upgradeable, not @openzeppelin/contracts// ❌ 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 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.
When to use:
// EIP-712 domain separator — prevents replay across contracts and chains
bytes32 public constant DOMAIN_TYPEHASH = keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);
bytes32 public constant PERMIT_TYPEHASH = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
function permit(
address owner, address spender, uint256 value,
uint256 deadline, uint8 v, bytes32 r, bytes32 s
) external {
require(block.timestamp <= deadline, "Permit expired");
bytes32 structHash = keccak256(abi.encode(
PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline
));
bytes32 digest = keccak256(abi.encodePacked(
"\x19\x01", DOMAIN_SEPARATOR(), structHash
));
address recovered = ecrecover(digest, v, r, s);
require(recovered == owner, "Invalid signature");
_approve(owner, spender, value);
}
Key properties:
EIP712 and ERC20Permit — don't implement from scratchdelegatecall 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:
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
Slither findings to NEVER ignore:
delegatecall or selfdestruct