Solidity Security Best Practices: 9 Keys
Quick answer. Write less code, design before you build, allow only expected interactions and handle every case. Then avoid for loops, never use parallel data structures, scrutinize external calls, add post checks and document user flows.
Solidity security best practices start long before the audit. How you develop decides whether your contracts get hacked, and it also decides how fast you ship and what your security process costs.
The right approach can make a six-figure difference in the security process. In a protocol hack, it can make a seven, eight, nine or even ten-figure difference.
This guide covers nine habits from years of security reviews. The first two are about the shape of the codebase. The other seven are line-by-line practices you can apply today.
Shape the Codebase First
1. Write less code
This one sounds obvious, but it is the biggest lever you have. It cuts critical bugs, shortens the security process and lets you ship faster.
In our experience, most deployed codebases could be cut in size by 50% or more. That is a lot of complexity nobody needed.
Complexity is where criticals live. Security researchers love complexity, because that is where they find bugs. The most complex, verbose and unreadable parts of a codebase are almost always where the critical bugs sit.
So make multiple passes on your code. Make it as readable as possible, tone down the verbosity and ask what the simplest design for the job would be.
There are many ways to make a contract do what you want. The most valuable one is the least complex, the most readable and the shortest.
Do not chase the literal minimum line count, though. Code squeezed too tight becomes less readable and more complex, so find the balance between the three.
Less code costs less to audit. Beyond avoiding criticals, a smaller codebase is directly cheaper to audit. Most firms quote by the week, based on how many weeks the project will take.
Here is the rule of thumb as it stood at the time of recording (2024). Firms often charged $20,000 to $30,000 per week and reviewed roughly 1,000 normalized lines of Solidity per week.
Normalized lines exclude comments and trivial lines, with functions collapsed. Take a project of about 2,000 to 2,100 normalized lines at $25,000 a week:
- As written: about two weeks, or roughly $50,000.
- Cut by about 30%, to 1,400 or 1,500 lines: about a week and a half, or $37,500.
- At a firm that bills in whole weeks: possibly one week, or $25,000.
Dense complexity can push the real number up or down.
The point stands: simpler code is faster to review, so it costs less, and it saves headaches in later development too.
For how audit pricing compares across firms, see audit firm pricing compared.
Start with your storage variables. The quickest way to write less code is to be very picky about storage. Use only the storage variables you must have.
Never track one value with two different storage variables. Rule 6 comes back to this.
2. Think through the design
Spend real time on design before you implement. In traditional software, it is common to pick the first way that works and head straight down that path.
Instead, stop and ask what the simplest, most approachable design is. Weigh security and gas costs as you consider every option in front of you.
This slows down development at first, which can be frustrating.
The time spent up front saves an exponential amount of time, money and possibly reputation compared with reaching deployment with a critical bug.
Expect to rewrite and redesign a few times before you land on the simplest, most readable design.
A few things help here:
- Design for every feature at once, including ones not built yet. A perpetuals exchange needs borrowing fees, funding fees and perhaps price impact, plus liquidations and opening, closing and decreasing positions. Pick the design that accommodates all of them.
- Develop away from the keyboard. At this stage, you should often spend more time thinking about the system than typing it out.
Control Inputs and Edge Cases
3. Allow only expected interactions
Now the line-by-line practices. The first is to let users interact with the protocol exactly how you expect, and no other way.
Validate every input parameter against the range you expect. If zero is not a valid value, reject it.
It is tempting to think zero is harmless there. It is fine until it isn't, so build the discipline to validate inputs every time.
Picture a function as a set of paths. Input zero takes one path, input one takes another, and the maximum value takes yet another.
Across 20 or 30 functions, one of those odd paths can create a small unexpected state. That state may be the only knob an attacker needs to build a catastrophic exploit.
So narrow the possible paths to only what you expect. Explicitly disallow unexpected inputs and interactions, and cut off unexpected states at the root.
4. Handle all cases
This is the other half of rule 3. Even with tight inputs, complex DeFi protocols will hit edge cases that are possible by design, and you must handle or mitigate them.
Insolvent liquidations are the classic example. A protocol with liquidations uses an LTV ratio or a maximum leverage threshold so positions are liquidated while still solvent, leaving no bad debt.
Usually that works. But an account can become insolvent before anyone liquidates it.
The unhandled answer is a liquidation that reverts because the debt cannot be repaid in full.
A handled answer might liquidate whatever is left and have a plan for the bad debt.
Expect the unexpected. If you have liquidations, make sure insolvent liquidations still work. For more on this area, see liquidation vulnerabilities.
Keep Loops and State Simple
5. Avoid for loops
Junior Solidity developers often use for loops too liberally. Loops usually travel with deeper design mistakes, such as pushing tokens to addresses instead of letting them pull.
Design with the EVM in mind. Gas is a constraint, and external calls or token transfers inside a loop carry their own risks.
Avoid for loops wherever possible. Never loop over an unbounded array, such as a list of users, unless a central party controls its size.
There is almost always a closed-form design instead:
- Rewards: rather than looping over users to distribute rewards, use a reward-per-share model.
- Global totals: rather than summing every position's open interest, have each position track its own and keep running totals for longs and shorts in storage.
This ties back to rule 2: much of your design time should go into making things closed form, with no unbounded gas cost.
Scrutinize every loop and ask whether a better design can replace it.
6. Never use parallel data structures
Never duplicate one value across two storage variables. A list and a mapping that must stay in sync are the common form.
Guardian's CEO learned this the hard way, on a DeFi protocol he launched as a junior Solidity developer.
Upgradeable contracts were less common then, so the team moved all funds out of an old vault strategy into a new one.
Users had to request withdrawals and wait about five days, because of the third-party projects the vault integrated.
So the protocol kept a list of users waiting to withdraw, in case their funds had to be sent to them directly.
It also kept a mapping from each user to a withdrawal struct. That struct stored the user's index in the list.
On completion, the code did the classic Solidity removal: move the last entry into the removed slot, then pop the end. It never updated the moved user's stored index.
struct Withdrawal { uint256 index; uint256 amount; }
address[] public pending;
mapping(address => Withdrawal) public withdrawals;
function _removePending(address user) internal {
uint256 i = withdrawals[user].index;
address last = pending[pending.length - 1];
pending[i] = last;
withdrawals[last].index = i; // the missing line
pending.pop();
delete withdrawals[user];
}
The real bug was less obvious than this, with more moving parts.
It passed testing and a security review, then left the vault in a state where nobody could withdraw, and the team had to work out how to rescue the funds.
Whenever you find yourself keeping parallel data structures, be extremely careful. Look hard for a better design, because there usually is one.
External Calls and Post Checks
7. Scrutinize external calls
In our experience, external calls and external dependencies are the number one source of vulnerabilities. They are the most adversarial lines of code you will write.
Check every external call for:
- Denial of service: can the call burn so much gas the transaction runs out, or simply revert and block something important?
- Gas griefing: can it force the caller to spend far more gas than expected?
- Unexpected return data: can it return a value that causes a logic error? Can it return so much data that a return data bomb eats the gas, which is also gas griefing and possibly denial of service?
- Reentrancy: can it re-enter the same function, another function on the same contract, another contract in the system, or another system that relies on this one's state (read-only reentrancy)?
Where you can, sandbox external calls. Prefer pull over push: rather than sending tokens to a list of arbitrary addresses, let each address claim in its own transaction.
For deeper coverage, see external call attacks and reentrancy attacks explained.
8. Build in post checks
Post checks are invariant checks written into the contracts themselves. They can stop an attack you never saw coming.
GMX V2 is a good model.
Its market token contract holds ERC-20 balances, and the accounting splits each balance across many variables: position impact pool, swap impact pool, collateral, funding fees and more.
All of those are derived from what the contract actually holds. So at the end of every action, the protocol asserts that their sum never gets out of line with the token balance.
Because of rounding, the check is not strict equality. The sum of accounted amounts must be less than or equal to the real balance.
error InvalidBalance();
function _validateBalance(IERC20 token) internal view {
uint256 accounted = poolAmount[token]
+ collateralAmount[token]
+ impactPoolAmount[token]
+ fundingFeeAmount[token];
if (accounted > token.balanceOf(address(this))) revert InvalidBalance();
}
In early reviews of GMX V2, a few specific scenarios double counted one of these values, which would break the invariant. With the post check in place, any such path simply reverts.
Across Guardian's reviews of GMX V2, no one found a way to break it. Even if someone did, the transaction would revert.
Post checks are a last line of defense when the rest of your security process fails. They also make your invariants explicit to anyone reviewing the code.
An aside on rounding. When precision loss is unavoidable, always round in favor of the protocol, never the user. Tiny rounding errors, abused over and over, have let attackers walk away with millions.
This matters most for novel concentrated liquidity protocols and any complex accounting system.
Rounding toward the protocol is also why the post check above uses "less than or equal": the contract keeps a little more than it accounts for. See rounding errors in Solidity.
Document for Reviewers
9. Write real documentation
Documentation is the least appreciated item on this list. It pays off most in the security review.
Some auditors refuse to read docs so they inherit no developer bias.
There is some validity to that, but far more value in knowing what the developers think and assume, so you can test whether those assumptions hold.
Docs also speed up understanding. Understanding the protocol deeply is the biggest bottleneck in web3 security research.
Good documentation includes:
- NatSpec comments.
- Explanations of formulas in the code.
- Notes on non-obvious lines, interesting decisions and unique behavior.
- An enumeration of user flows.
The last one is rare and very valuable. List the jobs the application does. For a perpetuals protocol, that might be deposit collateral, open position, close position, liquidate and borrowing fees.
Then walk through how a user's transaction achieves each one in the contracts. Blueberry Finance's docs are a strong example, with a summary, protocol components and a component interaction flow section.
That knowledge is already in your head. Written down, it scales to every reviewer, integrator and builder on your protocol, which makes it a high-leverage task.
The Nine Keys Checklist
- Cut code and complexity, starting with storage variables.
- Design for every feature before implementing, and expect to redesign.
- Validate every input and allow only expected interactions.
- Handle every possible case, including insolvent liquidations.
- Replace for loops with closed-form designs, and never loop over unbounded arrays.
- Never duplicate a value across parallel data structures.
- Check every external call for denial of service, gas griefing, return data and reentrancy, and prefer pull over push.
- Add post checks for your core invariants, and round in favor of the protocol.
- Document formulas, decisions and every user flow.
When you are ready for review, see how to prepare for a smart contract audit.
How Guardian Helps
Guardian's smart contract audit starts with a design review, so architectural risk is resolved before the code hardens. We then build invariant harnesses around solvency, accounting and rounding, and senior researchers attack what both leave standing.
Frequently Asked Questions
What is the most important Solidity security best practice?
Write less code. Complexity is where critical bugs hide, and most deployed codebases could be much smaller. Less code also means a faster, cheaper audit, since firms price reviews by how many weeks the code takes to cover.
Why avoid for loops in Solidity?
Loops cost gas, and loops over unbounded arrays such as user lists can run out of gas or carry external call risks. Closed-form designs, such as a reward-per-share model or running totals in storage, avoid the loop entirely.
What is a post check in a smart contract?
A post check is an invariant check that runs at the end of each action. For example, the sum of every accounted token amount must not exceed the contract's real balance. If it does, the transaction reverts.
Should smart contracts round in favor of the user or the protocol?
Always round in favor of the protocol. Rounding toward the user lets attackers repeat tiny rounding errors until they add up to large losses, especially in concentrated liquidity and other complex accounting systems.
Sources
Get your design reviewed with Guardian
Guardian reviews your design, builds invariant suites around it and attacks the code before it ships.