After multiple audit cycles on production DeFi protocols — managing contracts across four EVM chains, 90%+ test coverage on core contracts, and over $7M in secured TVL — one thing became clear early on:
An audit is not where security starts. It's where your preparation gets validated.
Most teams treat audits like a black box — send code, wait, receive a PDF. That approach produces wasted cycles, surface-level findings, and a dangerous false sense of security when the report comes back clean. Strong teams do the opposite — they eliminate noise before the engagement begins so auditors can focus on what actually matters: the deep, protocol-specific risks that no checklist will ever catch.
Here's the hierarchy that actually works, in order of impact.
1. Context Beats Tooling
Before engaging an auditor, ask yourself: would a competent auditor understand why this system is designed the way it is?
If the answer is no, you'll get false positives, misread design decisions, and an audit that spends time in the wrong places. Auditors are not mind-readers. Without context, your intentional trade-offs look like vulnerabilities.
The audit package matters more than most teams realize. Before engagement, prepare:
- Architecture overview: core flows, components, and system invariants
- Trusted roles and trust boundaries: who can do what, and what the protocol assumes about them
- Design trade-offs: where UX, flexibility, or performance was prioritized over strictness
- What is enforced on-chain vs. handled in the frontend: this distinction trips up auditors repeatedly
- Known limitations: anything imperfect but intentionally shipped, and why
That last point matters. Teams that hide known limitations to appear more polished waste everyone's time. Auditors have limited time. Every hour spent uncovering something you already knew about is an hour not spent on the deep, unknown risks — the ones that actually matter.
Explaining why a decision was made is as important as explaining what it does.
2. Real Usage Beats Unit Tests
This is the most underrated step in audit preparation — and the one most teams skip entirely.
Before external review, build a minimal interface and let people actually use the protocol. Not just engineers — product managers, designers, founders, even non-technical management. Especially non-technical stakeholders. They understand the intended behavior but don't know how to work around a broken flow, which makes them surprisingly effective at breaking things.
Run this on testnet, or on a low-cost chain if your integration constraints require it. The sessions don't need to be long. Even a few hours of real interaction consistently surfaces:
- Unexpected reverts that unit tests never triggered
- Missing validations that only appear when someone does something "wrong"
- Confusing state transitions that are logically correct but behaviorally broken
- Timing issues — price changes between transactions, delayed execution assumptions, liquidity-dependent outcomes
That last category is the important one. These scenarios are extremely difficult to fully simulate in Hardhat or Foundry. Real-world market behavior doesn't mock cleanly — and those gaps are where critical bugs tend to hide.
Run these sessions regularly if you can: after each sprint, or at minimum before each major release. Issues caught during internal usage cost nothing. Issues caught during audit cost time and money. Issues missed entirely cost users.
3. Continuous Review Beats Batch Review
One of the most expensive mistakes in DeFi development is treating security as a checkpoint rather than a process.
By the time a full protocol sits in front of an auditor, the attack surface is large and the context is compressed. It's far easier — and cheaper — to reason about security in small, isolated changes than in a 3,000-line codebase three days before mainnet.
Treat every PR like a mini audit. For each change, ask:
- What new attack surfaces does this introduce?
- Are the existing invariants still preserved?
- Does this interact safely with every integration currently live?
Run static analysis tools like Slither early. The challenge isn't detection — it's filtering. They generate significant noise, and learning to distinguish real findings from false positives takes time you don't want to spend the week before an audit.
A note on AI-assisted review. AI tools have become genuinely useful for surfacing missing validations, flagging risky external calls, and challenging business logic assumptions. But they need careful handling. AI-generated suggestions often look correct and behave like junior-level implementations — missing edge cases, ignoring security implications, failing under adversarial conditions. Use AI to generate hypotheses, not conclusions. Guide it to think like an attacker. Treat its output as a starting point for human review, not a replacement for it.
4. The Checklist Is a Safety Net, Not a Substitute for Thinking
Checklists are valuable precisely because the same issues appear across codebases, across teams, and across audit cycles. They catch the obvious. They don't catch the non-obvious.
Security
- Are all external and public functions validating inputs?
- Is function visibility as restrictive as possible?
- Is internal state updated before external calls? (checks-effects-interactions, consistently applied)
- Can any external interaction — ETH transfers, oracle calls — introduce unexpected risk?
- Are token decimals handled consistently throughout?
- Can any revert path lead to a denial-of-service condition?
The non-obvious pitfalls that appear in real audits:
- Avoid strict zero-amount checks — dust accumulates, especially with rebasing tokens
- Rebasing token balances cannot be precisely pre-calculated from the frontend; treat them as approximate at all times
- Be extremely careful with calldata execution on contracts where users have pre-approved tokens — this creates a token theft vector if not tightly constrained
A real example: calldata + pre-approvals in the same contract
This is one of the more subtle critical vulnerabilities that static analysis won't reliably catch, and it's worth understanding the mechanism fully.
Consider a contract that accepts ERC20 token approvals and executes
calldata through a whitelisted external protocol that uses delegatecall
internally. Note this is specific to ERC20 approval mechanics — ETH
deposits don't carry this risk since there is no approval to exploit:
function deposit(
address token,
address target,
uint256 amount,
bytes calldata data
) external {
// User has pre-approved this contract to spend their tokens
IERC20(token).transferFrom(msg.sender, address(this), amount);
// Executes calldata against target
(bool success,) = target.delegatecall(data);
require(success);
}This looks safe — the target is whitelisted, so only a trusted protocol
can be called. But whitelisting the target isn't enough if that protocol
uses delegatecall internally. delegatecall executes the calldata in
your contract's storage context, not the external protocol's. That means
transferFrom runs with your contract as the caller — and your contract
holds everyone's approvals.
// Attacker constructs calldata to drain another user's approved funds
bytes memory maliciousData = abi.encodeWithSelector(
IERC20.transferFrom.selector,
victim, // from: another user who approved this contract
attacker, // to: attacker
victimBalance // amount: their full approved balance
);
deposit(token, WHITELISTED_PROTOCOL, 1, maliciousData);
// target = whitelisted protocol (passes validation)
// protocol uses delegatecall - executes in your contract's context
// your contract is the caller, your contract has the approvals
// data = transferFrom(victim, attacker, amount)
// result: victim's approved funds drainedThe fix is architectural, not a patch. Tightening calldata validation will always leave edge cases — the safer approach is to eliminate the condition that makes the attack possible in the first place.
Separate approval scope from execution scope entirely. Users approve tokens to a dedicated approval contract. That contract transfers funds to a separate execution contract, which runs the calldata and has no approval rights of its own:
// ApprovalContract: holds no execution logic
function deposit(address token, uint256 amount) external {
IERC20(token).transferFrom(msg.sender, address(executionContract), amount);
executionContract.execute(token, amount, msg.sender);
}
// ExecutionContract: holds no approval rights
function execute(address token, uint256 amount, address user) external {
require(msg.sender == address(approvalContract));
// calldata execution happens here
// this contract has never been approved by any user
// there is nothing to drain
}The attack surface disappears because the execution contract holds no approval rights. Even with full calldata control, there is nothing to drain.
One could consider a timelock per user as an alternative, but it doesn't solve the problem — it just adds friction. If a user forgets to revoke after the timelock expires and their approval persists, they remain vulnerable. The separate contract approach eliminates the risk structurally rather than managing it operationally.
Testing
- Is value preservation guaranteed across all flows? No hidden leakage?
- Are edge cases covered — decimals, ETH vs ERC20, different asset types and behaviors?
- Are inherited contracts properly initialized?
- Are edge cases handled: division by zero, empty arrays, index mismatches?
5. Treat Remediation as Part of the Process
The audit report is not the end. It's the beginning of the most important phase.
For every finding, the work is to understand the root cause — not just apply a surface fix that satisfies the wording of the report. Superficial fixes that introduce new risks are worse than the original issue. They create false closure while the underlying vulnerability evolves.
By severity:
- Critical / High — fix before mainnet, unconditionally
- Medium — evaluate the actual exploitability, not just the theoretical one; fix or properly mitigate with documented reasoning
- Low / Informational — review each one honestly; most are worth fixing; document clearly when they're not
Collaborate with auditors — don't just respond to them. If a finding seems like a misunderstanding of the design, explain your reasoning. If it's genuinely unclear, align before fixing. A shared understanding of what a finding actually means is more valuable than a patch that closes the ticket without addressing the concern.
The Hard Truth About Clean Reports
A clean audit report does not guarantee security.
Often it means auditors didn't have enough time, lacked sufficient context, or focused on the areas you surfaced — missing the deeper risks in the areas you didn't.
The difference between average and strong DeFi teams isn't who audits them. It's how much they've already audited themselves before the engagement begins.
By the time an external auditor opens your codebase, the obvious issues should be gone. The false positives should be triaged and documented. The architecture should be clearly understood. The edge cases should be covered by tests.
What's left is what auditors are actually for: the deep, protocol-specific, adversarial thinking that process alone cannot replicate.
That's the audit worth paying for.
Has real usage testing ever caught something in your protocol that your test suite missed? Genuinely curious what flows tend to break first.