security
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.
// ❌ 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
2. No Floating Point in Solidity
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.
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.
// ❌ 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:
- Use TWAP (Time-Weighted Average Price) over 30+ minutes — resistant to single-block manipulation
- Uniswap V3 has built-in TWAP oracles via
observe() - Still less safe than Chainlink for high-value decisions
6. Vault Inflation Attack
The first depositor in an ERC-4626 vault can manipulate the share price to steal from subsequent depositors.
The attack:
- Attacker deposits 1 wei → gets 1 share
- Attacker donates 1000 tokens directly to the vault (not via deposit)
- Now 1 share = 1001 tokens
- Victim deposits 1999 tokens → gets
1999 * 1 / 2000 = 0 shares(rounds down) - Attacker redeems 1 share → gets all 3000 tokens
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.
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.).
9. Input Validation
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:
- 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
Pre-Deploy Security Checklist
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 +
nonReentranton all external-calling functions - Token decimal handling — no hardcoded
1e18for 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
- 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
- Tested edge cases — zero values, max values, unauthorized callers, reentrancy attempts
- Source verified on block explorer —
yarn verifyorforge verify-contractafter every deploy. Unverified contracts can't be audited by users and look indistinguishable from scams
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
Protection
// ✅ 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:
- Use Flashbots Protect RPC (
https://rpc.flashbots.net) — sends transactions to a private mempool, invisible to sandwich bots - Set tight slippage limits (0.5-1% for majors, 1-3% for small tokens)
- Use MEV-aware DEX aggregators (CoW Swap, 1inch Fusion) that route through solvers instead of the public mempool
When MEV matters:
- Any swap on a DEX (especially large swaps)
- Any large DeFi transaction (deposits, withdrawals, liquidations)
- NFT mints with high demand (bots frontrun to mint first)
When MEV doesn't matter:
- Simple ETH/token transfers
- L2 transactions (sequencers process transactions in order — no public mempool reordering)
- Private mempool transactions (Flashbots, MEV Blocker)
Proxy Patterns & Upgradeability
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
initializerinstead ofconstructor— 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.
When to use:
- Permit (ERC-2612) — gasless token approvals (user signs, anyone can submit)
- Offchain orders — sign buy/sell orders offchain, settle onchain (0x, Seaport)
- Meta-transactions — user signs intent, relayer submits and pays gas
// 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:
- 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
EIP712andERC20Permit— 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
Slither findings to NEVER ignore:
- Reentrancy vulnerabilities
- Unchecked return values
- Arbitrary
delegatecallorselfdestruct - Unprotected state-changing functions
Further Reading
- OpenZeppelin Contracts: https://docs.openzeppelin.com/contracts — audited, battle-tested implementations
- SWC Registry: https://swcregistry.io — comprehensive vulnerability catalog
- Rekt News: https://rekt.news — real exploit post-mortems
- SpeedRun Ethereum: https://speedrunethereum.com — hands-on secure development practice