@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,216 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web3-deployment-and-verification
|
|
3
|
+
description: Deploying smart contracts with forge script — broadcast artifacts as provenance, Etherscan verification, multi-chain flows, testnet rehearsals, mainnet pre-flight, post-deploy role hardening, and CREATE2 deterministic deploys
|
|
4
|
+
topics: [web3, deployment, verification, forge-script, etherscan]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Shipping a contract to mainnet is the most irreversible thing a smart-contract team ever does. There is no rollback, no patch deploy, no `kubectl rollout undo` — once the bytecode is live and users start interacting with it, you live with what you wrote. The instinct from web2 — "deploy fast, fix forward" — produces drained protocols on-chain. Treat deployment as a release event: every privileged operation scripted (not hand-called), every artifact archived (not transient), every step rehearsed on a testnet that mirrors mainnet, and every contract verified on Etherscan before you tell anyone the address. The cost of the discipline is half a day of process; the cost of skipping it is the entire protocol.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
Use `forge script` with a `Script`-extending contract for every deploy — never an ad-hoc `forge create` invocation, never a hand-pasted constructor argument. The script batches deploy + initial role grants + ownership transfer into one atomic broadcast so there is no intermediate state where the deployer EOA holds privileges. Commit the resulting `broadcast/Deploy.s.sol/<chainId>/run-latest.json` for every canonical chain — it is the provenance artifact tying a verified address back to a commit SHA. Verify on Etherscan inline (`--verify` on the script) or as a separate `forge verify-contract` step before announcing the address; an unverified mainnet contract is functionally invisible to users and auditors. Rehearse the full deploy on Sepolia first — same script, different `--rpc-url` — and only promote to mainnet once the testnet smoke test, role-hardening sequence, and (if upgradeable) upgrade burn-test all pass. Then run the mainnet pre-flight checklist before unlocking the hardware wallets.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### `forge script` deploys
|
|
16
|
+
|
|
17
|
+
Every mainnet deploy goes through a `Script`-extending contract in `script/` — not `forge create`, not a one-off REPL, not a `cast send` typed into a terminal at 2am. The script is reviewable, diffable, and re-runnable; it lives in the same repo as the contracts it deploys; and it captures every privileged operation in a single broadcast so there is no window where the deployer EOA holds powers it should not. A minimal but production-shaped deploy script:
|
|
18
|
+
|
|
19
|
+
```solidity
|
|
20
|
+
// script/Deploy.s.sol
|
|
21
|
+
import {Script} from "forge-std/Script.sol";
|
|
22
|
+
import {Vault} from "../src/Vault.sol";
|
|
23
|
+
|
|
24
|
+
contract Deploy is Script {
|
|
25
|
+
function run() external returns (Vault vault) {
|
|
26
|
+
address adminMultisig = vm.envAddress("ADMIN_MULTISIG");
|
|
27
|
+
address minterMultisig = vm.envAddress("MINTER_MULTISIG");
|
|
28
|
+
address timelock = vm.envAddress("TIMELOCK");
|
|
29
|
+
uint256 pk = vm.envUint("DEPLOYER_PK");
|
|
30
|
+
|
|
31
|
+
vm.startBroadcast(pk);
|
|
32
|
+
|
|
33
|
+
// Deploy
|
|
34
|
+
vault = new Vault(adminMultisig);
|
|
35
|
+
|
|
36
|
+
// Grant operational roles
|
|
37
|
+
vault.grantRole(vault.MINTER_ROLE(), minterMultisig);
|
|
38
|
+
vault.grantRole(vault.UPGRADER_ROLE(), timelock);
|
|
39
|
+
|
|
40
|
+
// Renounce deployer's transient admin (if constructor granted it)
|
|
41
|
+
// — preferred pattern is to pass the multisig to the constructor
|
|
42
|
+
// so the deployer never holds DEFAULT_ADMIN_ROLE in the first place.
|
|
43
|
+
|
|
44
|
+
vm.stopBroadcast();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`vm.startBroadcast(pk)` and `vm.stopBroadcast()` bracket the transactions that get sent on-chain — everything in between becomes a real transaction signed by the deployer key. Calls outside the broadcast block are simulation-only (useful for reading state to assert preconditions, e.g. `require(adminMultisig.code.length > 0, "admin must be a contract")`). Batching deploy + role grants in a single broadcast means the role-hardening steps cannot be forgotten mid-deploy; the contract goes live in its final access-control configuration.
|
|
50
|
+
|
|
51
|
+
`vm.startBroadcast()` has three forms worth distinguishing:
|
|
52
|
+
|
|
53
|
+
- `vm.startBroadcast()` — uses the default sender, typically the address derived from `--private-key`, `--account`, or `--ledger` passed on the command line.
|
|
54
|
+
- `vm.startBroadcast(uint256 pk)` — broadcasts as a specific private key, useful when the script needs to switch identities mid-run (rare; usually a smell).
|
|
55
|
+
- `vm.startBroadcast(address signer)` — broadcasts as a specific address; combined with `--unlocked` for local anvil testing, or used with `--sender` for prepare-only flows.
|
|
56
|
+
|
|
57
|
+
Prefer the no-argument form with `--account <name>` (encrypted keystore) or `--ledger` (hardware) at the CLI: the script stays identical between testnet and mainnet, and the signing identity is selected at invocation time. `vm.envUint("DEPLOYER_PK")` reads the private key into the script's memory, which is fine for a one-off mainnet deploy from a fresh ephemeral environment but is a footgun on a shared developer machine. The deployer EOA is privileged for exactly one transaction — its job is to produce the broadcast — and then it should be a dead account.
|
|
58
|
+
|
|
59
|
+
For deploys that originate from a Safe rather than an EOA, the `forge script` flow changes shape: the script is run with `--sender $SAFE_ADDRESS` and **not** `--broadcast`, producing an unsigned transaction batch (often via `--ffi` and a helper like `safe-tx-builder`) that gets posted to the Safe UI for signing. The broadcast artifact is then the Safe transaction hash plus the eventual execution receipt. This is the right pattern for any subsequent privileged operation after the initial deploy; the deployer EOA only ever produces the first transaction.
|
|
60
|
+
|
|
61
|
+
### Broadcast artifacts as provenance
|
|
62
|
+
|
|
63
|
+
Running `forge script script/Deploy.s.sol --rpc-url $RPC --broadcast` writes a JSON receipt to `broadcast/Deploy.s.sol/<chainId>/run-latest.json` (plus a timestamped copy alongside). That JSON contains the deployed address, the transaction hash, the block number, the constructor arguments, the gas used, and the ABI of the deployed contracts. Combined with the git commit SHA at the time of the run, it is the provenance artifact for the deployment. **Commit it.** Without the broadcast log, "which commit deployed the mainnet Vault at 0xabc..." becomes archaeology — you are diffing bytecode against historical builds trying to reconstruct which branch shipped. See `web3-project-structure.md` for the directory layout and `.gitignore` rules that keep canonical-chain broadcasts (1, 10, 8453, 42161) tracked while excluding ephemeral anvil (31337) noise.
|
|
64
|
+
|
|
65
|
+
Tag the release commit (`git tag mainnet-v1.0.0`) so the deployment is permanently locatable by name, and link the tag from your README's deployment table. A year from now, when someone asks "what version is live?", the answer is one `git checkout` away rather than a forensic exercise. Pair this with a CI job that uploads the broadcast artifact to an immutable store (release assets, IPFS) for the canonical record outside the repo.
|
|
66
|
+
|
|
67
|
+
The broadcast JSON itself is structured. Key fields to know:
|
|
68
|
+
|
|
69
|
+
- `transactions[].contractAddress` — the deployed address. Always present for `CREATE`/`CREATE2` transactions.
|
|
70
|
+
- `transactions[].hash` — the on-chain transaction hash for cross-referencing with Etherscan.
|
|
71
|
+
- `transactions[].arguments` — the constructor args as a JSON array, useful for re-verifying or auditing what was passed.
|
|
72
|
+
- `receipts[]` — the post-execution receipts including gas used, block number, and emitted logs.
|
|
73
|
+
- `commit` — Foundry records the current git commit SHA at the time of the script run.
|
|
74
|
+
|
|
75
|
+
Downstream tooling (deploy dashboards, indexer config generators, partner integrations) can parse this JSON directly. Treat it as a stable interface — your deployment story should never require running the script again to "look up" what happened the first time.
|
|
76
|
+
|
|
77
|
+
### Etherscan verification
|
|
78
|
+
|
|
79
|
+
An unverified mainnet contract is hostile UX: users see opaque bytecode, auditors cannot review the source, and Etherscan's "Read Contract" / "Write Contract" tabs are dead. Verify every deploy. Two paths:
|
|
80
|
+
|
|
81
|
+
**Inline during deploy** — pass `--verify` to `forge script` and Foundry verifies each deployed contract immediately after broadcast. This is the preferred flow because the verification happens in the same invocation that produced the address, eliminating the risk of forgetting:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
forge script script/Deploy.s.sol \
|
|
85
|
+
--rpc-url $MAINNET_RPC \
|
|
86
|
+
--broadcast \
|
|
87
|
+
--verify \
|
|
88
|
+
--etherscan-api-key $ETHERSCAN_API_KEY
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Standalone after the fact** — if verification was skipped or failed, run `forge verify-contract` against the deployed address:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
forge verify-contract 0xVaultAddress src/Vault.sol:Vault \
|
|
95
|
+
--chain mainnet \
|
|
96
|
+
--etherscan-api-key $ETHERSCAN_API_KEY \
|
|
97
|
+
--constructor-args $(cast abi-encode "constructor(address)" $ADMIN_MULTISIG) \
|
|
98
|
+
--watch
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`--watch` polls Etherscan until the verification completes. Constructor args must match exactly — encode them with `cast abi-encode` rather than typing the hex by hand, because a single nibble off means a "bytecode mismatch" error and another round of debugging.
|
|
102
|
+
|
|
103
|
+
Two reproducibility traps cause more "verified contract" failures than anything else. First, the compiler settings must match exactly: `solc_version`, `optimizer_runs`, `via_ir`, `evm_version`, and `bytecode_hash` in `foundry.toml` must be identical to what was used at deploy time. Setting `bytecode_hash = "none"` in `foundry.toml` removes the metadata hash from the compiled bytecode, which makes verification deterministic across machines — strongly recommended. Second, library addresses must be linked the same way; if your contract uses a non-inlined library, pass `--libraries` to `forge verify-contract` with the deployed library address. When verification fails, the Etherscan diff view shows which bytes diverge — read it before guessing.
|
|
104
|
+
|
|
105
|
+
Beyond Etherscan, consider also publishing to Sourcify (`--verifier sourcify`), which is a decentralized verification network not controlled by any single explorer. Sourcify verification produces a permanent, IPFS-backed record that survives any future Etherscan policy change or rate limit. For protocols that care about long-term decentralized verifiability, dual-verification (Etherscan + Sourcify) is cheap insurance.
|
|
106
|
+
|
|
107
|
+
L2 explorers vary: Basescan and Optimistic Etherscan use the same Etherscan stack and the same `forge verify-contract` flags. Arbiscan is similar but occasionally has stricter handling of via-IR contracts. Blockscout-based explorers (Gnosis Chain, several appchains) take `--verifier blockscout` with the chain-specific instance URL. Document the verifier per chain in your deploy runbook so a new operator does not have to discover it under deploy pressure.
|
|
108
|
+
|
|
109
|
+
### Multi-chain deploys
|
|
110
|
+
|
|
111
|
+
Modern protocols ship to several L2s alongside mainnet — Optimism, Base, Arbitrum, sometimes a half-dozen more. The same `forge script` runs against every chain; only the `--rpc-url` and chain-specific env vars change:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# Sepolia rehearsal
|
|
115
|
+
forge script script/Deploy.s.sol --rpc-url $SEPOLIA_RPC --broadcast --verify
|
|
116
|
+
|
|
117
|
+
# Mainnet
|
|
118
|
+
forge script script/Deploy.s.sol --rpc-url $MAINNET_RPC --broadcast --verify
|
|
119
|
+
|
|
120
|
+
# Base
|
|
121
|
+
forge script script/Deploy.s.sol --rpc-url $BASE_RPC --broadcast --verify
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Pin the expected chain ID inside the script with `require(block.chainid == 1, "wrong chain")` for mainnet-only operations — a wrong RPC URL is the kind of mistake that lands a "mainnet" deploy on Goerli, or worse, the other way around. Etherscan API keys are per-chain (Etherscan, Basescan, Arbiscan, Optimistic Etherscan, ...) — keep them in `.env` and never paste them inline.
|
|
125
|
+
|
|
126
|
+
Maintain a `deployments.json` (or similar) keyed by chain ID that captures the canonical address of every contract on every chain. Reference it from `Deploy.s.sol` for cross-contract wiring (e.g. "deploy a new Router that knows about the existing Vault on this chain"), and update it via the same broadcast that produced the new address. The file is the source of truth for downstream consumers — frontends, indexers, partner integrations — and lives in version control alongside the contracts. Some teams use `foundry-deployments` or `forge-deploy` plugins to manage this automatically; others write a 50-line helper in `script/utils/`. Either is fine; what is not fine is humans copy-pasting addresses between chats.
|
|
127
|
+
|
|
128
|
+
A multi-chain deploy is also a multi-chain **operations** problem: every chain has its own gas market, its own block-explorer quirks, its own reorg risk. Mainnet's 12-second blocks and ~$5 confirmation cost differ wildly from Arbitrum's sub-second blocks and sub-cent confirmation cost. Budget time for each chain individually — do not assume "we did mainnet, the L2s are a copy-paste" because the same script will still take a different amount of wall-clock time and produce different gas receipts.
|
|
129
|
+
|
|
130
|
+
### Testnet rehearsal
|
|
131
|
+
|
|
132
|
+
Sepolia is not optional. Every mainnet deploy is preceded by a full Sepolia run of the same script, against a real RPC, with real (testnet) ETH. The rehearsal must exercise the **entire** post-deploy path: deploy, role grants, ownership transfer, Etherscan verification, smoke-test script against the live address, and (if upgradeable) a burn-test of the upgrade flow through the proxy. Only after every step succeeds on testnet — and the team has reviewed the resulting broadcast artifact and the verified Etherscan page — does the same script run against mainnet. The discipline catches the boring failures: a missing env var, a constructor arg in the wrong order, a multisig address that is actually an EOA, an Etherscan API key with the wrong permissions. Catching them on Sepolia costs an hour; catching them on mainnet costs a redeploy and a public explanation.
|
|
133
|
+
|
|
134
|
+
The Sepolia smoke test is a separate `forge script` (`script/SmokeTest.s.sol`) that exercises the protocol's happy path against the just-deployed address: deposit, mint, transfer, withdraw, pause, unpause, role-grant. It runs unattended, asserts expected end states, and fails loudly on the first deviation. Treat it as part of the deploy pipeline, not an optional manual step. A "fork test" using `vm.createFork($MAINNET_RPC)` is **not** a substitute — forking simulates against historical state but does not exercise the real Etherscan verification, the real Safe-signing UX, or the real gas-pricing dynamics. Sepolia is closer to mainnet than any fork.
|
|
135
|
+
|
|
136
|
+
Be explicit about which testnet you target. Goerli is deprecated. Holesky and Sepolia are the supported Ethereum testnets; Sepolia is the default for most teams because faucets are accessible. L2 testnets (Sepolia-Optimism, Sepolia-Base, Sepolia-Arbitrum) all derive from Sepolia and use the same wallet/faucet flow. Pick once and stay consistent across your contract suite — splitting testnets across protocols means each deploy needs a fresh faucet trip.
|
|
137
|
+
|
|
138
|
+
Rehearse with the **real multisig**, not a placeholder EOA. The Safe UI on Sepolia is the same UI your signers will see on mainnet; their first encounter with the "Sign Transaction" button should be on testnet ETH. Walk through the Tenderly simulation step together, verify the call data matches the script's expected output, and have every signer practice rejecting a transaction (you want them to know what the cancel flow looks like before they ever need it). The rehearsal is as much a people-process check as a tooling check.
|
|
139
|
+
|
|
140
|
+
If the protocol is upgradeable, the testnet rehearsal must include an actual upgrade through the timelock: schedule the upgrade, wait the (compressed) delay, execute, and verify the new implementation. Skipping this step is how teams discover their `_authorizeUpgrade` is misgated only when they need to ship a hotfix.
|
|
141
|
+
|
|
142
|
+
### Mainnet pre-flight checklist
|
|
143
|
+
|
|
144
|
+
Before the deploy transaction is signed, every item is checked off, in order, with a named owner:
|
|
145
|
+
|
|
146
|
+
1. **All tests pass and Slither is clean** — `forge test`, `forge coverage`, and `slither .` all green on the exact commit being deployed. Cross-ref `web3-audit-workflow.md` for the full gate.
|
|
147
|
+
2. **Audit report delivered and findings remediated** — every P0/P1 from the audit has either a fix commit or a written acceptance with sign-off from the audit firm. No "we'll address this in v2" handwaves.
|
|
148
|
+
3. **Testnet rehearsal complete and verified** — Sepolia deploy succeeded end-to-end, including Etherscan verification and the smoke-test script. Broadcast artifact reviewed.
|
|
149
|
+
4. **Multisig signers ready and hardware wallets unlocked** — every required signer is online, hardware wallet plugged in and unlocked, Safe UI loaded, Tenderly simulation tab open. Rehearse the signing ceremony beforehand so the first signature is not the night of the deploy.
|
|
150
|
+
5. **Initial role assignments scripted** — every privileged role grant lives in `Deploy.s.sol` and runs inside the same broadcast as the deploy. **Never hand-call `grantRole` on mainnet.** A script is reviewable; a typed-out `cast send` at midnight is not. Cross-ref `web3-access-control.md`.
|
|
151
|
+
6. **Gas budget estimated and funded** — deploy gas estimated via the testnet run, mainnet base fee checked at the time of deploy, deployer EOA funded with enough ETH (plus headroom for retries). Stuck transactions because of insufficient gas are an avoidable embarrassment.
|
|
152
|
+
7. **Deploy window chosen with cost and contention in mind** — avoid known high-fee windows (NFT mints, market dislocations, hyped launches) unless the deploy is itself an event. A boring 4am-UTC Sunday deploy is the right kind of boring. Have a no-deploy list (Eth mainnet upgrades, scheduled L2 sequencer maintenance) so you do not ship in the middle of someone else's incident.
|
|
153
|
+
8. **Source code frozen** — the commit being deployed is tagged, the working tree is clean, and there is no in-flight PR that "we'll fold in real quick." A clean tree is the precondition for a reproducible verification.
|
|
154
|
+
|
|
155
|
+
Additional items worth treating as gates even though they are not strictly pre-flight:
|
|
156
|
+
|
|
157
|
+
- **Monitoring wired before the deploy lands.** Tenderly Alerts, OpenZeppelin Defender, or a custom indexer should already be configured for the (currently zero) contract address; flip them on the moment the deploy confirms. Catching an exploit ten minutes in is much better than catching it ten hours in.
|
|
158
|
+
- **Incident-response runbook ready.** Who pauses? Who calls the auditor? Who tweets? Decide before the deploy, not during the incident.
|
|
159
|
+
- **Block explorer pages bookmarked.** A panicked search for "Etherscan Vault address" during an incident is how the wrong contract gets paused.
|
|
160
|
+
|
|
161
|
+
### Post-deploy hardening
|
|
162
|
+
|
|
163
|
+
The minutes after a mainnet deploy are the most dangerous part of a deploy. The deployer EOA may still hold transient privileges; the contract may not yet be verified; the addresses are not yet published; the protocol may be paused-but-fundable or unpaused-but-untested. MEV searchers and on-chain attackers run scripts that watch for new deployments at known protocols and probe for misconfigurations within seconds. Run these steps immediately, ideally as part of the same `forge script` invocation:
|
|
164
|
+
|
|
165
|
+
1. **Transfer ownership to the multisig and verify on-chain** — the deploy script should pass the multisig to the constructor, not the deployer. If for any reason the deployer held admin transiently, transfer and verify with `hasRole(DEFAULT_ADMIN_ROLE, multisig) == true` before doing anything else.
|
|
166
|
+
2. **Renounce `DEFAULT_ADMIN_ROLE` from the deployer EOA** — `renounceRole` in the same broadcast as the deploy. After this transaction lands, the deployer key is no longer a risk to the protocol. Verify with `hasRole(DEFAULT_ADMIN_ROLE, deployerEOA) == false`.
|
|
167
|
+
3. **Set the timelock as upgrade-admin** — if the protocol is upgradeable, the proxy admin / `UPGRADER_ROLE` goes to the `TimelockController`, not directly to the multisig. See `web3-upgradeability.md`.
|
|
168
|
+
4. **Verify on Etherscan** — if `--verify` was not part of the deploy run, do it now with `forge verify-contract`. An unverified mainnet contract is not deployed in any meaningful sense.
|
|
169
|
+
5. **Publish the addresses to the README, protocol docs, and any subgraph/indexer configs** — together with the block number, commit SHA, and a link to the verified Etherscan page. Users and auditors should not have to ask which address is canonical.
|
|
170
|
+
6. **Run an on-chain assertion script.** A short `forge script PostDeployCheck.s.sol` that reads back every role assignment and configuration parameter and `require`s the expected value. Run it immediately, and again 24 hours later — if anything diverged in the meantime, you want to know before the community does. This is the on-chain analogue of a smoke test.
|
|
171
|
+
|
|
172
|
+
A common failure mode: a team intends to do all five steps but completes only the first three before "the deploy worked, we'll polish later." The unverified contract sits on Etherscan for a week, the deployer EOA still holds admin, and the addresses are scattered across Slack messages. The fix is to script all five steps as part of `Deploy.s.sol` so they happen atomically — the deploy is not "done" until role-hardening and verification have both succeeded.
|
|
173
|
+
|
|
174
|
+
Worked example of the verification step inside the script — Foundry's cheatcodes let you assert post-deploy invariants in the same broadcast:
|
|
175
|
+
|
|
176
|
+
```solidity
|
|
177
|
+
vm.stopBroadcast();
|
|
178
|
+
|
|
179
|
+
// Outside the broadcast — these are simulation reads, not transactions
|
|
180
|
+
require(vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), adminMultisig), "admin not granted");
|
|
181
|
+
require(!vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), msg.sender), "deployer still admin");
|
|
182
|
+
require(vault.hasRole(vault.UPGRADER_ROLE(), timelock), "timelock not upgrader");
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
A failing `require` after `vm.stopBroadcast()` means the script reverts and `forge script` exits non-zero — so a deploy that produced a misconfigured contract surfaces as a CI failure rather than a silent success. Wire the deploy script into a CI pipeline that gates merge-to-main on a successful Sepolia run, and the worst classes of "we forgot a role grant" bugs disappear before they can reach mainnet.
|
|
186
|
+
|
|
187
|
+
### CREATE2 deterministic deploys
|
|
188
|
+
|
|
189
|
+
For protocols that need the **same address on every chain** — typically cross-chain messaging contracts, omnichain tokens, or routers that other protocols hard-code — use `CREATE2` via a deterministic factory. `CREATE2` computes the deployed address as `keccak256(0xff ++ factory ++ salt ++ keccak256(initcode))`, so identical bytecode + identical salt + identical factory address produces the identical contract address across chains. The canonical factory is Safe's `CreateCall` or the widely-used deterministic deployment proxy at `0x4e59b44847b379578588920cA78FbF26c0B4956C` (which itself exists at the same address on most EVM chains, bootstrapped via a presigned transaction). Foundry supports `CREATE2` via `new Contract{salt: SALT}(args)` inside a script:
|
|
190
|
+
|
|
191
|
+
```solidity
|
|
192
|
+
bytes32 salt = keccak256("Vault.v1.mainnet");
|
|
193
|
+
Vault vault = new Vault{salt: salt}(adminMultisig);
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The constraint is that the initcode (including constructor args) must be byte-identical across chains — any chain-specific configuration must come from post-deploy setter calls, not constructor parameters. The salt is part of the public deployment scheme: document it alongside the deployed address so downstream protocols can independently verify the contract was deployed via the expected factory + salt. Vanity addresses (e.g. starting with multiple zero bytes for tiny gas savings on every call) come from grinding the salt against an off-chain miner; budget hours-to-days of GPU time for serious patterns.
|
|
197
|
+
|
|
198
|
+
Treat `CREATE2` as a specialist tool: most protocols do not need cross-chain address parity, and the operational overhead of locking the initcode is real. If your first deploy ever might want a same-address counterpart on another chain, deploy from a `CREATE2` factory from day one — converting later means either redeploying at a new address (breaking integrations) or accepting that the cross-chain story is asymmetric.
|
|
199
|
+
|
|
200
|
+
A common pattern is to pair `CREATE2` with **proxy** deployment: the deterministic factory deploys a minimal proxy at the cross-chain address, and the implementation contract (which can differ per chain) sits behind it. Argument-driven differences end up in proxy initialization (`initialize(...)`) calls that occur after the cross-chain `CREATE2` deploy. This separates "the address everyone integrates with" from "the chain-specific config the protocol needs," giving you the best of both worlds at the cost of one extra contract per chain. See `web3-upgradeability.md` for the proxy patterns.
|
|
201
|
+
|
|
202
|
+
### Common pitfalls
|
|
203
|
+
|
|
204
|
+
A short list of mistakes that have shipped to mainnet at well-funded protocols, each of which the above discipline prevents:
|
|
205
|
+
|
|
206
|
+
- **Deploying with a hard-coded admin address that turned out to be a test wallet from a stale `.env`.** Always read from `vm.envAddress` and require non-zero, plus assert `code.length > 0` if the admin is meant to be a contract.
|
|
207
|
+
- **Forgetting to verify on Etherscan because "we'll do it tomorrow."** A week later the deploy is in production, the verified flag is still off, and users are filing support tickets asking whether the protocol is a scam.
|
|
208
|
+
- **Granting `DEFAULT_ADMIN_ROLE` to the deployer EOA and never renouncing.** Six months later the EOA is on a former employee's laptop. The fix is in the constructor: grant admin to the multisig directly.
|
|
209
|
+
- **Multi-chain deploys producing different addresses because someone bumped the compiler between chains.** Pin everything in `foundry.toml`, commit the lockfile-equivalent (`lib/` submodule SHAs), and re-run from the tagged commit.
|
|
210
|
+
- **Discovering after the fact that the Sepolia RPC was actually pointed at Holesky.** Validate `block.chainid` in the script.
|
|
211
|
+
- **Constructor arg encoding off by one nibble.** Use `cast abi-encode` and pipe directly to `--constructor-args`; never type the hex.
|
|
212
|
+
- **Hand-calling `grantRole` from Etherscan's "Write Contract" tab as a "quick fix."** Scripts are reviewable; one-off Etherscan writes are not. If the deploy script needed a follow-up, the follow-up is itself a script.
|
|
213
|
+
|
|
214
|
+
The common thread is that every one of these failures was preventable by a script, a `require`, or a checklist. Mainnet deploys are not a place to be clever; they are a place to be boring.
|
|
215
|
+
|
|
216
|
+
See `web3-project-structure.md` for the broadcast directory layout, `web3-access-control.md` for role-assignment patterns inside deploy scripts, `web3-upgradeability.md` for proxy-aware deploys and the proxy-admin handoff, and `web3-audit-workflow.md` for the pre-deploy quality gates that feed the pre-flight checklist.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web3-dev-environment
|
|
3
|
+
description: Reproducible local Foundry environment for smart-contract teams — pinned solc, pinned forge, anvil, forge-std, direnv, and CI parity
|
|
4
|
+
topics: [web3, dev-environment, foundry, forge, anvil]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
A reproducible Foundry environment is what lets every developer (and CI) get the same compile output, the same gas snapshots, and the same fork-test results. The pieces are not exotic: install Foundry through the official channel, pin the toolchain and the Solidity compiler, lean on `forge-std` for tests, push secrets out of your global shell with `direnv`, and mirror the same versions in CI. Skip any one of these and "works on my machine" creeps back in — usually as a gas-snapshot diff that nobody can reproduce.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
Install Foundry via `foundryup` and pin the forge version in CI with `foundry-rs/foundry-toolchain@v1` so local and CI builds use the same compiler driver. Use `forge` to build and test, `cast` as the swiss-army RPC tool, and `anvil` as the local node — `anvil --fork-url $MAINNET_RPC` is the workflow for fork testing. Add `forge-std` (`forge install foundry-rs/forge-std`) so every test file extends `Test` and gets `Vm` plus `console.log` for free. Pin the Solidity compiler in `foundry.toml` with `solc_version = "0.8.24"` rather than relying on the pragma — pragma ranges let `forge` pick whatever satisfies them, and that is exactly how byte-identical builds rot. Keep RPC URLs and Etherscan keys in a `direnv` `.envrc` so they load only inside the project.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### Installing Foundry
|
|
16
|
+
|
|
17
|
+
`foundryup` is the official installer and the only one worth using. It manages the `forge`, `cast`, `anvil`, and `chisel` binaries together so they never drift apart, and it supports pinning to a specific release. Avoid Homebrew formulae and random binaries — they lag, and a stale `forge` against current `forge-std` is a special kind of debugging hell.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
curl -L https://foundry.paradigm.xyz | bash # installs foundryup
|
|
21
|
+
foundryup # installs latest stable forge/cast/anvil/chisel
|
|
22
|
+
foundryup --install nightly-abc1234 # pin to a specific nightly for CI parity
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
For a team, pick a release tag (or nightly hash) and write it into the repo's README and CI workflow. Local devs run `foundryup --install <tag>` and CI uses the same value in `foundry-toolchain@v1`. Drift between dev and CI shows up as gas-snapshot diffs nobody can explain.
|
|
26
|
+
|
|
27
|
+
### `forge`, `cast`, `anvil`
|
|
28
|
+
|
|
29
|
+
The three CLIs are orthogonal and you will use all three daily.
|
|
30
|
+
|
|
31
|
+
- **`forge`** builds and tests. `forge build` compiles, `forge test` runs Solidity tests, `forge snapshot` captures gas usage per test, `forge fmt` formats sources, `forge coverage` reports coverage. It is the project-management tool.
|
|
32
|
+
- **`cast`** is the RPC swiss-army knife. `cast call`, `cast send`, `cast balance`, `cast storage`, `cast abi-decode`, `cast 4byte`, `cast wallet` — anything you would otherwise script with ethers.js in a one-off. Keep `cast --help` open the first week.
|
|
33
|
+
- **`anvil`** is the local Ethereum node. It boots in milliseconds, ships pre-funded accounts, and supports forking any reachable RPC.
|
|
34
|
+
|
|
35
|
+
### Local node and fork testing with `anvil`
|
|
36
|
+
|
|
37
|
+
Plain `anvil` gives you a clean chain with 10 funded accounts at `http://127.0.0.1:8545` — fine for unit tests that do not need real protocol state. The killer feature is forking: point `anvil` at a mainnet (or L2) archive RPC and you get a local chain that lazily fetches state from that block.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
anvil \
|
|
41
|
+
--fork-url "$MAINNET_RPC" \
|
|
42
|
+
--fork-block-number 19000000 \
|
|
43
|
+
--chain-id 31337 \
|
|
44
|
+
--accounts 10 \
|
|
45
|
+
--balance 10000
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Pin `--fork-block-number` in CI and in any reproducible local script — forking "latest" makes tests non-deterministic the moment a tested protocol changes state. For fork tests inside `forge test`, set `eth_rpc_url` in `foundry.toml` and use `vm.createSelectFork(...)` instead of running `anvil` separately.
|
|
49
|
+
|
|
50
|
+
### `forge-std` library
|
|
51
|
+
|
|
52
|
+
`forge-std` is the standard Foundry test library. It is not optional — every serious Foundry repo depends on it. Install it as a git submodule:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
forge install foundry-rs/forge-std
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Then every test file extends `Test` and gets `Vm` cheatcodes, `console.log`, assertions, and helpers like `deal`, `prank`, and `expectRevert`:
|
|
59
|
+
|
|
60
|
+
```solidity
|
|
61
|
+
// test/Counter.t.sol
|
|
62
|
+
pragma solidity 0.8.24;
|
|
63
|
+
|
|
64
|
+
import {Test, console} from "forge-std/Test.sol";
|
|
65
|
+
import {Counter} from "../src/Counter.sol";
|
|
66
|
+
|
|
67
|
+
contract CounterTest is Test {
|
|
68
|
+
Counter counter;
|
|
69
|
+
|
|
70
|
+
function setUp() public {
|
|
71
|
+
counter = new Counter();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function test_Increment() public {
|
|
75
|
+
counter.increment();
|
|
76
|
+
assertEq(counter.number(), 1);
|
|
77
|
+
console.log("number", counter.number());
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Pinning the Solidity compiler
|
|
83
|
+
|
|
84
|
+
Pragmas declare a range (`pragma solidity ^0.8.20;`) but ranges are not pins. By default `forge` resolves the range and downloads whatever release satisfies it — usually the newest. That means a `0.8.24` build today and a `0.8.27` build six months from now from the same source tree, with different bytecode and different gas. Pin the compiler in `foundry.toml`:
|
|
85
|
+
|
|
86
|
+
```toml
|
|
87
|
+
# foundry.toml
|
|
88
|
+
[profile.default]
|
|
89
|
+
src = "src"
|
|
90
|
+
out = "out"
|
|
91
|
+
libs = ["lib"]
|
|
92
|
+
solc_version = "0.8.24"
|
|
93
|
+
optimizer = true
|
|
94
|
+
optimizer_runs = 200
|
|
95
|
+
evm_version = "cancun"
|
|
96
|
+
bytecode_hash = "none" # deterministic metadata across machines
|
|
97
|
+
|
|
98
|
+
[profile.ci]
|
|
99
|
+
fuzz = { runs = 10000 }
|
|
100
|
+
invariant = { runs = 256, depth = 32 }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Now every developer and CI run uses `solc 0.8.24` regardless of what the pragma allows. Bumping the compiler is a deliberate PR, not a silent drift.
|
|
104
|
+
|
|
105
|
+
### `direnv` for env vars
|
|
106
|
+
|
|
107
|
+
Foundry reads `ETH_RPC_URL`, `ETHERSCAN_API_KEY`, and per-chain RPC variables straight from the environment. Use `direnv` to scope them to the repo:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
# .envrc — commit this file
|
|
111
|
+
export ETH_RPC_URL="http://127.0.0.1:8545"
|
|
112
|
+
export MAINNET_RPC="https://eth-mainnet.g.alchemy.com/v2/$ALCHEMY_KEY"
|
|
113
|
+
export SEPOLIA_RPC="https://eth-sepolia.g.alchemy.com/v2/$ALCHEMY_KEY"
|
|
114
|
+
|
|
115
|
+
# Secrets — never commit
|
|
116
|
+
[[ -f .envrc.local ]] && source_env .envrc.local
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Add `.envrc.local` to `.gitignore` and keep `ALCHEMY_KEY`, `ETHERSCAN_API_KEY`, and any deployment private key there. Run `direnv allow` once after editing `.envrc`. Editors: `JuanBlanco.solidity` or `NomicFoundation.hardhat-solidity` are the practical options; Foundry's own `solidity-language-server` is alpha — try it, but do not require it of teammates.
|
|
120
|
+
|
|
121
|
+
### CI: pinned toolchain
|
|
122
|
+
|
|
123
|
+
Mirror the local toolchain version in CI with `foundry-rs/foundry-toolchain@v1`. The action installs `forge`, `cast`, and `anvil` at the pinned version and caches the RPC fork state across runs.
|
|
124
|
+
|
|
125
|
+
```yaml
|
|
126
|
+
# .github/workflows/ci.yml
|
|
127
|
+
name: ci
|
|
128
|
+
on: [push, pull_request]
|
|
129
|
+
jobs:
|
|
130
|
+
test:
|
|
131
|
+
runs-on: ubuntu-latest
|
|
132
|
+
steps:
|
|
133
|
+
- uses: actions/checkout@v4
|
|
134
|
+
with:
|
|
135
|
+
submodules: recursive
|
|
136
|
+
|
|
137
|
+
- uses: foundry-rs/foundry-toolchain@v1
|
|
138
|
+
with:
|
|
139
|
+
version: nightly-abc1234 # same hash devs run locally
|
|
140
|
+
|
|
141
|
+
- run: forge --version
|
|
142
|
+
- run: forge fmt --check
|
|
143
|
+
- run: forge build --sizes
|
|
144
|
+
- run: forge test -vvv
|
|
145
|
+
env:
|
|
146
|
+
MAINNET_RPC: ${{ secrets.MAINNET_RPC }}
|
|
147
|
+
- run: forge snapshot --check # fails if gas drifts
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The combination of pinned `foundryup`, pinned `solc_version`, and pinned `foundry-toolchain@v1` is what makes `forge snapshot --check` a reliable CI gate instead of a flaky one.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web3-gas-optimization
|
|
3
|
+
description: Practical EVM gas optimization — measure first with forge snapshot, storage packing, unchecked loops, calldata vs memory, immutable, external-call discipline, and CI regression checks
|
|
4
|
+
topics: [web3, gas-optimization, solidity, foundry]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Gas matters because every operation a user pays for on Ethereum mainnet is real dollars, and on L2s the calldata you post to L1 is the dominant cost driver — both directions are visible in your users' wallets. But most "optimizations" save under 1% of a transaction's gas and cost clarity in exchange; that trade is rarely worth it. The point of this doc is to give you the small set of techniques that do pay for themselves, the discipline to measure before applying them, and the CI plumbing that catches regressions before they ship. If a section here ever recommends a change that obscures the code without producing a measurable saving, ignore it.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
Measure first — `forge snapshot` and `forge test --gas-report` tell you where the gas actually goes, and most contracts have two or three hot paths and a long tail that does not matter. The big wins come from storage layout (pack structs into fewer 32-byte slots) and avoiding storage writes altogether (emit events, use `immutable`/`constant`, cache reads in memory). The free wins come from `calldata` over `memory` on external functions, `external` over `public` visibility where appropriate, and custom errors over revert strings. Use `unchecked { ... }` only in arithmetic you have proved safe — typically a bounded loop counter — never as a default. Wire `forge snapshot --check` into CI so a 5% gas regression fails the build instead of slipping in unnoticed. Never sacrifice clarity or security for fewer than ~100 gas; that bar removes 80% of cargo-cult "optimizations" from your codebase before they land.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### Measure first
|
|
16
|
+
|
|
17
|
+
Before touching any code in the name of gas, generate a baseline. Foundry gives you three tools that together answer "where is the gas going":
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
forge snapshot # writes .gas-snapshot per test
|
|
21
|
+
forge test --gas-report # per-function min/avg/median/max table
|
|
22
|
+
forge inspect Vault storageLayout # exact slot/offset map for each state var
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The snapshot file is your baseline; commit it. The gas report tells you which functions dominate user cost (the ones called every transaction matter; an admin function called once at deployment does not). The storage layout dump is the source of truth for whether your packing actually worked — guessing is how you ship a 4-slot struct you thought was 1. Only optimize the functions that show up in user-flow traces; everything else is wasted effort.
|
|
26
|
+
|
|
27
|
+
A useful discipline: before applying any optimization, run `forge snapshot` on `main`, apply the change on a branch, re-run, and `diff` the two snapshots. If the saving is under ~100 gas per call on a function called once per user action, revert the change and keep the clearer code. If the saving is 1,000+ gas on a hot path, the change has paid for itself and probably for the next reviewer's time too. Anything in between is a judgement call — and the judgement should default to clarity unless the function is called in a batched flow where 200 gas times 1,000 iterations becomes meaningful.
|
|
28
|
+
|
|
29
|
+
### Storage packing
|
|
30
|
+
|
|
31
|
+
Storage costs dominate everything else on the EVM: 20,000 gas for a first-time `SSTORE`, 2,900 to update, 2,100 for the cold `SLOAD` that precedes it. Packing multiple state variables into one 32-byte slot is the single largest optimization most contracts can apply, and the compiler will not reorder fields for you — declaration order matters.
|
|
32
|
+
|
|
33
|
+
```solidity
|
|
34
|
+
// 1 slot — uint64 + uint64 + uint128 = 256 bits
|
|
35
|
+
struct Position {
|
|
36
|
+
uint64 openedAt;
|
|
37
|
+
uint64 lastUpdatedAt;
|
|
38
|
+
uint128 amount;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 3 slots — naive
|
|
42
|
+
struct PositionBad {
|
|
43
|
+
uint256 openedAt;
|
|
44
|
+
uint256 lastUpdatedAt;
|
|
45
|
+
uint256 amount;
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`uint64` holds timestamps until year 584,942,417,355; `uint128` holds ~3.4e38, plenty for any token amount. Dynamic types (`string`, `bytes`, arrays, mappings) always take their own slot regardless of position, so do not try to "pack" them.
|
|
50
|
+
|
|
51
|
+
Two subtleties worth naming. First, mappings of structs do not benefit from packing across keys — each value occupies its own slots starting at `keccak256(key . slot)`, so the packing only saves gas when the struct is read or written as a unit. Second, packed fields share a slot, which means a write to one field is implemented as a full-slot read-modify-write — the SLOAD cost is paid even if you only meant to change one field. Net it is still cheaper than three separate slots, but if a single packed field is updated far more often than its neighbors, consider splitting it out. Use `forge inspect Vault storageLayout` and `web3-architecture.md` together when deciding the final layout.
|
|
52
|
+
|
|
53
|
+
### `unchecked { ... }` in safe loops
|
|
54
|
+
|
|
55
|
+
Post-Solidity-0.8.0, arithmetic operations include overflow checks by default — safe, and ~30-50 gas per op. In tight loops where overflow is provably impossible (e.g., `i` bounded by `array.length`), wrap the increment in `unchecked` to skip the check:
|
|
56
|
+
|
|
57
|
+
```solidity
|
|
58
|
+
function sum(uint256[] calldata xs) external pure returns (uint256 total) {
|
|
59
|
+
uint256 len = xs.length;
|
|
60
|
+
for (uint256 i = 0; i < len;) {
|
|
61
|
+
total += xs[i];
|
|
62
|
+
unchecked { ++i; }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Use `unchecked` only where you have written down why overflow cannot occur. A loop counter bounded by an in-memory array length qualifies; an accumulator over user-supplied values does not. The rule of thumb that has held up across audits: if a colleague reading the diff cannot tell you in one sentence why the math in the `unchecked` block cannot overflow, the block should not exist. Compromising overflow protection for ~40 gas on a path that runs once per transaction is not worth the rounding-error severity vulnerability you ship if the proof is wrong. See `web3-security.md` for the broader stance on never optimizing at security's expense.
|
|
68
|
+
|
|
69
|
+
### `calldata` vs `memory`
|
|
70
|
+
|
|
71
|
+
For `external` functions taking arrays or strings, declare the parameter `calldata` rather than `memory`. `calldata` is read directly from the transaction payload at no copy cost; `memory` forces a copy that scales linearly with input size:
|
|
72
|
+
|
|
73
|
+
```solidity
|
|
74
|
+
function processBatch(uint256[] calldata ids) external {
|
|
75
|
+
uint256 len = ids.length;
|
|
76
|
+
for (uint256 i = 0; i < len;) {
|
|
77
|
+
_process(ids[i]);
|
|
78
|
+
unchecked { ++i; }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
This is a free win — no clarity cost, real gas saving, and the compiler will refuse to let you mutate calldata so it doubles as an immutability hint.
|
|
84
|
+
|
|
85
|
+
For large arrays the savings compound: copying a 100-element `uint256` array from calldata into memory is ~6,000 gas (~3 gas per byte over 3,200 bytes plus memory expansion); reading the same array directly from calldata is free at the boundary, you only pay for each element you actually touch. On L2s where calldata is the dominant cost component, this difference is multiplied by the rollup's calldata pricing — Optimism and Arbitrum both compress calldata before posting to L1, but the in-EVM read cost still favors calldata over memory. Default to `calldata` for every `external` function parameter; only switch to `memory` if you need to mutate the value, in which case you should also question whether mutation is the right pattern.
|
|
86
|
+
|
|
87
|
+
### Function visibility (`external` vs `public`)
|
|
88
|
+
|
|
89
|
+
A function marked `public` must be callable both externally and internally, which means the compiler generates a memory copy of arguments for the internal call path. `external` skips that copy. If a function is never called from inside the contract, mark it `external` — about 24 gas per call, plus the calldata-vs-memory savings on array arguments.
|
|
90
|
+
|
|
91
|
+
The corollary: when one of your `public` functions needs to be called from another function in the same contract, refactor the body into an `internal` helper and have both the `external` entrypoint and the internal caller go through the helper. Two visibility tiers (`external` for the API, `internal` for shared logic) is almost always the right shape; `public` is the lazy compromise that costs gas without buying clarity.
|
|
92
|
+
|
|
93
|
+
### `immutable` and `constant`
|
|
94
|
+
|
|
95
|
+
Values fixed at deploy time belong in `immutable`; compile-time literals belong in `constant`. Both are inlined into bytecode and read for ~3 gas, against ~2,100 for a cold `SLOAD`:
|
|
96
|
+
|
|
97
|
+
```solidity
|
|
98
|
+
contract Vault {
|
|
99
|
+
address public immutable asset;
|
|
100
|
+
uint256 public constant MAX_FEE_BPS = 500;
|
|
101
|
+
|
|
102
|
+
constructor(address _asset) {
|
|
103
|
+
asset = _asset;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
If a value never changes after deployment and you wrote it as a regular state variable, you are paying SLOAD on every read forever. Fix that before any other optimization.
|
|
109
|
+
|
|
110
|
+
`immutable` works for any value type fixed in the constructor — addresses, integers, `bytes32`. `constant` is stricter: the value must be expressible as a compile-time literal, no constructor logic allowed. Strings and bytes cannot be `immutable` in current Solidity (they require dynamic storage); for those cases either use a `bytes32` hash if you only need to check equality, or accept the SLOAD cost. When upgrading from a regular state variable, the storage slot is freed and the constructor accepts the value — a deployment-level change, not a runtime one, so existing deployments are unaffected until you redeploy.
|
|
111
|
+
|
|
112
|
+
### Avoid unbounded loops
|
|
113
|
+
|
|
114
|
+
Ethereum's per-block gas limit (~30M as of 2024) caps how much work any transaction can do. Looping over a user-controlled array is therefore a denial-of-service vector — an attacker grows the array until your contract's critical function exceeds the block limit and bricks. Replace with pull-payments (record an entitlement, let each user withdraw their own) or cursor pagination (process a bounded chunk per call). See `web3-security.md` for the pull-payment pattern in full.
|
|
115
|
+
|
|
116
|
+
This rule has no exception. Even if today's array is bounded by your own writes, the next refactor may expose a write path you did not anticipate; even if every iteration looks cheap, a single malicious entry can blow up the cost. The DoS surface is so cheap to introduce and so expensive to recover from (typically a migration to a new contract) that "but the loop is small in practice" is not a defense — it is a deferred incident.
|
|
117
|
+
|
|
118
|
+
### Custom errors and events vs storage
|
|
119
|
+
|
|
120
|
+
Custom errors replace revert strings with a 4-byte selector and ABI-encoded args, saving ~50 gas per emit and shrinking bytecode (covered in `web3-conventions.md`). A related habit: when data is consumed off-chain — by your UI, a subgraph, or an indexer — emit an event instead of writing storage. Events cost ~375 gas plus ~8 gas per byte of data; an `SSTORE` to a new slot is 20,000. Use storage when another contract reads the value; use events when only humans and indexers do.
|
|
121
|
+
|
|
122
|
+
```solidity
|
|
123
|
+
error InsufficientBalance(uint256 requested, uint256 available);
|
|
124
|
+
|
|
125
|
+
event Deposit(address indexed user, uint256 amount, uint64 timestamp);
|
|
126
|
+
|
|
127
|
+
function deposit(uint256 amount) external {
|
|
128
|
+
if (amount > balances[msg.sender]) {
|
|
129
|
+
revert InsufficientBalance(amount, balances[msg.sender]);
|
|
130
|
+
}
|
|
131
|
+
emit Deposit(msg.sender, amount, uint64(block.timestamp));
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Caching and short-circuit ordering
|
|
136
|
+
|
|
137
|
+
Reading the same storage slot multiple times in one function pays the SLOAD cost each time after the first warm access (~100 gas vs 2,100 cold). Cache into a local variable:
|
|
138
|
+
|
|
139
|
+
```solidity
|
|
140
|
+
function applyFee(address user) external {
|
|
141
|
+
uint256 _fee = fee; // 1 SLOAD
|
|
142
|
+
balances[user] -= _fee; // local read, no SLOAD
|
|
143
|
+
treasury += _fee;
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
And in conditional expressions, order cheap checks before expensive ones — `&&` and `||` short-circuit, so a failing cheap check skips the expensive one:
|
|
148
|
+
|
|
149
|
+
```solidity
|
|
150
|
+
if (amount > 0 && _externalOracle.price() > minPrice) { ... }
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### CI: `forge snapshot --check`
|
|
154
|
+
|
|
155
|
+
Gas regressions slip in silently unless CI catches them. Wire `forge snapshot --check` into your pipeline — it diffs the committed `.gas-snapshot` against the current run and exits non-zero on regression beyond the configured tolerance.
|
|
156
|
+
|
|
157
|
+
```yaml
|
|
158
|
+
# .github/workflows/ci.yml
|
|
159
|
+
- name: Gas regression check
|
|
160
|
+
run: forge snapshot --check --tolerance 5
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Bump the snapshot deliberately when an intentional change costs gas: re-run `forge snapshot`, review the diff in the PR, commit. The discipline is the same as commit-the-lockfile — the snapshot is part of the contract's review surface, not a build artifact. See `web3-testing.md` for the full Foundry test workflow this plugs into.
|
|
164
|
+
|
|
165
|
+
Taken together, these techniques compose into a small playbook that pays for itself on every deployment: measure to find the hot paths, pack storage and reach for `immutable` to eliminate the SLOAD-heavy ones, use `calldata` and `external` to harvest the free wins, and let CI guard the result. Everything else — the clever bit-twiddling tricks, the assembly inlining, the "I saved 12 gas by reordering this branch" patches — should be viewed with suspicion until a benchmark and a reviewer agree the savings are worth the loss of clarity. Gas optimization is a tool; clarity is the asset. Spend the tool on the asset only when the receipt justifies it.
|