Originally published at Smart Contract Security Checklist for Web3 Developers.
A smart contract can look simple in a code editor and still hold millions of dollars in user funds.
That is one of the uncomfortable realities of Web3 development. A function may only be 20 lines long, but a small mistake in accounting, permissions, token handling, or price calculations can become an exploitable path once the contract is deployed.
The numbers make the risk difficult to ignore.
Immunefi reported more than $1.46 billion lost to hacks across 192 incidents in 2024. CertiK reported $3.35 billion in losses across Web3 security incidents in 2025, although the 2025 figure was heavily affected by several exceptionally large attacks.
This does not mean every smart contract needs an enormous security team.
It does mean that security should be considered during development rather than added immediately before deployment.
This checklist covers the areas Web3 developers should review before putting a smart contract into production.
Smart Contract Security Checklist
Before deployment, make sure you can answer "yes" to the following questions:
- [ ] Are all privileged functions properly protected?
- [ ] Can an attacker manipulate an oracle or price source?
- [ ] Are external calls handled safely?
- [ ] Have reentrancy paths been considered?
- [ ] Are token balances and accounting calculated correctly?
- [ ] Are ERC-20 edge cases handled?
- [ ] Are flash-loan-assisted attacks possible?
- [ ] Are upgrade and initialization functions protected?
- [ ] Are signatures and replay protection implemented correctly?
- [ ] Are emergency functions restricted and tested?
- [ ] Have important invariants been tested?
- [ ] Has the contract been tested with malicious inputs?
- [ ] Have dependencies and compiler versions been reviewed?
- [ ] Is the deployment configuration secure?
- [ ] Is there a monitoring and incident-response plan?
The sections below explain why each item matters.
1. Review Access Control First
Access control is one of the highest-impact areas in smart contract security.
OWASP's 2025 Smart Contract Top 10 places Access Control Vulnerabilities at #1. Its analysis of 2024 incidents attributed approximately $953.2 million in losses to access-control vulnerabilities.
That makes permission checks one of the first things developers should review.
Start by identifying every function that can change important protocol state.
For example:
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
This may look harmless, but if mint() is supposed to be an administrative operation, anyone can call it.
A safer implementation might be:
function mint(address to, uint256 amount)
external
onlyRole(MINTER_ROLE)
{
_mint(to, amount);
}
But adding onlyRole() is not the end of the review.
You also need to understand:
- Who receives the role?
- Who can grant the role?
- Who can revoke it?
- Can the role administrator be changed?
- Can the owner replace the role manager?
- Can an upgrade bypass the access-control system?
- Is the privileged account an EOA or multisig?
Ethereum's security documentation recommends carefully designing access control and notes that role-based access control and multisig administration can reduce single points of failure.
Access-control checklist
Review every function that can:
- Mint or burn tokens
- Transfer protocol-owned funds
- Change fees
- Change oracle addresses
- Change implementation contracts
- Pause or unpause the protocol
- Change collateral parameters
- Change liquidation parameters
- Modify trusted contracts
- Withdraw accumulated fees
- Upgrade contracts
A useful audit question is:
"What happens if an attacker obtains this role?"
If the answer is "the attacker can drain the protocol", that role deserves particularly strong protection.
2. Check Upgradeability Carefully
Upgradeable contracts introduce another layer of security assumptions.
A proxy system may separate:
User
↓
Proxy
↓
Implementation
That means the security of the application depends not only on the implementation contract but also on the proxy and upgrade mechanism.
Check:
- Who controls the proxy?
- Who can upgrade the implementation?
- Is the upgrade authority a multisig?
- Is there a timelock?
- Can the implementation be initialized again?
- Can an attacker call initialization directly?
- Are storage layouts compatible?
- Can an upgrade change critical permissions?
- Is there an emergency upgrade mechanism?
A common mistake is focusing only on the business logic while ignoring the upgrade path.
For example:
function upgradeTo(address newImplementation)
external
{
implementation = newImplementation;
}
If this function is not properly protected, the entire security model of the protocol can collapse.
OpenZeppelin specifically recommends treating upgrade mechanisms and administrative privileges as important parts of the security architecture.
3. Protect Initializers
Upgradeable contracts often use an initializer instead of a constructor.
For example:
function initialize(address admin) external {
owner = admin;
}
If this function can be called more than once, an attacker may be able to take control of the contract.
A common pattern is:
function initialize(address admin)
external
initializer
{
owner = admin;
}
But developers should still verify the complete initialization flow.
Check:
- Can initialization happen twice?
- Can an attacker initialize an implementation contract?
- Is the proxy initialized immediately after deployment?
- Are all inherited contracts initialized?
- Can initialization be front-run?
- Are initialization parameters validated?
Initialization bugs are especially dangerous because they can effectively turn deployment into an ownership race.
4. Review Reentrancy
Reentrancy is one of the classic smart contract vulnerabilities, and it remains relevant today.
The basic problem occurs when a contract makes an external call before updating its own state.
Consider:
function withdraw(uint256 amount) external {
require(balance[msg.sender] >= amount);
payable(msg.sender).call{value: amount}("");
balance[msg.sender] -= amount;
}
An attacker-controlled contract can potentially call withdraw() again before the balance is updated.
The safer pattern is generally:
function withdraw(uint256 amount) external {
require(balance[msg.sender] >= amount);
balance[msg.sender] -= amount;
(bool success, ) =
payable(msg.sender).call{value: amount}("");
require(success);
}
This follows the Checks-Effects-Interactions pattern.
Ethereum's official security documentation still recommends this approach when dealing with external calls and reentrancy.
But don't only search for call()
Modern reentrancy reviews should also consider:
- ERC-777 hooks
- ERC-721 callbacks
- ERC-1155 callbacks
- ERC-4626 interactions
- Flash-loan callbacks
- DEX callbacks
- Cross-contract calls
- Read-only reentrancy
A contract does not need to explicitly call:
msg.sender.call(...)
to have a reentrancy problem.
The important question is:
"Can control flow leave the current contract before critical state is finalized?"
5. Review External Calls
External calls are trust boundaries.
Whenever your contract interacts with another contract, ask:
What contract am I calling?
Who controls it?
What can it call back?
What assumptions am I making about its return value?
Can it revert?
Can it consume excessive gas?
Can it manipulate state before returning?
For example:
token.transfer(user, amount);
is not necessarily equivalent to:
SafeERC20.safeTransfer(token, user, amount);
ERC-20 implementations are not always perfectly consistent.
Some tokens return false.
Some return no value.
Some have unusual transfer behavior.
Using battle-tested libraries reduces the amount of token-specific behavior your protocol needs to handle itself. Ethereum's security guidance recommends using well-tested libraries rather than repeatedly implementing common primitives from scratch.
6. Don't Assume Every ERC-20 Behaves the Same
One of the most common mistakes in DeFi development is treating ERC-20 as if every token follows exactly the same behavior.
Your contract may interact with tokens that have:
- Fee-on-transfer mechanics
- Rebasing
- Transfer restrictions
- Blacklists
- Pausable transfers
- Non-standard return values
- Decimals other than 18
- Upgradeable implementations
- Callback-like behavior
Consider:
uint256 before = token.balanceOf(address(this));
token.transferFrom(
msg.sender,
address(this),
amount
);
uint256 received = amount;
The contract assumes it received exactly amount.
That assumption may not always hold.
A safer accounting model may measure the actual balance difference:
uint256 before = token.balanceOf(address(this));
token.safeTransferFrom(
msg.sender,
address(this),
amount
);
uint256 received =
token.balanceOf(address(this)) - before;
Whether this approach is appropriate depends on the protocol's intended token model, but the important point is to make the assumption explicit.
7. Check Oracle Security
Oracle manipulation has become one of the most important DeFi attack surfaces.
OWASP's 2025 Smart Contract Top 10 ranks Price Oracle Manipulation #2.
Any protocol using prices should answer:
- Where does the price come from?
- Can users influence the price?
- Is the source based on a single pool?
- What liquidity does the pool have?
- Is the price based on spot or historical data?
- Is a TWAP being used?
- What happens when the oracle becomes stale?
- What happens if the oracle returns zero?
- What happens if the price moves significantly in one block?
A dangerous pattern is relying directly on a low-liquidity AMM spot price.
For example:
uint256 price =
reserve1 * 1e18 / reserve0;
If an attacker can temporarily move the reserves, the protocol may use a manipulated price.
The problem becomes much more serious when the manipulated price controls:
- Borrowing capacity
- Liquidation prices
- Collateral valuation
- Share minting
- Redemption amounts
- LP token valuation
8. Review Flash-Loan Attack Paths
Flash loans do not create vulnerabilities by themselves.
They make existing vulnerabilities much easier to exploit.
An attacker can borrow a large amount of capital, manipulate temporary protocol state, execute an exploit, and repay the loan within the same transaction.
When reviewing a DeFi protocol, ask:
"What can an attacker do if they temporarily have $100 million?"
Then test:
- Price manipulation
- Governance voting
- Collateral deposits
- Liquidations
- Reward calculations
- Share-price manipulation
- LP token pricing
- Reserve manipulation
- Temporary liquidity attacks
OWASP includes Flash Loan Attacks in its 2025 Smart Contract Top 10.
The important thing is not simply checking whether the protocol supports flash loans.
The real question is whether protocol logic incorrectly assumes that capital must come from the attacker's own wallet.
9. Check Accounting Invariants
Many serious DeFi vulnerabilities are not obvious "Solidity bugs".
They are accounting bugs.
For example:
shares = assets * totalShares / totalAssets;
may look mathematically correct.
But what happens when:
totalAssets = 0
or:
totalShares = 0
or:
assets * totalShares
overflows?
Even when Solidity prevents arithmetic overflow, incorrect ordering and rounding can still create exploitable economic behavior.
For every financial function, define the invariant first.
For example:
Total user balances <= protocol assets
or:
Shares minted <= proportional contribution
or:
Debt cannot become negative
Then test whether an attacker can violate the invariant.
This approach is often more productive than simply searching for known vulnerability patterns.
10. Pay Special Attention to ERC-4626 Vaults
ERC-4626 vaults deserve their own checklist.
Review:
deposit()
mint()
withdraw()
redeem()
previewDeposit()
previewMint()
previewWithdraw()
previewRedeem()
convertToShares()
convertToAssets()
Then compare the accounting between these functions.
Important questions include:
- Can the first depositor manipulate the share price?
- Can donations change the exchange rate?
- Are previews consistent with execution?
- Are rounding directions correct?
- Can assets be lost due to rounding?
- Are fees included consistently?
- Can
totalAssets()be manipulated? - Can external yield change the accounting unexpectedly?
- Are pending withdrawals handled correctly?
For vaults, it is often useful to test sequences rather than individual calls:
deposit
→ donate
→ deposit
→ withdraw
→ redeem
Many accounting vulnerabilities only appear after several state transitions.
11. Validate All User Inputs
Never assume users will provide reasonable parameters.
Check:
require(amount > 0);
require(receiver != address(0));
require(deadline >= block.timestamp);
require(price > 0);
depending on the application.
Look for:
- Zero addresses
- Zero amounts
- Extremely large values
- Duplicate IDs
- Invalid enum values
- Expired deadlines
- Invalid signatures
- Invalid array lengths
- Unexpected array ordering
- Unsupported tokens
- Unsupported chains
OWASP's 2025 ranking places Lack of Input Validation at #4.
A good rule is:
Validate assumptions at the boundary where the value enters the system.
Don't allow invalid state to propagate through several contracts and only fail later.
12. Check Signature Verification
Protocols using off-chain signatures should review:
Who signed?
What exactly was signed?
Which chain?
Which contract?
What nonce?
What deadline?
Can the signature be replayed?
For example, a signed message should generally bind itself to the intended domain.
Important parameters often include:
chainId
verifyingContract
nonce
deadline
Without replay protection, a valid signature can potentially be reused.
Also review:
- EIP-712 domain separation
- ERC-1271 contract signatures
- Signature malleability
- Nonce management
- Deadline handling
- Cross-chain replay
Do not assume:
ecrecover(...)
alone provides a complete signature-security model.
13. Be Careful With tx.origin
Avoid using:
require(tx.origin == owner);
for authorization.
tx.origin represents the original transaction initiator, not necessarily the contract directly interacting with your contract.
For access control, msg.sender is normally the appropriate primitive:
require(msg.sender == owner);
Even then, the overall permission architecture still needs to be reviewed.
14. Review Denial-of-Service Risks
Security is not only about stealing money.
An attacker may also make an important function unusable.
Common examples include:
for (uint256 i = 0; i < users.length; i++) {
...
}
If users.length can grow without bounds, eventually the function may exceed the block gas limit.
Review:
- Unbounded loops
- Large arrays
- User-controlled iteration
- Failed external calls
- DoS through reverting receivers
- Forced ETH
- Storage growth
- Queue processing
OWASP includes Denial of Service in its smart contract security categories.
15. Don't Ignore selfdestruct, ETH Transfers, and Forced ETH
Developers sometimes assume:
address(this).balance
exactly represents the ETH deposited through the protocol.
That assumption can be wrong.
ETH can be forced into a contract through mechanisms outside the normal deposit flow.
Therefore, accounting should generally not depend blindly on:
address(this).balance
when exact internal accounting is required.
Instead, distinguish between:
Protocol accounting
and:
Actual blockchain balance
and explicitly decide how unexpected funds should be handled.
16. Review Compiler and Dependency Versions
The Solidity version matters.
Before deployment, check:
Solidity version
OpenZeppelin version
External libraries
Compiler settings
Optimizer settings
Proxy tooling
Build configuration
Do not blindly copy an old contract and change:
pragma solidity ...
without understanding the differences between compiler versions.
Also avoid unnecessary custom implementations of common primitives.
For example, if a well-tested library already provides:
ERC20
AccessControl
SafeERC20
ReentrancyGuard
ECDSA
Math
there is usually little value in rewriting them unless there is a specific reason.
Ethereum's security guidance similarly recommends well-tested libraries and dependency management.
17. Write Tests for Attackers, Not Just Users
A normal unit test might look like:
Alice deposits 100 ETH
Alice withdraws 100 ETH
That proves the happy path works.
Security tests should look more like:
Attacker deposits 1 wei
Attacker manipulates price
Attacker calls function twice
Attacker uses a flash loan
Attacker passes address(0)
Attacker reenters callback
Attacker uses an expired signature
Attacker calls function from an unauthorized account
The difference is important.
A good security test suite should deliberately try to break the protocol.
18. Test Invariants and Properties
Unit tests answer:
"Does this example work?"
Invariant tests answer:
"Can this important property ever become false?"
For example:
sum(userBalances) <= totalAssets
or:
totalSupply cannot decrease unexpectedly
or:
only authorized addresses can modify protocol parameters
Tools such as fuzzing and invariant testing can explore many combinations that manually written test cases will never cover.
Ethereum's security guidance specifically recommends extensive testing and tools such as Slither, Echidna, and Manticore as part of the security process.
19. Run Static Analysis — But Don't Stop There
Static analyzers are useful for catching common patterns.
For example:
slither .
can identify issues involving:
- Reentrancy
- Dangerous calls
- Access control
- Unused return values
- Shadowing
- Incorrect Solidity patterns
- Uninitialized variables
But a clean Slither report does not mean the protocol is secure.
Static analysis is much better at finding:
known code patterns
than:
incorrect business logic
A contract can pass static analysis and still have a critical economic vulnerability.
20. Perform Manual State-Transition Analysis
This is where experienced security review becomes especially valuable.
Instead of reading functions independently, follow how state changes.
For example:
deposit()
↓
mint shares
↓
accrue yield
↓
borrow()
↓
liquidate()
↓
repay()
↓
withdraw()
Ask:
"Can I reach an unexpected state by combining these functions?"
Many vulnerabilities exist not inside one function, but in the interaction between several apparently safe functions.
This is especially important for:
- Lending protocols
- Staking systems
- Vaults
- AMMs
- Restaking protocols
- Governance systems
- Cross-chain bridges
21. Review Deployment Configuration
A secure contract can still be deployed insecurely.
Before mainnet deployment, verify:
- Correct chain ID
- Correct token addresses
- Correct oracle addresses
- Correct admin address
- Correct proxy implementation
- Correct initialization parameters
- Correct fee configuration
- Correct multisig
- Correct ownership transfer
- Correct pause configuration
A surprisingly common failure mode is deploying correct code with incorrect parameters.
Create a deployment checklist and verify every address before sending the transaction.
22. Verify Contracts on the Block Explorer
After deployment:
Deploy
↓
Initialize
↓
Verify source
↓
Check ownership
↓
Check implementation
↓
Check roles
↓
Check configuration
Source verification makes the deployed bytecode easier to inspect and allows other researchers to review the actual production contract.
Do not assume the deployment script succeeded simply because the transaction succeeded.
Verify the resulting state on-chain.
23. Monitor the Contract After Deployment
Security does not end when the contract is deployed.
Monitor events and important state changes such as:
OwnershipTransferred
RoleGranted
RoleRevoked
Upgraded
Paused
Unpaused
OracleUpdated
FeeUpdated
Large withdrawals
Large deposits
Unexpected transfers
Ethereum's security guidance also recommends monitoring deployed contracts and having an incident-response plan.
A vulnerability discovered quickly is much easier to contain than one discovered after an attacker has drained the protocol.
24. Keep an Emergency Response Plan
Every protocol handling meaningful value should know what happens after an exploit.
For example:
Suspicious transaction
↓
Alert
↓
Security team investigation
↓
Pause if appropriate
↓
Identify affected contracts
↓
Assess attacker access
↓
Contact infrastructure / exchanges / security teams
↓
Prepare remediation
The exact process depends on the protocol.
The important thing is to decide before the incident happens.
A Practical Pre-Deployment Checklist
Here is a shorter version you can keep next to your codebase.
Architecture
- [ ] Attack surface documented
- [ ] Trust assumptions documented
- [ ] External dependencies identified
- [ ] Upgrade architecture reviewed
- [ ] Admin architecture reviewed
Access Control
- [ ] All privileged functions identified
- [ ]
onlyOwner/ roles verified - [ ] Role administrators reviewed
- [ ] Ownership transfer tested
- [ ] Multisig configuration verified
- [ ] Timelock considered for sensitive operations
DeFi Logic
- [ ] Oracle manipulation tested
- [ ] Flash-loan attack paths tested
- [ ] Liquidation logic tested
- [ ] Interest calculations tested
- [ ] Share accounting tested
- [ ] Rounding reviewed
- [ ] Donation attacks considered
Token Handling
- [ ] ERC-20 return values handled
- [ ] Fee-on-transfer behavior considered
- [ ] Rebasing tokens considered
- [ ] Decimals handled correctly
- [ ] Zero address checks implemented
- [ ] Token approval flow reviewed
External Calls
- [ ] Reentrancy reviewed
- [ ] Callbacks reviewed
- [ ] Return values checked
- [ ] Untrusted contracts identified
- [ ] DoS through external calls considered
Upgradeability
- [ ] Proxy admin secured
- [ ] Implementation verified
- [ ] Initializer protected
- [ ] Storage layout checked
- [ ] Upgrade authorization tested
- [ ] Upgrade events monitored
Testing
- [ ] Unit tests
- [ ] Fuzz tests
- [ ] Invariant tests
- [ ] Negative tests
- [ ] Access-control tests
- [ ] Reentrancy tests
- [ ] Economic attack tests
- [ ] Static analysis
Deployment
- [ ] Correct chain
- [ ] Correct addresses
- [ ] Correct oracle
- [ ] Correct admin
- [ ] Correct initialization
- [ ] Contract verified
- [ ] Roles verified
- [ ] Monitoring enabled
- [ ] Incident response prepared
The Most Important Part of Smart Contract Security
A checklist is useful, but it should not become a substitute for understanding the protocol.
The most dangerous bugs are often not obvious Solidity mistakes.
They are usually assumptions such as:
"This price cannot be manipulated."
"This function can only be called by the frontend."
"The token always transfers exactly the requested amount."
"Nobody can borrow enough money to do this."
"The owner would never abuse this function."
"This callback cannot reenter."
"This value can never be zero."
"This contract will always be initialized correctly."
An attacker does not need to respect these assumptions.
They only need to find one that the code fails to enforce.
That is why a strong smart contract security review combines automated analysis, adversarial testing, economic reasoning, and manual code review.
Final Thoughts
Smart contract security is not a single step at the end of development.
It starts with architecture, continues through implementation and testing, and remains important after deployment.
The current security data makes that clear. OWASP's 2025 Smart Contract Top 10 puts access control, oracle manipulation, logic errors, input validation, and reentrancy among the major smart contract risks.
For developers, the practical approach is straightforward:
Define the security assumptions.
Turn those assumptions into invariants.
Try to break those invariants.
Then verify the implementation and deployment configuration.
A smart contract should not be considered secure simply because it compiles, passes unit tests, or has no obvious static-analysis warnings.
The real question is much harder:
Can an untrusted user make the protocol enter a state that its designers never intended?
That is the question a good security review should answer.
References
- OWASP Smart Contract Top 10 — 2025
- Ethereum Foundation — Smart Contract Security
- OpenZeppelin Contracts — Access Control
- Immunefi — Crypto Losses 2024 Report
- CertiK — Hack3d Web3 Security Report 2025
Top comments (0)