How to Audit a Smart Contract Like a Pro
(Complete Guide + Tools, CI Pipelines, Invariants, Manual Checklist & Bounty Platforms)
Target audience: Solidity developers, security engineers, and auditors who want to transition from “code‑and‑hope” to a repeatable, professional audit workflow.
What you’ll get:
| Section | What you’ll learn |
|---|---|
| 1️⃣ Mindset | How to think like an attacker; why every external call is a trap and every state variable is “tainted”. |
| 2️⃣ Static Analysis | Deep‑dive into Slither, Mythril, Solhint, their detectors, and how to embed them in CI (GitHub Actions / GitLab CI). |
| 3️⃣ Fuzzing | Using Foundry’s built‑in fuzzer, Echidna invariant testing, and best‑practice fuzzing parameters. |
| 4️⃣ Invariants | Writing, testing and automating custom invariants (total‑supply caps, balance non‑negativity, etc.). |
| 5️⃣ Manual Review Checklist | A 30‑item checklist that covers re‑entrancy, access control, oracle attacks, precision loss, upgradeability, and more. |
| 6️⃣ Bounty Platforms | How to get paid for finding bugs on Code4rena, Sherlock, Immunefi, and how to build a reputation that unlocks $10 K‑$1 M payouts. |
By the end of this guide you’ll be able to spin up a single reproducible CI job that runs every static analyzer, a fuzzing suite that reaches 1 M runs, and a manual audit checklist you can hand‑off to junior auditors. Let’s get started.
1️⃣ Mindset: Assume Malicious – Every External Call Is a Trap, Every Variable Tainted
“Security is a mindset, not a checklist.” – (Paraphrasing Dan Boneh)
The most common reason auditors miss critical bugs is optimism bias: they assume the contract will be used as intended. The professional mindset flips that on its head.
1.1 Treat Everything as Untrusted
| Concept | Why it matters | Practical implication |
|---|---|---|
External Calls (call, delegatecall, staticcall, low‑level .call{value:…}) |
The callee can re‑enter, revert, consume all gas, or modify storage via delegatecall. |
Never rely on the success of an external call without a solid guard (checks‑effects‑interactions, re‑entrancy guard, or whitelisting). |
| msg.sender | In a proxy pattern msg.sender can be a contract that forwards calls. |
Validate that the caller is an EOA or a trusted contract when required. |
| msg.value | The amount of ether sent can be 0, any uint256, or even overflow in older compilers. | Ensure you don’t rely on msg.value > 0 without explicit checks. |
| Returned Data from low‑level calls | Can be malformed, truncated, or maliciously crafted. | Verify length & decode safely (abi.decode with try/catch). |
| State Variables (including constants) | Even a “constant” can be changed via delegatecall on a proxy. |
Assume any variable may be altered by an attacker who controls the implementation address. |
| Libraries | Library code is linked at deployment; if a library address is upgradeable, it can be swapped. | Prefer internal libraries or immutable addresses. |
1.2 “Taint” Propagation
- Taint analysis is a static‑analysis technique that marks any data originating from an untrusted source (e.g.,
msg.sender, external call return values) as tainted. - Follow the taint through assignments, arithmetic, and storage writes. If a tainted value reaches a critical decision point (e.g.,
require,if) without sanitisation, flag it.
Quick mental exercise:
When you read a function, ask:
Is any variable derived from msg.sender, msg.value, or an external call?
→ Yes → Is it validated before being used in a critical branch or state change?
If the answer is “no”, you’ve identified a potential vulnerability.
1.3 “Zero‑Trust” Design Principles
| Principle | Description | Example |
|---|---|---|
| Checks‑Effects‑Interactions (CEI) | Do all state changes before calling out. | balances[msg.sender] -= amount; (bool ok,) = external.call(...); require(ok); |
| Least Privilege | Only grant the minimum role required for a function. | Use onlyOwner for admin, never onlyOwnerOrOperator unless needed. |
| Explicit Fail‑Safe | If an external call fails, revert or gracefully degrade. | if (!external.call(...)) revert("External call failed"); |
| Immutable Safety Nets | Deploy immutable contracts for critical logic; avoid delegatecall unless absolutely necessary. |
Deploy a “Math” library as a static linked library. |
| Defence‑in‑Depth | Combine static analysis, fuzzing, formal verification, and manual review. | Run Slither + Echidna + manual checklist. |
A professional auditor internalises these principles and uses them as a filter when scanning the code. Anything that violates a principle becomes a “high‑priority” item.
2️⃣ Static Analysis – Slither, Mythril, Solhint (and CI Integration)
Static analysis can surface hundreds of low‑level issues in seconds. The trick is to run them early, often, and as part of CI.
2.1 Slither – 100+ Built‑in Detectors
Slither is the go‑to static analyzer for Solidity. It parses the AST, builds an SSA (static single‑assignment) model, and runs detectors that look for patterns such as re‑entrancy, uninitialized storage pointers, and unprotected upgradeability.
2.1.1 Install
# Using pip (recommended)
python3 -m pip install slither-analyzer
# Or via Docker (useful for CI isolation)
docker pull trailofbits/slither
2.1.2 Run the full detector suite
# Simple command:
slither src/**/*.sol
# Run only a subset (e.g., reentrancy, uninitialized-state)
slither src/**/*.sol --detect reentrancy,uninitialized-state
2.1.3 Understanding the output
- Info – Code smells (e.g., “Unused variable”).
- Warning – Likely bug (e.g., “Unprotected self‑destruct”).
- Error – Definite bug (e.g., “Integer overflow”).
The output is JSON‑friendly:
slither src/**/*.sol --json slither-report.json
You can then parse slither-report.json to fail the CI if any Error or Warning meets your severity threshold.
2.2 Mythril – Symbolic Execution
Mythril explores the contract bytecode using symbolic execution, looking for under‑approximated bugs such as integer over/underflows, transaction ordering dependence (TOD), and re‑entrancy.
2.2.1 Install
# Using pip
python3 -m pip install mythril
# Or Docker (recommended for CI)
docker pull mythril/mythril
2.2.2 Run Mythril
myth analyze src/**/*.sol \
--solc-json solc-config.json \
--max-depth 128 \
--execution-timeout 300 \
--output json > mythril-report.json
-
--max-depthcontrols the maximum call depth for symbolic execution. -
--execution-timeoutprevents infinite loops.
2.2.3 Interpreting results
Mythril returns a list of findings with a severity (Low, Medium, High, Critical). You can filter:
myth analyze src/**/*.sol --max-severity High
2.3 Solhint – Style & Best‑Practice Linter
While Slither & Mythril focus on security, Solhint enforces coding style, naming conventions, and best‑practice patterns that indirectly reduce attack surface (e.g., avoiding tx.origin, using pragma solidity ^0.8.0).
2.3.1 Install
npm i -g solhint
2.3.2 Run
solhint "src/**/*.sol" -f stylish
You can also use a custom config (.solhint.json) to enable/disable rules:
{
"extends": "solhint:recommended",
"rules": {
"func-name-mixedcase": "off",
"compiler-version": ["error", "^0.8.0"],
"no-empty-blocks": "warn"
}
}
2.4 CI Integration – One‑Click “Run All Analyses”
Below is a GitHub Actions workflow that runs Slither, Mythril, and Solhint on every PR. It also fails the build if any critical or high severity findings appear.
# .github/workflows/security.yml
name: Security Audits
on:
pull_request:
push:
branches: [main, develop]
jobs:
static-analysis:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
steps:
# 1️⃣ Checkout code
- name: Checkout repository
uses: actions/checkout@v4
# 2️⃣ Install dependencies (node + python)
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install Solhint
run: npm i -g solhint
- name: Set up Python + Slither + Mythril
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install analysis tools
run: |
python -m pip install --upgrade pip
pip install slither-analyzer mythril
# 3️⃣ Run Solhint (style)
- name: Run Solhint
run: solhint "src/**/*.sol" -f json > solhint-report.json
# 4️⃣ Run Slither (detectors)
- name: Run Slither
run: slither "src/**/*.sol" --json slither-report.json
# 5️⃣ Run Mythril (symbolic)
- name: Run Mythril
run: |
myth analyze "src/**/*.sol" \
--solc-json solc-config.json \
--output json > mythril-report.json
# 6️⃣ Evaluate reports – fail on high severity
- name: Evaluate security reports
id: evaluate
run: |
python - <<'PY'
import json, sys, os
# Helper to load report & check severity
def load(path):
with open(path) as f:
return json.load(f)
# Solhint (no severity, treat any error as fail)
solhint = load('solhint-report.json')
if solhint:
print("⚠️ Solhint found issues")
sys.exit(1)
# Slither
slither = load('slither-report.json')
for issue in slither.get('detectors', []):
if issue['severity'] in ('Error', 'Warning'):
print(f"❗ Slither {issue['severity']}: {issue['title']}")
sys.exit(1)
# Mythril
myth = load('mythril-report.json')
for finding in myth.get('findings', []):
if finding['severity'] in ('High', 'Critical'):
print(f"❗ Mythril {finding['severity']}: {finding['title']}")
sys.exit(1)
print("✅ No high‑severity findings")
PY
Key takeaways:
- All three tools run in parallel (you can split them into separate jobs for speed).
- The
evaluatestep parses the JSON output and aborts the pipeline on any high‑severity issue. - You can add a report artifact step (
actions/upload-artifact) to preserve the JSON files for later review.
GitLab CI follows a similar pattern; just replace the actions/checkout with gitlab/checkout, and use script blocks.
3️⃣ Fuzzing – Foundry, Echidna & The 1 M‑Run Rule
Static analysis can’t prove absence of bugs; fuzzing attempts to break the contract by feeding random (but type‑correct) inputs. The goal is to reach statistical confidence – a rule of thumb is 1 M total calls across all test suites.
3.1 Foundry – Forge Fuzz Tests
Foundry is a fast, Rust‑based toolkit for Solidity development. Its forge test command includes a built‑in property‑based fuzzer that mutates calldata values.
3.1.1 Install
curl -L https://foundry.paradigm.xyz | bash
foundryup # pulls the latest version
3.1.2 Write a fuzz test
// test/Token.t.sol
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/Token.sol";
contract TokenFuzz is Test {
Token token;
address alice = address(0x111);
address bob = address(0x222);
function setUp() public {
token = new Token();
token.mint(alice, 1_000 ether);
token.mint(bob, 1_000 ether);
}
// The fuzzer will generate random values for `from`, `to`, and `amt`
function testTransferFuzz(address from, address to, uint256 amt) public {
// Bound addresses to known actors (prevent sending to 0x0)
vm.assume(from != address(0) && to != address(0));
// Bound amount to max supply to avoid overflow in the test harness
amt = bound(amt, 0, token.totalSupply());
// Pre‑condition: give `from` enough balance
uint256 bal = token.balanceOf(from);
if (bal < amt) {
token.mint(from, amt - bal);
}
// Act
token.transferFrom(from, to, amt);
// Invariant: total supply unchanged
assertEq(token.totalSupply(), 2_000 ether);
// Invariant: balances are non‑negative (always true in uint256)
assertLe(token.balanceOf(to), token.totalSupply());
}
}
Explanation of key commands:
-
vm.assume– tells the fuzzer to discard inputs that violate a pre‑condition (e.g., zero address). -
bound– clamps a uint256 to a safe range. -
assertEq/assertLe– built‑in assertions that will cause a test failure if violated.
3.1.3 Run the fuzzer
forge test --fuzz-runs 10_000 # 10k runs per test function
Why 10 k? It gives a decent coverage baseline. For high‑value contracts increase to 100 k or 1 M runs:
forge test --fuzz-runs 1_000_000
The fuzzer prints a summary:
Running 1 test for TokenFuzz::testTransferFuzz
🐛 1,234,567 runs, 0 reverts, 0 failures
If any run triggers a revert or a failed assertion, Foundry will shrink the input to the minimal counter‑example and display it.
3.2 Echidna – Invariant Fuzzing
Echidna (by Trail of Bits) is a property‑based fuzzer focused on invariants. You write a contract that inherits from Test and defines functions prefixed with echidna_ that must always return true.
3.2.1 Install
# Binary release (Linux/macOS)
curl -L https://github.com/crytic/echidna/releases/download/v2.2.0/echidna-2.2.0-linux-x86_64.tar.gz | tar xz
sudo mv echidna /usr/local/bin/
3.2.2 Invariant contract example
solidity
// contracts/EchidnaTokenInvariant.sol
pragma solidity ^0.8.19;
import "./Token.sol";
contract EchidnaTokenInvariant is Token {
// The contract inherits all token functionality
// Invariant 1: totalSupply never exceeds MAX_SUPPLY (hard‑coded 2M)
uint256 constant MAX_SUPPLY = 2_000_000 ether;
// Invariant 2: balances are never negative (uint256 ensures it)
// We'll still expose a function for Echidna to call.
function echidna_totalSupplyCap() public view returns (bool) {
return totalSupply() <= MAX_SUPPLY;
}
// Echidna will call this function with random inputs.
// The function must be **public** and return bool.
function echidna_noOverflowOnMint(address user, uint256 amount) public returns (bool) {
// Guard against overflow; if overflow would happen, revert.
// Echidna treats a revert as a failure, so we must handle it.
if (totalSupply() + amount > MAX_SUPPLY) {
return true;
#SmartContracts #Security #Audit #Solidity
Top comments (0)