@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,212 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web3-project-structure
|
|
3
|
+
description: Opinionated Foundry project layout for smart-contract teams — src, test, script, lib, broadcast, foundry.toml, remappings — covering test naming, deploy provenance, and what belongs in git
|
|
4
|
+
topics: [web3, project-structure, foundry, solidity]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
A smart-contract repository is read by more adversarial eyes than almost any other kind of codebase: auditors, MEV searchers, frontrunners, and the occasional regulator. Structure carries weight that goes beyond developer ergonomics. An auditor opening the repo for the first time should be able to find the contract under review, its tests, its deploy script, and its on-chain deployment receipts within thirty seconds. Gas snapshots and fuzz seeds need a fixed home so regressions are diffable. Broadcast logs are the audit trail tying a verified contract address back to a commit SHA — losing or muddling them turns "which version is mainnet running?" into a forensic exercise. Foundry's conventions answer most of these questions; this doc records the opinionated version a team should adopt before the first PR lands.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
A Foundry project has six top-level directories, each answering one question: `src/` (contracts under audit), `test/` (Foundry tests mirroring `src/`), `script/` (deploy and management scripts inheriting `Script`), `lib/` (forge-installed dependencies, committed as submodules or git-trees), `broadcast/` (deploy logs keyed by chain ID and script name), and `docs/` (NatSpec-generated or hand-written architecture notes). `foundry.toml` at the root configures the compiler, fuzz/invariant runners, and formatter; `remappings.txt` aliases `lib/` paths so imports stay readable. The `.gitignore` excludes `cache/`, `out/`, and broadcast directories for ephemeral local chains (anvil, chain ID 31337), but **keeps** broadcast artifacts for canonical chain IDs (1, 10, 8453, 42161, ...) because they are the deploy provenance. Tests follow strict naming — `test_`, `testFuzz_`, `invariant_` — so the runner can pick them up and reviewers can read intent off the function name.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### Top-level layout
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
project-root/
|
|
19
|
+
├── src/ # Contracts under audit — the deliverable
|
|
20
|
+
│ ├── interfaces/ # I*.sol interfaces, importable by consumers
|
|
21
|
+
│ ├── libraries/ # Pure libraries (no state)
|
|
22
|
+
│ └── tokens/ # Domain-grouped subdirectories
|
|
23
|
+
├── test/ # Foundry tests — mirrors src/
|
|
24
|
+
│ ├── unit/ # Per-contract unit tests
|
|
25
|
+
│ ├── integration/ # Multi-contract / forked-mainnet tests
|
|
26
|
+
│ ├── invariant/ # Invariant suites + Handler contracts
|
|
27
|
+
│ └── utils/ # Shared test helpers, mocks, base contracts
|
|
28
|
+
├── script/ # Deploy + management scripts (forge script)
|
|
29
|
+
│ ├── Deploy.s.sol
|
|
30
|
+
│ └── Upgrade.s.sol
|
|
31
|
+
├── lib/ # forge install dependencies (submodules)
|
|
32
|
+
│ ├── forge-std/
|
|
33
|
+
│ ├── openzeppelin-contracts/
|
|
34
|
+
│ └── solmate/
|
|
35
|
+
├── broadcast/ # Deploy artifacts keyed by script + chain ID
|
|
36
|
+
│ └── Deploy.s.sol/
|
|
37
|
+
│ ├── 1/ # Mainnet — KEEP, this is provenance
|
|
38
|
+
│ ├── 10/ # Optimism — KEEP
|
|
39
|
+
│ └── 31337/ # Anvil — gitignored
|
|
40
|
+
├── docs/ # NatSpec output or hand-written architecture
|
|
41
|
+
├── foundry.toml # Project config
|
|
42
|
+
├── remappings.txt # Import aliases for lib/
|
|
43
|
+
├── .gitignore
|
|
44
|
+
└── README.md
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
One-liners:
|
|
48
|
+
- `src/` — every contract that ships; group by domain, never by Solidity language feature
|
|
49
|
+
- `test/` — mirrors `src/` so reviewers can find the test for any contract in one jump
|
|
50
|
+
- `script/` — every deploy, upgrade, and ops script (parameter tuning, role grants); each inherits `forge-std/Script.sol`
|
|
51
|
+
- `lib/` — third-party Solidity tracked via `forge install` (git submodules under the hood)
|
|
52
|
+
- `broadcast/` — receipts from `forge script --broadcast`; partition by chain ID; **keep** canonical chains, gitignore ephemeral ones
|
|
53
|
+
- `docs/` — `forge doc` output or hand-written `architecture.md`, `threat-model.md`, `invariants.md`
|
|
54
|
+
|
|
55
|
+
### `foundry.toml`
|
|
56
|
+
|
|
57
|
+
`foundry.toml` is the project config — compiler version, optimizer settings, fuzz/invariant runner knobs, formatter rules, and per-profile overrides. A minimal but production-shaped config:
|
|
58
|
+
|
|
59
|
+
```toml
|
|
60
|
+
[profile.default]
|
|
61
|
+
src = "src"
|
|
62
|
+
out = "out"
|
|
63
|
+
libs = ["lib"]
|
|
64
|
+
test = "test"
|
|
65
|
+
script = "script"
|
|
66
|
+
solc_version = "0.8.26"
|
|
67
|
+
evm_version = "cancun"
|
|
68
|
+
optimizer = true
|
|
69
|
+
optimizer_runs = 200
|
|
70
|
+
via_ir = false
|
|
71
|
+
bytecode_hash = "none" # Reproducible builds (no metadata hash)
|
|
72
|
+
cbor_metadata = false
|
|
73
|
+
gas_reports = ["*"]
|
|
74
|
+
|
|
75
|
+
[profile.ci]
|
|
76
|
+
fuzz = { runs = 10_000 }
|
|
77
|
+
invariant = { runs = 256, depth = 128 }
|
|
78
|
+
verbosity = 3
|
|
79
|
+
|
|
80
|
+
[fmt]
|
|
81
|
+
line_length = 120
|
|
82
|
+
tab_width = 4
|
|
83
|
+
bracket_spacing = true
|
|
84
|
+
int_types = "long" # uint256 not uint, by name
|
|
85
|
+
quote_style = "double"
|
|
86
|
+
number_underscore = "thousands" # 1_000_000
|
|
87
|
+
|
|
88
|
+
[fuzz]
|
|
89
|
+
runs = 256 # Local default; CI overrides via [profile.ci]
|
|
90
|
+
max_test_rejects = 65_536
|
|
91
|
+
|
|
92
|
+
[invariant]
|
|
93
|
+
runs = 64
|
|
94
|
+
depth = 32
|
|
95
|
+
fail_on_revert = false
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`optimizer_runs` is the only setting non-obvious enough to justify thinking. The number is **not** "how many times to run the optimizer" — it's the expected number of times each opcode will run over the contract's lifetime. Higher values trade deploy-time bytecode size for cheaper runtime gas. Defaults:
|
|
99
|
+
- `200` (Solidity default) — balanced; right for most contracts including infrequently-called governance
|
|
100
|
+
- `1_000_000` — protocol contracts with hot paths (AMMs, lending pools, perp engines); pay extra deploy cost once, save gas on every swap forever
|
|
101
|
+
- `1` — one-shot contracts (deploy scripts, factories that deploy once and self-destruct conceptually); minimize deploy cost
|
|
102
|
+
|
|
103
|
+
The `ci` profile overrides fuzz runs to 10k for thorough coverage on PRs; locally 256 keeps `forge test` snappy.
|
|
104
|
+
|
|
105
|
+
### Test naming
|
|
106
|
+
|
|
107
|
+
Foundry's runner discovers tests by function-name prefix. Three prefixes matter, and the convention is load-bearing — auditors read intent off the name:
|
|
108
|
+
|
|
109
|
+
- `test_<unit>_<behavior>` — concrete unit tests; e.g. `test_transfer_revertsWhenInsufficientBalance`
|
|
110
|
+
- `testFuzz_<unit>_<property>` — property tests with fuzzed inputs; e.g. `testFuzz_deposit_creditsExactAmount(uint256 amount)`
|
|
111
|
+
- `invariant_<property>` — invariant tests run by the invariant engine over a stateful handler; e.g. `invariant_totalSupplyEqualsSumOfBalances`
|
|
112
|
+
|
|
113
|
+
File mirror: a contract `src/Vault.sol` gets its tests at `test/unit/Vault.t.sol` (the `.t.sol` suffix is convention, not required, but make it consistent across the repo). Invariant suites live in `test/invariant/Vault.invariants.t.sol` with their `Handler` contract alongside.
|
|
114
|
+
|
|
115
|
+
```solidity
|
|
116
|
+
// test/unit/Vault.t.sol
|
|
117
|
+
contract VaultTest is Test {
|
|
118
|
+
Vault vault;
|
|
119
|
+
|
|
120
|
+
function setUp() public {
|
|
121
|
+
vault = new Vault();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function test_deposit_creditsBalance() public {
|
|
125
|
+
vault.deposit{value: 1 ether}();
|
|
126
|
+
assertEq(vault.balanceOf(address(this)), 1 ether);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function testFuzz_deposit_creditsExactAmount(uint96 amount) public {
|
|
130
|
+
vm.deal(address(this), amount);
|
|
131
|
+
vault.deposit{value: amount}();
|
|
132
|
+
assertEq(vault.balanceOf(address(this)), amount);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Use `uint96` (or another bounded type) for fuzz parameters tied to ETH amounts — full `uint256` blows the available balance and triggers `max_test_rejects` exhaustion.
|
|
138
|
+
|
|
139
|
+
### Deploy scripts and `broadcast/`
|
|
140
|
+
|
|
141
|
+
Deploy scripts inherit `forge-std/Script.sol` and read environment-specific addresses via `vm.envAddress`. Hard-coded addresses in script bodies are a category of bug — they survive a `git mv` from staging to mainnet and you find out at $4 gwei.
|
|
142
|
+
|
|
143
|
+
```solidity
|
|
144
|
+
// script/Deploy.s.sol
|
|
145
|
+
import {Script} from "forge-std/Script.sol";
|
|
146
|
+
import {Vault} from "../src/Vault.sol";
|
|
147
|
+
|
|
148
|
+
contract Deploy is Script {
|
|
149
|
+
function run() external returns (Vault vault) {
|
|
150
|
+
address admin = vm.envAddress("ADMIN_ADDRESS");
|
|
151
|
+
uint256 pk = vm.envUint("DEPLOYER_PK");
|
|
152
|
+
|
|
153
|
+
vm.startBroadcast(pk);
|
|
154
|
+
vault = new Vault(admin);
|
|
155
|
+
vm.stopBroadcast();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Running `forge script script/Deploy.s.sol --rpc-url $RPC --broadcast --verify` writes a receipt to `broadcast/Deploy.s.sol/<chainId>/run-latest.json` and a timestamped copy. That JSON contains the deployed address, transaction hash, block number, constructor args, and a SHA tying it back to the commit. **This is the provenance artifact.** Keep it for canonical chains; without it, "which commit deployed the mainnet Vault at 0xabc..." becomes archaeology. Verify on Etherscan in the same command (`--verify`) so the audit trail extends to the public block explorer.
|
|
161
|
+
|
|
162
|
+
### `lib/` and remappings
|
|
163
|
+
|
|
164
|
+
Solidity dependencies are installed via `forge install`, which adds the upstream repo as a git submodule under `lib/`:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
forge install OpenZeppelin/openzeppelin-contracts@v5.0.0
|
|
168
|
+
forge install transmissions11/solmate
|
|
169
|
+
forge install foundry-rs/forge-std
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Pin to a tag (`@v5.0.0`), never to `main`. Submodule SHAs are recorded in `.gitmodules` and the commit, so a fresh `forge install` after clone produces the same dependency set.
|
|
173
|
+
|
|
174
|
+
Imports through `lib/openzeppelin-contracts/contracts/...` are ugly. `remappings.txt` aliases them:
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
@openzeppelin/=lib/openzeppelin-contracts/
|
|
178
|
+
@openzeppelin-upgradeable/=lib/openzeppelin-contracts-upgradeable/
|
|
179
|
+
solmate/=lib/solmate/src/
|
|
180
|
+
forge-std/=lib/forge-std/src/
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Then `import "@openzeppelin/contracts/token/ERC20/ERC20.sol";` resolves cleanly. Keep `remappings.txt` checked in; some IDE plugins and `forge` itself read it.
|
|
184
|
+
|
|
185
|
+
### `.gitignore`
|
|
186
|
+
|
|
187
|
+
```gitignore
|
|
188
|
+
# Build artifacts
|
|
189
|
+
cache/
|
|
190
|
+
out/
|
|
191
|
+
|
|
192
|
+
# Coverage
|
|
193
|
+
lcov.info
|
|
194
|
+
|
|
195
|
+
# Node (Hardhat carryovers, JS tooling)
|
|
196
|
+
node_modules/
|
|
197
|
+
|
|
198
|
+
# Environment / secrets
|
|
199
|
+
.env
|
|
200
|
+
.env.*
|
|
201
|
+
!.env.example
|
|
202
|
+
|
|
203
|
+
# Anvil / ephemeral local chains — chain ID 31337
|
|
204
|
+
broadcast/*/31337/
|
|
205
|
+
broadcast/**/dry-run/
|
|
206
|
+
|
|
207
|
+
# Editor
|
|
208
|
+
.vscode/
|
|
209
|
+
.idea/
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The load-bearing line is `broadcast/*/31337/`. Anvil's chain ID is 31337 and every local script run leaves a receipt; those are noise. Mainnet (`1`), Optimism (`10`), Base (`8453`), Arbitrum (`42161`), and other canonical chain IDs are **not** in the ignore list because their broadcast artifacts are the on-chain deploy provenance. Treat them with the same care as the contracts themselves: commit, review, and tag at release.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web3-requirements
|
|
3
|
+
description: Problem framing, invariants, threat model, trust assumptions, and success metrics for shipping smart contracts and protocols to EVM chains
|
|
4
|
+
topics: [web3, requirements, invariants, threat-model, security]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
A smart contract shipped to an EVM chain without a written invariant set, a threat model, and an explicit list of trust assumptions is a guessing game with adversarial counterparties and irreversible state. This document defines the acceptance spec for a contract or protocol going to Ethereum mainnet, an L2 (Optimism, Arbitrum, Base), or a compatible sidechain. The audience is a senior Solidity engineer or protocol architect who can ship code but has not yet hardened it against funded adversaries. The goal is to force, in writing, the questions an auditor will ask on day one.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
A web3 requirements doc states what the contract does for which users (problem framing), enumerates the invariants that must hold across every reachable state, names the threat model in terms of capabilities and time horizon, lists each trust assumption as a documented failure mode, and defines success in concrete economic, gas, or capability terms. State invariants up front and write them as Foundry invariant tests before you finish the implementation. If you cannot enumerate your trust assumptions, you have not designed a protocol — you have written code that happens to compile.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### Problem framing
|
|
16
|
+
|
|
17
|
+
The framing answers one question: what does this contract do for which users so that what becomes possible. Write it before you open `forge init`. The template below forces concrete nouns and verbs; if you find yourself writing "leverage" or "unlock value" you are not ready.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
This contract does <user-visible action> for <user class> so that <decision/outcome>.
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Worked example — a yield vault: "This contract lets monthly retail depositors deposit ETH and earn variable yield routed through Aave v3, so that they can hold a yield-bearing position without managing positions themselves." That sentence implies the whole surface area: deposit, withdraw, accounting for accrued yield, an external adapter to Aave, an admin for adapter rotation. Everything else is implementation.
|
|
24
|
+
|
|
25
|
+
Drop the framing into a `docs/spec.md` block at the top of the repo. Keep out-of-scope explicit — out-of-scope is where audits find missing checks.
|
|
26
|
+
|
|
27
|
+
```markdown
|
|
28
|
+
# docs/spec.md
|
|
29
|
+
user_action: deposit ETH; withdraw ETH + accrued yield
|
|
30
|
+
user_class: retail depositors holding their own keys (EOAs and Safe multisigs)
|
|
31
|
+
outcome: passive yield routed through Aave v3 on Optimism
|
|
32
|
+
out_of_scope:
|
|
33
|
+
- non-ETH assets
|
|
34
|
+
- leveraged positions
|
|
35
|
+
- cross-chain bridging (vault is single-chain only)
|
|
36
|
+
- permissioned access (anyone with ETH can deposit)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Invariants
|
|
40
|
+
|
|
41
|
+
Invariants are properties that must hold across every reachable state and every call sequence. They are not unit-test assertions about one transaction — they are global truths the protocol exposes. Write them in English first, then in a Foundry invariant test before you finish the implementation. If an invariant is hard to state in one sentence, decompose it; if it cannot be tested with a fuzzer, it is not really an invariant.
|
|
42
|
+
|
|
43
|
+
Opinionated defaults for a vault-style contract:
|
|
44
|
+
|
|
45
|
+
- **Conservation**: `totalAssets()` is greater than or equal to the sum of user-owed principal. The vault never owes more than it can pay.
|
|
46
|
+
- **Solvency**: at any block, a full sequential withdrawal of every depositor at their current share would not revert for accounting reasons.
|
|
47
|
+
- **Monotonicity of share price**: in the absence of admin-triggered loss recognition, `convertToAssets(1e18)` is non-decreasing. A surprise drop indicates a bug or an external loss the protocol failed to flag.
|
|
48
|
+
- **Access control**: only `admin` can call functions tagged `onlyAdmin`. Stated as `forall caller != admin, call to onlyAdmin function reverts`.
|
|
49
|
+
- **No reentrancy reachable state**: no external call to user-controlled code happens before state writes are finalized in any deposit/withdraw path.
|
|
50
|
+
|
|
51
|
+
Encode them as Foundry invariant tests. The handler is the load-bearing part — a weak handler proves nothing.
|
|
52
|
+
|
|
53
|
+
```solidity
|
|
54
|
+
// test/invariant/VaultInvariants.t.sol
|
|
55
|
+
pragma solidity ^0.8.24;
|
|
56
|
+
|
|
57
|
+
import {Test} from "forge-std/Test.sol";
|
|
58
|
+
import {Vault} from "src/Vault.sol";
|
|
59
|
+
import {VaultHandler} from "./VaultHandler.sol";
|
|
60
|
+
|
|
61
|
+
contract VaultInvariants is Test {
|
|
62
|
+
Vault internal vault;
|
|
63
|
+
VaultHandler internal handler;
|
|
64
|
+
|
|
65
|
+
function setUp() public {
|
|
66
|
+
vault = new Vault();
|
|
67
|
+
handler = new VaultHandler(vault);
|
|
68
|
+
targetContract(address(handler));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Conservation: assets under management cover all outstanding shares.
|
|
72
|
+
function invariant_solvency() public view {
|
|
73
|
+
uint256 owed = vault.convertToAssets(vault.totalSupply());
|
|
74
|
+
assertGe(vault.totalAssets(), owed, "vault is insolvent");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Share price is monotonic in the absence of admin loss recognition.
|
|
78
|
+
function invariant_sharePriceMonotonic() public view {
|
|
79
|
+
assertGe(vault.convertToAssets(1e18), handler.lastSharePrice());
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Run with `forge test --match-contract VaultInvariants --fuzz-runs 50000`. A passing invariant suite at 50k runs is a precondition for audit submission — not a substitute for it.
|
|
85
|
+
|
|
86
|
+
### Threat model
|
|
87
|
+
|
|
88
|
+
A threat model names who attacks, with what capabilities, on what time horizon, and — critically — who you do not defend against. Skipping the last part is how teams ship contracts that are "secure" against an unspecified adversary and break against a real one.
|
|
89
|
+
|
|
90
|
+
Rubric, in order of how often each is skipped:
|
|
91
|
+
|
|
92
|
+
- **Capabilities**: what can the attacker do on-chain. Concretely: deploy arbitrary contracts, hold arbitrary token balances, call any public function in any order, observe and reorder mempool transactions (front-running), pay arbitrary gas, hold flash-loan-scale capital (think hundreds of millions of dollars for a single transaction on mainnet), submit governance proposals if your protocol has on-chain governance, manipulate spot prices on thin DEX pools.
|
|
93
|
+
- **Time horizon**: are we defending across one block (atomic flash-loan attack), one transaction batch (sandwich), a governance timelock window (typically 24-72 hours), or months (slow-roll oracle drift). Different defenses apply at each horizon.
|
|
94
|
+
- **Out of scope**: name them. Common honest answers: nation-state actors capable of reorgs on L1, the chain itself going down or censoring, supply-chain compromise of a dependency outside the audit boundary, social-engineering of the multisig signers, an exploit in the underlying L2 sequencer.
|
|
95
|
+
|
|
96
|
+
Concrete attack scenarios to write down before audit:
|
|
97
|
+
|
|
98
|
+
- **Oracle manipulation**: an adversary moves the price reported by a feed (spot DEX TWAP on a thin pool, a stale Chainlink feed during a fast move) and exploits a function that trusts it. Defense: use a Chainlink feed with a heartbeat check and a deviation circuit-breaker, never a single-block DEX spot price.
|
|
99
|
+
- **Governance proposal abuse**: an adversary accumulates governance tokens (or buys voting power via a flash loan against a vulnerable token design) and pushes a malicious proposal — for example, upgrading the vault implementation to one that drains funds. Defense: timelock all upgrade and parameter changes; size the timelock to the off-chain alerting and response window.
|
|
100
|
+
- **Reentrancy via callbacks**: any function that makes an external call into user-controlled code before finalizing state is vulnerable. Defense: checks-effects-interactions, plus `nonReentrant` on every function that touches accounting. Treat ERC-777, ERC-1363, and any token with transfer hooks as user-controlled code for this purpose.
|
|
101
|
+
- **Front-running and MEV**: on a public mempool, any profitable action you broadcast is visible. Defense: commit-reveal for sensitive ordering, private mempools (Flashbots Protect) for admin actions, or making the action order-independent. Sandwich-resistance for swaps means a strict slippage cap on every quote, sourced from the caller and not from an oracle the attacker can move.
|
|
102
|
+
- **Donation / inflation attack**: an attacker deposits a tiny first share, then transfers a large amount directly to the vault address to inflate share price and round the next depositor's shares to zero. Defense: virtual shares / dead shares in the ERC-4626 implementation, or an initial deposit by the deployer that is permanently locked.
|
|
103
|
+
|
|
104
|
+
### Trust assumptions
|
|
105
|
+
|
|
106
|
+
Every contract trusts something. The job here is to make each trust explicit, paired with the failure mode if the trust is misplaced. The list below is the minimum for a DeFi vault — extend it for your specific design.
|
|
107
|
+
|
|
108
|
+
- **Price oracle (Chainlink ETH/USD on the target chain)**: trusted to be correct within the configured deviation and heartbeat. If it fails — feed goes stale during a fast move, or the off-chain operators collude — the vault can mis-price deposits and withdrawals. Mitigation: pause-on-stale check (`updatedAt` is within the heartbeat window) and an emergency pause that the multisig can trigger.
|
|
109
|
+
- **Upgrade admin (3-of-5 Safe multisig)**: trusted to act in users' interest and to keep keys secure. If three signers are compromised, they can upgrade the vault to drain funds. Mitigation: 48-hour timelock on every upgrade; off-chain monitoring with on-call alerting; a separate guardian role with veto power on suspicious upgrades but no positive authority.
|
|
110
|
+
- **Underlying protocol (Aave v3)**: trusted to honor its own accounting and not to grief depositors. If Aave's pool is compromised, the vault inherits the loss. Mitigation: cap exposure per adapter; document the inherited risk in the user-facing README.
|
|
111
|
+
- **Solidity compiler and toolchain**: trusted to compile source to faithful bytecode. Mitigation: pin a specific `solc` version (e.g. `0.8.24`), reproducible builds via Foundry's `forge build --deterministic`, and verified source on Etherscan.
|
|
112
|
+
- **The chain itself**: trusted not to reorg meaningfully or to censor the contract. For L2s, also trusted to honor its withdrawal-window guarantees. Document the chain-specific risk (e.g. "this protocol is deployed on Base; a Base sequencer outage halts deposits and withdrawals until it recovers").
|
|
113
|
+
|
|
114
|
+
Each line is a documented risk. Surface them in the user-facing docs — not just in the audit report — so depositors can size their exposure.
|
|
115
|
+
|
|
116
|
+
### Success metrics
|
|
117
|
+
|
|
118
|
+
State success in concrete numbers, written before launch. Vague goals ("be secure", "have lots of users") let the team declare victory after any outcome. Three categories to name:
|
|
119
|
+
|
|
120
|
+
- **Economic security**: a $-TVL bar paired with a no-exploit time horizon. Example: `$50M TVL with no successful exploit in the first 6 months post-launch`. The dollar figure forces the team to size the bug bounty (a useful default: 10% of TVL up to a cap), and the time horizon forces a monitoring commitment.
|
|
121
|
+
- **Gas budget**: per-function ceilings on the target chain, measured by `forge snapshot`. Example: `deposit costs less than 200k gas on Optimism; withdraw less than 250k`. Encode these as snapshot tests in CI so a regression breaks the build, not just makes things slightly more expensive.
|
|
122
|
+
- **Capability counts**: scale targets that drive design. Example: `supports 10,000 unique depositors without unbounded loops`, or `supports adapter rotation without migrating user balances`. Each capability target rules out a class of naive implementations (no `address[] depositors` you iterate over).
|
|
123
|
+
|
|
124
|
+
```solidity
|
|
125
|
+
// test/gas/Snapshot.t.sol
|
|
126
|
+
pragma solidity ^0.8.24;
|
|
127
|
+
import {Test} from "forge-std/Test.sol";
|
|
128
|
+
import {Vault} from "src/Vault.sol";
|
|
129
|
+
|
|
130
|
+
contract GasSnapshot is Test {
|
|
131
|
+
Vault internal vault;
|
|
132
|
+
address internal user = address(0xBEEF);
|
|
133
|
+
|
|
134
|
+
function setUp() public { vault = new Vault(); vm.deal(user, 10 ether); }
|
|
135
|
+
|
|
136
|
+
function test_gas_deposit() public {
|
|
137
|
+
vm.prank(user);
|
|
138
|
+
uint256 g = gasleft();
|
|
139
|
+
vault.deposit{value: 1 ether}();
|
|
140
|
+
uint256 used = g - gasleft();
|
|
141
|
+
assertLt(used, 200_000, "deposit exceeds gas budget");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
A few more disciplines that pay off late but cost little up front:
|
|
147
|
+
|
|
148
|
+
- **Deployment script as code**: the canonical deployment lives in `script/Deploy.s.sol`, not in a one-off REPL session. Anyone should be able to reproduce mainnet bytecode from a tagged commit.
|
|
149
|
+
- **Pause-and-recover drill**: before mainnet, simulate the full incident response on a fork — the multisig signs, the contract pauses, an exploit is contained, an upgrade is queued through timelock, and depositors are made whole. If the runbook is not exercised, it does not exist.
|
|
150
|
+
- **Post-deploy verification**: verified source on Etherscan or the L2 explorer, a published address registry (one line per chain) committed to the repo, and a public read-only dashboard for the invariant metrics so anyone can re-derive solvency from on-chain state.
|
|
151
|
+
|
|
152
|
+
Taken together — framing, invariants, threat model, trust assumptions, success metrics — these five sections are what an auditor will ask for in the kickoff call. Write them before you ship, commit them next to the contracts, and treat any drift as a scope change that requires re-auditing.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web3-security
|
|
3
|
+
description: Layered security practices for smart contracts heading to mainnet — defense-in-depth, Checks-Effects-Interactions, pull payments, OpenZeppelin primitives, pause + multisig, and input validation discipline
|
|
4
|
+
topics: [web3, security, solidity, openzeppelin, defense-in-depth]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
A smart contract is a public, immutable bank vault that anyone on the planet can call. The mempool is hostile, the bytecode is permanent, and the only patch deployment is a redeploy + migration that your users may or may not follow. Most exploits aren't novel cryptography — they're missed standard patterns: a state update after an external call, a `tx.origin` check that a phishing contract bypassed, a `transfer` to a contract that reverts and bricks an auction. Layered defense beats clever one-off mitigations, every time.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
Layer your defenses: a written spec with invariants, secure-by-construction patterns, static analysis and forge tests, an external audit, then a bug bounty — each catches what the previous layer missed. Use `Checks-Effects-Interactions` religiously and wrap any function that touches an external contract with `ReentrancyGuard` from OpenZeppelin. Prefer `pull payments` (users withdraw) over push (contract sends) so a malicious or buggy recipient cannot stall everyone else. Treat `OpenZeppelin` as your baseline — audited, well-known primitives (`AccessControl`, `Pausable`, `SafeERC20`) have fewer surprises than hand-rolled equivalents. Wire a `Pausable` kill-switch to a 2-of-N or 3-of-N Safe multisig and time-lock the truly dangerous operations.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### Defense-in-depth layered model
|
|
16
|
+
|
|
17
|
+
Treat security as five distinct layers, each independently valuable. The point of layering is that any single layer will miss something — the protocol survives because the next layer catches it.
|
|
18
|
+
|
|
19
|
+
1. **Spec and invariants** — Before any Solidity, write down what must always be true: "total supply equals sum of balances," "no user can withdraw more than they deposited," "only governance can change fee tiers," "the protocol is solvent at every block." These become the oracle for tests, fuzzing, and audit conversations. A contract without a written invariant set is a contract whose author has not decided what "correct" means, and reviewers cannot tell you whether the code is correct against an undefined target.
|
|
20
|
+
2. **Secure-by-construction patterns** — CEI, pull-payments, `ReentrancyGuard`, custom errors with parameters, `immutable` where possible, explicit visibility. Most exploits target contracts that skipped one of these — the pattern existed, the author didn't reach for it.
|
|
21
|
+
3. **Tooling** — Slither for static analysis on every PR, Foundry fuzz and invariant tests aimed directly at the spec from layer 1, Echidna or Halmos for deeper property testing, mythril or symbolic execution where it earns its keep. CI fails the build on new Slither high/medium findings; coverage gates keep the test suite from rotting.
|
|
22
|
+
4. **Audit** — One or two reputable firms (Trail of Bits, OpenZeppelin, Spearbit, Code4rena contest). Audit late enough that the code is frozen, early enough that fixes don't push the launch. Code freeze before audit, no scope creep during. Treat every finding — even "informational" — as a real signal about your design.
|
|
23
|
+
5. **Bug bounty** — Immunefi or Cantina, sized to the TVL at risk. A $50k bounty on a $50M protocol is an insult to whitehats and an invitation to blackhats; scale rewards to the prize. Run the bounty continuously, not for a launch week and then off.
|
|
24
|
+
|
|
25
|
+
See `web3-audit-workflow.md` for the tooling integration details and `web3-common-vulnerabilities.md` for the SWC-level checklist that audits work through.
|
|
26
|
+
|
|
27
|
+
### Checks-Effects-Interactions
|
|
28
|
+
|
|
29
|
+
The single most important pattern in Solidity. Order every state-changing function as: (1) **Check** preconditions and inputs, (2) update **Effects** in storage, (3) make external **Interactions** last. Reentrancy exploits work by letting an attacker re-enter your function before step 2 has run; CEI removes that window.
|
|
30
|
+
|
|
31
|
+
Vulnerable:
|
|
32
|
+
|
|
33
|
+
```solidity
|
|
34
|
+
function withdraw() external {
|
|
35
|
+
uint256 amount = balances[msg.sender];
|
|
36
|
+
(bool ok, ) = msg.sender.call{value: amount}(""); // INTERACTION first
|
|
37
|
+
require(ok, "send failed");
|
|
38
|
+
balances[msg.sender] = 0; // EFFECT after — exploitable
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Corrected with CEI plus `ReentrancyGuard` as a belt-and-braces backstop:
|
|
43
|
+
|
|
44
|
+
```solidity
|
|
45
|
+
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
|
|
46
|
+
|
|
47
|
+
contract Vault is ReentrancyGuard {
|
|
48
|
+
mapping(address => uint256) public balances;
|
|
49
|
+
error NothingToWithdraw();
|
|
50
|
+
error TransferFailed();
|
|
51
|
+
|
|
52
|
+
function withdraw() external nonReentrant {
|
|
53
|
+
uint256 amount = balances[msg.sender]; // CHECK
|
|
54
|
+
if (amount == 0) revert NothingToWithdraw();
|
|
55
|
+
balances[msg.sender] = 0; // EFFECT (before interaction)
|
|
56
|
+
(bool ok, ) = msg.sender.call{value: amount}(""); // INTERACTION last
|
|
57
|
+
if (!ok) revert TransferFailed();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`nonReentrant` is cheap insurance for the case where you, or a future maintainer, miss a CEI ordering somewhere subtle (cross-function reentrancy, view-function reentrancy on read-after-write).
|
|
63
|
+
|
|
64
|
+
### Pull-payments over push
|
|
65
|
+
|
|
66
|
+
Never proactively send funds to N users in a loop. One reverting recipient bricks the whole batch — a single griefing contract can stall every auction, raffle, or dividend you ship. Instead, credit each user's balance in storage and let them pull:
|
|
67
|
+
|
|
68
|
+
```solidity
|
|
69
|
+
mapping(address => uint256) public pending;
|
|
70
|
+
|
|
71
|
+
function _credit(address user, uint256 amount) internal {
|
|
72
|
+
pending[user] += amount;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function claim() external nonReentrant {
|
|
76
|
+
uint256 amount = pending[msg.sender];
|
|
77
|
+
if (amount == 0) revert NothingToWithdraw();
|
|
78
|
+
pending[msg.sender] = 0;
|
|
79
|
+
(bool ok, ) = msg.sender.call{value: amount}("");
|
|
80
|
+
if (!ok) revert TransferFailed();
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The contract's job is bookkeeping; the user's job is taking custody. If their withdrawal fails — recipient is a contract that reverts, ran out of gas, or hit a blacklist — only they are affected, and they can fix it on their side without coordinating with everyone else in the queue. OpenZeppelin's `PullPayment` contract is a drop-in implementation if you want one less thing to write.
|
|
85
|
+
|
|
86
|
+
The rare exceptions where push is acceptable: payouts to a single, trusted address you control (your treasury); ERC-20 `transfer` calls inside `SafeERC20` where you've already validated the recipient. Even then, never wrap a push in a user-iterating loop.
|
|
87
|
+
|
|
88
|
+
### OpenZeppelin as baseline
|
|
89
|
+
|
|
90
|
+
Pin a specific OpenZeppelin Contracts version in `foundry.toml` remappings and inherit from their primitives instead of hand-rolling. The core set worth knowing cold:
|
|
91
|
+
|
|
92
|
+
- `ReentrancyGuard` — the `nonReentrant` modifier shown above. Tracks a `_status` slot to reject reentrant calls cheaply.
|
|
93
|
+
- `Pausable` — `whenNotPaused` modifier on user-facing entry points; emergency `_pause()` callable by a privileged role. Pair with `AccessControl` so the pauser role is granular and revocable.
|
|
94
|
+
- `AccessControl` — role-based permissions instead of a single `owner`. Lets you separate `PAUSER_ROLE`, `UPGRADER_ROLE`, and `TREASURER_ROLE` to different keys, with `DEFAULT_ADMIN_ROLE` controlling grants. Deep dive in `web3-access-control.md`.
|
|
95
|
+
- `SafeERC20` — `safeTransfer`, `safeTransferFrom`, `forceApprove`. Handles non-standard tokens that don't return a bool (USDT) and the approve-race-condition pattern. Use it for every ERC-20 interaction without exception.
|
|
96
|
+
- `EIP712` and `Nonces` — typed structured signatures and replay-protected nonces when you need permit-style or meta-tx flows.
|
|
97
|
+
|
|
98
|
+
These are audited, widely deployed, and reviewed continuously by the ecosystem. A hand-rolled `nonReentrant` will pass review once and then rot as the team rotates; OZ's gets re-validated every release and every external audit of every protocol that uses it. Pin the version (`@openzeppelin/contracts@5.0.2`) in `package.json` and the remapping so an `npm update` cannot silently swap your security primitives, and read the changelog before upgrading — major versions occasionally change defaults (initializer patterns, role bytes32 encoding) in ways that matter.
|
|
99
|
+
|
|
100
|
+
### Pause + emergency multisig
|
|
101
|
+
|
|
102
|
+
Wire a kill-switch on every entry point that moves funds, and put the pause key behind a Safe multisig — never an EOA. Two-of-three for small protocols, three-of-five for serious TVL, with signers on different hardware in different physical locations.
|
|
103
|
+
|
|
104
|
+
```solidity
|
|
105
|
+
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
|
|
106
|
+
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
|
|
107
|
+
|
|
108
|
+
contract Market is AccessControl, Pausable {
|
|
109
|
+
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
|
|
110
|
+
|
|
111
|
+
constructor(address multisig) {
|
|
112
|
+
_grantRole(DEFAULT_ADMIN_ROLE, multisig); // multisig owns role management
|
|
113
|
+
_grantRole(PAUSER_ROLE, multisig);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function pause() external onlyRole(PAUSER_ROLE) { _pause(); }
|
|
117
|
+
function unpause() external onlyRole(PAUSER_ROLE) { _unpause(); }
|
|
118
|
+
|
|
119
|
+
function trade(uint256 amount) external whenNotPaused {
|
|
120
|
+
// ... real logic ...
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Anything irreversible — upgrading an implementation, draining the treasury, raising fee caps, changing oracle sources — goes behind a `TimelockController` with a 24–72 hour delay so users have time to exit if the multisig is compromised or coerced. The pause itself stays instantaneous (you can't time-lock an emergency); the dangerous knobs are slow.
|
|
126
|
+
|
|
127
|
+
Two adjacent practices worth wiring up at the same time:
|
|
128
|
+
|
|
129
|
+
- Publish a public dashboard or `roles.md` of who holds which role and where the timelock points, so users can verify the kill-switch exists and is wired correctly. An invisible kill-switch is a trust claim, not a security control.
|
|
130
|
+
- Rehearse a pause-and-recover with the multisig signers at least once before mainnet so the first time someone signs is not the night of an incident. Practice the unhappy path: signer unavailable, hardware wallet bricked, mis-typed function selector.
|
|
131
|
+
- Subscribe the multisig to an on-chain monitoring service (OpenZeppelin Defender, Forta, Tenderly Alerts) that pages on anomalous activity — large withdrawals, oracle deviation, paused-state changes. The kill-switch is only useful if someone is watching.
|
|
132
|
+
|
|
133
|
+
### Input validation and visibility
|
|
134
|
+
|
|
135
|
+
Validate every input at the public boundary with custom errors that carry the offending values — they're cheaper than revert strings and far more useful when triaging a failed tx:
|
|
136
|
+
|
|
137
|
+
```solidity
|
|
138
|
+
error AmountZero();
|
|
139
|
+
error AmountExceedsCap(uint256 requested, uint256 cap);
|
|
140
|
+
error RecipientZero();
|
|
141
|
+
|
|
142
|
+
uint256 public immutable cap; // immutable: set in constructor, never changes
|
|
143
|
+
uint8 public constant DECIMALS = 18; // constant: known at compile time
|
|
144
|
+
|
|
145
|
+
function deposit(address to, uint256 amount) external whenNotPaused {
|
|
146
|
+
if (to == address(0)) revert RecipientZero();
|
|
147
|
+
if (amount == 0) revert AmountZero();
|
|
148
|
+
if (amount > cap) revert AmountExceedsCap(amount, cap);
|
|
149
|
+
// ...
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Mark functions `external` rather than `public` unless you call them internally — `external` is cheaper and signals "this is an entry point, audit the inputs." Use `immutable` for constructor-set values (deploy-time addresses, supply caps, the multisig address) and `constant` for compile-time literals; both save gas and tell the reader "this cannot change," which is itself a security property. Default state variables to `private` or `internal` and expose specific getters where you need them, rather than leaning on `public` which generates a getter you may not have meant to commit to as part of the ABI.
|
|
154
|
+
|
|
155
|
+
Custom errors with parameters are strictly better than `require(cond, "string")`: they're cheaper, they survive ABI decoding so off-chain tools can show the failing values, and they force you to name the failure mode rather than describe it. Reserve `require` strings for one-off scripts and tests.
|
|
156
|
+
|
|
157
|
+
### What NOT to use
|
|
158
|
+
|
|
159
|
+
- **`tx.origin` for authorization** — `tx.origin` is the EOA that started the transaction, which a phishing contract can trick a user into being. Always check `msg.sender` (the immediate caller). `tx.origin` is acceptable only for narrow checks like "refuse to be called by any contract" (`require(tx.origin == msg.sender)`), and even that is fragile in a post-EIP-3074 / account-abstraction world.
|
|
160
|
+
- **Raw low-level `call` without checking return data** — `(bool ok, ) = addr.call(...)` ignores the actual return payload; an unverified `ok` says "the call returned," not "the call succeeded as intended." Use `SafeERC20` for tokens and check `ok` plus decode return data for everything else. Bubble up the revert reason where the caller cares.
|
|
161
|
+
- **Unbounded loops over user-supplied arrays** — gas grows with N, an attacker submits a million-element array, the function reverts forever and locks whatever it gated. Cap input lengths or paginate. The same hazard applies to iterating over a `users` mapping that anyone can grow.
|
|
162
|
+
- **Calling arbitrary user-supplied addresses** — every external call is a trust boundary. Whitelist the targets you call (routers, oracles, your own modules) via an admin-managed allowlist; never `target.call(data)` where `target` came from `msg.sender`. If the protocol genuinely needs to integrate with arbitrary external contracts, isolate the interaction in a sandbox contract with no privileges and no funds beyond the immediate call's value.
|
|
163
|
+
- **`block.timestamp` for fine-grained ordering or randomness** — miners (or proposers post-merge) have a small window to nudge it. Fine for "did 24 hours pass since deploy"; not fine for "who got the millisecond-precise winning bid" or as an entropy source.
|