Smart contract audit checklist: readiness and security checks
What to prepare before a smart contract audit, the checks to run for each class in the OWASP Smart Contract Top 10: 2026, what Slither, fuzzers and provers catch and miss, and what to do after the report.
- Written by
- Quantix Research, Smart Contract Security
- Published
- Reading time
- 11 minutes
- Category
- Smart Contract Vulnerabilities
Primer. Explains a known class of issue. Code is simplified and illustrative, not for production use.

Contents
An audit applies a fixed amount of expert attention to a fixed version of the code. How much of it reaches real problems depends on what the team hands over, whether the predictable classes of bug were checked first, and what happens after the report.
This checklist covers all three, for teams preparing for a review and for reviewers working through one. Its security checks follow the OWASP Smart Contract Top 10: 2026, which OWASP derives from security incidents and survey data collected during 2025.
Audit readiness: before the review starts
Reviewers need these on the first day; each missing item turns review time into reconstruction. They feed the first three stages of the Quantix seven-stage workflow.
- Freeze a commit and list the files in scope with their nSLOC. Findings and fixes only mean something against one commit; if code must change mid-review, agree a new one. Reviewers size the effort from nSLOC (normalised source lines: no comments or blank lines, multi-line function headers counted once) and query anything outside scope that moves value.
- Make the build and tests run from a clean checkout. Hours spent repairing a build are not spent reviewing. Reviewers read tests as intended behaviour and write proofs of concept in the same framework.
- Report coverage and include invariant tests. Coverage shows paths nobody executed; invariants are the nearest thing to a specification. Reviewers start from uncovered branches and extend the suite. A covered line can still be wrong.
- Document every role and what it can do. Give each role’s holder (key, multisig, timelock, governance), powers and delay; without this, a missing check looks like a design decision. Reviewers compare it with the code, starting from Slither’s
vars-and-authprinter. - Write down the deployment and upgrade plan. Deployments fail too: an uninitialised implementation, an initialiser left to a separate, front-runnable transaction, a wrong parameter. Reviewers read the scripts and initial values, and compare storage layouts for upgrades.
- Keep a known-issues list. It saves rediscovery and makes accepted risks explicit; reviewers still check each entry, since a known issue can be worse than described.
- State the threat model and trust assumptions. Who is trusted (administrators, keepers, oracles, bridges), what each could do if compromised, what an outsider controls. Reviewers prioritise from it and test each assumption.
- Pin dependencies and state your assumptions about them. Contracts inherit the behaviour of what they call. Reviewers diff forks against upstream and test assumptions such as “this token charges no transfer fee”.
- Lock the compiler settings. Version, optimiser runs,
via-irand EVM version all change the bytecode. Reviewers review the build that ships, check the version against Solidity’s list of known compiler bugs and confirm each target chain supports the EVM version.
Two files make items 1, 2, 3 and 9 checkable:
# scope.txt for a model vault protocol (illustrative)commit <full 40-character commit hash>compiler solc 0.8.28, optimizer 200 runs, via-ir off, evm cancunin scope src/Vault.sol 212 nSLOC src/Strategy.sol 148 nSLOC src/libraries/FeeMath.sol 41 nSLOC total 401 nSLOCdeployment script/Deploy.s.sol (reviewed with the deployment plan)out of scope test/, lib/ (versions pinned in DEPENDENCIES.md)documents ROLES.md, THREAT_MODEL.md, KNOWN_ISSUES.md# Illustrative: from an empty directory, at the commit in scope.git clone --recurse-submodules <repository-url> protocol && cd protocolgit checkout <commit-hash> && git submodule update --init --recursiveforge build # compiler and settings come from foundry.tomlforge test # everything passes; any skipped test is explainedforge coverage --report summary # coverage per file, shared with the reviewersAn audit request asks for most of this package.
The checklist at a glance
SC01 to SC10 are the OWASP 2026 classes; the last three rows are classes the list does not name.
| Class | First check | Tools that help | In depth |
|---|---|---|---|
| SC01 Access control | Each state-changing function checks its caller. | vars-and-auth, unprotected-upgrade; fuzzing with any caller | Access control; initializers |
| SC02 Business logic | Each promise to users runs as an invariant. | Invariant fuzzing; formal verification | Writing invariants; liquidation risk |
| SC03 Price oracle manipulation | Nothing is valued from a same-transaction spot price. | Fork tests that move the price | Oracle designs; manipulation cost |
| SC04 Flash loan–facilitated attacks | No check assumes attackers lack capital. | Fuzzing with very large balances | Flash loan attacks; governance |
| SC05 Lack of input validation | Parameters are bounded; signatures name chain, contract and nonce. | missing-zero-check; negative fuzz tests | Signature replay; Solana accounts |
| SC06 Unchecked external calls | Every low-level call’s result is checked. | unchecked-lowlevel, unchecked-transfer, unused-return | OWASP SC06; token checklist |
| SC07 Arithmetic errors | Multiply before dividing; round for the protocol. | divide-before-multiply; dust and maximum-value fuzzing | ERC-4626 inflation |
| SC08 Reentrancy | State is final before any external call. | reentrancy-eth, reentrancy-no-eth | Read-only reentrancy |
| SC09 Integer overflow and underflow | Each unchecked block, downcast and shift is justified. | SMTChecker overflow targets; boundary fuzzing | Solidity: checked arithmetic |
| SC10 Proxy and upgradeability | Implementations are locked; storage layouts stay compatible. | slither-check-upgradeability; OpenZeppelin validateUpgrade | Proxy security; initializers |
| Denial of service | No loop runs over a list users can grow. | calls-loop, costly-loop; gas reports | Solidity: gas limit and loops |
| Transaction ordering | Price-sensitive calls take a minimum output and a deadline. | Fork tests that sandwich the call | MEV and protocol risk |
| On-chain randomness | No value is derived from block data. | weak-prng | Solidity: randomness |
Security checks by vulnerability class
SC01 Access control
Every privileged function is a key to the system; one missing modifier hands it to anyone. In depth: access control.
- List each state-changing function with its allowed role; test that every other caller reverts.
- Authorise with
msg.sender, nevertx.origin. - Put upgrades, parameter changes and treasury movements behind a timelock or multisig, not one key.
SC02 Business logic
Code can match no known pattern and still pay the wrong amount; only a statement of intent exposes it. In depth: writing invariants; for lending, liquidation risk.
- Write each promise to users as a sentence, then as an invariant.
- Map which actions each state allows, and how each state is reached.
- Test accruals across long gaps, pauses and parameter changes, and at zero and maximum amounts.
SC03 Price oracle manipulation
A price that can be moved inside one transaction is a price the attacker chooses. In depth: oracle designs and what it costs to move a price.
- Never value collateral from pool reserves or share prices read in the same transaction.
- For push feeds, check staleness, decimals, non-positive answers and rollup sequencer status.
- Compare each source’s cost of manipulation with the value it controls.
SC04 Flash loan–facilitated attacks
Flash loans lend anyone, for one transaction, whatever lenders hold, so checks that assume attackers lack capital fail. In depth: flash loan attacks, and governance attacks for borrowed votes.
- Read balances, votes and prices from past snapshots wherever a same-transaction value could be borrowed.
- In flash-loan callbacks, check both the lender (
msg.sender) and the initiator. - Fuzz with balances far larger than any real user’s.
SC05 Lack of input validation
OWASP’s entry covers parameters, addresses, calldata, signed payloads and cross-chain messages. In depth: signature replay, approval phishing, Solana account validation and bridge withdrawal limits.
- Bound every fee, rate and duration, including those only administrators set; reject harmful zero addresses.
- Allowlist caller-supplied tokens, pools and routers.
- Bind signatures to chain, contract, nonce and deadline, and messages to source chain and sender.
SC06 Unchecked external calls
A failed low-level call returns false instead of reverting, and the Solidity documentation notes that call, delegatecall and staticcall return true when the account called does not exist. Some tokens return false, others nothing. OWASP SC06 is the reference, and the token security checklist covers token behaviours.
// Simplified, illustrative. Not production code.function payOut(address to, uint256 amount) internal { to.call{value: amount}(""); // result ignored: a failed payment is silent} function payOutChecked(address to, uint256 amount) internal { (bool ok, ) = to.call{value: amount}(""); if (!ok) revert PaymentFailed(to, amount);} function sendToken(IERC20 token, address to, uint256 amount) internal { token.safeTransfer(to, amount); // SafeERC20: reverts if the token returns false}- Check the success flag of every low-level call, and every return value.
- Check that the target has code wherever success is taken as proof that something ran.
- Use OpenZeppelin’s
SafeERC20for tokens, as OWASP recommends, and prefer pull payments.
SC07 Arithmetic errors (rounding and precision)
Integer division truncates. An attacker repeats a small truncation, or makes one unit valuable enough for it to matter. In depth: ERC-4626 inflation attacks.
Take a 30-basis-point fee, amount × 30 / 10,000, on 19,999 units. Multiplying first: 19,999 × 30 = 599,970, and 599,970 / 10,000 = 59.997, truncated to 59. Dividing first: 19,999 / 10,000 truncates to 1, and 1 × 30 = 30, about 49% less (29 / 59, rounded for display). Even in the right order, amounts up to 333 units pay nothing (333 × 30 = 9,990, below 10,000); rounding the fee up charges 1.
- Multiply before dividing; use a full-precision
mulDivwhere the product could overflow. - Round every conversion in the protocol’s favour, and test the direction.
- Fuzz with dust, maximum values and many repeated small operations.
SC08 Reentrancy
An external call hands control to code you do not control, which can re-enter before your state is consistent or let another protocol read it mid-update. In depth: read-only reentrancy.
- Update state before external calls, and guard functions that share state, across contracts too.
- Treat ether transfers, token hooks and receiver callbacks as external calls.
- Check that view functions other protocols read cannot return mid-update values.
SC09 Integer overflow and underflow
Since Solidity 0.8.0, overflow and underflow revert with Panic(0x11). The Solidity documentation lists the gaps: unchecked blocks wrap, shifts are never checked, and explicit conversions truncate. OWASP adds assembly and code compiled before 0.8.0, where overflow is silent.
// Simplified, illustrative. Not production code.function wraps() external pure returns (uint8) { uint8 x = 250; unchecked { return x + 10; } // 260 does not fit in uint8: returns 260 - 256 = 4} // without unchecked: reverts with Panic(0x11) function narrow(uint256 amount) external pure returns (uint128) { return uint128(amount); // truncates silently: 2**128 + 5 becomes 5} function narrowSafe(uint256 amount) external pure returns (uint128) { return SafeCast.toUint128(amount); // reverts if amount > type(uint128).max}- Justify each
uncheckedblock in a comment and test its boundaries. - Replace downcasts that are not provably in range with OpenZeppelin’s
SafeCast. - Check shifts, assembly and pre-0.8 code by hand. Outside the EVM, check the language’s default; OWASP notes that some wrap and some abort (see the Solana and Move primers).
SC10 Proxy and upgradeability
A proxy separates the address users trust from the code behind it, and every upgrade can break the agreement. In depth: upgradeable proxy security and initializers and proxies.
- Lock each implementation with
_disableInitializers()in its constructor; deploy and initialise proxies in one transaction. - Before each upgrade, compare storage layouts: append only, or use ERC-7201 namespaces.
- Check who can upgrade, behind which timelock, and that every upgrade raises an alert.
Classes the 2026 list does not name
The 2026 list has no entry for denial of service, transaction ordering or randomness, though its 2025 edition listed the first and the last.
Denial of service through unbounded loops
The Solidity documentation warns that a loop whose length depends on storage can outgrow the gas a transaction may use. On Ethereum, EIP-7825 caps a transaction at 224 = 16,777,216 gas. Suppose a gas report puts one iteration at 5,000 gas: the loop alone reaches the cap after 16,777,216 / 5,000 = 3,355.4 iterations, so at most 3,355 members fit, before anything else the transaction does. If joining is cheap, anyone can make the function unusable.
// Simplified, illustrative. Not production code.address[] public members; // anyone can join: no bound on length function distribute() external onlyOwner { // gas grows with members.length for (uint256 i; i < members.length; i++) { token.safeTransfer(members[i], shareEach); // one reverting recipient blocks everyone }} // Fix: credit a bounded batch per call; each member then withdraws their own balance.function creditBatch() external onlyOwner { uint256 end = Math.min(cursor + MAX_BATCH, members.length); for (uint256 i = cursor; i < end; i++) owed[members[i]] += shareEach; cursor = end;}- Bound loops over storage that users can grow, or process them in batches.
- Credit balances and let recipients withdraw, so one failing recipient cannot block the rest.
- Measure gas per iteration and set bounds from each target chain’s limit.
Front-running and transaction ordering
Pending transactions are public and their order is for sale, so price-sensitive or first-come actions can be sandwiched or pre-empted. In depth: MEV and protocol risk.
- Give every swap, deposit and withdrawal a minimum output and a deadline.
- Use commit–reveal where a transaction’s content is itself valuable.
On-chain randomness
The Solidity documentation notes that all contract data is public, private variables included, and that randomness is hard to produce without letting block builders cheat.
- Derive nothing of value from block data; use a verifiable randomness service or commit–reveal.
What tools catch, and what they miss
Each technique finds violations of something written down: a detector’s pattern, a fuzzer’s property, a prover’s specification. None knows what the code is for.
| Technique | Catches | Misses |
|---|---|---|
| Static analysis | Known code shapes, in seconds: reentrancy orderings, ignored return values, unprotected upgrades, calls in loops. | Intent, economics, oracle and governance assumptions, deployed integrations. Every result needs triage. |
| Fuzzing | Violations of written properties, over call sequences nobody wrote by hand. | Bugs that break no written property; states the harness never reaches. |
| Formal verification | Violations for every input and, with an unbounded engine, every transaction sequence in the model. | Unwritten properties, behaviour outside the model, properties the solver cannot decide. |
Static analysis. Slither compiles the project (slither . in a Foundry or Hardhat directory) and runs its detectors, 100 at the time of writing; its vars-and-auth printer and slither-check-upgradeability support the access and proxy checks above. The Quantix Contract Analyzer runs Slither on verified contracts, pasted source or public repositories. An empty report does not mean the code is secure.
Fuzzing. Foundry’s invariant testing checks properties against random call sequences; the configuration in its documentation, 256 runs of depth 100, makes 256 × 100 = 25,600 calls per invariant test. It recommends handlers and ghost variables, and logging call counts to confirm that every function ran. Echidna and Medusa add coverage guidance; our fuzzing primer explains how to judge a clean run.
Formal verification. The SMTChecker in the Solidity compiler is off until an engine is selected. It then tries to prove each assert and built-in targets such as division by zero and out-of-bounds access; overflow targets must be requested explicitly for Solidity 0.8.7 and later. External calls count as unknown code by default, large contracts may leave properties unsolved, and a clean result assumes a bug-free checker and solver. Formal verification in DeFi covers dedicated provers.
After the audit
The report describes one commit. Keeping its conclusions true in production is a separate job.
- Review fixes, not just diffs. One commit per fix, naming the finding, with the proof of concept kept as a regression test. Stage 6 of the workflow checks each fix and any new code it adds.
- Re-scope every other change. Diff the deployment commit against the reviewed one. Anything that is not a reviewed fix, dependency and compiler updates included, is unreviewed, as is code that calls it.
- Check the deployment. Verified source and compiler settings must match the reviewed commit; parameters and role holders must match the plan. How to read an audit report shows how to compare deployed bytecode with the audited commit.
- Monitor what the threat model says matters. On-chain monitoring turns privileged calls, upgrades, oracle deviations and large outflows into alerts with an owner and a runbook.
- Run a bug bounty. Reuse the audit scope, severity rubric and known issues as its scope, rubric and exclusions, and size rewards to the funds at risk.
Conclusions
A review is most productive when it starts from a frozen commit, a working build, invariants that state intent and a threat model. The class checks clear the predictable findings first, leaving reviewers the system’s own logic and assumptions.
Readiness is largely the work of writing down intent, roles and assumptions, and it pays twice: in the audit, and afterwards as regression tests, monitoring rules and bounty scope.
Recommendations
Teams preparing for an audit
- Freeze the commit, and send the scope with nSLOC, a clean-checkout build, coverage and invariant tests.
- Run Slither and the invariant suite before the review, and fix or annotate every result.
Reviewers
- Check the threat model and role table against the code before reading line by line.
- Record, for each contract, which class checks apply and why the others do not.
- Deliver each material finding as a failing test that the fix turns green.
After the report
- Review everything between the reviewed and the deployed commit that is not a reviewed fix.
- Turn the threat model into monitoring rules and the audit scope into a bug bounty.
Bibliography
Primary sources: standards, official documentation, incident reports and papers.
- 1OWASP Smart Contract Top 10: 2026(opens in a new tab)OWASP Smart Contract Security project
- 2Security Considerations(opens in a new tab)Solidity documentation
- 3Expressions and Control Structures: checked or unchecked arithmetic(opens in a new tab)Solidity documentation
- 4SMTChecker and Formal Verification(opens in a new tab)Solidity documentation
- 5Writing Upgradeable Contracts(opens in a new tab)OpenZeppelin Upgrades Plugins documentation
- 6Invariant Testing(opens in a new tab)Foundry documentation
- 7Slither, the Solidity and Vyper static analyzer(opens in a new tab)Trail of Bits (crytic), GitHub
- 8EIP-7825: Transaction Gas Limit Cap(opens in a new tab)Ethereum Improvement Proposals
Applied on this site
Assessments, case studies and tools that apply this publication.







