@zigrivers/scaffold 3.26.0 → 3.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +21 -7
  2. package/content/knowledge/core/ai-memory-management.md +17 -0
  3. package/content/knowledge/core/claude-md-patterns.md +2 -2
  4. package/content/knowledge/core/coding-conventions.md +2 -2
  5. package/content/knowledge/core/task-decomposition.md +4 -4
  6. package/content/knowledge/core/task-tracking.md +120 -29
  7. package/content/knowledge/core/user-stories.md +1 -1
  8. package/content/knowledge/execution/multi-agent-coordination.md +118 -0
  9. package/content/knowledge/execution/task-claiming-strategy.md +15 -3
  10. package/content/knowledge/execution/worktree-management.md +5 -3
  11. package/content/knowledge/web3/web3-access-control.md +189 -0
  12. package/content/knowledge/web3/web3-architecture.md +162 -0
  13. package/content/knowledge/web3/web3-audit-workflow.md +151 -0
  14. package/content/knowledge/web3/web3-common-vulnerabilities.md +171 -0
  15. package/content/knowledge/web3/web3-conventions.md +162 -0
  16. package/content/knowledge/web3/web3-deployment-and-verification.md +216 -0
  17. package/content/knowledge/web3/web3-dev-environment.md +150 -0
  18. package/content/knowledge/web3/web3-gas-optimization.md +165 -0
  19. package/content/knowledge/web3/web3-oracles-and-external-data.md +155 -0
  20. package/content/knowledge/web3/web3-project-structure.md +212 -0
  21. package/content/knowledge/web3/web3-requirements.md +152 -0
  22. package/content/knowledge/web3/web3-security.md +163 -0
  23. package/content/knowledge/web3/web3-testing.md +180 -0
  24. package/content/knowledge/web3/web3-upgradeability.md +189 -0
  25. package/content/methodology/web3-overlay.yml +40 -0
  26. package/content/pipeline/build/multi-agent-resume.md +27 -7
  27. package/content/pipeline/build/multi-agent-start.md +35 -7
  28. package/content/pipeline/build/new-enhancement.md +8 -1
  29. package/content/pipeline/build/quick-task.md +9 -0
  30. package/content/pipeline/build/single-agent-resume.md +11 -4
  31. package/content/pipeline/build/single-agent-start.md +13 -4
  32. package/content/pipeline/consolidation/workflow-audit.md +1 -1
  33. package/content/pipeline/environment/git-workflow.md +2 -2
  34. package/content/pipeline/foundation/beads.md +148 -22
  35. package/content/pipeline/foundation/coding-standards.md +1 -1
  36. package/content/tools/post-implementation-review.md +6 -6
  37. package/content/tools/prompt-pipeline.md +1 -1
  38. package/content/tools/release.md +5 -5
  39. package/content/tools/review-code.md +347 -3
  40. package/content/tools/review-pr.md +349 -7
  41. package/content/tools/version-bump.md +5 -5
  42. package/dist/cli/commands/observe.d.ts +2 -0
  43. package/dist/cli/commands/observe.d.ts.map +1 -1
  44. package/dist/cli/commands/observe.js +9 -1
  45. package/dist/cli/commands/observe.js.map +1 -1
  46. package/dist/cli/commands/observe.test.js +36 -0
  47. package/dist/cli/commands/observe.test.js.map +1 -1
  48. package/dist/config/schema.d.ts +672 -126
  49. package/dist/config/schema.d.ts.map +1 -1
  50. package/dist/config/schema.js +8 -1
  51. package/dist/config/schema.js.map +1 -1
  52. package/dist/config/schema.test.js +2 -2
  53. package/dist/config/schema.test.js.map +1 -1
  54. package/dist/config/validators/index.d.ts.map +1 -1
  55. package/dist/config/validators/index.js +2 -0
  56. package/dist/config/validators/index.js.map +1 -1
  57. package/dist/config/validators/web3.d.ts +4 -0
  58. package/dist/config/validators/web3.d.ts.map +1 -0
  59. package/dist/config/validators/web3.js +15 -0
  60. package/dist/config/validators/web3.js.map +1 -0
  61. package/dist/e2e/project-type-overlays.test.js +76 -0
  62. package/dist/e2e/project-type-overlays.test.js.map +1 -1
  63. package/dist/observability/adapters/beads.d.ts +4 -0
  64. package/dist/observability/adapters/beads.d.ts.map +1 -1
  65. package/dist/observability/adapters/beads.js +25 -2
  66. package/dist/observability/adapters/beads.js.map +1 -1
  67. package/dist/observability/adapters/beads.test.js +40 -2
  68. package/dist/observability/adapters/beads.test.js.map +1 -1
  69. package/dist/observability/engine/ledger-writer.d.ts +11 -1
  70. package/dist/observability/engine/ledger-writer.d.ts.map +1 -1
  71. package/dist/observability/engine/ledger-writer.js +6 -0
  72. package/dist/observability/engine/ledger-writer.js.map +1 -1
  73. package/dist/observability/engine/llm-dispatcher.d.ts.map +1 -1
  74. package/dist/observability/engine/llm-dispatcher.js +36 -5
  75. package/dist/observability/engine/llm-dispatcher.js.map +1 -1
  76. package/dist/observability/engine/llm-dispatcher.test.js +23 -0
  77. package/dist/observability/engine/llm-dispatcher.test.js.map +1 -1
  78. package/dist/project/adopt.d.ts.map +1 -1
  79. package/dist/project/adopt.js +3 -1
  80. package/dist/project/adopt.js.map +1 -1
  81. package/dist/project/detectors/coverage.test.js +3 -2
  82. package/dist/project/detectors/coverage.test.js.map +1 -1
  83. package/dist/project/detectors/disambiguate.js +1 -1
  84. package/dist/project/detectors/disambiguate.js.map +1 -1
  85. package/dist/project/detectors/index.d.ts.map +1 -1
  86. package/dist/project/detectors/index.js +2 -0
  87. package/dist/project/detectors/index.js.map +1 -1
  88. package/dist/project/detectors/resolve-detection.test.js +57 -0
  89. package/dist/project/detectors/resolve-detection.test.js.map +1 -1
  90. package/dist/project/detectors/types.d.ts +6 -2
  91. package/dist/project/detectors/types.d.ts.map +1 -1
  92. package/dist/project/detectors/types.js.map +1 -1
  93. package/dist/project/detectors/web3.d.ts +4 -0
  94. package/dist/project/detectors/web3.d.ts.map +1 -0
  95. package/dist/project/detectors/web3.js +37 -0
  96. package/dist/project/detectors/web3.js.map +1 -0
  97. package/dist/project/detectors/web3.test.d.ts +2 -0
  98. package/dist/project/detectors/web3.test.d.ts.map +1 -0
  99. package/dist/project/detectors/web3.test.js +75 -0
  100. package/dist/project/detectors/web3.test.js.map +1 -0
  101. package/dist/types/config.d.ts +8 -1
  102. package/dist/types/config.d.ts.map +1 -1
  103. package/dist/wizard/copy/core.d.ts.map +1 -1
  104. package/dist/wizard/copy/core.js +4 -0
  105. package/dist/wizard/copy/core.js.map +1 -1
  106. package/dist/wizard/copy/index.d.ts.map +1 -1
  107. package/dist/wizard/copy/index.js +2 -0
  108. package/dist/wizard/copy/index.js.map +1 -1
  109. package/dist/wizard/copy/types.d.ts +5 -1
  110. package/dist/wizard/copy/types.d.ts.map +1 -1
  111. package/dist/wizard/copy/types.test-d.js +7 -0
  112. package/dist/wizard/copy/types.test-d.js.map +1 -1
  113. package/dist/wizard/copy/web3.d.ts +3 -0
  114. package/dist/wizard/copy/web3.d.ts.map +1 -0
  115. package/dist/wizard/copy/web3.js +15 -0
  116. package/dist/wizard/copy/web3.js.map +1 -0
  117. package/dist/wizard/questions.d.ts +2 -1
  118. package/dist/wizard/questions.d.ts.map +1 -1
  119. package/dist/wizard/questions.js +8 -1
  120. package/dist/wizard/questions.js.map +1 -1
  121. package/dist/wizard/questions.test.js +14 -0
  122. package/dist/wizard/questions.test.js.map +1 -1
  123. package/dist/wizard/wizard.d.ts.map +1 -1
  124. package/dist/wizard/wizard.js +1 -0
  125. package/dist/wizard/wizard.js.map +1 -1
  126. package/package.json +1 -1
@@ -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.
@@ -0,0 +1,155 @@
1
+ ---
2
+ name: web3-oracles-and-external-data
3
+ description: Oracle discipline for smart contracts — Chainlink price feeds with staleness/decimals/sign checks, TWAP for DEX prices, VRF for randomness, and fallback patterns that fail safe
4
+ topics: [web3, oracles, chainlink, security]
5
+ ---
6
+
7
+ A smart contract is a deterministic state machine in a closed universe. The real world — ETH/USD, the weather in Lagos, the winner of last night's match, a genuinely random number — is none of those things. Oracles are the bridge between the two, and every bridge is a new attack surface with its own trust assumptions, latency, and failure modes. The job is not to remove the trust assumption (you cannot) but to make the bridge robust enough that an exploit costs more than the protocol holds. Most "oracle hacks" are not novel cryptography either — they are missed standard patterns: an unchecked staleness, a spot price used where a TWAP belonged, a `block.timestamp` standing in for entropy.
8
+
9
+ ## Summary
10
+
11
+ Prefer `Chainlink` price feeds via `AggregatorV3Interface` and validate every read: positive `answer`, recent `updatedAt`, `answeredInRound >= roundId`, and normalize by `feed.decimals()` rather than assuming 18. Reject answers older than your tolerance (typically 2× heartbeat plus a small buffer) — a stale feed is worse than no feed because it lies confidently. Never use `block.timestamp` for pricing or randomness, and never use a DEX spot price for anything an attacker can sandwich with a flash loan; use a Uniswap V3 `TWAP` of at least 30 minutes for high-value paths and Chainlink VRF for randomness that matters. When the staleness check fails, fail safe: revert or pause the protocol. Cross-chain data adds another trust layer — budget for it explicitly.
12
+
13
+ ## Deep Guidance
14
+
15
+ ### The oracle problem
16
+
17
+ A contract cannot see outside the EVM. Anything off-chain — price, time-of-flight, sports score, weather, random bytes — has to be pushed in by an external actor, and that actor becomes a trust dependency. The dependency does not disappear by calling it "decentralized"; a Chainlink feed is more robust than a single signer, but it is still a set of nodes you did not audit, on a schedule you did not pick, reporting a number whose derivation you cannot reproduce on-chain. Treat every oracle read as a privileged input that crosses a trust boundary (see `web3-security.md` for the trust-boundary stance and `web3-architecture.md` for how to draw the boundary in your contract layout). Cheap protocols die from oracle attacks more often than from reentrancy in 2026 — the EVM-side patterns are well known, the off-chain edges are where the surprises live.
18
+
19
+ Three properties define an oracle's risk surface and you have to think about each one independently: **authenticity** (is this value really from the source it claims?), **timeliness** (was it produced recently enough to still be true?), and **manipulation cost** (how expensive is it for an attacker to move the value to a number that drains the protocol?). A signed Chainlink response is authentic and reasonably timely but its manipulation cost is set by the consensus of the node operator set. A Uniswap spot price is authentic and instantaneous but its manipulation cost is one flash loan. A TWAP raises that cost in proportion to its window. The right oracle is the one whose manipulation cost exceeds the value at stake by a comfortable margin — and "comfortable margin" gets larger as TVL grows.
20
+
21
+ ### Chainlink price feeds
22
+
23
+ Chainlink's `AggregatorV3Interface` is the default recommendation: decentralized off-chain consensus, on-chain aggregation, audited by multiple firms, used by every major DeFi protocol on mainnet. Read it through `latestRoundData()` and validate every field rather than trusting the tuple blindly:
24
+
25
+ ```solidity
26
+ import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
27
+
28
+ contract PriceConsumer {
29
+ AggregatorV3Interface public immutable feed;
30
+ uint256 public immutable maxStaleness; // seconds; e.g. 2 * heartbeat + buffer
31
+
32
+ error StaleOracle(uint256 updatedAt, uint256 maxStaleness);
33
+ error InvalidRound();
34
+ error NegativeAnswer(int256 answer);
35
+
36
+ constructor(address feedAddr, uint256 maxStaleness_) {
37
+ feed = AggregatorV3Interface(feedAddr);
38
+ maxStaleness = maxStaleness_;
39
+ }
40
+
41
+ /// @notice Returns the latest price normalized to 18 decimals.
42
+ function latestPrice18() public view returns (uint256) {
43
+ (uint80 roundId, int256 answer, , uint256 updatedAt, uint80 answeredInRound) =
44
+ feed.latestRoundData();
45
+ if (updatedAt == 0 || answeredInRound < roundId) revert InvalidRound();
46
+ if (block.timestamp - updatedAt > maxStaleness) {
47
+ revert StaleOracle(updatedAt, maxStaleness);
48
+ }
49
+ if (answer <= 0) revert NegativeAnswer(answer);
50
+ uint8 decimals = feed.decimals();
51
+ return _scaleTo18(uint256(answer), decimals);
52
+ }
53
+
54
+ function _scaleTo18(uint256 v, uint8 d) internal pure returns (uint256) {
55
+ if (d == 18) return v;
56
+ if (d < 18) return v * (10 ** (18 - d));
57
+ return v / (10 ** (d - 18));
58
+ }
59
+ }
60
+ ```
61
+
62
+ The four invariants that matter on every read:
63
+
64
+ - `updatedAt > 0` — the round actually exists; a zero here means the feed has never published, usually a misconfigured address.
65
+ - `answeredInRound >= roundId` — the answer belongs to this round, not a stale one carried over by a buggy upstream. The two values should normally be equal; a strict inequality is a yellow flag worth reverting on.
66
+ - `answer > 0` — Chainlink's answer is `int256` so the type permits negatives; only `> 0` is safe to cast to `uint256` and use in price math. Equal-to-zero is also rejected because a zero price collapses divisions and lets dust amounts buy the world.
67
+ - `block.timestamp - updatedAt <= maxStaleness` — the round was published recently enough that the protocol's pricing assumptions still hold.
68
+
69
+ Miss any one and you are pricing off a number that is not what the feed currently says. Wrap the read in a single library function and have every caller route through it; do not let individual call sites re-implement the validation, because the call site that forgets `answeredInRound` is the one that gets exploited.
70
+
71
+ ### Staleness checks
72
+
73
+ Chainlink feeds publish on a heartbeat — ETH/USD on mainnet is 1 hour, BTC/USD is 1 hour, most stablecoin pairs are 24 hours, L2 feeds vary. The heartbeat is the maximum time between publishes when no deviation threshold is hit; under normal market movement the feed updates more often. A reasonable `maxStaleness` is `2 * heartbeat + small buffer` — long enough that one missed heartbeat does not brick the protocol, short enough that a multi-hour outage stops trades:
74
+
75
+ ```solidity
76
+ require(block.timestamp - updatedAt <= maxStaleness, StaleOracle(updatedAt, maxStaleness));
77
+ ```
78
+
79
+ Source the heartbeat from Chainlink's data feed page and pin it per-feed, not per-protocol — different feeds have different cadences and treating them uniformly will either nuisance-revert on slow feeds or accept dangerously old prices on fast ones. Re-verify the heartbeat when you add or rotate a feed; Chainlink occasionally retunes parameters for low-volume pairs. A stale price is strictly worse than reverting: it tells the protocol the world looks one way when it looks another, and arbitrage will harvest the difference within a block.
80
+
81
+ Two adjacent failure modes that staleness alone does not cover and are worth wiring up: **sequencer downtime** on L2s and **circuit-breaker pinning**. On Arbitrum and Optimism, read the L2 sequencer uptime feed (`L2SequencerUptimeFeed`) before trusting any price feed — if the sequencer has been down or has just come back up within your grace period, refuse to price. Chainlink price feeds also stop updating when the upstream market hits a circuit breaker (some equity-tracked feeds, some forex pairs over weekends); a feed that has been pinned at the same value for hours has not "frozen" in any helpful sense, it is reporting a number that does not exist. Pair staleness checks with a deviation sanity check (e.g., reject reads that disagree with a TWAP by more than X%) for any feed that prices off a market that closes.
82
+
83
+ ### Decimals discipline
84
+
85
+ Every feed has its own decimals and they are not always 18. ETH/USD on mainnet is 8 decimals; many forex and commodity feeds are 8; ERC-20 token amounts are usually 18 (but USDC is 6, WBTC is 8). Mixing scales is the easiest way to be off by a factor of 10^10 in a price calculation, and the bug will only show up when the price moves outside a comfortable range or when the feed is rotated.
86
+
87
+ ```solidity
88
+ (, int256 answer,, uint256 updatedAt,) = feed.latestRoundData();
89
+ uint8 feedDecimals = feed.decimals(); // 8 for ETH/USD on mainnet
90
+ uint256 price18 = uint256(answer) * (10 ** (18 - feedDecimals));
91
+ ```
92
+
93
+ Never hardcode decimals to 18, never cast `int256 answer` to `uint256` without checking `answer > 0` first (a negative answer becomes an astronomically large unsigned number), and document the normalization once at the boundary so downstream math can assume a single fixed-point convention.
94
+
95
+ A few decimal traps that show up repeatedly in audits:
96
+
97
+ - **Mixing token decimals with feed decimals.** A swap between USDC (6) and WBTC (8) priced off BTC/USD (8) and ETH/USD (8) requires three separate normalizations; doing them in one expression with hardcoded constants is a recipe for off-by-N errors.
98
+ - **Cross-feed math.** Computing an ETH/BTC ratio from ETH/USD and BTC/USD requires that both are read in the same units and that you divide after scaling, not before — otherwise you lose precision on the smaller denominator.
99
+ - **Feed migrations.** Chainlink occasionally re-deploys feeds with different decimals (8 → 18 conversions have happened on lower-volume pairs). Reading `feed.decimals()` once in the constructor and caching it is a bug; read it on every call, or re-read it whenever the feed address is rotated.
100
+
101
+ ### Avoid `block.timestamp` for pricing/entropy
102
+
103
+ `block.timestamp` is set by the proposer and only loosely constrained — post-merge it must be strictly greater than the parent and within ~15 seconds of wall-clock, but the proposer has a window inside that range to nudge it. For "did 24 hours pass since deploy," that is fine. For "what is the price of ETH right now" or "which user wins the millisecond-precise auction" or "what are the random bytes for this NFT mint," it is not — the proposer can choose a timestamp that maximizes their MEV without ever appearing malicious. The rule is simple: never use `block.timestamp` as a pricing input and never use it as an entropy source. Use a Chainlink feed for the first and Chainlink VRF for the second.
104
+
105
+ ### Randomness: VRF over prevrandao
106
+
107
+ Post-merge, `block.prevrandao` (formerly `block.difficulty`) carries the beacon chain's randomness output for the slot. It is acceptable for low-stakes randomness — a cosmetic trait roll, a tiebreaker in a small game — because the proposer's only manipulation lever is to skip their slot, which costs them the block reward. The moment the economic incentive to manipulate exceeds the slot reward (NFT lotteries, gambling, anything with a meaningful jackpot), the proposer will skip, and the next proposer rolls again. Use Chainlink VRF for anything where the prize justifies that calculation: it commits to randomness off-chain, reveals it on-chain with a verifiable proof, and the caller pays a small LINK fee per request. The pattern is request-and-callback (`requestRandomWords` then `fulfillRandomWords`), so design state transitions that tolerate the one-to-several-block delay.
108
+
109
+ The state-machine implication matters: do not let users take any action that depends on the random outcome between `requestRandomWords` and `fulfillRandomWords`. Lock the relevant state at request time (snapshot eligible participants, freeze deposits, mark the round as pending) and unlock it only inside `fulfillRandomWords`. Anything else opens a window where the user knows a randomness request is in flight and can position themselves to win or refund based on a partial view of state. Equally, never `revert` inside `fulfillRandomWords` — VRF's coordinator will mark the request as failed and you will have spent LINK for nothing; validate inputs in the request path and make the fulfillment path total.
110
+
111
+ ### TWAP for on-chain DEX prices
112
+
113
+ If the price has to come from a DEX rather than a feed, never read the spot price. Spot is the marginal trade — a flash loan can move it arbitrarily within a single transaction, read the manipulated value, and snap it back, all atomically. Use Uniswap V3's time-weighted average price (TWAP) from the pool oracle, which averages the geometric mean price over a window:
114
+
115
+ ```solidity
116
+ import {IUniswapV3Pool} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
117
+ import {OracleLibrary} from "@uniswap/v3-periphery/contracts/libraries/OracleLibrary.sol";
118
+
119
+ uint32 constant TWAP_WINDOW = 1800; // 30 minutes
120
+
121
+ function twapTick(address pool) internal view returns (int24 arithmeticMeanTick) {
122
+ (arithmeticMeanTick, ) = OracleLibrary.consult(pool, TWAP_WINDOW);
123
+ }
124
+ ```
125
+
126
+ Thirty minutes is the floor for high-value paths — anything shorter and a sufficiently funded attacker can move price across several blocks. Pick pools with deep liquidity (the cost to move a TWAP scales with TVL and window length) and increase the cardinality of the pool's observation buffer (`pool.increaseObservationCardinalityNext(...)`) so the oracle can actually serve your window.
127
+
128
+ Two TWAP failure modes to design around explicitly:
129
+
130
+ - **Insufficient cardinality.** A fresh pool starts with cardinality 1 — `consult` will revert until the buffer is grown and enough observations accumulate. Bump cardinality at deploy time and verify the pool has enough history before relying on it.
131
+ - **Low-liquidity pools.** A 30-minute TWAP on a $50K-TVL pool is not 30 minutes of security; it is 30 minutes of cheap arbitrage. Match the window to the pool's depth, not to a number you picked because it sounded conservative.
132
+
133
+ See `web3-common-vulnerabilities.md` for the canonical oracle-manipulation attack catalogue, including price-feed sandwiches and TWAP window-shortening exploits.
134
+
135
+ ### Fallback patterns
136
+
137
+ When a feed fails its staleness or sanity check, you have three options. (a) **Revert** — the trade or borrow does not happen; users wait for the feed to recover. This is correct for most protocols: a brief denial of service is much cheaper than a mispriced liquidation. (b) **Secondary oracle** — fall through to a second feed (e.g., a Uniswap V3 TWAP) and use its answer. This is appropriate only when the secondary has comparable security guarantees and the protocol has explicitly designed for both; ad-hoc fallback to a cheaper oracle has caused multiple eight-figure exploits. (c) **Pause** — flip `Pausable` and stop accepting new positions until the multisig assesses the situation. Best for protocols where a multi-hour outage is acceptable and a wrong price is catastrophic (lending, perps). The default recommendation is (a) at the function level and (c) at the protocol level via a monitor that pages the on-call when staleness is observed.
138
+
139
+ Three asymmetries to keep in mind when picking the fallback. First, **write paths and read paths deserve different policies**: it can be safe to let a user close a position when the feed is stale (read-only liquidation prevention) while refusing to let them open a new one. Second, **liquidations need their own carve-out**: a frozen feed should not prevent liquidating an underwater position, because the position is underwater at the last good price too — design the liquidation path to accept a slightly older feed than the trading path. Third, **never silently fail open**: a `try`/`catch` around a feed read that returns a default value on failure is the platonic ideal of an oracle exploit; the safer pattern is explicit branching on a validated read with a named revert.
140
+
141
+ ### Cross-chain data
142
+
143
+ Bringing data across chains adds another trust layer on top of the oracle: a bridge or messaging protocol whose security model is independent of the feed's. Chainlink CCIP is the most conservative choice — it reuses the same node operator set as Chainlink's price feeds and adds a separate Risk Management Network as a circuit breaker. LayerZero, Axelar, and Wormhole each have their own trust models, ranging from a small validator set to a full optimistic-style proof system; read the security pages, not the marketing pages. Treat the bridge's signer set as part of your threat model — a $100M protocol that depends on a 5-of-9 multisig bridge has, effectively, a 5-of-9 multisig oracle. Where possible, source the data natively on the chain you settle on, and bridge only what cannot be obtained locally.
144
+
145
+ A short checklist before shipping any oracle integration to mainnet:
146
+
147
+ - The feed address is `immutable` (or behind a timelocked setter), pinned to the exact contract you reviewed on the chain you reviewed it on, and the address is documented in `roles.md`.
148
+ - Staleness, decimals, sign, and `answeredInRound` are validated on every read through a single helper.
149
+ - Heartbeats are pinned per feed and re-verified before each major release.
150
+ - The fallback policy is named: revert, secondary oracle, or pause — and the choice is justified in writing.
151
+ - DEX-sourced prices use a TWAP with a window proportional to the value at stake, against a pool with cardinality high enough to serve the window.
152
+ - Randomness above a documented value threshold uses VRF; below it can use `prevrandao` but the threshold is recorded.
153
+ - An off-chain monitor (Defender, Forta, Tenderly) pages the on-call when any feed staleness, sequencer downtime, or unusual deviation is observed.
154
+
155
+ The contract cannot tell you any of these are wrong at runtime in a way that prevents loss — they are policy decisions that the deployer commits to and the auditor verifies. Write them down before the audit, not after.