$197M drained from Euler Finance in 13 minutes because of a donation function nobody thought was dangerous. The attacker didn't need a sophisticated zero-day. They needed one unguarded function, a flash loan, and the fact that nobody had mapped out what donating collateral does to your liquidation math. That's the nature of smart contract security — the dangerous code usually looks harmless.
This checklist isn't theoretical. Every item on it maps to a real exploit, a real loss, a real protocol that wished someone had asked the question before mainnet.
Before Anything Else: Change Your Mental Model
Most developers approach security like they're looking for bugs. That's wrong. You're looking for incentives. Ask yourself: if someone could make money by breaking this function, how would they do it? That reframe catches more than any static analysis tool.
Also — and this is the take that surprises people — everyone obsesses over reentrancy. It's the Solidity boogeyman. But access control vulnerabilities have caused 3x more total losses in DeFi history. Reentrancy is the thing you learned about in your first Solidity tutorial. Access control is the thing that actually drains your contract.
Keep that in mind as you go through this.
The Checklist
1. Access Control — Check Every State-Changing Function
Go through every function that modifies state. Ask: who is allowed to call this? Is that enforced on-chain, or just assumed?
The $80M Rari Capital hack in 2022 came down to a reentrancy guard that had been removed during a migration, combined with a missing access check on a root-level function. Two things. Both obvious in retrospect.
Vulnerable pattern:
// Solidity 0.8.24
// ❌ Anyone can call this
function setFeeReceiver(address _receiver) external {
feeReceiver = _receiver;
}
Fixed:
// Solidity 0.8.24 + OpenZeppelin 5.x
import "@openzeppelin/contracts/access/Ownable.sol";
// ✅ Only owner
function setFeeReceiver(address _receiver) external onlyOwner {
require(_receiver != address(0), "Zero address");
feeReceiver = _receiver;
}
Practical step: grep your codebase for every external and public function. For each one, write down who's allowed to call it. If your answer is "anyone" and it changes state, that's a red flag that needs justification.
2. Reentrancy — Yes, Still
It's 2024 and protocols are still getting hit. Not because developers don't know about reentrancy — they do. It's because reentrancy patterns keep showing up in unexpected places: ERC-777 token callbacks, NFT safeTransfer hooks, flash loan receivers.
The rule is simple. Checks-Effects-Interactions. Always. No exceptions.
// ❌ Vulnerable — external call before state update
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount; // Too late
}
// ✅ Fixed — state update before external call
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, "Insufficient");
balances[msg.sender] -= amount; // State update first
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
Use OpenZeppelin's ReentrancyGuard on anything that moves ETH or tokens. And check if your ERC-20 or ERC-721 implementation calls out to external contracts on transfer — that's where the surprise reentrancy lives.
3. Integer Overflow and Underflow
Solidity 0.8.x has built-in overflow checks. If you're on an older version, stop reading this and update your compiler. Seriously.
If you're using unchecked {} blocks for gas optimization — which is valid — audit every single one manually. Unchecked turns off the protection. The batchTransfer overflow in BEC token wiped $900M in market cap in 2018. Ancient history, but the pattern of using unchecked math in loops for gas savings and creating overflow conditions is not ancient history.
// ❌ Dangerous unchecked in a loop
function batchTransfer(address[] calldata recipients, uint256 amount) external {
uint256 total;
unchecked {
total = recipients.length * amount; // Can overflow
}
require(balances[msg.sender] >= total);
// ...
}
// ✅ Safe
function batchTransfer(address[] calldata recipients, uint256 amount) external {
uint256 total = recipients.length * amount; // 0.8.x checks this
require(balances[msg.sender] >= total);
// ...
}
4. Oracle Manipulation
If your protocol uses price data, you need to answer one question: can someone move that price within a single transaction and profit from it?
Spot price from a DEX pool is not a price oracle. It's a suggestion that can be distorted with a flash loan in the same block. The Mango Markets exploit in 2022 — $114M — was essentially just someone pumping their own token's oracle price to borrow against inflated collateral. Clean, simple, devastating.
What to check:
- Are you using a Chainlink price feed with a staleness check on the
updatedAttimestamp? - If you're using a Uniswap V3 TWAP, is the observation window long enough to resist manipulation (at least 30 minutes for low-liquidity pairs)?
- Does your liquidation logic rely on a price that an attacker could move in one tx?
// ❌ Spot price — manipulable in one transaction
function getPrice(address token) internal view returns (uint256) {
(uint112 reserve0, uint112 reserve1, ) = IUniswapV2Pair(pair).getReserves();
return (reserve1 * 1e18) / reserve0;
}
// ✅ Chainlink with staleness check
function getPrice(address feed) internal view returns (uint256) {
(, int256 price, , uint256 updatedAt, ) = AggregatorV3Interface(feed).latestRoundData();
require(block.timestamp - updatedAt <= 3600, "Stale price");
require(price > 0, "Invalid price");
return uint256(price);
}
5. Approval and Allowance Exploits
This one hits traders, not just developers. Most people interacting with DeFi don't realize that approving a token with approve(spender, type(uint256).max) gives that contract unlimited access to that token in your wallet — forever, until you revoke it. If that contract gets exploited or upgraded maliciously, your balance is gone.
For developers: never ask for max approvals unless you have a clear UX reason, and always provide a revoke path. Build with OpenZeppelin's SafeERC20 to handle the allowance race condition (where setting a non-zero allowance over a non-zero allowance can be front-run).
// ❌ Vulnerable to allowance front-running
token.approve(spender, newAmount);
// ✅ Use increaseAllowance/decreaseAllowance, or reset to 0 first
token.safeApprove(spender, 0);
token.safeApprove(spender, newAmount);
// Or better in OpenZeppelin 5.x:
token.forceApprove(spender, newAmount);
6. Front-Running and MEV
Transactions sit in the mempool before they're included in a block. Anyone watching can see your transaction, understand what it does, and submit one right before it with a higher gas price. This is front-running, and it's not illegal — it's just math.
If you're building a DEX, AMM, or anything that executes trades: enforce slippage limits and transaction deadlines. If you're building something where order matters (like an NFT mint with a price advantage), consider commit-reveal schemes.
// ❌ No slippage protection — you get whatever price the block gives you
function swap(uint256 amountIn, address tokenOut) external {
// Executes at whatever price exists at inclusion time
}
// ✅ Caller sets their acceptable minimum
function swap(
uint256 amountIn,
uint256 amountOutMin,
address tokenOut,
uint256 deadline
) external {
require(block.timestamp <= deadline, "Expired");
uint256 amountOut = _executeSwap(amountIn, tokenOut);
require(amountOut >= amountOutMin, "Slippage too high");
}
7. Upgradeable Contract Hazards
Proxy patterns are powerful. They're also a new surface area for exploits. Storage collisions between the proxy and implementation, uninitialized implementations, and selfdestruct in implementation contracts have all been exploited.
Things to verify:
- Implementation contract is initialized — call
_disableInitializers()in its constructor (OpenZeppelin 5.x) - No
selfdestructordelegatecallto user-controlled addresses in the implementation - Storage layout is append-only — never reorder or remove variables between upgrades
- Who controls the upgrade function? Is there a timelock?
8. Denial of Service Via Unbounded Loops
A loop that iterates over an array that users can grow is a gas bomb waiting to go off. Once the array is large enough, every call to that function hits the block gas limit and reverts. The function is permanently bricked.
// ❌ Array grows unbounded — eventually DoS
function distributeRewards() external {
for (uint256 i = 0; i < stakers.length; i++) {
_sendReward(stakers[i]);
}
}
// ✅ Pull pattern — users claim their own rewards
function claimReward() external {
uint256 reward = pendingRewards[msg.sender];
require(reward > 0, "Nothing to claim");
pendingRewards[msg.sender] = 0;
_sendReward(msg.sender, reward);
}
9. Signature Replay Attacks
If your contract accepts signed messages to authorize actions, those signatures can be replayed — on the same chain, or across chains if you deployed to multiple networks.
Always include: chain ID, contract address, nonce, and expiry in your signed message hash. OpenZeppelin's EIP712 and ECDSA abstractions handle the structure — use them instead of rolling your own.
10. The Event and Logging Audit
This one isn't about exploits — it's about what happens after. Every state-changing function should emit an event. Missing events make post-incident analysis painful and make your contract harder to monitor. If you're running a protocol and can't set up alerting because your contract emits nothing, you won't know you've been exploited until someone posts about it on Twitter.
How I'd Actually Catch These Before Shipping
When I'm reviewing a contract, I don't start with the code. I start with the spec. What is this contract supposed to do? Then I look for everywhere the code deviates from that — intentionally or not.
After that, I run Slither. It catches a lot of the mechanical stuff — missing access modifiers, state variables that could be constants, reentrancy in obvious patterns. Takes two minutes. No excuse not to run it.
But here's what static tools miss: the economic logic. Slither doesn't know that your price oracle feeds into a liquidation mechanism, and that the liquidation threshold creates an incentive for someone to manipulate the oracle. That's not a code pattern — that's a game theory problem. That's where human review and AI-assisted analysis matter.
I also do a manual check of every external call, every delegatecall, and every place ETH moves. Those are the highest-risk lines in any contract. I trace the call graph from those points backward — what state assumptions are made before this call? Can those assumptions be violated?
Before You Deploy: A Fast Sanity Check
Run these specific commands and questions:
- Run
slither . --print human-summary— fix anything flagged as high or medium - Grep for
tx.origin— if it's used for authentication, that's a bug - Grep for
block.timestamp— if critical logic depends on it being exact, reconsider - Check every
require()without a message string — you'll hate yourself during debugging - Verify your constructor or
initialize()can't be called twice on the deployed contract
Paste your contract into SmartContractAuditor.ai before you deploy. It flags exactly these patterns — the access control issues, the reentrancy paths, the oracle risks — and explains in plain English why each one is dangerous and how to fix it. Not after you've deployed and lost funds. Before. That's the only time it matters.
The developers who skip this step aren't bad engineers. They're just in a hurry. And the thing about smart contracts is that being in a hurry is permanent — you can't patch a deployed contract without an upgrade mechanism, and you can't undo a drain. Thirty seconds of automated analysis is genuinely worth it.
Further Reading
Related Articles
Continue exploring smart contract security with these related insights
OpenZeppelin vs Custom Smart Contract Implementations: The Security Trade-offs Nobody Talks About
Custom ERC20 implementations have caused hundreds of millions in losses — not because developers are bad, but because rolling your own token logic is a minefield. Here's what actually separates safe contracts from the ones that get drained.
OpenZeppelin's Library Secured $37 Trillion. Are You Actually Using It Right?
OpenZeppelin's library secures an estimated $37 trillion. But the library being secure and your deployment being secure are two different claims — here are the real misuse patterns that slip through.
Gas Optimization Without Sacrificing Security: The Developer's Guide to Efficient Smart Contracts
Gas optimization in Solidity is the process of reducing computation costs in smart contracts — but the shortcuts that save gas are often the same ones that get protocols exploited. One wrong optimization pattern opened the door to the $25M Uni v1 reentrancy-style drain. Here's how to do it right.
Explore more insights on smart contract security andblockchain vulnerabilities