@zigrivers/scaffold 3.26.0 → 3.27.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/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/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/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,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.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web3-testing
|
|
3
|
+
description: Testing discipline for Foundry smart contracts — unit, fuzz, invariant, and fork tests with coverage and gas snapshots wired into CI
|
|
4
|
+
topics: [web3, testing, foundry, fuzz, invariants, fork-tests]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Smart contracts must work the first time. Once deployed, a bug is a bounty for the next adversary who reads your storage layout, and "we'll patch it next sprint" is not an option when the only patch path is a migration to a brand-new address. Foundry's `forge test` runner makes property-level confidence cheap: unit tests pin known behavior, fuzz tests stress the boundaries you forgot, invariants assert the laws that must hold across every state sequence, and fork tests replay against real mainnet state. Use all four — none of them substitutes for any of the others.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
Treat Foundry testing as four layers with distinct jobs. Use **unit tests** (`test_` prefix, `Test`-extended contracts) for deterministic state-change, event, and revert assertions. Use **fuzz tests** (`testFuzz_` prefix) to let Foundry generate adversarial inputs; constrain with `vm.assume` and bump `[fuzz] runs` when the property warrants. Use **invariant tests** (`invariant_` functions with a `Handler` contract) to assert system-wide properties that must hold across every reachable state. Use **fork tests** (`--fork-url` with a pinned block number) as the contract equivalent of e2e — replay against real protocols, real liquidity, real attackers. Wire `forge coverage` and `forge snapshot --check` into CI so coverage and gas regressions fail the build.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### Unit tests
|
|
16
|
+
|
|
17
|
+
Every external function in a contract has at least three unit tests: happy path, every revert branch, and every emitted event. Foundry's `Test` base contract supplies `vm.expectRevert`, `vm.expectEmit`, and `vm.prank` for the three; `setUp()` deploys fresh contracts before each test so state never leaks across tests.
|
|
18
|
+
|
|
19
|
+
```solidity
|
|
20
|
+
// test/Vault.t.sol
|
|
21
|
+
import {Test} from "forge-std/Test.sol";
|
|
22
|
+
import {Vault} from "../src/Vault.sol";
|
|
23
|
+
|
|
24
|
+
contract VaultTest is Test {
|
|
25
|
+
Vault vault;
|
|
26
|
+
address alice = makeAddr("alice");
|
|
27
|
+
|
|
28
|
+
event Deposited(address indexed user, uint256 amount);
|
|
29
|
+
|
|
30
|
+
function setUp() public {
|
|
31
|
+
vault = new Vault();
|
|
32
|
+
vm.deal(alice, 10 ether);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function test_Deposit_IncrementsBalance() public {
|
|
36
|
+
vm.prank(alice);
|
|
37
|
+
vault.deposit{value: 1 ether}();
|
|
38
|
+
assertEq(vault.balanceOf(alice), 1 ether);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function test_Deposit_EmitsEvent() public {
|
|
42
|
+
vm.expectEmit(true, false, false, true);
|
|
43
|
+
emit Deposited(alice, 1 ether);
|
|
44
|
+
vm.prank(alice);
|
|
45
|
+
vault.deposit{value: 1 ether}();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function test_RevertWhen_DepositZero() public {
|
|
49
|
+
vm.prank(alice);
|
|
50
|
+
vm.expectRevert(Vault.ZeroAmount.selector);
|
|
51
|
+
vault.deposit{value: 0}();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Name tests after the behavior, not the function: `test_Deposit_IncrementsBalance` and `test_RevertWhen_DepositZero` read as sentences in CI output. Use `makeAddr("alice")` instead of hard-coded addresses so labels appear in traces. Run with `forge test -vv` locally; bump to `-vvvv` when a failing test needs full call traces.
|
|
57
|
+
|
|
58
|
+
### Fuzz tests
|
|
59
|
+
|
|
60
|
+
A fuzz test takes parameters and Foundry feeds it pseudo-random values — 256 runs by default. The job is to encode a property that must hold for every input in the valid range, then let the fuzzer try to break it. Use `vm.assume` to reject inputs outside the domain (overflow, zero address) without polluting the test body with conditionals.
|
|
61
|
+
|
|
62
|
+
```solidity
|
|
63
|
+
function testFuzz_Deposit_BalanceMatchesInput(uint96 amount) public {
|
|
64
|
+
vm.assume(amount > 0 && amount <= 1000 ether);
|
|
65
|
+
vm.deal(alice, amount);
|
|
66
|
+
vm.prank(alice);
|
|
67
|
+
vault.deposit{value: amount}();
|
|
68
|
+
assertEq(vault.balanceOf(alice), amount);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Prefer narrow types (`uint96`, `uint128`) when the domain is bounded — they generate more interesting values than `uint256` and naturally avoid overflow. Bump the run count for properties that matter via `foundry.toml`:
|
|
73
|
+
|
|
74
|
+
```toml
|
|
75
|
+
[fuzz]
|
|
76
|
+
runs = 1024
|
|
77
|
+
max_test_rejects = 65536
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
When a fuzz test fails, Foundry prints the failing seed; rerun with `forge test --fuzz-seed <seed>` to reproduce.
|
|
81
|
+
|
|
82
|
+
### Invariant tests
|
|
83
|
+
|
|
84
|
+
Unit and fuzz tests assert behavior for a single call. Invariants assert properties that must hold *across every state sequence the system can reach*. The fuzzer calls random functions on random target contracts with random arguments; after each sequence the invariant function is checked. The classic invariant: total supply equals the sum of balances, no matter what trades, transfers, or deposits happened.
|
|
85
|
+
|
|
86
|
+
Constrain the actor surface with a `Handler` contract — without one, the fuzzer wastes runs on reverting calls.
|
|
87
|
+
|
|
88
|
+
```solidity
|
|
89
|
+
// test/handlers/VaultHandler.sol
|
|
90
|
+
contract VaultHandler is Test {
|
|
91
|
+
Vault public vault;
|
|
92
|
+
uint256 public ghost_totalDeposited;
|
|
93
|
+
address[] public actors;
|
|
94
|
+
|
|
95
|
+
constructor(Vault _vault) {
|
|
96
|
+
vault = _vault;
|
|
97
|
+
for (uint256 i; i < 5; ++i) actors.push(makeAddr(string.concat("actor", vm.toString(i))));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function deposit(uint256 actorSeed, uint96 amount) external {
|
|
101
|
+
address actor = actors[actorSeed % actors.length];
|
|
102
|
+
amount = uint96(bound(amount, 1, 100 ether));
|
|
103
|
+
vm.deal(actor, amount);
|
|
104
|
+
vm.prank(actor);
|
|
105
|
+
vault.deposit{value: amount}();
|
|
106
|
+
ghost_totalDeposited += amount;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// test/Vault.invariant.t.sol
|
|
111
|
+
contract VaultInvariantTest is Test {
|
|
112
|
+
Vault vault;
|
|
113
|
+
VaultHandler handler;
|
|
114
|
+
|
|
115
|
+
function setUp() public {
|
|
116
|
+
vault = new Vault();
|
|
117
|
+
handler = new VaultHandler(vault);
|
|
118
|
+
targetContract(address(handler));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function invariant_TotalSupplyEqualsSumOfBalances() public view {
|
|
122
|
+
assertEq(address(vault).balance, handler.ghost_totalDeposited());
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Run with `forge test --invariant` or tune via `[invariant] runs`, `depth`, and `fail_on_revert` in `foundry.toml`. Set `fail_on_revert = true` while writing handlers so silent reverts surface; flip to `false` once the handler is realistic.
|
|
128
|
+
|
|
129
|
+
### Fork tests
|
|
130
|
+
|
|
131
|
+
A fork test runs against a snapshot of a real chain. This is how you assert "our adapter actually works against the live Uniswap pool" without redeploying the entire DeFi stack to a local node. Always pin the block number — without one, the test will silently behave differently as mainnet state drifts.
|
|
132
|
+
|
|
133
|
+
```solidity
|
|
134
|
+
contract UniswapAdapterForkTest is Test {
|
|
135
|
+
uint256 mainnetFork;
|
|
136
|
+
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
|
|
137
|
+
|
|
138
|
+
function setUp() public {
|
|
139
|
+
mainnetFork = vm.createSelectFork(vm.envString("MAINNET_RPC"), 19_000_000);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function test_SwapAgainstLiveLiquidity() public {
|
|
143
|
+
// ... interacts with real USDC, real Uniswap V3 pool, real prices
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Run with `forge test --fork-url $MAINNET_RPC --fork-block-number 19000000` for one-off fork suites, or use `vm.createSelectFork` per-test for multi-chain tests. Use `vm.makePersistent(address)` to keep a deployed test contract alive across `selectFork` calls. Fork tests are slow — mark them with a separate profile in `foundry.toml` and run nightly, not on every commit.
|
|
149
|
+
|
|
150
|
+
### Coverage and gas snapshots
|
|
151
|
+
|
|
152
|
+
`forge coverage` reports line, branch, and function coverage. Target >90% line coverage and >80% branch coverage on `src/` — anything lower means a code path has no test pinning it.
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
forge coverage --report lcov --report summary
|
|
156
|
+
forge coverage --report lcov --no-match-coverage "(test|script)"
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Gas costs are part of the contract's contract with the world. `forge snapshot` writes `.gas-snapshot`; commit it. `forge snapshot --check` fails CI when any test's gas cost regresses beyond the tolerance, so an "innocent refactor" that doubles the gas of a hot function gets caught in review.
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
forge snapshot # record
|
|
163
|
+
forge snapshot --check --tolerance 1 # CI gate (1% drift allowed)
|
|
164
|
+
forge test --gas-report # per-function gas table
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Cheatcodes reference
|
|
168
|
+
|
|
169
|
+
A short list of the cheatcodes you reach for daily, all under the `vm` namespace from `forge-std/Test.sol`:
|
|
170
|
+
|
|
171
|
+
- `vm.prank(addr)` / `vm.startPrank(addr)` / `vm.stopPrank()` — set `msg.sender` for the next call or a range of calls.
|
|
172
|
+
- `vm.expectRevert(selector)` — assert the next call reverts with the given custom-error selector or string.
|
|
173
|
+
- `vm.expectEmit(checkTopic1, checkTopic2, checkTopic3, checkData)` — emit the expected event, then make the call.
|
|
174
|
+
- `vm.warp(timestamp)` / `vm.roll(blockNumber)` — fast-forward `block.timestamp` and `block.number`.
|
|
175
|
+
- `vm.deal(addr, amount)` — set an account's ETH balance directly.
|
|
176
|
+
- `vm.assume(condition)` — discard fuzz inputs that violate a precondition.
|
|
177
|
+
- `vm.label(addr, "name")` and `makeAddr("name")` — readable addresses in traces.
|
|
178
|
+
- `vm.createSelectFork(url, block)` / `vm.makePersistent(addr)` — fork-test plumbing.
|
|
179
|
+
|
|
180
|
+
When a test gets noisy with cheatcode plumbing, that is a signal to extract a helper into the `Test` base or a shared utility, not to keep stacking lines.
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web3-upgradeability
|
|
3
|
+
description: Upgradeable contracts — when not to upgrade, UUPS vs Transparent vs Beacon, OpenZeppelin Upgrades with the Foundry plugin, storage gaps, ERC-7201, initializers, timelocked authorization
|
|
4
|
+
topics: [web3, upgradeability, proxy, openzeppelin, storage]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Upgradeable contracts trade simplicity for the ability to fix bugs after deployment. The cost is real: a new threat surface (the upgrade key itself), a class of storage-layout bugs that do not exist in immutable contracts, and a permanent dependency on whoever holds upgrade rights. An "upgradeable" protocol is one whose trust assumptions include a future action by an admin — every user of the protocol is implicitly trusting that admin to behave, in perpetuity. The honest default for most protocols is: do not upgrade unless you have to.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
Default to immutable contracts and reach for upgradeability only when the protocol genuinely needs it — regulatory shifts, post-audit critical fixes, or planned feature evolution. When you do upgrade, prefer UUPS over the Transparent proxy: it is gas-cheaper and the upgrade logic lives on the implementation where it can be audited as a normal contract function. Use OpenZeppelin's `@openzeppelin/contracts-upgradeable` library with the `openzeppelin-foundry-upgrades` plugin so storage-layout validation runs as part of CI — skipping that validation is how protocols brick themselves. For new protocols, use ERC-7201 namespaced storage instead of the `__gap` pattern; it eliminates an entire category of storage-collision bugs at the cost of slightly more verbose accessors. Always gate `_authorizeUpgrade` behind `onlyRole(UPGRADER_ROLE)` and route the role through a `TimelockController` so users have an exit window.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### When to upgrade (and when not to)
|
|
16
|
+
|
|
17
|
+
Immutable contracts have a smaller attack surface, a cleaner decentralization story, and a much shorter trust prompt: "the code you see is the code that will run, forever." Upgradeable contracts can never make that claim. Every governance-related FAQ, every audit report, every user-facing risk disclosure has to spend a paragraph on the upgrade path — who can call it, how long it takes, what they can change. That is not free.
|
|
18
|
+
|
|
19
|
+
Reach for upgradeability when the trade is worth it: a regulated stablecoin issuer who must be able to comply with new sanctions regimes, a lending market whose risk parameters need to evolve as collateral assets change, a protocol whose audit recommended a fix that the team wants to be able to ship after launch without forcing every integrator to migrate. Do not reach for upgradeability because "we might want to change things later" — that is the same impulse that makes throwaway scripts grow into production systems by accident. If the only reason you can articulate for upgradeability is optionality, ship immutable and earn the trust dividend.
|
|
20
|
+
|
|
21
|
+
A useful test: write the sentence "users must trust ___ to ___" and see if you can defend it. "Users must trust the team's 4-of-7 Safe, behind a 72h timelock, to upgrade the protocol only when an audit-validated patch is needed" is defensible. "Users must trust the team" is not.
|
|
22
|
+
|
|
23
|
+
A pragmatic middle ground worth considering before reaching for full proxy upgradeability: ship immutable core contracts with a clearly-scoped admin surface (set fee, set oracle, set risk parameter), and migrate users to a new immutable deployment if the core logic itself ever needs to change. Most protocols that think they need upgradeability actually need parameter governance, which is much simpler and considerably less dangerous. Reserve true upgradeability for code paths where a migration would be operationally impossible — token contracts with millions of existing holders, NFT collections with active marketplaces, deeply-integrated infrastructure that other protocols import as a dependency.
|
|
24
|
+
|
|
25
|
+
When a migration is feasible, prefer it. Migrations make the trust boundary explicit (users opt in by moving funds), they let the new contract address be re-audited as if it were a fresh deployment, and they avoid the perpetual "what if the upgrade key gets compromised" tail risk. The cost is real — coordinating wallet UIs, third-party integrations, exchange listings, indexer pipelines, and user education is non-trivial — but it is a one-time cost, paid in exchange for permanent simplicity of the trust model. Compare that to the recurring cost of every upgrade requiring a fresh community review, fresh audit, fresh announcement, and fresh timelock execution. Migrations are often cheaper in total over a multi-year horizon than the cumulative cost of running an upgradeable system safely.
|
|
26
|
+
|
|
27
|
+
### Proxy patterns
|
|
28
|
+
|
|
29
|
+
Three proxy patterns dominate the OpenZeppelin ecosystem:
|
|
30
|
+
|
|
31
|
+
- **`TransparentUpgradeableProxy`** — Separates the admin (who can call upgrade functions on the proxy) from users (whose calls are forwarded to the implementation). Requires a `ProxyAdmin` contract sitting beside the proxy. Slightly more expensive per call because of the admin-vs-user check; safer in older Solidity versions where function-selector clashes were a worry.
|
|
32
|
+
- **`UUPSUpgradeable`** — The upgrade function lives on the implementation contract itself, not the proxy. The proxy is minimal — just `delegatecall` and a storage slot for the implementation address. Cheaper at runtime, simpler bytecode, and the upgrade authorization is a normal Solidity function on the implementation that auditors can reason about like any other access-controlled function.
|
|
33
|
+
- **`BeaconProxy`** — Many proxies share a single beacon contract that holds the implementation address. Upgrading the beacon upgrades every proxy that points at it. Useful when you deploy many instances of the same contract (per-user vaults, per-market lending pools) and want one upgrade to fan out to all of them.
|
|
34
|
+
|
|
35
|
+
UUPS is the right default for most protocols. The slimmer proxy is gas-cheaper for every user call for the entire life of the protocol — a non-trivial saving at scale — and the upgrade logic is a single auditable function rather than a separate `ProxyAdmin` contract. The catch: UUPS has a uniquely dangerous failure mode. If your v2 implementation forgets to inherit `UUPSUpgradeable` or accidentally removes the `_authorizeUpgrade` function, the proxy is permanently stuck on v2 with no upgrade path. The `openzeppelin-foundry-upgrades` plugin checks this for you; never deploy a UUPS upgrade without running it.
|
|
36
|
+
|
|
37
|
+
Reach for the Transparent proxy when the upgrade authorization model is materially different from the rest of the contract's access control — for example, a third party (like a foundation or DAO timelock) holds the upgrade key while the protocol team holds operational roles. The `ProxyAdmin` separation cleanly enforces "the upgrader cannot call user functions, even by accident" at the proxy layer rather than depending on Solidity-level modifiers. Reach for the Beacon pattern when you operate a contract factory — a yield-vault platform deploying one vault per strategy, a lending protocol deploying one pool per market — and want a single upgrade to propagate to every deployed instance atomically. Each pattern is correct in its niche; UUPS is the right answer when you do not have a strong reason to pick one of the other two.
|
|
38
|
+
|
|
39
|
+
A note on minimal proxies (EIP-1167 "clone" contracts): these are not upgradeable. Clones are a deployment-cost optimization — many cheap proxies forwarding all calls to a single immutable implementation — and changing the implementation requires deploying new clones. Do not confuse the two when picking a pattern. If you want one-shot cheap deployments without upgrade capability, use clones; if you want upgradeability across many deployed instances, use beacons.
|
|
40
|
+
|
|
41
|
+
### OpenZeppelin Upgrades + Foundry plugin
|
|
42
|
+
|
|
43
|
+
Use `@openzeppelin/contracts-upgradeable` for the implementation and the `openzeppelin-foundry-upgrades` plugin to deploy and validate. The plugin parses the storage layout of both old and new implementations and refuses to deploy an upgrade that would corrupt state — reordered fields, changed types, removed variables without a gap. CI should fail any PR that triggers a layout-incompatible change without an explicit reviewer override.
|
|
44
|
+
|
|
45
|
+
```solidity
|
|
46
|
+
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
|
|
47
|
+
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
|
|
48
|
+
import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
|
|
49
|
+
|
|
50
|
+
contract Protocol is Initializable, UUPSUpgradeable, AccessControlUpgradeable {
|
|
51
|
+
bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE");
|
|
52
|
+
|
|
53
|
+
/// @custom:oz-upgrades-unsafe-allow constructor
|
|
54
|
+
constructor() {
|
|
55
|
+
_disableInitializers(); // see "Initializers vs constructors" below
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function initialize(address admin, address upgrader) external initializer {
|
|
59
|
+
__AccessControl_init();
|
|
60
|
+
__UUPSUpgradeable_init();
|
|
61
|
+
_grantRole(DEFAULT_ADMIN_ROLE, admin);
|
|
62
|
+
_grantRole(UPGRADER_ROLE, upgrader);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _authorizeUpgrade(address newImpl) internal override onlyRole(UPGRADER_ROLE) {}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Deploy via the plugin's `Upgrades.deployUUPSProxy(...)` helper, and run `Upgrades.upgradeProxy(...)` for subsequent versions. Both invocations validate storage layout against the previously-deployed implementation before broadcasting the transaction. Treat the validation output as a release-gating signal; the plugin is the single most effective defense against storage-corruption bugs you can wire into a CI run.
|
|
70
|
+
|
|
71
|
+
Wire the plugin into CI explicitly, not just into local dev. Add a job that runs the upgrade-validation step against the current main-branch deployment artifacts for every PR that touches an upgradeable contract — if the PR introduces an incompatible layout change, the CI job fails before review even starts. The plugin emits a structured error pointing at the specific variable that broke compatibility, which makes triage fast. Pair this with a deployment artifact convention: every deployed implementation address gets recorded in a `deployments/<chain>.json` file checked into the repo, so future upgrades have a stable reference to compare against. The plugin uses these artifacts as its source of truth for "what is currently deployed."
|
|
72
|
+
|
|
73
|
+
### Storage collision and the gap pattern
|
|
74
|
+
|
|
75
|
+
In a proxy-based contract, state lives in the proxy's storage and is read through slot offsets baked into the implementation bytecode. When v2 of the implementation adds a state variable in the middle of the existing list, every later variable shifts by one slot — and the values that v1 wrote now appear in the wrong fields. `owner` becomes garbage, `totalSupply` reads from a different variable's slot, and the protocol silently corrupts.
|
|
76
|
+
|
|
77
|
+
The traditional fix is the storage gap: reserve a fixed array of unused slots at the end of every upgradeable contract so v2 can append fields by shrinking the gap rather than shifting existing slots.
|
|
78
|
+
|
|
79
|
+
```solidity
|
|
80
|
+
contract ProtocolV1 is Initializable, UUPSUpgradeable {
|
|
81
|
+
uint256 public totalSupply;
|
|
82
|
+
mapping(address => uint256) public balances;
|
|
83
|
+
// ... other state ...
|
|
84
|
+
|
|
85
|
+
/// @custom:storage-location erc7201:none
|
|
86
|
+
uint256[50] private __gap; // reserve 50 slots for future fields
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// In ProtocolV2:
|
|
90
|
+
contract ProtocolV2 is Initializable, UUPSUpgradeable {
|
|
91
|
+
uint256 public totalSupply;
|
|
92
|
+
mapping(address => uint256) public balances;
|
|
93
|
+
uint256 public newFeeBps; // new field consumes 1 slot from the gap
|
|
94
|
+
uint256[49] private __gap; // gap shrinks by exactly 1
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The discipline is exact: when you add a 1-slot field, shrink the gap by 1. When you add a `mapping`, shrink by 1 (mappings always take one slot, regardless of contents). When you add a `struct`, shrink by however many slots the struct packs into. The Foundry plugin verifies the math; do not eyeball it.
|
|
99
|
+
|
|
100
|
+
Two further hazards specific to the gap pattern. Inheritance chains compound the bookkeeping — every parent contract in the upgradeable hierarchy needs its own gap, sized for that parent's future evolution, and changes to a parent contract's storage have to respect every child's gap. This is one of the strongest arguments for ERC-7201 in any contract with non-trivial inheritance. And: do not "rescue" leftover gap space by inserting a field at the start of the contract's state. Adding `uint256 public newFee` at the top reshuffles every existing slot. The gap exists at the end of state precisely so additions land in unused, never-written slots; appending is safe, prepending is catastrophic.
|
|
101
|
+
|
|
102
|
+
### ERC-7201 namespaced storage (preferred for new protocols)
|
|
103
|
+
|
|
104
|
+
ERC-7201 ("Namespaced Storage Layout") avoids the slot-collision problem entirely by storing state inside a library-style struct anchored to a deterministic, name-derived slot, instead of sequential slot 0 onward. Add fields freely without worrying about layout drift — there is no "next slot" to corrupt because each contract's state lives in its own keccak-derived corner of storage.
|
|
105
|
+
|
|
106
|
+
```solidity
|
|
107
|
+
library ProtocolStorage {
|
|
108
|
+
/// @custom:storage-location erc7201:scaffold.protocol.main
|
|
109
|
+
struct Layout {
|
|
110
|
+
uint256 totalSupply;
|
|
111
|
+
mapping(address => uint256) balances;
|
|
112
|
+
uint256 newFeeBps; // added in v2 — no gap to shrink, no slot to collide
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// keccak256(abi.encode(uint256(keccak256("scaffold.protocol.main")) - 1)) & ~bytes32(uint256(0xff))
|
|
116
|
+
bytes32 internal constant SLOT =
|
|
117
|
+
0x...; // computed once, hardcoded
|
|
118
|
+
|
|
119
|
+
function layout() internal pure returns (Layout storage l) {
|
|
120
|
+
bytes32 slot = SLOT;
|
|
121
|
+
assembly { l.slot := slot }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Implementations read and write via `ProtocolStorage.layout().totalSupply` rather than a direct state variable. The cost is verbosity — every storage access goes through the library — and slightly more bytecode. The benefit is that upgrade reviews stop being about slot math and start being about business logic. Prefer ERC-7201 for any new upgradeable protocol; the `__gap` pattern is fine for existing contracts that already shipped without namespaced storage but is no longer the recommended default.
|
|
127
|
+
|
|
128
|
+
ERC-7201 also makes mixin-style composition safe. A `Pausable` mixin can declare its own namespaced storage struct under `scaffold.protocol.pausable`, and a `Permit` mixin can declare its own under `scaffold.protocol.permit`, and the two will never collide regardless of inheritance order — each lives in a deterministic slot derived from its name, not from its position in the linearization. Contrast this with the gap pattern, where reordering parents in `is A, B, C` versus `is A, C, B` shifts storage slots in ways that can corrupt state without changing any field. Namespaced storage decouples logical composition from physical storage layout, which is exactly the invariant you want for a long-lived upgradeable system.
|
|
129
|
+
|
|
130
|
+
### Initializers vs constructors
|
|
131
|
+
|
|
132
|
+
Proxies do not run the implementation's constructor — storage lives on the proxy, but a constructor only writes to the implementation contract's own storage, which the proxy never reads. Initialization runs through a regular function on the implementation, called via the proxy after deployment. Use the `initializer` modifier from `Initializable` to prevent the function from being called twice:
|
|
133
|
+
|
|
134
|
+
```solidity
|
|
135
|
+
function initialize(address admin) external initializer {
|
|
136
|
+
__AccessControl_init();
|
|
137
|
+
__UUPSUpgradeable_init();
|
|
138
|
+
_grantRole(DEFAULT_ADMIN_ROLE, admin);
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Two related hazards. First, every parent contract that has its own initializer needs an explicit `__Parent_init()` call from your `initialize` function — forgetting one leaves that subsystem (AccessControl, Pausable, ERC20) in a half-initialized state where modifiers pass but invariants are wrong. Second, the implementation contract itself — independent of the proxy — is a normal deployed contract that an attacker can call directly. If you leave its `initialize` callable, the attacker becomes admin of the implementation. They cannot reach proxy state, but they can call self-destructing or upgrade-bricking functions on the implementation. Defend with `_disableInitializers()` in the constructor:
|
|
143
|
+
|
|
144
|
+
```solidity
|
|
145
|
+
/// @custom:oz-upgrades-unsafe-allow constructor
|
|
146
|
+
constructor() {
|
|
147
|
+
_disableInitializers();
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The `unsafe-allow constructor` annotation tells the OpenZeppelin plugin that this constructor is intentional and safe — it touches no storage, it only locks down the implementation. Skipping `_disableInitializers()` for a UUPS implementation is a textbook audit finding; bake it into every upgradeable contract template.
|
|
152
|
+
|
|
153
|
+
When a v2 upgrade needs to write new state on first use (set a new fee parameter, initialize a new mapping, populate a new role), use the `reinitializer(version)` modifier rather than `initializer`:
|
|
154
|
+
|
|
155
|
+
```solidity
|
|
156
|
+
function initializeV2(uint256 newFeeBps) external reinitializer(2) {
|
|
157
|
+
ProtocolStorage.layout().newFeeBps = newFeeBps;
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`reinitializer(2)` runs once and only once, and only if the contract has been initialized to a version strictly less than 2. Call it as the same transaction that performs the upgrade (the OpenZeppelin plugin supports this via `Upgrades.upgradeProxy(..., abi.encodeCall(Protocol.initializeV2, (newFee)))`) so the protocol is never observable in a partially-upgraded state. Skipping the atomic init-during-upgrade pattern leaves a window — sometimes minutes, sometimes blocks — where the proxy points at v2 logic but has not yet had `initializeV2` called, which is precisely the kind of inconsistency attackers monitor for.
|
|
162
|
+
|
|
163
|
+
### Upgrade authorization
|
|
164
|
+
|
|
165
|
+
`_authorizeUpgrade(address)` is the single most dangerous function in an upgradeable protocol. Whoever can call it can replace your contract logic with anything — a backdoor, a rug pull, an oracle override. Treat it accordingly:
|
|
166
|
+
|
|
167
|
+
- Gate with `onlyRole(UPGRADER_ROLE)` (or `onlyOwner` if you have explicitly chosen the simpler model). Never leave it empty or default-public.
|
|
168
|
+
- Grant `UPGRADER_ROLE` to a `TimelockController` (see `web3-access-control.md`), never directly to a Safe or EOA. The timelock buys users a 48–72 hour window between proposal and execution to exit if the upgrade is malicious.
|
|
169
|
+
- Audit `_authorizeUpgrade` specifically. It is one line of Solidity that gates the entire contract; any reviewer should be able to recite its access check from memory.
|
|
170
|
+
- Emit an event on every upgrade — OpenZeppelin's `Upgraded(address)` from ERC-1967 fires automatically, but pair it with off-chain alerting so an unexpected upgrade pages your team within minutes.
|
|
171
|
+
|
|
172
|
+
Calibrate the timelock delay to the protocol's exit time, exactly as in the access-control doc: an upgrade users cannot react to in 24 hours should not run on a 24-hour timelock. The asymmetry — admin proposes fast, users react slow — is the entire point.
|
|
173
|
+
|
|
174
|
+
Two operational practices that strengthen this layer further. First, publish the proposed implementation address and its source code as soon as the upgrade is scheduled — not only on-chain via the timelock event, but in a human-readable post (Discord, governance forum, blog) explaining what changed and why. Users cannot evaluate the upgrade if all they see is an opaque bytecode hash. Second, simulate the upgrade against a forked mainnet state before the timelock window opens. The simulation produces a diff of every storage slot the upgrade touches; reviewers can audit that diff rather than re-reading the Solidity source. Both practices add hours of work to the upgrade process, which is precisely the point — slowing upgrades down is the security feature.
|
|
175
|
+
|
|
176
|
+
### Common pitfalls
|
|
177
|
+
|
|
178
|
+
- **Forgetting `_disableInitializers()` in the constructor.** Lets an attacker take over the implementation contract directly.
|
|
179
|
+
- **Reusing storage slots.** Reordering, renaming with a type change, or deleting fields without a gap corrupts state. The Foundry plugin catches this; do not bypass it.
|
|
180
|
+
- **Changing variable types between versions.** `uint128` to `uint256` looks innocent but changes how the slot is packed. Treat any type change as a storage migration, not a refactor.
|
|
181
|
+
- **Forgetting to shrink the storage gap.** Adding a new field without subtracting from `__gap` shifts everything after the gap. Always update both in the same diff.
|
|
182
|
+
- **Removing `_authorizeUpgrade` in a v2 UUPS upgrade.** The proxy cannot be upgraded again. There is no recovery.
|
|
183
|
+
- **Granting `UPGRADER_ROLE` directly to an EOA "for now".** That EOA is one phishing email from being able to replace your protocol with arbitrary bytecode.
|
|
184
|
+
- **Skipping the OpenZeppelin Foundry plugin in CI.** The plugin is the canonical defense against storage and authorization bugs; running it manually means somebody will eventually forget.
|
|
185
|
+
- **Calling `selfdestruct` from an implementation, even by accident.** `delegatecall` runs the destruct in the proxy's context, deleting the proxy. The infamous Parity multisig freeze hit this exact pattern. Never include `selfdestruct` in an upgradeable implementation.
|
|
186
|
+
- **Letting the deployer EOA temporarily hold `UPGRADER_ROLE` after deploy.** The brief window between deploy and role transfer is exactly when phishing campaigns target your team. Grant `UPGRADER_ROLE` to the timelock from the constructor's `initialize` call, never to the deployer.
|
|
187
|
+
- **Treating the implementation as "internal."** Block explorers index implementation addresses and verify their source independently of the proxy. Anyone can call them directly. Assume every external function on the implementation is reachable by an attacker — and use `_disableInitializers` accordingly.
|
|
188
|
+
|
|
189
|
+
See `web3-access-control.md` for `UPGRADER_ROLE` and `TimelockController` wiring, `web3-security.md` for the broader security posture this upgrade model sits inside, and `web3-audit-workflow.md` for the upgrade-specific checks an auditor will run against your storage layout, initializer, and authorization function.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# methodology/web3-overlay.yml
|
|
2
|
+
name: web3
|
|
3
|
+
description: >
|
|
4
|
+
Web3 overlay — injects smart-contract domain knowledge (EVM chains) into
|
|
5
|
+
existing pipeline steps for contract architecture, security, testing,
|
|
6
|
+
upgradeability, gas optimization, and audit workflow.
|
|
7
|
+
project-type: web3
|
|
8
|
+
|
|
9
|
+
knowledge-overrides:
|
|
10
|
+
# Foundational
|
|
11
|
+
create-prd: { append: [web3-requirements] }
|
|
12
|
+
user-stories: { append: [web3-requirements] }
|
|
13
|
+
coding-standards: { append: [web3-conventions] }
|
|
14
|
+
project-structure: { append: [web3-project-structure] }
|
|
15
|
+
dev-env-setup: { append: [web3-dev-environment] }
|
|
16
|
+
git-workflow: { append: [web3-conventions] }
|
|
17
|
+
|
|
18
|
+
# Architecture & Design
|
|
19
|
+
system-architecture: { append: [web3-architecture, web3-access-control, web3-upgradeability, web3-oracles-and-external-data] }
|
|
20
|
+
tech-stack: { append: [web3-architecture, web3-dev-environment] }
|
|
21
|
+
adrs: { append: [web3-architecture, web3-upgradeability] }
|
|
22
|
+
domain-modeling: { append: [web3-architecture] }
|
|
23
|
+
api-contracts: { append: [web3-architecture] }
|
|
24
|
+
security: { append: [web3-security, web3-common-vulnerabilities, web3-access-control] }
|
|
25
|
+
operations: { append: [web3-deployment-and-verification, web3-gas-optimization] }
|
|
26
|
+
|
|
27
|
+
# Testing
|
|
28
|
+
tdd: { append: [web3-testing] }
|
|
29
|
+
add-e2e-testing: { append: [web3-testing] }
|
|
30
|
+
create-evals: { append: [web3-testing, web3-common-vulnerabilities] }
|
|
31
|
+
|
|
32
|
+
# Reviews
|
|
33
|
+
review-architecture: { append: [web3-architecture, web3-access-control, web3-upgradeability] }
|
|
34
|
+
review-api: { append: [web3-architecture] }
|
|
35
|
+
review-security: { append: [web3-security, web3-common-vulnerabilities, web3-audit-workflow] }
|
|
36
|
+
review-operations: { append: [web3-deployment-and-verification, web3-gas-optimization] }
|
|
37
|
+
review-testing: { append: [web3-testing, web3-audit-workflow] }
|
|
38
|
+
|
|
39
|
+
# Planning
|
|
40
|
+
implementation-plan: { append: [web3-architecture] }
|