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.
Before deployment, make sure you can answer “yes” to the following questions:
The sections below explain why each item matters.
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:
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.
Review every function that can:
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.
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:
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.
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:
Initialization bugs are especially dangerous because they can effectively turn deployment into an ownership race.
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.
call()Modern reentrancy reviews should also consider:
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?“
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.
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:
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.
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:
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:
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:
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.
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.
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:
totalAssets() be manipulated?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.
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:
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.
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:
Do not assume:
ecrecover(...)
alone provides a complete signature-security model.
tx.originAvoid 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.
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:
OWASP includes Denial of Service in its smart contract security categories.
selfdestruct, ETH Transfers, and Forced ETHDevelopers 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.
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.
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.
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.
Static analyzers are useful for catching common patterns.
For example:
slither .
can identify issues involving:
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.
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:
A secure contract can still be deployed insecurely.
Before mainnet deployment, verify:
A surprisingly common failure mode is deploying correct code with incorrect parameters.
Create a deployment checklist and verify every address before sending the transaction.
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.
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.
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.
Here is a shorter version you can keep next to your codebase.
onlyOwner / roles verifiedA 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.
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?
UNCX's token vesting contract lost roughly $87.7K when an attacker exploited a share-accounting flaw in release(), topUpVesting(), and cancel(). We break down the root cause, the exact attack sequence, and what builders should take away from it — without the jargon.
Autheo’s THEO token lost 92% after attackers used a Hyperlane TrustedRelayerIsm misconfiguration to mint 19 million unbacked tokens on Base and extract roughly $1.76 million. Here’s how the attack worked and why single-key security models remain dangerous for cross-chain assets.
Uniswap v4 hooks explained: the PoolManager singleton, flash accounting, and what the Cork and Bunni exploits teach us about hook security. A PandaSecurity deep dive.
An attacker forced a Uniswap V4 vault into a zero-liquidity state, redeemed one minimal share unit, and withdrew roughly 14,838.47 DAI. Panda Security breaks down the transaction, vulnerable code, root cause, and practical fixes.