Security
Smart Contract Security: A Practical Review Checklist
The vulnerability classes behind most losses, with code, mitigations and a review process teams can adopt.
Smart contract bugs are unusually costly. Code is public, value is directly attached to it, transactions are irreversible, and attackers can use automated tools to scan every new deployment. Experience across the industry shows that the large majority of serious incidents fall into a short list of recurring patterns. Knowing that list is the most efficient security investment a team can make. This guide describes the patterns, shows representative code and sets out a process that combines several complementary techniques. It is educational and does not replace an independent audit for any contract that will hold value.
Start with the threat model
Before reading any code, write down three things. What assets does the contract hold or control? Who are the actors, including users, administrators, other contracts, oracles and validators? And what would a malicious actor want: to steal funds, lock them, manipulate prices, or take over administration? Each later finding should map back to this model. It keeps reviews focused on what matters, and exposes assumptions, such as "the admin key will never be compromised", that need an explicit mitigation.
Reentrancy
If a contract sends value or calls another contract before it finishes updating its own state, the callee can call back into the original function and exploit the stale state. The classic case is a withdrawal function:
// VULNERABLE: external call happens before the balance is updated
function withdraw() external {
uint256 amount = balances[msg.sender];
(bool ok, ) = msg.sender.call{value: amount}(""); // attacker's code runs here
require(ok, "send failed");
balances[msg.sender] = 0; // too late
}
An attacker's contract can implement a receive function that calls withdraw() again while the balance is still non-zero, draining the contract in a loop. The fix is the checks-effects-interactions pattern, optionally combined with a reentrancy guard from a well-reviewed library:
// SAFER: checks-effects-interactions, plus a reentrancy guard
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw"); // checks
balances[msg.sender] = 0; // effects
(bool ok, ) = msg.sender.call{value: amount}(""); // interactions
require(ok, "send failed");
}
Reentrancy can also occur across several functions or several contracts ("cross-function" and "read-only" reentrancy), so it is not enough to guard one function. Review every place where control leaves your contract.
Access control
Many incidents involve a function that was supposed to be restricted but was not, or a privileged role that could be taken over. Review every external and public function and ask who should be able to call it. Specific mistakes to look for:
- Missing modifiers on administrative functions, including initialisers.
- Use of
tx.originfor authorisation, which can be abused by a contract that tricks the owner into calling it:
// VULNERABLE: tx.origin is the original signer, not the immediate caller
function setOwner(address next) external {
require(tx.origin == owner, "not owner"); // a malicious contract can relay the owner's tx
owner = next;
}
// SAFER
function setOwner(address next) external {
require(msg.sender == owner, "not owner");
owner = next;
}
- A single externally owned account holding all powers. Prefer a multi-signature wallet and, for critical actions, a timelock so users can see changes before they take effect.
- Role-management functions that allow privilege escalation, or that cannot be revoked.
Unchecked calls and non-standard tokens
Low-level calls return a success flag that must be checked. In addition, not every ERC-20 token behaves identically: some do not return a boolean, some return false instead of reverting, some charge a fee on transfer, and a few can be paused or can rebase balances. Contracts that assume ideal behaviour break against real tokens. Use a library such as SafeERC20, and compare balances before and after a transfer if you need to know how much actually arrived:
// Use SafeERC20 so tokens that return nothing, or false, are handled
using SafeERC20 for IERC20;
function pay(IERC20 token, address to, uint256 amount) external onlyOwner {
token.safeTransfer(to, amount); // reverts on failure instead of silently continuing
}
Oracle and price manipulation
Contracts that need an external price often read it from a decentralised exchange pool. A spot price from a single pool can be moved within one transaction using a flash loan, tricking a lending or collateral contract into mispricing. Mitigations include using time-weighted average prices over a meaningful window, using decentralised oracle networks with multiple sources, setting sanity bounds and circuit breakers, and never using a manipulable spot price as the sole input to a value-critical decision.
Front-running and transaction ordering
Pending transactions are visible before they are included, and block producers can order them. Others can copy, precede or sandwich a profitable trade. Defences depend on the use case. Include slippage limits and deadlines in swaps, use commit-reveal schemes where the action must be hidden until it is final, and consider private transaction relays for sensitive operations. Remember that any value that depends on ordering can be a target.
Signature handling and replay
Contracts that accept off-chain signatures, for example for gasless approvals or meta-transactions, must make sure each signature is valid for exactly one purpose. Use a structured signing standard such as EIP-712, which binds a signature to a domain including the chain ID and the verifying contract. Include a nonce that is consumed on use, and an expiry time. Without these, a signature valid on one chain, one contract or one occasion can be replayed on another. Also reject malleable or zero-address results when recovering the signer.
Arithmetic, rounding and casting
Solidity 0.8 and later reverts on overflow by default, which removes a whole class of historical bugs, but several risks remain. Code inside unchecked blocks does not have these protections. Narrowing type casts, for example from a large integer type to a small one, can silently truncate. Division rounds down, which matters in share-price calculations: some vault designs can be manipulated through the first depositor by exploiting rounding, a problem known as the inflation or donation attack. Test with tiny and huge values, and be deliberate about which direction you round.
Denial of service and gas
Unbounded loops over user-controlled arrays can grow until a function no longer fits in a block, permanently locking funds. A single failing recipient in a loop of payments can block the entire batch. Prefer pull payments (users withdraw their own funds) over pushing payments to many addresses, and paginate or bound any iteration.
Upgradeability risks
Proxy-based upgrade systems add flexibility but also a distinct set of hazards:
- Storage layout collisions: adding or reordering variables in a new implementation can overwrite existing data. Use tooling that checks layout compatibility.
- Uninitialised implementations: an attacker who can call the initialiser on the implementation contract may take control of it. Lock implementations on deployment.
- Delegatecall to untrusted code executes in your contract's context and can overwrite anything.
- Admin key risk: whoever can upgrade can change the rules. Document it, use multi-signature control with a timelock, and consider eventually renouncing the ability to upgrade.
Randomness and time
Values such as block.timestamp, blockhash and similar on-chain data are predictable or influenceable, so they are not secure sources of randomness. Use a verifiable randomness service when a fair random result matters. Timestamps are also adjustable within a small range by block producers, so avoid logic that depends on second-level precision.
A layered review process
No single technique finds everything. Teams that ship safely combine several, in roughly this order:
- Design reviewWalk through the threat model and the on-chain and off-chain boundary before code is written. Simpler designs have fewer places to hide bugs.
- Use proven componentsPrefer audited libraries for tokens, access control and upgrades over custom implementations.
- Tests with high coverageUnit tests for every function, including failure and permission cases, plus integration tests against real protocols using a fork.
- Fuzzing and invariantsUse property-based tests that hammer the contract with random sequences. State the invariants explicitly, for example "total shares always equals the sum of balances".
- Static analysisRun analysers such as Slither on every commit and triage the results; fix or document each finding.
- Peer reviewA second engineer reads every line with the vulnerability list in front of them.
- Independent auditOne or more specialist firms review the final code. Fix findings, then get the fixes reviewed.
- Staged rolloutDeploy to a testnet, then mainnet with deposit caps, monitored closely before limits are raised.
- Monitoring and responseAlerts for unusual flows, a tested pause mechanism, and a documented plan for who does what in an incident, including communication.
Pre-deployment checklist
- Every external and public function has an explicit access-control decision and a test for it.
- State is updated before external calls, and reentrancy has been considered across functions.
- All return values of low-level calls and token transfers are checked.
- No spot prices, block data or other manipulable values drive value-critical logic.
- Signatures use domain separation, nonces and expiries.
- Loops are bounded and payments are pull-based where practical.
- Upgrade paths, keys and timelocks are documented, and implementations are locked.
- Compiler version is pinned and recent, and no known compiler bugs apply to the code in use.
- Events are emitted for all important state changes, to support monitoring.
- Emergency controls and their governance are defined and have been exercised in a drill.
Security is a property of the whole system, not just the contract. Keys, deployment scripts, front ends and RPC infrastructure are all part of the attack surface. Our Web3 testing guide covers how to build the supporting tests, and our integration guide covers key management for backend signers.
Key takeaways
- Most losses come from a small set of well-known classes: reentrancy, access control, oracle manipulation and unsafe upgrades.
- Follow checks-effects-interactions, restrict privileged functions and use audited libraries instead of writing your own.
- Treat any external contract, price source or signature as untrusted input.
- Combine tests, fuzzing, static analysis, peer review and an independent audit. No single technique is enough.
- Plan for failure: pause mechanisms, spending limits, monitoring and a rehearsed incident response.
Related guides
Further reading
About this guide. Written by the Kensaltensi engineering and QA team for educational purposes. Examples are illustrative, and standards evolve, so check the primary sources above before relying on a detail in production. If you spot an error or an out-of-date statement, email hello@kensaltensi.org and we will review and correct it.