@zigrivers/scaffold 3.26.0 → 3.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -7
- package/content/knowledge/core/ai-memory-management.md +17 -0
- package/content/knowledge/core/claude-md-patterns.md +2 -2
- package/content/knowledge/core/coding-conventions.md +2 -2
- package/content/knowledge/core/task-decomposition.md +4 -4
- package/content/knowledge/core/task-tracking.md +120 -29
- package/content/knowledge/core/user-stories.md +1 -1
- package/content/knowledge/execution/multi-agent-coordination.md +118 -0
- package/content/knowledge/execution/task-claiming-strategy.md +15 -3
- package/content/knowledge/execution/worktree-management.md +5 -3
- package/content/knowledge/web3/web3-access-control.md +189 -0
- package/content/knowledge/web3/web3-architecture.md +162 -0
- package/content/knowledge/web3/web3-audit-workflow.md +151 -0
- package/content/knowledge/web3/web3-common-vulnerabilities.md +171 -0
- package/content/knowledge/web3/web3-conventions.md +162 -0
- package/content/knowledge/web3/web3-deployment-and-verification.md +216 -0
- package/content/knowledge/web3/web3-dev-environment.md +150 -0
- package/content/knowledge/web3/web3-gas-optimization.md +165 -0
- package/content/knowledge/web3/web3-oracles-and-external-data.md +155 -0
- package/content/knowledge/web3/web3-project-structure.md +212 -0
- package/content/knowledge/web3/web3-requirements.md +152 -0
- package/content/knowledge/web3/web3-security.md +163 -0
- package/content/knowledge/web3/web3-testing.md +180 -0
- package/content/knowledge/web3/web3-upgradeability.md +189 -0
- package/content/methodology/web3-overlay.yml +40 -0
- package/content/pipeline/build/multi-agent-resume.md +27 -7
- package/content/pipeline/build/multi-agent-start.md +35 -7
- package/content/pipeline/build/new-enhancement.md +8 -1
- package/content/pipeline/build/quick-task.md +9 -0
- package/content/pipeline/build/single-agent-resume.md +11 -4
- package/content/pipeline/build/single-agent-start.md +13 -4
- package/content/pipeline/consolidation/workflow-audit.md +1 -1
- package/content/pipeline/environment/git-workflow.md +2 -2
- package/content/pipeline/foundation/beads.md +148 -22
- package/content/pipeline/foundation/coding-standards.md +1 -1
- package/content/tools/post-implementation-review.md +6 -6
- package/content/tools/prompt-pipeline.md +1 -1
- package/content/tools/release.md +5 -5
- package/content/tools/review-code.md +347 -3
- package/content/tools/review-pr.md +349 -7
- package/content/tools/version-bump.md +5 -5
- package/dist/cli/commands/observe.d.ts +2 -0
- package/dist/cli/commands/observe.d.ts.map +1 -1
- package/dist/cli/commands/observe.js +9 -1
- package/dist/cli/commands/observe.js.map +1 -1
- package/dist/cli/commands/observe.test.js +36 -0
- package/dist/cli/commands/observe.test.js.map +1 -1
- package/dist/config/schema.d.ts +672 -126
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +8 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/config/schema.test.js +2 -2
- package/dist/config/schema.test.js.map +1 -1
- package/dist/config/validators/index.d.ts.map +1 -1
- package/dist/config/validators/index.js +2 -0
- package/dist/config/validators/index.js.map +1 -1
- package/dist/config/validators/web3.d.ts +4 -0
- package/dist/config/validators/web3.d.ts.map +1 -0
- package/dist/config/validators/web3.js +15 -0
- package/dist/config/validators/web3.js.map +1 -0
- package/dist/e2e/project-type-overlays.test.js +76 -0
- package/dist/e2e/project-type-overlays.test.js.map +1 -1
- package/dist/observability/adapters/beads.d.ts +4 -0
- package/dist/observability/adapters/beads.d.ts.map +1 -1
- package/dist/observability/adapters/beads.js +25 -2
- package/dist/observability/adapters/beads.js.map +1 -1
- package/dist/observability/adapters/beads.test.js +40 -2
- package/dist/observability/adapters/beads.test.js.map +1 -1
- package/dist/observability/engine/ledger-writer.d.ts +11 -1
- package/dist/observability/engine/ledger-writer.d.ts.map +1 -1
- package/dist/observability/engine/ledger-writer.js +6 -0
- package/dist/observability/engine/ledger-writer.js.map +1 -1
- package/dist/observability/engine/llm-dispatcher.d.ts.map +1 -1
- package/dist/observability/engine/llm-dispatcher.js +36 -5
- package/dist/observability/engine/llm-dispatcher.js.map +1 -1
- package/dist/observability/engine/llm-dispatcher.test.js +23 -0
- package/dist/observability/engine/llm-dispatcher.test.js.map +1 -1
- package/dist/project/adopt.d.ts.map +1 -1
- package/dist/project/adopt.js +3 -1
- package/dist/project/adopt.js.map +1 -1
- package/dist/project/detectors/coverage.test.js +3 -2
- package/dist/project/detectors/coverage.test.js.map +1 -1
- package/dist/project/detectors/disambiguate.js +1 -1
- package/dist/project/detectors/disambiguate.js.map +1 -1
- package/dist/project/detectors/index.d.ts.map +1 -1
- package/dist/project/detectors/index.js +2 -0
- package/dist/project/detectors/index.js.map +1 -1
- package/dist/project/detectors/resolve-detection.test.js +57 -0
- package/dist/project/detectors/resolve-detection.test.js.map +1 -1
- package/dist/project/detectors/types.d.ts +6 -2
- package/dist/project/detectors/types.d.ts.map +1 -1
- package/dist/project/detectors/types.js.map +1 -1
- package/dist/project/detectors/web3.d.ts +4 -0
- package/dist/project/detectors/web3.d.ts.map +1 -0
- package/dist/project/detectors/web3.js +37 -0
- package/dist/project/detectors/web3.js.map +1 -0
- package/dist/project/detectors/web3.test.d.ts +2 -0
- package/dist/project/detectors/web3.test.d.ts.map +1 -0
- package/dist/project/detectors/web3.test.js +75 -0
- package/dist/project/detectors/web3.test.js.map +1 -0
- package/dist/types/config.d.ts +8 -1
- package/dist/types/config.d.ts.map +1 -1
- package/dist/wizard/copy/core.d.ts.map +1 -1
- package/dist/wizard/copy/core.js +4 -0
- package/dist/wizard/copy/core.js.map +1 -1
- package/dist/wizard/copy/index.d.ts.map +1 -1
- package/dist/wizard/copy/index.js +2 -0
- package/dist/wizard/copy/index.js.map +1 -1
- package/dist/wizard/copy/types.d.ts +5 -1
- package/dist/wizard/copy/types.d.ts.map +1 -1
- package/dist/wizard/copy/types.test-d.js +7 -0
- package/dist/wizard/copy/types.test-d.js.map +1 -1
- package/dist/wizard/copy/web3.d.ts +3 -0
- package/dist/wizard/copy/web3.d.ts.map +1 -0
- package/dist/wizard/copy/web3.js +15 -0
- package/dist/wizard/copy/web3.js.map +1 -0
- package/dist/wizard/questions.d.ts +2 -1
- package/dist/wizard/questions.d.ts.map +1 -1
- package/dist/wizard/questions.js +8 -1
- package/dist/wizard/questions.js.map +1 -1
- package/dist/wizard/questions.test.js +14 -0
- package/dist/wizard/questions.test.js.map +1 -1
- package/dist/wizard/wizard.d.ts.map +1 -1
- package/dist/wizard/wizard.js +1 -0
- package/dist/wizard/wizard.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web3-access-control
|
|
3
|
+
description: Role-based access control for smart contracts — Ownable2Step, OpenZeppelin AccessControl, Safe multisig as admin, TimelockController on dangerous ops, role separation, and decentralization via renouncing
|
|
4
|
+
topics: [web3, access-control, openzeppelin, multisig, timelock]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Most contract exploits are not novel cryptography — they are either missing access-control checks ("anyone can call `setFeeRecipient`") or a single admin key getting drained, phished, or coerced. Access control is the answer to three questions: who can change what, when can they change it, and whose consent is required to authorize the change. A protocol whose answer is "the deployer EOA, immediately, alone" is a protocol one phishing email away from a post-mortem. Production-grade access control replaces each of those answers with a deliberate engineering choice: granular roles, time-locked execution windows, and multi-party signing.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
Default to OpenZeppelin's `AccessControl` over `Ownable` for anything heading to mainnet — multiple narrow roles beat one omnipotent owner, and revocation lets you respond to a key compromise without a redeploy. When you do need single-admin semantics in an early prototype, use `Ownable2Step` not raw `Ownable`, so a fat-fingered transfer to a wrong address cannot brick your protocol. The admin role itself should live on a Safe multisig — 2-of-3 minimum, 3-of-5 typical for serious TVL — with signers on different hardware in different physical locations. Wrap every irreversible operation (upgrades, treasury drains, fee-cap changes, oracle swaps) in a `TimelockController` with a 24–72 hour delay so users can exit if a malicious or compromised proposal lands. Separate `MINTER_ROLE`, `PAUSER_ROLE`, `UPGRADER_ROLE`, and `TREASURER_ROLE` to distinct multisigs where the budget allows, and renounce roles you no longer need once decentralization milestones are reached.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### Ownable vs AccessControl
|
|
16
|
+
|
|
17
|
+
`Ownable` gives you exactly one privileged address — the `owner` — and one modifier, `onlyOwner`. That is fine for a hackathon contract or a private prototype. It is wrong for anything production-bound, because every privileged operation in your protocol now collapses to the same key: pausing, upgrading, withdrawing treasury, rotating oracle, minting. Compromise that key and the attacker gets everything in one transaction.
|
|
18
|
+
|
|
19
|
+
The post-mortem pattern is depressingly consistent: protocol launches with `Ownable` and a single key on a laptop, team plans to "migrate to a multisig later," the migration keeps slipping because nothing is on fire, the laptop is compromised, attacker calls every `onlyOwner` function in a single transaction, protocol is drained. The fix is not "be more careful with the key" — it is to never have a contract whose privileged surface is one key in the first place.
|
|
20
|
+
|
|
21
|
+
`AccessControl` from OpenZeppelin gives you `bytes32`-identified roles, each independently grantable and revocable, gated by an `onlyRole(ROLE)` modifier. The `DEFAULT_ADMIN_ROLE` controls grants and revocations; everything else is whatever you define. Default to `AccessControl` for production:
|
|
22
|
+
|
|
23
|
+
```solidity
|
|
24
|
+
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
|
|
25
|
+
|
|
26
|
+
contract Protocol is AccessControl {
|
|
27
|
+
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
|
|
28
|
+
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
|
|
29
|
+
bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE");
|
|
30
|
+
|
|
31
|
+
constructor(address admin) {
|
|
32
|
+
_grantRole(DEFAULT_ADMIN_ROLE, admin); // admin can grant/revoke others
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
|
|
36
|
+
// ...
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`AccessControl` also gives you `hasRole(role, account)` for off-chain queries (your indexer, your dashboard, your audit checklist), `getRoleAdmin(role)` for understanding the grant hierarchy, and the standard `RoleGranted` / `RoleRevoked` / `RoleAdminChanged` events for the kind of monitoring an EOA owner cannot offer. The auditability of the system is itself a security property — when every grant lands as an indexed event, missing or unexpected role grants are observable, not invisible.
|
|
42
|
+
|
|
43
|
+
`Ownable` makes sense in exactly one narrow case: a brand-new contract where you genuinely have only one privileged action and you will migrate to `AccessControl` before mainnet. Anything else, reach for `AccessControl` from the first commit.
|
|
44
|
+
|
|
45
|
+
The two are not mutually exclusive at the protocol level — you can have an `Ownable2Step` factory that deploys `AccessControl`-based clones — but pick one as the canonical access pattern for any given contract. Mixing `onlyOwner` and `onlyRole(DEFAULT_ADMIN_ROLE)` in the same contract is a footgun: reviewers have to mentally re-derive which check actually gates a given function, and a future maintainer will inevitably gate one function with the wrong modifier.
|
|
46
|
+
|
|
47
|
+
### Two-step ownership transfer (Ownable2Step)
|
|
48
|
+
|
|
49
|
+
If you must use `Ownable`, use `Ownable2Step`. Raw `Ownable.transferOwnership(newOwner)` is a single-call transfer: type the wrong address (zero-knowledge-proof copy-paste hash, address with one character flipped, an address that is actually a contract that cannot accept ownership) and your protocol is now owned by a black hole. There is no recovery.
|
|
50
|
+
|
|
51
|
+
`Ownable2Step` requires the recipient to actively `acceptOwnership` in a second transaction:
|
|
52
|
+
|
|
53
|
+
```solidity
|
|
54
|
+
import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol";
|
|
55
|
+
|
|
56
|
+
contract Treasury is Ownable2Step {
|
|
57
|
+
constructor(address initialOwner) Ownable(initialOwner) {}
|
|
58
|
+
// existing owner calls transferOwnership(newOwner); pendingOwner = newOwner
|
|
59
|
+
// newOwner calls acceptOwnership(); owner = newOwner, pendingOwner = 0
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The pending-owner pattern means a wrong address simply never accepts, the transfer expires harmlessly, and you try again. Never deploy raw `Ownable` to mainnet — the fat-fingering risk is real and the cost of `Ownable2Step` is one extra transaction at handoff time. The same two-step pattern applies inside `AccessControl` for admin-role transfers: grant the new admin first, verify they hold the role, then revoke the old one. Atomic role swaps with no overlap are the same hazard as one-step ownership transfers.
|
|
64
|
+
|
|
65
|
+
Real-world incidents have happened where a team intended to transfer ownership to a multisig but pasted an address that was actually a contract with no payable fallback, or an address from the wrong network's deployment — in both cases the new owner could neither act on the contract nor recover from the mistake. Two-step transfer makes both classes of error visible during the pending window: the recipient calls `acceptOwnership` from the intended address or the handoff fails safely.
|
|
66
|
+
|
|
67
|
+
A quick decision tree for new contracts:
|
|
68
|
+
|
|
69
|
+
- One privileged action, prototype scope, throwaway deploy → `Ownable` is fine.
|
|
70
|
+
- One privileged action, mainnet deploy, single admin → `Ownable2Step` only.
|
|
71
|
+
- Two or more privileged actions, or any mainnet deploy with meaningful TVL → `AccessControl`.
|
|
72
|
+
- Any contract that may be upgraded later → `AccessControl` from day one, because retrofitting roles into a deployed `Ownable` contract via upgrade is painful and error-prone.
|
|
73
|
+
|
|
74
|
+
### Roles via OpenZeppelin AccessControl
|
|
75
|
+
|
|
76
|
+
Roles are `bytes32` identifiers, by convention `keccak256("ROLE_NAME")`. Define each role as a `public constant` so off-chain tooling can read it from the ABI. Use `_grantRole` and `_revokeRole` inside the constructor or admin functions; user-facing grants go through `grantRole(role, account)` which is gated by the role's admin (defaults to `DEFAULT_ADMIN_ROLE`). Never hand-compute the bytes32 literal in code reviews — paste the keccak256 call instead, because reviewers compare role definitions across files by their string name, not by their hash.
|
|
77
|
+
|
|
78
|
+
```solidity
|
|
79
|
+
bytes32 public constant TREASURER_ROLE = keccak256("TREASURER_ROLE");
|
|
80
|
+
|
|
81
|
+
constructor(address adminMultisig, address treasurerMultisig) {
|
|
82
|
+
_grantRole(DEFAULT_ADMIN_ROLE, adminMultisig);
|
|
83
|
+
_grantRole(TREASURER_ROLE, treasurerMultisig);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function withdrawFees(address to, uint256 amount) external onlyRole(TREASURER_ROLE) {
|
|
87
|
+
// ...
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
A subtle hazard with `_grantRole` in constructors: if you grant `DEFAULT_ADMIN_ROLE` to two addresses (say, an EOA deployer and the eventual multisig), you have just doubled your attack surface for the entire lifetime of the contract. Grant exactly one admin from the constructor, then have that admin perform any further grants in a subsequent transaction so the on-chain history records each privilege handoff as a distinct event.
|
|
92
|
+
|
|
93
|
+
Set distinct admin roles per role with `_setRoleAdmin(MINTER_ROLE, MINTER_ADMIN_ROLE)` when you want minter grants to require a different multisig than the protocol-wide admin. That extra layer is worth it for roles that are commonly granted (a permissioned launchpad granting minter rights to dozens of partners) so the global admin key is not constantly touched.
|
|
94
|
+
|
|
95
|
+
Emit and index the standard `RoleGranted` and `RoleRevoked` events that `AccessControl` provides for free — your off-chain monitoring (Tenderly, OpenZeppelin Defender, custom indexer) should page on any role-membership change so an unexpected grant cannot land silently. Off-chain visibility is half the value of on-chain access control: a granted role nobody noticed is the same as an unconfigured permission. Pair this with a published `roles.md` listing the canonical holder of each role so the community can independently verify that on-chain reality matches the documented governance.
|
|
96
|
+
|
|
97
|
+
A common mistake worth calling out explicitly: granting `DEFAULT_ADMIN_ROLE` to the `msg.sender` of the constructor when deploying from a CI script. The deployer EOA now permanently holds the protocol's most powerful role, and unless step two of your deploy script is a `grantRole(DEFAULT_ADMIN_ROLE, multisig)` followed by `renounceRole(DEFAULT_ADMIN_ROLE, msg.sender)` in the same forge script, you have a window where the deployer key gates everything. Bake the multisig address into the constructor or deploy script parameters and grant the admin role to the multisig directly from the constructor; never use the deployer EOA as a permanent admin.
|
|
98
|
+
|
|
99
|
+
### Multisig (Safe)
|
|
100
|
+
|
|
101
|
+
Every privileged role on mainnet goes behind a Safe — `safe.global`, formerly Gnosis Safe — never an EOA. A Safe is itself a smart contract that requires `M-of-N` signatures from configured signer addresses before executing any transaction. The trade-off is simple: an EOA owner is a single phishing email or compromised laptop away from a drained protocol; a 3-of-5 Safe survives any two signers being compromised or unavailable.
|
|
102
|
+
|
|
103
|
+
Defaults worth memorizing:
|
|
104
|
+
|
|
105
|
+
- **2-of-3** — minimum acceptable for any mainnet deployment with non-trivial value. Three signers, two required. Cheap to operate, survives one compromise.
|
|
106
|
+
- **3-of-5** — typical for serious TVL. Five signers (ideally with operational diversity — different team members, different geographies, different hardware vendors), three required. Survives two compromises and tolerates one signer being on vacation.
|
|
107
|
+
- **4-of-7 or 5-of-9** — for protocols with eight-or-nine-figure TVL or those acting as governance for a DAO treasury.
|
|
108
|
+
|
|
109
|
+
Signer hygiene matters as much as the threshold. Every signer holds their key on a hardware wallet (Ledger, GridPlus, Keystone) — never a hot wallet, never a browser extension as the sole layer. Rotate signers when team members leave: `addOwnerWithThreshold` and `removeOwner` are governance operations, not afterthoughts. Publish the current signer set and threshold in your docs so users can verify the Safe matches your stated security model. The Safe should hold `DEFAULT_ADMIN_ROLE` and any sensitive operational role; passing `safe.address` as the admin in the constructor is the standard pattern.
|
|
110
|
+
|
|
111
|
+
Two operational practices worth wiring up at deployment time:
|
|
112
|
+
|
|
113
|
+
- **Rehearse a signing ceremony before mainnet.** Have every signer connect their hardware, review the transaction in Safe's UI, simulate via Tenderly, and sign — for a no-op transaction like a self-pause-and-unpause. The first time someone signs should not be the night of an incident, and unfamiliar tooling is how `_setImplementation` gets signed by accident.
|
|
114
|
+
- **Threshold-aware time budget.** A 3-of-5 across three time zones means the realistic time-to-execute is hours, not minutes. Size your `Pausable` kill-switch on a tighter 2-of-3 ops Safe so emergency pauses are fast, and keep the slow `DEFAULT_ADMIN_ROLE` on the wider Safe.
|
|
115
|
+
|
|
116
|
+
### Timelock (TimelockController)
|
|
117
|
+
|
|
118
|
+
A multisig stops one compromised key. A timelock stops a compromised multisig — or, more commonly, a coerced one. `TimelockController` from OpenZeppelin sits between the multisig and the protocol: dangerous operations are first `schedule`d (recording the call hash and a delay), then `execute`d after the delay elapses. During the delay, the call is publicly visible on-chain; if it is malicious, users have time to exit.
|
|
119
|
+
|
|
120
|
+
Coercion is the often-overlooked threat model. A 3-of-5 Safe defeats one or two compromised keys but does not defeat a court order, a physical threat to multiple signers in the same jurisdiction, or social-engineering of a quorum through a coordinated phishing campaign. The timelock buys 48 hours during which the broader community can observe the proposed action, raise the alarm, and exit positions even if every signer wanted the operation to land. It also creates a real audit trail for any future post-mortem about whether the operation was legitimate.
|
|
121
|
+
|
|
122
|
+
```solidity
|
|
123
|
+
import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
|
|
124
|
+
|
|
125
|
+
// proposers: addresses that can schedule (typically the Safe multisig)
|
|
126
|
+
// executors: addresses that can execute after delay (often address(0) for "anyone")
|
|
127
|
+
// minDelay: seconds — 48 hours is typical for protocol-altering ops
|
|
128
|
+
address[] memory proposers = new address[](1);
|
|
129
|
+
proposers[0] = safeMultisig;
|
|
130
|
+
address[] memory executors = new address[](1);
|
|
131
|
+
executors[0] = address(0); // anyone can execute after the delay
|
|
132
|
+
TimelockController timelock = new TimelockController(
|
|
133
|
+
48 hours, // delay
|
|
134
|
+
proposers,
|
|
135
|
+
executors,
|
|
136
|
+
address(0) // admin — set to 0 to lock the timelock's own params
|
|
137
|
+
);
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Note the `address(0)` admin: passing zero means the timelock cannot have its own parameters (delay, proposer set, executor set) changed after deployment except through the timelock itself. That bootstraps a system where any change to the timelock's own configuration has to go through the timelock's delay — including a change to the delay itself. Leaving the admin set to an EOA or even the Safe means the multisig can shorten the delay with no warning, defeating the whole point.
|
|
141
|
+
|
|
142
|
+
Grant the protocol's `UPGRADER_ROLE`, `DEFAULT_ADMIN_ROLE`, or treasury-drain role to the `timelock` address, not to the Safe directly. The Safe proposes via `timelock.schedule(...)`; after 48 hours, anyone calls `timelock.execute(...)`. The pause kill-switch stays on the Safe directly (you cannot time-lock an emergency); the dangerous knobs are slow. Publish a monitoring feed of all scheduled operations — Tenderly Alerts, OpenZeppelin Defender, or a custom indexer — so users do not have to watch the mempool themselves.
|
|
143
|
+
|
|
144
|
+
Calibrate the delay to your protocol's exit time. A lending market where users can withdraw collateral in one transaction can run a 24h timelock; a liquid-staking protocol with a 7-day unbonding queue needs a 7-day-plus timelock or users have no real exit window. The asymmetry to avoid: a 24h timelock on an upgrade whose effect users cannot exit from in 24h is security theater. Pair the timelock with an `OperationCancelled` event so the multisig can publicly back out of a proposal mid-window if it discovers a mistake, and so users can verify cancellations rather than guess.
|
|
145
|
+
|
|
146
|
+
One pitfall worth noting: the timelock's `execute` permission. If `executors` is set to a specific address rather than `address(0)`, only that address can execute scheduled operations. Setting it to the multisig means a hostile multisig can simply refuse to execute a beneficial proposal it scheduled; setting it to `address(0)` means anyone can execute after the delay, which is almost always what you want — the multisig commits to the operation by scheduling it, and the community guarantees execution by being able to send the final transaction themselves.
|
|
147
|
+
|
|
148
|
+
### Role separation
|
|
149
|
+
|
|
150
|
+
Granular roles only matter if they are held by genuinely different parties. A `MINTER_ROLE`, `PAUSER_ROLE`, `UPGRADER_ROLE`, and `TREASURER_ROLE` all held by the same Safe collapse back to a single point of compromise — the ABI looks like role separation, but the on-chain reality is one key gating everything. Reviewers will spot this immediately; users may not, which is precisely the kind of asymmetry that destroys trust after an incident. Where the operational budget supports it, each role goes on a different multisig:
|
|
151
|
+
|
|
152
|
+
- **`MINTER_ROLE`** — held by the issuance multisig, ideally with a `MAX_MINT_PER_PERIOD` cap enforced in-contract. Compromise mints tokens; it does not drain the treasury.
|
|
153
|
+
- **`PAUSER_ROLE`** — held by an ops multisig with lower threshold (2-of-3) for faster incident response. Compromise pauses the protocol; it does not steal funds. Often paired with a `GUARDIAN_ROLE` that can pause but not unpause, so a noisy alert can trigger a halt without exposing the unpause power to the same hot key.
|
|
154
|
+
- **`UPGRADER_ROLE`** — held by a governance multisig behind the timelock. Compromise of the multisig still requires waiting out the timelock with users watching. See `web3-upgradeability.md` for the proxy-admin coupling.
|
|
155
|
+
- **`TREASURER_ROLE`** — held by a treasury multisig with the highest threshold and most conservative signer set. Withdrawals above a configured threshold should additionally require the timelock; small operational disbursements can bypass the delay.
|
|
156
|
+
- **`FEE_RECIPIENT`** — often stored as an address rather than a role; restrict who can change it with `DEFAULT_ADMIN_ROLE` behind the timelock. The fee recipient is a common phishing target because a single-line config change can redirect every protocol fee — keep it slow.
|
|
157
|
+
|
|
158
|
+
The principle: any single multisig compromise should bound the blast radius to one role's worth of damage. A pauser key getting phished should not drain the treasury.
|
|
159
|
+
|
|
160
|
+
Smaller protocols routinely cannot afford four distinct multisigs with non-overlapping signers, and that is fine — collapse to two (an "ops" Safe for pause and an "admin" Safe for everything else) rather than one. Document the consolidation honestly in your security model: "we run a single 3-of-5 Safe for all privileged roles" is a defensible position with a clear risk profile; "we have four roles" while all four point at the same address is misleading documentation and an audit finding waiting to happen.
|
|
161
|
+
|
|
162
|
+
In-contract caps are the complement to role separation: a `MINTER_ROLE` with a `MAX_MINT_PER_DAY` ceiling is materially safer than the same role without one, because a compromised minter cannot exceed the cap in a single block. Caps on the most blast-prone roles (mint rate, fee ceiling, oracle drift tolerance, withdrawal-from-treasury rate) are cheap to add at deploy time and very hard to bolt on after an incident.
|
|
163
|
+
|
|
164
|
+
### Renouncing roles
|
|
165
|
+
|
|
166
|
+
`renounceRole(role, msg.sender)` permanently removes the calling account from a role with no recovery. Use it deliberately at decentralization milestones — once the protocol parameters are frozen and the community is governing through a DAO contract, renounce `DEFAULT_ADMIN_ROLE` from the original deployer multisig so the role becomes uncallable. The on-chain renouncement is auditable proof that the team can no longer unilaterally change the protocol.
|
|
167
|
+
|
|
168
|
+
Critically, `renounceRole` only removes the role from `msg.sender` — the caller. There is no way to renounce on behalf of someone else, which is deliberate: a stolen admin key cannot be "renounced out" by the rest of the team, only revoked through normal `revokeRole` machinery. Users sometimes confuse "the team renounced ownership" (a single account dropping its own role) with "the protocol is unowned" (no account holds the admin role). The two coincide only when the team is the sole admin and explicitly renounces. Verify on-chain via `hasRole(DEFAULT_ADMIN_ROLE, account)` for every previously-privileged address, not by reading a blog post.
|
|
169
|
+
|
|
170
|
+
```solidity
|
|
171
|
+
// after a governance vote moves admin to a DAO timelock:
|
|
172
|
+
protocol.renounceRole(protocol.DEFAULT_ADMIN_ROLE(), originalDeployerMultisig);
|
|
173
|
+
// future grants/revokes must go through the DAO
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Two cautions. First, renouncing is irreversible: if you renounce the only admin and then discover you needed it for an upgrade, you have bricked the upgrade path. Stage decentralization — grant the new admin first, exercise it once on a no-op operation to prove it works, then renounce the old. Second, renounce specific roles, not blanket admin: an "immutable" stage usually still wants a pauser for emergencies. Audit each role independently and decide which ones are safe to surrender.
|
|
177
|
+
|
|
178
|
+
A pragmatic decentralization ladder, in order of typical adoption:
|
|
179
|
+
|
|
180
|
+
1. **Deploy with EOA as admin** for the launch week, when bugs are likeliest and fast iteration matters. Tolerable only because TVL is still capped by deposit limits.
|
|
181
|
+
2. **Migrate admin to a small team Safe** (2-of-3) once the contract is stable enough that the team is no longer hot-fixing daily.
|
|
182
|
+
3. **Add a `TimelockController` between the Safe and the protocol** for upgrade and treasury operations. Pause stays on the Safe directly.
|
|
183
|
+
4. **Expand to a larger, more diverse Safe** (3-of-5 or 4-of-7) as TVL grows.
|
|
184
|
+
5. **Migrate admin to a DAO governance contract** that itself proposes through the timelock, once the community is real and voting power is sufficiently distributed.
|
|
185
|
+
6. **Renounce role-specific admin powers** as parameters are locked permanently (e.g., supply cap, fee ceiling) — turn dials into constants.
|
|
186
|
+
|
|
187
|
+
Each step is a deliberate governance event with its own announcement, monitoring window, and rollback plan. Skipping straight from step 1 to step 6 is the "rugpull-by-incompetence" pattern that bricks more protocols than malicious admins do.
|
|
188
|
+
|
|
189
|
+
See `web3-upgradeability.md` for how the proxy-admin role interacts with this access-control model, `web3-audit-workflow.md` for the operational checks an audit will run against your role wiring, and `web3-security.md` for the wider security layer this access-control model lives inside.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web3-architecture
|
|
3
|
+
description: EVM smart-contract architecture decisions — modular vs monolithic decomposition, OpenZeppelin baseline, state minimization, library vs inheritance, diamond pattern caveats, and external-call discipline
|
|
4
|
+
topics: [web3, architecture, solidity, evm, openzeppelin]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
This overlay targets **EVM** chains — Ethereum mainnet, the major L2s (Arbitrum, Optimism, Base, zkSync, Polygon zkEVM), and other EVM-compatible execution layers running Solidity 0.8.x bytecode. Non-EVM ecosystems (Solana / Anchor, Aptos and Sui / Move, Cosmos / CosmWasm) have fundamentally different storage, account, and execution models and are deliberately out of scope here; a future W3-2 overlay may cover dApp / frontend work and non-EVM runtimes. Architecture in this doc means the contract-side decisions a protocol lead makes before the first `forge build`: how to decompose, what to inherit, how state is laid out, and where the trust boundaries fall.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
Decompose by responsibility, not by cleverness: a protocol over ~500 LOC almost always wants multiple contracts (each under 500 LOC of behavior), a small single-purpose contract is fine as one file. Inherit from `OpenZeppelin` for every standard primitive — ERC20, ERC721, `AccessControl`, `Pausable`, `ReentrancyGuard` — never re-implement them. Pack storage so each `struct` fits the fewest 32-byte slots possible and mark deploy-time constants `immutable` (or `constant` for compile-time literals) — every saved SLOAD is real money for users. Reach for libraries when logic is pure utility callable from many contracts, and for inheritance when you want shared state and roles. Avoid the `diamond pattern` (EIP-2535) unless you genuinely hit the 24 KB code-size limit or need pluggable facets — it adds storage-layout and audit complexity most protocols never need.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### EVM-only scope
|
|
16
|
+
|
|
17
|
+
This doc assumes Solidity ≥0.8.20 targeting EVM bytecode. L2-specific quirks (precompile differences, calldata pricing on rollups, account abstraction on zkSync) are noted only where they change a decomposition decision. If your protocol must run on Solana or Move-based chains, the entire decomposition vocabulary here — contracts, libraries, inheritance, slots — does not translate, and you should not generalize from this overlay. The reverse direction is roughly safe: most of what works on Ethereum mainnet works on any EVM L2, with gas constants and finality assumptions adjusted; consult chain-specific docs before deploying anything that depends on `block.timestamp` granularity, opcode pricing, or `msg.sender == tx.origin`.
|
|
18
|
+
|
|
19
|
+
### Modular vs monolithic decomposition
|
|
20
|
+
|
|
21
|
+
The default rule is brutal and useful: **if the contract is over ~500 lines of behavior, split it.** Above that line, auditors stop being able to hold the whole thing in their head, your test surface explodes, and you start brushing against the 24 KB deployed-code limit. Below it, one contract is almost always the right answer — cross-contract calls cost ~2,600 gas of `CALL` overhead plus calldata, and every external surface is a new trust boundary you must reason about. Split along responsibility lines: a vault, an oracle adapter, and a fee router are three contracts; a single ERC20 with a mint guard is one. Per-contract LOC budget keeps each component reviewable, but more importantly it forces you to name the boundary — what does this contract own, what does it merely call?
|
|
22
|
+
|
|
23
|
+
The tradeoffs to weigh consciously:
|
|
24
|
+
|
|
25
|
+
| Concern | Monolithic | Modular |
|
|
26
|
+
|---------------------|---------------------------------------------|-----------------------------------------------|
|
|
27
|
+
| Gas per user action | Cheapest — internal calls are JUMP | +2,600 gas per `CALL` plus calldata |
|
|
28
|
+
| Upgradeability | All-or-nothing redeploy | Swap one module without touching others |
|
|
29
|
+
| Audit surface | One file, one storage layout | Multiple interfaces, every boundary is review |
|
|
30
|
+
| Code-size headroom | Tight against 24 KB EIP-170 limit | Each contract has its own 24 KB budget |
|
|
31
|
+
|
|
32
|
+
A useful heuristic: if two responsibilities never share storage and could be developed by different people without merge conflicts, they should be different contracts.
|
|
33
|
+
|
|
34
|
+
Concrete worked example — a yield vault that supports multiple collateral types and a single fee recipient. Three contracts beat one:
|
|
35
|
+
|
|
36
|
+
- `Vault.sol` — accounting for shares, deposits, withdrawals, share-price math. ERC4626-shaped.
|
|
37
|
+
- `StrategyRegistry.sol` — maps each collateral asset to an approved strategy adapter; governance can rotate strategies without touching the vault.
|
|
38
|
+
- `FeeRouter.sol` — collects performance fees, splits between protocol treasury and stakers; can be swapped to change fee policy.
|
|
39
|
+
|
|
40
|
+
If you wrote that as one contract you would have ~800 lines, three independent governance flows tangled together, and every audit finding in one area would force re-review of the whole file. Three contracts gives you a 300 / 250 / 150 LOC split where each piece has one job and one storage layout. The gas cost of the extra `CALL`s — call it ~5,000 gas per deposit — is real but small relative to the SLOAD-heavy share accounting that dominates the transaction.
|
|
41
|
+
|
|
42
|
+
### OpenZeppelin as baseline
|
|
43
|
+
|
|
44
|
+
Never re-implement standard primitives. `OpenZeppelin` contracts have been audited dozens of times, formally verified in pieces, and are the de-facto reference implementations auditors expect to see. Inherit, don't fork:
|
|
45
|
+
|
|
46
|
+
```solidity
|
|
47
|
+
// src/Vault.sol
|
|
48
|
+
pragma solidity 0.8.24;
|
|
49
|
+
|
|
50
|
+
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
|
51
|
+
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
|
|
52
|
+
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
|
|
53
|
+
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
|
|
54
|
+
|
|
55
|
+
contract Vault is ERC20, AccessControl, Pausable, ReentrancyGuard {
|
|
56
|
+
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
|
|
57
|
+
// ...
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The mental model: OZ is your standard library. If you find yourself writing `_transfer` or a `nonReentrant` modifier from scratch, stop. See `web3-access-control.md` for how role partitioning composes with `AccessControl`.
|
|
62
|
+
|
|
63
|
+
Pin OZ to an exact minor version in `foundry.toml` (or `package.json` for Hardhat) and bump it deliberately — OZ ships breaking changes between majors (v4 → v5 reworked `AccessControl` initialization, removed `Ownable` defaults, and changed several event signatures). Track their security advisories; an audited OZ release is one of the few dependencies whose patch notes you should actually read on update. If you must override OZ internals, do so by overriding `_update`, `_mint`, or other documented hooks rather than copy-pasting the contract — every fork loses the next round of fixes.
|
|
64
|
+
|
|
65
|
+
### State minimization
|
|
66
|
+
|
|
67
|
+
Every storage slot is 20,000 gas to write the first time and 2,900 gas to update — at $50 ETH gas this is real money per user action. Two disciplines pay for themselves immediately. **First, pack structs to fit slots.** The EVM lays out storage in 32-byte (256-bit) slots; smaller types share a slot when declared contiguously:
|
|
68
|
+
|
|
69
|
+
```solidity
|
|
70
|
+
// 1 slot total — uint64 + uint64 + uint128 = 256 bits
|
|
71
|
+
struct Position {
|
|
72
|
+
uint64 openedAt; // timestamp fits in uint64 until year 584942417355
|
|
73
|
+
uint64 lastUpdatedAt;
|
|
74
|
+
uint128 amount; // up to ~3.4e38 — plenty for most token amounts
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// vs. the naive 3-slot version
|
|
78
|
+
struct PositionBad {
|
|
79
|
+
uint256 openedAt;
|
|
80
|
+
uint256 lastUpdatedAt;
|
|
81
|
+
uint256 amount;
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Second, use `immutable` for values fixed at deploy and `constant` for compile-time literals.** Both live in bytecode, not storage, so reads cost ~3 gas instead of ~2,100:
|
|
86
|
+
|
|
87
|
+
```solidity
|
|
88
|
+
contract Vault {
|
|
89
|
+
address public immutable asset; // set once in constructor
|
|
90
|
+
uint256 public constant MAX_FEE_BPS = 500; // compile-time
|
|
91
|
+
|
|
92
|
+
constructor(address _asset) {
|
|
93
|
+
asset = _asset;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A third habit worth naming: **prefer events over storage for log-shaped data**. If a value is consumed off-chain (UI, indexer, subgraph) and never read by another contract, emit an event instead of writing storage. Events are roughly an order of magnitude cheaper than `SSTORE` and don't bloat the state your node operators carry forever.
|
|
99
|
+
|
|
100
|
+
One subtlety to watch: struct packing only works when fields are declared in order from smallest to largest within a 32-byte slot, and only for **value types** (uints, addresses, bools, bytes32). Dynamic types (`string`, `bytes`, arrays, mappings) always occupy their own slot regardless of position, so don't try to "pack" them. Use `forge inspect <Contract> storage-layout` (Foundry) or `hardhat-storage-layout` to dump the actual layout and verify your packing assumptions before you ship — guessing is how you end up with a 4-slot struct you thought was 1.
|
|
101
|
+
|
|
102
|
+
### Libraries vs inheritance
|
|
103
|
+
|
|
104
|
+
Solidity offers two reuse mechanisms and they answer different questions. A `library` is pure logic — no state, no inheritance hierarchy, called via `DELEGATECALL` (for `external` library functions) or inlined at compile (for `internal`). Use libraries when the logic is stateless and called from many contracts: math helpers, byte manipulation, signature recovery. The `using X for Y` syntax attaches library functions to a type for ergonomic call sites:
|
|
105
|
+
|
|
106
|
+
```solidity
|
|
107
|
+
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
|
|
108
|
+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
109
|
+
|
|
110
|
+
contract Vault {
|
|
111
|
+
using SafeERC20 for IERC20;
|
|
112
|
+
|
|
113
|
+
function withdraw(IERC20 token, address to, uint256 amount) external {
|
|
114
|
+
token.safeTransfer(to, amount); // resolves to SafeERC20.safeTransfer(token, to, amount)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Use **inheritance** when you want shared state, roles, modifiers, or a partial implementation — `ERC20`, `AccessControl`, and `Pausable` all carry storage and require inheritance. Rule of thumb: stateless and reusable → library; stateful and specializable → base contract. Beware deep inheritance chains (more than three levels): C3 linearization, function-override resolution, and storage-layout ordering all become harder to reason about, and auditors will flag it. If your hierarchy looks like a Java class tree, refactor toward composition with internal helpers instead.
|
|
120
|
+
|
|
121
|
+
Two further library-design notes worth remembering. **`internal` library functions are inlined** at the call site, so they cost nothing extra in deployed bytecode beyond the inlined ops — they are essentially free abstraction. **`external` library functions** are deployed as a separate contract and invoked via `DELEGATECALL`, which means they share the caller's storage layout and execution context but live at their own address; you must link the library at deployment time. For most utility code (math helpers, conversion, packing), `internal` is what you want — keep external libraries for genuinely large logic blocks where the deployment-size savings justify the linking step.
|
|
122
|
+
|
|
123
|
+
### Diamond pattern (EIP-2535)
|
|
124
|
+
|
|
125
|
+
The diamond pattern routes calls through a proxy to one of many "facet" contracts via a function-selector table, letting a single address expose effectively unlimited code. It is the answer when you genuinely need pluggable facets — large protocols like Aave v3 use related patterns — or when you are wedged against the **24 KB EVM contract-size limit** (EIP-170). For everything else it is over-engineering with real cost: storage layout is now governed by namespaced "diamond storage" patterns that auditors must reason about, upgrade flows multiply, and tooling support is uneven. Default to plain proxies or no upgradeability at all (see `web3-upgradeability.md`), and only adopt the `diamond pattern` after you have written down the specific facets you need and confirmed a simpler decomposition won't fit. Premature diamonds have shipped more bugs than they have prevented.
|
|
126
|
+
|
|
127
|
+
A simple checklist before reaching for a diamond:
|
|
128
|
+
|
|
129
|
+
1. Have you tried splitting into 2–3 plain contracts that share an interface? Most "we need a diamond" intuitions dissolve here.
|
|
130
|
+
2. Is the 24 KB code-size limit a *current* problem, or a hypothetical future one? Don't pre-pay for a complexity tax you may never owe.
|
|
131
|
+
3. Do you actually need to add new facets after deployment, or do you just want clean module boundaries? The latter is better served by separate contracts with explicit interfaces.
|
|
132
|
+
4. Does your audit firm have diamond expertise on the team you'd hire? If not, you are also paying for their learning curve.
|
|
133
|
+
|
|
134
|
+
### External call discipline
|
|
135
|
+
|
|
136
|
+
Every `CALL` to another contract is a trust boundary — execution leaves your codebase and re-enters at the callee's whim. Two disciplines: type your external interactions through **interfaces**, not concrete types, and treat every external call as a reentrancy and revert risk (see `web3-security.md` for Checks-Effects-Interactions). Interfaces decouple deployment order, let you point at a mock in tests, and document the API surface in one place:
|
|
137
|
+
|
|
138
|
+
```solidity
|
|
139
|
+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
140
|
+
|
|
141
|
+
interface IUniswapV2Pair {
|
|
142
|
+
function getReserves() external view returns (uint112, uint112, uint32);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
contract Adapter {
|
|
146
|
+
IERC20 public immutable token;
|
|
147
|
+
IUniswapV2Pair public immutable pair;
|
|
148
|
+
// ...
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Public/external functions are your API; everything else stays `internal` for gas (cheaper than `public`/`external` jumps) and composability. Document the external surface with NatSpec — `@notice`, `@param`, `@return` — because that is what auditors and integrators read first. For oracles and any data source you don't control, see `web3-oracles-and-external-data.md` for staleness, deviation, and fallback discipline.
|
|
153
|
+
|
|
154
|
+
A final architectural habit: write the **interface file first**. Before implementing `Vault`, write `IVault.sol` with the function signatures, NatSpec, and custom errors you intend to expose. That file becomes the contract between your code and every integrator, and reviewing it before implementation forces you to commit to an API surface you can defend in audit. The implementation may evolve; the interface should not, except by versioning a new one alongside it.
|
|
155
|
+
|
|
156
|
+
Three concrete external-call rules worth codifying as review checks:
|
|
157
|
+
|
|
158
|
+
1. **Never `.call{value: x}("")` without checking the return value.** Solidity 0.8 will not auto-revert on a failed low-level call; you must `require(success, "transfer failed")` or use a custom error. Better still, use OZ's `Address.sendValue` which does it for you.
|
|
159
|
+
2. **Wrap any function that hands execution to an external contract with `nonReentrant`**, even if you "know" the callee is trusted. Trust assumptions rot — an audited token today becomes a callback-injecting fork tomorrow.
|
|
160
|
+
3. **Pull, don't push.** When sending value or tokens to a user-supplied address, prefer the pull-payment pattern (record an entitlement, let them withdraw) over pushing in the same transaction. One failing recipient cannot then brick a batch operation for everyone else. `web3-security.md` covers this in depth.
|
|
161
|
+
|
|
162
|
+
Taken together, the architecture decisions in this doc compose: a modular layout makes role partitioning (`web3-access-control.md`) tractable, immutable interfaces make upgrades (`web3-upgradeability.md`) safer, and disciplined external-call boundaries make oracle integration (`web3-oracles-and-external-data.md`) something you can actually reason about. The point is not to memorize patterns — it is to make the constraints of the EVM (gas, code size, immutability, public mempool) visible in the shape of the code itself, so they show up at design time rather than during an audit two weeks before mainnet.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web3-audit-workflow
|
|
3
|
+
description: Pre-audit readiness, tooling (Slither, Echidna, Halmos, Certora), CI integration, firm selection, timing, remediation, and post-launch bug bounties for protocol teams preparing for a smart-contract audit
|
|
4
|
+
topics: [web3, audit, slither, echidna, halmos, security]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Audits are expensive ($30k–$500k+) and time-consuming, and the meter starts the day the auditors open your repo — not the day they find their first bug. A team that hands over a half-finished spec, 40% test coverage, and a Slither report full of unaddressed mediums is paying senior security engineers to do the work the team should have done before kickoff. The protocols that get the most out of an audit treat the engagement as a final review against work already proven correct, not as a substitute for it. Maximize value by being ready before the audit starts.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
Walk into the audit with a written spec, >90% line and >80% branch coverage, a green Slither CI gate, a committed threat model, and a testnet-deployed contract — anything less and you are paying $5k/day for engineers to fill in your gaps. Wire `Slither` into every PR as a blocking check, layer `Echidna` and `Halmos` over invariant tests for the critical paths, and reserve `Certora` for high-TVL functions where formal verification pays for itself. Pick the firm to match the stakes: top-tier (Trail of Bits, OpenZeppelin, Consensys Diligence, Spearbit, Cantina) for serious TVL, competitive platforms (Code4rena, Sherlock, Cantina) for cost-sensitive scope. Book 2–4 months ahead, freeze code 1–2 weeks before kickoff, fix every High and Medium with a test plus an auditor re-verification, then run an `Immunefi` bounty continuously after launch.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### Pre-audit readiness checklist
|
|
16
|
+
|
|
17
|
+
Work this list in order. Each item is a precondition for the next; skipping one means the auditors spend their hours on what you should have shipped. A useful mental test: pretend you are paying $5,000/day out of your own pocket for each engineer on the audit. Would you rather they spend day one orienting on your README, or finding bugs? Every gap in this checklist trades day-zero engineering hours of your own for day-one auditor hours that cost ten times as much.
|
|
18
|
+
|
|
19
|
+
1. **Specification document with invariants explicitly listed.** "Total supply equals sum of balances," "no user withdraws more than they deposited," "only governance can change fee tiers," "the protocol is solvent at every block." Auditors cannot tell you whether your code is correct against an undefined target — without invariants they are reduced to pattern-matching for known vulnerabilities, which is the cheapest, lowest-value mode of audit work. The spec also becomes the artifact the audit report references, so a thin spec produces a thin report. Cross-ref `web3-requirements.md` for the spec template and invariant style.
|
|
20
|
+
2. **>90% line coverage, >80% branch coverage.** Generated via `forge coverage --report lcov` and enforced in CI. Anything less means auditors are reviewing code paths your tests have never executed, and they will flag those paths as untested rather than as subtly broken — wasting findings on hygiene. Aim higher on the contracts that touch funds (95%+) and accept lower on view-only helpers if the budget is tight. Cross-ref `web3-testing.md` for the fuzz and invariant patterns that make coverage meaningful rather than ceremonial.
|
|
21
|
+
3. **`forge test --gas-report` baseline captured.** Commit the report as `gas-snapshot.txt` at the repo root. Auditors flag gas regressions and griefing vectors (loops that scale with user count, unbounded storage growth); without a baseline you cannot tell whether a remediation fix made gas worse, and reviewers cannot tell whether a hot path you claim is "cheap" actually is.
|
|
22
|
+
4. **Slither CI gate green.** Zero high/medium findings, or each one annotated with a justification comment that survives review. Auditors who open the repo and see a wall of unaddressed Slither warnings will assume the rest of the codebase is held to the same standard, and price the engagement accordingly. See the next section for the config.
|
|
23
|
+
5. **Threat model document committed.** Who are the attackers (MEV searchers, governance attackers, malicious LPs, compromised oracles), what are their capabilities (flashloans, large stake, validator control, social engineering), what assets are at risk (TVL, governance tokens, NFT ownership), and what assumptions does the protocol make about external contracts and oracles. A short doc — one page is fine — that frames the audit conversation and makes implicit assumptions explicit.
|
|
24
|
+
6. **Deployment scripts tested on a public testnet.** Sepolia, Base Sepolia, Arbitrum Sepolia — whichever matches your target. Auditors should be able to interact with a live deployment, not just read source. A working testnet deployment also flushes out the deploy-script bugs (mis-ordered constructor args, wrong proxy admin, forgotten role grants) that are easy to miss in unit tests but catastrophic on mainnet.
|
|
25
|
+
7. **README with architecture, contracts list, deployment addresses.** A one-paragraph mental model per contract, a diagram of how they call each other, the testnet addresses, and a "how to run the tests" section that works from a clean checkout. Saves the auditors a full day of orientation that you are otherwise paying for.
|
|
26
|
+
|
|
27
|
+
### Mandatory tooling: Slither
|
|
28
|
+
|
|
29
|
+
`Slither` is the static-analysis baseline for Solidity. It is free, fast, catches a wide class of footguns (reentrancy, uninitialized storage, shadowed variables, incorrect ERC-20 returns), and runs in under a minute on most repos. Install once, run on every PR, fail the build on regressions.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install slither-analyzer
|
|
33
|
+
slither . --config-file .slither.config.json
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Minimal `.slither.config.json`:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"filter_paths": "lib/|test/|script/",
|
|
41
|
+
"exclude_informational": true,
|
|
42
|
+
"exclude_low": false,
|
|
43
|
+
"fail_high": true,
|
|
44
|
+
"fail_medium": true
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The CI rule is simple: zero high and zero medium findings unless explicitly justified with a `// slither-disable-next-line <detector>` comment naming the detector and the reason. Every disable is a claim the team is making in writing; auditors will read them and push back on weak ones.
|
|
49
|
+
|
|
50
|
+
Slither also has a `slither-mutate` mode for mutation testing — useful but slow, run it weekly rather than per-PR. The `--print human-summary` flag produces a one-screen overview that is excellent to commit as `slither-summary.txt` for auditors to skim during orientation. Complement Slither with `Aderyn` (Cyfrin's Rust-based static analyzer) for a second-opinion pass; the detectors overlap but each catches a small set the other misses.
|
|
51
|
+
|
|
52
|
+
### Recommended tooling: Echidna, Halmos, Certora
|
|
53
|
+
|
|
54
|
+
Static analysis catches patterns; dynamic and symbolic tools catch logic. Each tool answers a different question, and they stack rather than substitute.
|
|
55
|
+
|
|
56
|
+
- **`Echidna`** — property-based fuzzing from Trail of Bits. Extends Foundry invariant tests with smarter input generation: a maintained corpus that survives between runs, shrinking of failing sequences to minimal reproductions, and coverage-guided exploration that biases toward unvisited branches. Reach for it when your Foundry invariant tests pass too quickly to trust — Echidna will find the input you didn't think to write, and produces a replayable test case when it does. The cost is wiring up a config and learning to write properties in its slightly different style; budget half a day per contract.
|
|
57
|
+
- **`Halmos`** — open-source formal verification via symbolic execution from a16z. Where Echidna explores by sampling, Halmos explores by symbolically encoding all input values within a bound and asking an SMT solver whether the property can be violated. Great for path-explosion analysis on small, critical functions: pricing math, share calculations, access-control checks, ERC-4626 share/asset round-trips. Free, fast on bounded loops (it struggles with unbounded ones), and crucially it runs against the same `forge test` signatures you already have — write a `check_*` test that asserts the property, point Halmos at it, and get a proof or a counterexample.
|
|
58
|
+
- **`Certora`** — commercial formal verification. The gold standard, used by Aave, Compound, MakerDAO, and Lido. Demands a separate specification language (CVL — Certora Verification Language) and an engagement model where Certora engineers help write the spec, but the result is a machine-checked proof against arbitrary state evolution rather than a sample of cases. Expensive — six figures for a real engagement — and slow to start, so reserve it for the most consequential invariants on a high-TVL protocol where the cost of being wrong dwarfs the cost of being thorough.
|
|
59
|
+
|
|
60
|
+
The reach order is cumulative, not exclusive: invariant tests on every state-changing function first, Echidna on the critical contracts that handle funds, Halmos on the highest-stakes pure functions (math, accounting), Certora only if TVL or systemic importance justifies the budget. A protocol that ships with all four is rare and signals seriousness to the ecosystem.
|
|
61
|
+
|
|
62
|
+
### CI integration
|
|
63
|
+
|
|
64
|
+
Run static analysis, tests, and coverage on every PR — not nightly, not weekly, every PR. Quality gates that run after merge catch bugs after they have already been reviewed by humans operating under the assumption that the suite is green. A representative GitHub Actions snippet:
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
name: audit-readiness
|
|
68
|
+
on: [pull_request]
|
|
69
|
+
jobs:
|
|
70
|
+
check:
|
|
71
|
+
runs-on: ubuntu-latest
|
|
72
|
+
steps:
|
|
73
|
+
- uses: actions/checkout@v4
|
|
74
|
+
with: { submodules: recursive }
|
|
75
|
+
- uses: foundry-rs/foundry-toolchain@v1
|
|
76
|
+
- uses: actions/setup-python@v5
|
|
77
|
+
with: { python-version: "3.11" }
|
|
78
|
+
- run: pip install slither-analyzer
|
|
79
|
+
- run: forge build --sizes
|
|
80
|
+
- run: forge test -vvv
|
|
81
|
+
- run: forge coverage --report lcov
|
|
82
|
+
- run: slither . --config-file .slither.config.json
|
|
83
|
+
- name: enforce coverage floor
|
|
84
|
+
run: |
|
|
85
|
+
line_pct=$(grep -m1 'lines\.\.\.\.\.\.' lcov.info.summary | awk '{print $2}' | tr -d '%')
|
|
86
|
+
awk "BEGIN { exit !($line_pct >= 90) }" || { echo "coverage below 90%"; exit 1; }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Add a coverage threshold check (e.g., `lcov-summary` or the inline script above) so the build fails when line coverage drops below 90%. Coverage that only goes up is the only coverage gate that keeps a test suite honest — a soft target lets a Friday-afternoon PR shave a percent and then never recover. Wire the Slither output to a PR comment so reviewers see new findings inline rather than digging through CI logs; both `crytic/slither-action` and a custom step that posts via `gh pr comment` work fine.
|
|
90
|
+
|
|
91
|
+
Long-running checks (Echidna campaigns, Halmos runs over the full critical surface) belong on a separate nightly or pre-release workflow, not the PR gate — a 30-minute fuzz run on every push burns CI minutes and trains the team to ignore failures. Keep the PR gate fast (<5 minutes) and reserve the slower campaigns for tagged releases and the final pre-audit freeze.
|
|
92
|
+
|
|
93
|
+
### What auditors actually do during the engagement
|
|
94
|
+
|
|
95
|
+
Knowing the workflow on the other side helps you prepare what they need. A typical engagement runs in three phases:
|
|
96
|
+
|
|
97
|
+
1. **Orientation (days 1–3).** Reading the spec, the README, and the source. Building a mental model, drawing call graphs, identifying trust boundaries. This phase is entirely paid for by your documentation quality — a great spec compresses this to a day, a missing one stretches it to a week.
|
|
98
|
+
2. **Active review (days 4 through N−2).** Running tools (Slither, Aderyn, custom semgrep rules, sometimes Echidna or Halmos against the firm's internal property libraries), reading code adversarially against the spec, writing PoC exploits for suspected bugs. This is where bugs are found. Your responsiveness here directly translates to findings.
|
|
99
|
+
3. **Reporting (final 2 days).** Writing up findings with severity, impact, recommendation, and PoC. Each finding takes hours to write well; a firm that finds 20 bugs in week one and spends week two writing them up is doing exactly the right thing, even if it looks like they "stopped finding bugs."
|
|
100
|
+
|
|
101
|
+
If your audit appears to slow down halfway through, that is usually the firm transitioning from phase 2 to phase 3, not running out of material.
|
|
102
|
+
|
|
103
|
+
A few interaction norms that auditors appreciate and many teams skip: keep the Slack/Discord channel open for the full engagement, not just business hours; do not push code to the audited branch (use a `post-audit-fixes` branch instead); confirm receipt of each finding within a day so the firm knows the report landed; and resist the urge to argue severity in writing — every back-and-forth on "this should be Medium not High" burns hours that could go to finding the next bug. Severity disputes are a small final-call meeting at the end, not a running thread.
|
|
104
|
+
|
|
105
|
+
### Choosing an audit firm
|
|
106
|
+
|
|
107
|
+
Match the firm to the stakes. The wrong firm for the wrong protocol wastes money in both directions — overpaying a top-tier firm to review a 200-line vault, or sending a complex cross-chain protocol to a competitive audit that lacks the senior architectural review it needs.
|
|
108
|
+
|
|
109
|
+
- **High-TVL or novel protocols** — top-tier private engagements: `Trail of Bits`, `OpenZeppelin`, `Consensys Diligence`, `Spearbit`, `Cantina`. Expect $50k–$500k+ for two to six weeks with two or three senior engineers. These firms produce public reports the ecosystem trusts, which is itself a marketing asset on launch day. Each has a flavor: Trail of Bits is rigorous and broad, OpenZeppelin is deep on standards and patterns they helped define, Consensys Diligence is strong on EVM internals, Spearbit and Cantina pool elite independent researchers under a coordinator.
|
|
110
|
+
- **Lower TVL or budget-constrained** — competitive audits: `Code4rena`, `Sherlock`, `Cantina` contests. A wide pool of reviewers competes for a fixed prize pool. Cheaper, faster turnaround, more findings by volume, but less narrative analysis of architectural risk — you get a list of bugs, not a coherent picture of how the system might fail. Pair with a short private review if the protocol is non-trivial. Sherlock additionally backs findings with a payout guarantee that functions like insurance.
|
|
111
|
+
- **Pre-audit pass** — a one-week private review from a solo security researcher (many ex-firm engineers freelance on Cantina or Twitter) is a cheap way to shake out the obvious bugs before the main audit, so the firm spends its time on the subtle ones rather than the embarrassing ones.
|
|
112
|
+
|
|
113
|
+
Never rely on a single audit for a complex protocol. Two firms in series, or one firm plus a competitive audit, finds materially more bugs than either alone — the second reviewer sees what the first missed precisely because they came in fresh, and the cost is roughly additive while the bug-discovery rate is more than additive. For high-TVL launches, three layers (private firm → competitive audit → ongoing bounty) is the modern standard.
|
|
114
|
+
|
|
115
|
+
### Writing the audit RFP
|
|
116
|
+
|
|
117
|
+
When reaching out to a firm or platform, send a single document with: protocol summary in two paragraphs, line count in scope (split by `cloc src/`), spec + threat model attached, current test coverage numbers, the SHA you intend to freeze on, the launch date you are targeting, and your budget range. Firms reply faster and more concretely to specific asks than to "we'd like to audit our protocol, please send pricing." Naming the specific contracts in scope (not "all contracts") and explicitly excluding test helpers and deploy scripts from the line count keeps the quote tight and honest. If you need an NDA, send it with the first email — the back-and-forth otherwise burns a week.
|
|
118
|
+
|
|
119
|
+
### Audit timing and process
|
|
120
|
+
|
|
121
|
+
Book 2–4 months ahead — the firms worth hiring are booked out, and the last-minute slots that do appear come from teams whose contracts slipped, which is not a market you want to be buying into. When you reach out, send the spec, the threat model, the test coverage numbers, and a one-page protocol summary; firms triage proposals by readiness, and a well-prepared inquiry gets a better slot than a vague one even with similar timing.
|
|
122
|
+
|
|
123
|
+
Code freeze 1–2 weeks before the audit start date: no new features, only documentation, additional tests, and bug fixes against issues you find internally during the freeze. Tag the commit (`v1.0-audit-start`) and share that exact SHA with the firm in writing. Do not change scope mid-audit — every change invalidates findings the auditors already wrote against the prior version and forces them to redo work you are paying for at $5k/day. If a serious bug is discovered internally during the audit, fix it on a side branch and hand the patch to the auditors as a "fix to verify" rather than rebasing main.
|
|
124
|
+
|
|
125
|
+
During the audit, be responsive. Auditors will ask design questions ("is this rounding direction intentional?"), request access to off-chain components (subgraphs, keeper scripts), and float hypotheses they want confirmed before writing them up. A 24-hour response time is fine; a four-day silence wastes a full senior-engineer-day of audit budget per question. Designate one team member as the audit liaison so questions land in one place and answers come from one source — fragmented responses from three engineers produce contradictory context the auditors then have to reconcile.
|
|
126
|
+
|
|
127
|
+
### Post-audit remediation
|
|
128
|
+
|
|
129
|
+
Every High and Medium finding gets three things: a fix, a test that would have caught the bug (regression coverage that lives in the repo forever), and a re-verification pass from the auditor against the actual fix commit. Lows and informationals get triaged — fix the cheap ones, document the rest with a written rationale in a remediation appendix. Do not close a finding because the fix "looks right" or "the test passes" — the auditor's re-verification is the only signal that matters, because most bad fixes pass the obvious test and fail a subtler condition the auditor noticed but did not write up.
|
|
130
|
+
|
|
131
|
+
Publish the report after fixes land. Do not hide findings — the community can spot a hidden bug from on-chain behavior, and a transparent disclosure with a remediation appendix is a credibility asset that pays back over the protocol's lifetime. Include the commit hash that addressed each finding so anyone can verify the fix independently. Hiding a Critical finding is the worst-case mode: it discredits the team, invalidates the audit's value as a signal, and historically precedes the exploit by weeks.
|
|
132
|
+
|
|
133
|
+
### Bug bounty post-launch
|
|
134
|
+
|
|
135
|
+
Audits are a snapshot; bug bounties are continuous. List on `Immunefi` (or Cantina, or both) for protocol-grade payouts with a tiered, TVL-scaled payout table — typical curves are $50k for critical low-TVL bugs, $250k–$500k for mid-TVL, $1M–$2M+ for critical high-TVL findings. A $10k bounty on a $100M protocol is an insult to whitehats and an invitation to blackhats: a sophisticated researcher who finds a critical bug will rationally sell it on the open market unless your bounty pays meaningfully more than the bug is worth exploited.
|
|
136
|
+
|
|
137
|
+
Run the bounty continuously, not for a launch week and then off — most exploitable bugs are found weeks or months after deployment, by researchers who only look at protocols with live, well-funded, fast-paying programs. Pay quickly when reports land (within days, not weeks), grade reports honestly against the published tier table, and publish post-mortems for valid findings so the next researcher trusts you. A program that ghosts researchers or downgrades findings to underpay loses access to the whitehat community within one cycle.
|
|
138
|
+
|
|
139
|
+
Define the scope precisely in the bounty listing — which contracts, which networks, which versions, and what is explicitly out-of-scope (frontend bugs, off-chain infra, social engineering). Vague scope produces low-quality reports and disputes over payout. Include a "safe harbor" clause so researchers know exploit-style testing on testnet (or sometimes on a forked mainnet) is welcome rather than legally fraught. Immunefi's template legal language is the de facto industry standard; adapt it rather than writing your own.
|
|
140
|
+
|
|
141
|
+
### Anti-patterns to avoid
|
|
142
|
+
|
|
143
|
+
A few patterns reliably destroy audit value, and the firms see them constantly.
|
|
144
|
+
|
|
145
|
+
- **Booking the audit before the code is ready.** "We'll be done by then" almost never holds. Slipping the freeze date by a week to fit the booked slot leaves a half-finished codebase under review, which produces a half-useful report. Either delay the audit or pay a partial rebooking fee — both are cheaper than a shallow review of unfinished code.
|
|
146
|
+
- **Changing scope mid-audit.** Adding a contract on day 4 of a 10-day audit invalidates the threat-model assumptions auditors are reasoning from. Hold new scope until the next engagement.
|
|
147
|
+
- **Treating audit findings as optional.** Every High and Medium is a real finding even if "we don't think it's exploitable" — auditors qualify severity, not exploitability under your current threat model, and threat models change post-launch.
|
|
148
|
+
- **Hiring the cheapest firm to check a box for investors.** A weak audit from a low-reputation firm is worse than no audit, because it manufactures false confidence. Investors and integrators who know the space recognize the difference; a $20k audit on a $50M protocol reads as negligence, not frugality.
|
|
149
|
+
- **Skipping the bounty because "we already audited."** Audits are a snapshot; bounties are continuous. Every protocol that has been exploited had been audited.
|
|
150
|
+
- **Ignoring informational findings.** Many of yesterday's High-severity exploits started as last year's "Informational" notes the team didn't bother to fix. An informational finding is still a real signal about your design — treat it as a free pre-warning, not as noise.
|
|
151
|
+
- **Auditing the same contracts repeatedly while leaving glue code unaudited.** Deployment scripts, multisig configuration, upgrade procedures, off-chain keepers, and frontend transaction construction are all attack surface. Several high-profile exploits have targeted exactly these — a perfectly audited contract called incorrectly by a buggy frontend or initialized wrong by an unaudited script. Scope at least one engagement around the operational glue.
|