3.9.2 Preparing for an External Audit
The interval between "we should get audited" and "the audit starts" is where most of the audit's quality is determined. An audit performed against well-prepared code finds the subtle issues that matter. An audit performed against unprepared code spends most of its time on context-building and pattern-matching that internal review should have already done. The same auditors produce vastly different outputs depending on what they're given to work with.
Preparation has two purposes. First, it makes the audit more effective — the auditors find real bugs rather than redocumenting the basics. Second, it makes the audit cheaper — auditor time spent figuring out what your contract does is auditor time not spent finding what's wrong with it. For a team paying $50,000 to $250,000 for an audit, every hour of saved context-building converts directly to additional bug-finding capacity.
This subsection covers the concrete deliverables a development team should have ready before engaging an auditor: the codebase freeze, NatSpec documentation, the threat model, invariant documentation, deployment scripts and configuration, the known-issue list, and the scope document. Each is examined as a discrete artifact with concrete examples of what "good" looks like.
The work is not glamorous. None of it produces new code. But every hour spent here is worth multiple hours of auditor time saved, and the saved hours convert directly to audit findings — bugs caught rather than missed.
Codebase Freeze
Before the audit starts, the codebase under review must be frozen. Auditors are reviewing a specific commit; if the commit changes during the engagement, every prior finding may be invalidated. The freeze is the single most important operational discipline in the pre-audit phase.
What "Frozen" Means
A frozen codebase is one where:
- A specific git commit hash is identified and shared with the auditors
- No further commits will be made to the audited branches during the engagement
- Any changes deemed essential during the engagement are made on a separate branch and explicitly communicated
- The audit report references the frozen commit; remediation happens against that baseline
The discipline matters because audits are time-bounded. A 3-week audit on a 2,000-line codebase can find a substantial fraction of the issues. A 3-week audit where the codebase is being edited daily produces a report against code that no longer exists.
Practical Mechanics
Create a dedicated audit branch:
# Tag the commit being audited
git tag -a audit-v1.0 -m "Initial audit by [Auditor]"
git push origin audit-v1.0
# Create a dedicated remediation branch off the audit point
git checkout -b audit-remediation audit-v1.0
git push -u origin audit-remediation
Communicate the exact commit hash to the auditor in writing. Most audit engagement contracts include a "scope" section that references the specific hash. If they don't, add it.
Active development continues on main during the audit. Changes for the next milestone, new features, etc. happen on main. Audit findings get fixed on the audit-remediation branch. After the audit completes, audit-remediation is merged into main (with the audit's findings as proof of fix) and the cycle starts again for the next engagement.
What If Something Critical Changes Mid-Audit?
A bug is discovered. A dependency breaks. A regulatory requirement appears. The codebase must change during the engagement.
The right protocol:
- Pause the audit if practical
- Communicate the change to the auditor explicitly: what's changing, why, and what new commit hash is in scope
- Negotiate scope: do the auditors review the new commit, the diff between commits, or both?
- Add cost / time as appropriate
The wrong protocol is to silently make changes hoping the auditors don't notice. They will. The trust is more valuable than the speed.
NatSpec Documentation
NatSpec (Solidity's Natural Language Specification) is the inline documentation format. Properly applied, it answers the questions an auditor asks of every function: what does this do, what does it require, what does it return, what events does it emit.
Minimum-Viable NatSpec
Every public and external function should have, at minimum:
/// @notice [Plain-language description of what this function does]
/// @dev [Implementation notes, gotchas, assumptions]
/// @param paramName [What this parameter represents and what valid values are]
/// @return [What the return value represents]
function withdraw(uint256 amount) external nonReentrant returns (uint256 withdrawn) {
// ...
}
@notice is what end users would see in a transaction-signing UI (MetaMask renders it). It should be plain-language, descriptive, and accurate. "Withdraws funds from the vault" is fine; "withdraw" is not.
@dev is for developer-facing notes. Gotchas, assumptions, version-specific behavior, references to relevant EIPs, anything a maintainer needs to know. Auditors read this carefully.
@param documents each parameter. Critical for any parameter with a non-obvious meaning or valid range.
@return documents return values. Should match what the implementation actually returns, including edge cases (e.g., "Returns 0 if the user has no balance" rather than "Returns the user's balance").
Custom Tags for Auditors
NatSpec supports custom tags via @custom:. Use them for audit-relevant annotations:
/// @notice Sets the protocol fee rate in basis points
/// @dev Reverts if the new rate exceeds MAX_FEE_BPS
/// @custom:security-contact security@example.com
/// @custom:audit-status pending-audit-v2
function setFeeRate(uint256 newRate) external onlyRole(OPERATOR_ROLE) {
require(newRate <= MAX_FEE_BPS, "fee too high");
feeRate = newRate;
emit FeeRateUpdated(newRate);
}
The @custom:security-contact tag is recognized by Etherscan and similar block explorers; it surfaces the security contact for the contract on the explorer page. Set this before deployment.
NatSpec for State Variables
State variables get NatSpec too:
/// @notice The current protocol fee rate, in basis points (1 bp = 0.01%)
/// @dev Maximum value: 1000 (10%); enforced in setFeeRate()
uint256 public feeRate;
Comprehensive NatSpec on state variables makes the contract self-documenting. Auditors can read the storage layout and understand what each slot represents without having to trace through every function that touches it.
What NatSpec Should Cover (and What It Shouldn't)
NatSpec should document:
- Function purpose, parameters, return values, and emitted events
- Invariants the function preserves
- Required state preconditions (e.g., "the contract must be unpaused")
- Caller restrictions ("only callable by the OPERATOR_ROLE")
- Important edge cases and how they're handled
- References to relevant EIPs or external standards
NatSpec should not be:
- A line-by-line restatement of the code (
// increment counternext tocounter++) - Marketing language about the protocol
- Stale information (NatSpec that doesn't match the code is worse than no NatSpec)
Tooling
forge inspect <Contract> userdoc and forge inspect <Contract> devdoc extract NatSpec into machine-readable JSON. Use these to verify completeness:
forge inspect MyContract userdoc | jq '.methods | keys'
# Lists all public/external methods that have @notice documentation
Cross-reference with the function list to identify gaps. A function without NatSpec is a function the auditor will have to reverse-engineer; that's wasted time.
Threat Model Document
The threat model document — produced during internal audit (Section 3.9.1) — is the single most valuable input to an external audit. It tells the auditor what the contract is supposed to guarantee, what risks the team has identified, and what assumptions the team is making.
Without a threat model, the auditor must build one themselves before they can find bugs. Building a threat model for a complex protocol can take days. Providing one shifts those days to bug-finding.
What a Pre-Audit Threat Model Should Contain
The threat model template from 3.9.1, expanded with audit-specific framing:
# Threat Model: <Protocol Name>
## Protocol Overview
[2-3 paragraphs describing what the protocol does, its core operations, its economic model]
## Trust Assumptions
[What we assume about external systems, users, and the environment]
- Oracle: [Source, update frequency, failure mode assumed]
- Governance: [Who can change parameters, on what timeline]
- Users: [What capabilities they have, what they cannot do]
- Integrations: [Which other protocols this depends on]
## Assets and Attack Surfaces
| Asset | Held Where | How Protected | Attack Surface |
|---|---|---|---|
| User deposits (ETH) | Contract balance | Pull-based withdrawal; rate limit | Owner key, reentrancy, flash loan |
| LP tokens | Contract storage | Access control on mint/burn | Mint authority, supply manipulation |
| Oracle reads | External (Chainlink + TWAP) | Staleness check, deviation check | Oracle compromise, TWAP manipulation |
## Invariants
[Formal statements of "the contract is functioning correctly"]
1. Solvency: total_collateral_value >= total_debt at all times
2. Conservation: sum(user_balances) == total_supply
3. ...
## Known Issues (Out of Scope or Accepted Risk)
[Issues the team has identified but is not addressing in this engagement]
1. Centralization risk: owner can pause indefinitely. Accepted as governance is via multisig.
2. ...
## Specific Concerns
[Areas the team specifically wants the auditor to focus on]
1. The liquidation logic in Liquidator.sol — recently rewritten, less internal review than other modules
2. The signature verification in Bridge.sol — uses custom EIP-712 implementation
3. ...
The "Specific Concerns" section is particularly valuable. Auditors are constrained by time; directing them to known-uncertain areas focuses their attention where it pays off best.
Auditors Will Read This Carefully
A well-written threat model demonstrates that the team understands their own protocol. Auditors calibrate their review against the team's stated assumptions: if the team claims "oracle manipulation cannot move the price by more than X% in a window," the auditor verifies that claim. If the team's claim is wrong, the audit finding is "your threat model is incorrect" — which is often a more valuable finding than any individual code-level bug.
A threat model that overclaims invites the auditor to falsify the claims. A threat model that underclaims wastes the auditor's time re-deriving what the team already knows. The right tone is honest specificity.
Invariant Documentation
Adjacent to the threat model, but tactical rather than strategic: a list of specific, testable invariants the contract maintains. These become the basis for the auditor's invariant testing — Echidna properties, Foundry invariant tests, formal verification specifications.
Format
# Invariants: <Contract Name>
## Per-User Invariants
- I1: user.collateral >= user.debt * liquidation_threshold / 100
- I2: user.borrowed_at_time <= block.timestamp
- I3: ...
## Global Invariants
- G1: sum(user.balance for user in users) == totalSupply
- G2: address(this).balance >= sum(user.withdrawable for user in users)
- G3: protocol_fee_collected == fees_owed - fees_paid
## Conditional Invariants
- C1: When paused, no user.balance can decrease except through completeWithdrawal()
- C2: When unpaused for less than 1 hour, withdrawal limits apply
- C3: ...
Each invariant has an ID (for cross-referencing in audit reports), a precise mathematical statement, and an implicit "for all valid sequences of operations" quantifier.
Why This Matters
Invariants are how auditors think. The audit process is largely "for each thing the protocol claims, can I construct a counterexample?" A clear invariants list gives the auditor explicit targets. Without it, the auditor must infer the invariants from the code — which is exactly the kind of context-building work that should be done by the developer instead.
Sophisticated audits (Certora, Trail of Bits' Manticore, OpenZeppelin Defender's formal verification) take invariants as direct inputs. Providing them in a format that can be ingested by these tools shortcuts the engagement substantially.
Deployment Scripts and Configuration
The auditor needs to see not just the contracts but the deployment process. A perfectly-written contract deployed incorrectly is still broken. The deployment scripts, constructor arguments, and initial configuration are part of the audit scope.
What to Provide
- The complete deployment script (typically a Foundry
Scriptor Hardhat task) - The exact constructor arguments and
initialize()parameters being passed - The deployment topology: which contracts are deployed in what order, and the dependencies between them
- The post-deployment configuration: role grants, parameter sets, ownership transfers
Example
// scripts/Deploy.s.sol
contract Deploy is Script {
function run() external returns (Protocol protocol) {
vm.startBroadcast();
// 1. Deploy implementation
ProtocolImpl impl = new ProtocolImpl();
// 2. Deploy proxy pointing at implementation
address adminSafe = vm.envAddress("ADMIN_SAFE");
address operatorSafe = vm.envAddress("OPERATOR_SAFE");
address pauser = vm.envAddress("PAUSER_BOT");
bytes memory initData = abi.encodeCall(
ProtocolImpl.initialize,
(adminSafe, operatorSafe, pauser, 1000 ether)
);
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData);
protocol = Protocol(address(proxy));
// 3. Verify deployment
require(protocol.hasRole(protocol.DEFAULT_ADMIN_ROLE(), adminSafe), "admin not set");
require(protocol.hasRole(protocol.OPERATOR_ROLE(), operatorSafe), "operator not set");
require(protocol.hasRole(protocol.PAUSER_ROLE(), pauser), "pauser not set");
require(protocol.globalLimit() == 1000 ether, "limit not set");
vm.stopBroadcast();
}
}
The verification steps at the end (require statements) are the most valuable part of the script. They explicitly assert what the deployment is supposed to produce. If the deployment ever fails to set up the expected state, the script reverts rather than silently completing.
Provide the script to auditors. Walk them through the deployment flow during the kickoff. Many audits have caught bugs not in the contract code but in the deployment script.
Known-Issue List
Issues the team has identified but is not addressing in this engagement. Documenting them upfront avoids wasted auditor time and signals discipline.
Format
# Known Issues
## Acknowledged, In Scope for Future Engagement
- KI-1: Front-running on commit/reveal mint can be mitigated by per-block rate limits.
Planned for v2; out of scope for current audit.
- KI-2: ...
## Centralization Risks (By Design)
- CR-1: Admin can pause indefinitely. Mitigated by 3-of-5 multisig.
- CR-2: Operator can change fee rate up to MAX_FEE_BPS (10%). Mitigated by timelock.
- CR-3: ...
## Outside the Audit Scope
- OS-1: Off-chain order matching system (audited separately by [Firm])
- OS-2: Front-end input validation (not in scope; backend validation in scope)
- OS-3: ...
Why This Matters
A known-issue list does three things:
- Saves auditor time. They don't re-discover and write up issues the team already knows about
- Signals operational maturity. A team that has identified and triaged its own risks is taken more seriously
- Sets expectations for the report. When the auditor sees a centralization concern, they know whether to flag it as a finding or note it as a known acceptance
Be honest. Don't list issues you haven't actually thought through — auditors will probe them. Don't omit issues you're aware of hoping the auditor misses them — they'll find them anyway, and the omission becomes a credibility problem.
Scope Document
The contract that governs what the audit covers. Often this is part of the engagement agreement; if not, write it as a separate document and have both parties sign off.
What a Scope Document Specifies
- In-scope files — the exact list of
.solfiles being reviewed, by path - Out-of-scope files — what is not being reviewed (third-party dependencies, tests, scripts, etc.)
- Lines of code — the auditor's pricing typically references LoC; specify the count
- Specific functionality areas of focus — see the threat model's "Specific Concerns"
- Excluded analysis types — e.g., "economic / game-theoretic analysis out of scope"
- Deliverables — what the auditor will produce (report format, severity classifications, fix verification)
- Timeline — start date, duration, expected delivery date
- Communication protocol — frequency of status updates, channels (Slack, email), escalation paths
Example
# Audit Scope: Protocol v1.0
## In Scope
The following files at commit hash `abc123def456`:
- src/Protocol.sol (450 LoC)
- src/PriceOracle.sol (180 LoC)
- src/Liquidator.sol (320 LoC)
- src/governance/Timelock.sol (120 LoC)
Total: ~1,070 LoC
## Out of Scope
- All files under `lib/` (third-party dependencies, audited separately by their authors)
- Test files under `test/`
- Deployment scripts under `scripts/`
- Front-end code in separate repository
## Focus Areas
1. Liquidation logic in Liquidator.sol (high complexity, recently rewritten)
2. Oracle integration in PriceOracle.sol (uses both Chainlink and TWAP fallback)
3. Access control across all contracts (multi-role hierarchy)
## Out of Scope Analysis
- Economic / game-theoretic analysis
- Front-end integration security
- Cryptographic primitive analysis (standard ECDSA, OpenZeppelin libraries assumed correct)
## Deliverables
- Final audit report (PDF + Markdown)
- Severity classification: Critical / High / Medium / Low / Informational
- Fix verification round included for findings of Medium severity and above
- Public release of report after team's remediation period (30 days)
## Timeline
- Start: 2025-06-01
- Initial findings shared: 2025-06-15
- Final report delivered: 2025-06-22
- Fix verification window: 2025-06-22 to 2025-07-22
## Communication
- Weekly status calls on Tuesdays
- Slack channel: #protocol-audit (auditors invited)
- Critical findings communicated within 24 hours of discovery via direct message
This document, signed by both parties before the engagement starts, prevents most of the disputes that arise during audits.
The Pre-Audit Checklist (Compact Version)
The mechanical version of everything above:
- Codebase frozen at a specific commit hash, tagged in git
-
All public/external functions have
@notice,@dev,@param,@returnNatSpec -
All state variables have
@noticeNatSpec -
@custom:security-contactset on the main contract - Threat model document complete and shared
- Invariants document complete and shared
- Deployment script with verification assertions provided
- Known-issues document complete and shared
- Scope document signed by both parties
- All compiler warnings resolved (or explicitly listed as accepted)
- Slither runs cleanly (or findings triaged and documented)
- Test coverage at 100% for all functions in scope
- Internal threat-model-driven review pass completed
- At least one mock deployment to a testnet succeeded
- Deployment script's post-deployment assertions all pass
A team that can check every box has done their preparation. A team that cannot is asking the auditor to do work the team should have done first.
Section 3.9.6 expands this checklist into a more detailed reference. The version above is the minimum-viable list.
Cross-References
- Internal audit — Section 3.9.1 covers the internal review work that should be done before this preparation phase
- Selecting an audit path — Section 3.9.3 covers the firm vs. independent vs. contest decision
- During the audit — Section 3.9.4 covers the operational dynamics once preparation is complete
- Post-audit remediation — Section 3.9.5 covers what to do with the report
- Auditor's prerequisites — Section 4.3 covers the same material from the auditor's perspective: what they expect to receive and how they use it
- Patterns — Section 3.7 covers the constructive patterns the auditor will be looking for; ensuring the codebase follows them reduces audit findings