@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.
Files changed (80) hide show
  1. package/README.md +21 -7
  2. package/content/knowledge/web3/web3-access-control.md +189 -0
  3. package/content/knowledge/web3/web3-architecture.md +162 -0
  4. package/content/knowledge/web3/web3-audit-workflow.md +151 -0
  5. package/content/knowledge/web3/web3-common-vulnerabilities.md +171 -0
  6. package/content/knowledge/web3/web3-conventions.md +162 -0
  7. package/content/knowledge/web3/web3-deployment-and-verification.md +216 -0
  8. package/content/knowledge/web3/web3-dev-environment.md +150 -0
  9. package/content/knowledge/web3/web3-gas-optimization.md +165 -0
  10. package/content/knowledge/web3/web3-oracles-and-external-data.md +155 -0
  11. package/content/knowledge/web3/web3-project-structure.md +212 -0
  12. package/content/knowledge/web3/web3-requirements.md +152 -0
  13. package/content/knowledge/web3/web3-security.md +163 -0
  14. package/content/knowledge/web3/web3-testing.md +180 -0
  15. package/content/knowledge/web3/web3-upgradeability.md +189 -0
  16. package/content/methodology/web3-overlay.yml +40 -0
  17. package/dist/config/schema.d.ts +672 -126
  18. package/dist/config/schema.d.ts.map +1 -1
  19. package/dist/config/schema.js +8 -1
  20. package/dist/config/schema.js.map +1 -1
  21. package/dist/config/schema.test.js +2 -2
  22. package/dist/config/schema.test.js.map +1 -1
  23. package/dist/config/validators/index.d.ts.map +1 -1
  24. package/dist/config/validators/index.js +2 -0
  25. package/dist/config/validators/index.js.map +1 -1
  26. package/dist/config/validators/web3.d.ts +4 -0
  27. package/dist/config/validators/web3.d.ts.map +1 -0
  28. package/dist/config/validators/web3.js +15 -0
  29. package/dist/config/validators/web3.js.map +1 -0
  30. package/dist/e2e/project-type-overlays.test.js +76 -0
  31. package/dist/e2e/project-type-overlays.test.js.map +1 -1
  32. package/dist/project/adopt.d.ts.map +1 -1
  33. package/dist/project/adopt.js +3 -1
  34. package/dist/project/adopt.js.map +1 -1
  35. package/dist/project/detectors/coverage.test.js +3 -2
  36. package/dist/project/detectors/coverage.test.js.map +1 -1
  37. package/dist/project/detectors/disambiguate.js +1 -1
  38. package/dist/project/detectors/disambiguate.js.map +1 -1
  39. package/dist/project/detectors/index.d.ts.map +1 -1
  40. package/dist/project/detectors/index.js +2 -0
  41. package/dist/project/detectors/index.js.map +1 -1
  42. package/dist/project/detectors/resolve-detection.test.js +57 -0
  43. package/dist/project/detectors/resolve-detection.test.js.map +1 -1
  44. package/dist/project/detectors/types.d.ts +6 -2
  45. package/dist/project/detectors/types.d.ts.map +1 -1
  46. package/dist/project/detectors/types.js.map +1 -1
  47. package/dist/project/detectors/web3.d.ts +4 -0
  48. package/dist/project/detectors/web3.d.ts.map +1 -0
  49. package/dist/project/detectors/web3.js +37 -0
  50. package/dist/project/detectors/web3.js.map +1 -0
  51. package/dist/project/detectors/web3.test.d.ts +2 -0
  52. package/dist/project/detectors/web3.test.d.ts.map +1 -0
  53. package/dist/project/detectors/web3.test.js +75 -0
  54. package/dist/project/detectors/web3.test.js.map +1 -0
  55. package/dist/types/config.d.ts +8 -1
  56. package/dist/types/config.d.ts.map +1 -1
  57. package/dist/wizard/copy/core.d.ts.map +1 -1
  58. package/dist/wizard/copy/core.js +4 -0
  59. package/dist/wizard/copy/core.js.map +1 -1
  60. package/dist/wizard/copy/index.d.ts.map +1 -1
  61. package/dist/wizard/copy/index.js +2 -0
  62. package/dist/wizard/copy/index.js.map +1 -1
  63. package/dist/wizard/copy/types.d.ts +5 -1
  64. package/dist/wizard/copy/types.d.ts.map +1 -1
  65. package/dist/wizard/copy/types.test-d.js +7 -0
  66. package/dist/wizard/copy/types.test-d.js.map +1 -1
  67. package/dist/wizard/copy/web3.d.ts +3 -0
  68. package/dist/wizard/copy/web3.d.ts.map +1 -0
  69. package/dist/wizard/copy/web3.js +15 -0
  70. package/dist/wizard/copy/web3.js.map +1 -0
  71. package/dist/wizard/questions.d.ts +2 -1
  72. package/dist/wizard/questions.d.ts.map +1 -1
  73. package/dist/wizard/questions.js +8 -1
  74. package/dist/wizard/questions.js.map +1 -1
  75. package/dist/wizard/questions.test.js +14 -0
  76. package/dist/wizard/questions.test.js.map +1 -1
  77. package/dist/wizard/wizard.d.ts.map +1 -1
  78. package/dist/wizard/wizard.js +1 -0
  79. package/dist/wizard/wizard.js.map +1 -1
  80. package/package.json +1 -1
@@ -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.
@@ -0,0 +1,212 @@
1
+ ---
2
+ name: web3-project-structure
3
+ description: Opinionated Foundry project layout for smart-contract teams — src, test, script, lib, broadcast, foundry.toml, remappings — covering test naming, deploy provenance, and what belongs in git
4
+ topics: [web3, project-structure, foundry, solidity]
5
+ ---
6
+
7
+ A smart-contract repository is read by more adversarial eyes than almost any other kind of codebase: auditors, MEV searchers, frontrunners, and the occasional regulator. Structure carries weight that goes beyond developer ergonomics. An auditor opening the repo for the first time should be able to find the contract under review, its tests, its deploy script, and its on-chain deployment receipts within thirty seconds. Gas snapshots and fuzz seeds need a fixed home so regressions are diffable. Broadcast logs are the audit trail tying a verified contract address back to a commit SHA — losing or muddling them turns "which version is mainnet running?" into a forensic exercise. Foundry's conventions answer most of these questions; this doc records the opinionated version a team should adopt before the first PR lands.
8
+
9
+ ## Summary
10
+
11
+ A Foundry project has six top-level directories, each answering one question: `src/` (contracts under audit), `test/` (Foundry tests mirroring `src/`), `script/` (deploy and management scripts inheriting `Script`), `lib/` (forge-installed dependencies, committed as submodules or git-trees), `broadcast/` (deploy logs keyed by chain ID and script name), and `docs/` (NatSpec-generated or hand-written architecture notes). `foundry.toml` at the root configures the compiler, fuzz/invariant runners, and formatter; `remappings.txt` aliases `lib/` paths so imports stay readable. The `.gitignore` excludes `cache/`, `out/`, and broadcast directories for ephemeral local chains (anvil, chain ID 31337), but **keeps** broadcast artifacts for canonical chain IDs (1, 10, 8453, 42161, ...) because they are the deploy provenance. Tests follow strict naming — `test_`, `testFuzz_`, `invariant_` — so the runner can pick them up and reviewers can read intent off the function name.
12
+
13
+ ## Deep Guidance
14
+
15
+ ### Top-level layout
16
+
17
+ ```
18
+ project-root/
19
+ ├── src/ # Contracts under audit — the deliverable
20
+ │ ├── interfaces/ # I*.sol interfaces, importable by consumers
21
+ │ ├── libraries/ # Pure libraries (no state)
22
+ │ └── tokens/ # Domain-grouped subdirectories
23
+ ├── test/ # Foundry tests — mirrors src/
24
+ │ ├── unit/ # Per-contract unit tests
25
+ │ ├── integration/ # Multi-contract / forked-mainnet tests
26
+ │ ├── invariant/ # Invariant suites + Handler contracts
27
+ │ └── utils/ # Shared test helpers, mocks, base contracts
28
+ ├── script/ # Deploy + management scripts (forge script)
29
+ │ ├── Deploy.s.sol
30
+ │ └── Upgrade.s.sol
31
+ ├── lib/ # forge install dependencies (submodules)
32
+ │ ├── forge-std/
33
+ │ ├── openzeppelin-contracts/
34
+ │ └── solmate/
35
+ ├── broadcast/ # Deploy artifacts keyed by script + chain ID
36
+ │ └── Deploy.s.sol/
37
+ │ ├── 1/ # Mainnet — KEEP, this is provenance
38
+ │ ├── 10/ # Optimism — KEEP
39
+ │ └── 31337/ # Anvil — gitignored
40
+ ├── docs/ # NatSpec output or hand-written architecture
41
+ ├── foundry.toml # Project config
42
+ ├── remappings.txt # Import aliases for lib/
43
+ ├── .gitignore
44
+ └── README.md
45
+ ```
46
+
47
+ One-liners:
48
+ - `src/` — every contract that ships; group by domain, never by Solidity language feature
49
+ - `test/` — mirrors `src/` so reviewers can find the test for any contract in one jump
50
+ - `script/` — every deploy, upgrade, and ops script (parameter tuning, role grants); each inherits `forge-std/Script.sol`
51
+ - `lib/` — third-party Solidity tracked via `forge install` (git submodules under the hood)
52
+ - `broadcast/` — receipts from `forge script --broadcast`; partition by chain ID; **keep** canonical chains, gitignore ephemeral ones
53
+ - `docs/` — `forge doc` output or hand-written `architecture.md`, `threat-model.md`, `invariants.md`
54
+
55
+ ### `foundry.toml`
56
+
57
+ `foundry.toml` is the project config — compiler version, optimizer settings, fuzz/invariant runner knobs, formatter rules, and per-profile overrides. A minimal but production-shaped config:
58
+
59
+ ```toml
60
+ [profile.default]
61
+ src = "src"
62
+ out = "out"
63
+ libs = ["lib"]
64
+ test = "test"
65
+ script = "script"
66
+ solc_version = "0.8.26"
67
+ evm_version = "cancun"
68
+ optimizer = true
69
+ optimizer_runs = 200
70
+ via_ir = false
71
+ bytecode_hash = "none" # Reproducible builds (no metadata hash)
72
+ cbor_metadata = false
73
+ gas_reports = ["*"]
74
+
75
+ [profile.ci]
76
+ fuzz = { runs = 10_000 }
77
+ invariant = { runs = 256, depth = 128 }
78
+ verbosity = 3
79
+
80
+ [fmt]
81
+ line_length = 120
82
+ tab_width = 4
83
+ bracket_spacing = true
84
+ int_types = "long" # uint256 not uint, by name
85
+ quote_style = "double"
86
+ number_underscore = "thousands" # 1_000_000
87
+
88
+ [fuzz]
89
+ runs = 256 # Local default; CI overrides via [profile.ci]
90
+ max_test_rejects = 65_536
91
+
92
+ [invariant]
93
+ runs = 64
94
+ depth = 32
95
+ fail_on_revert = false
96
+ ```
97
+
98
+ `optimizer_runs` is the only setting non-obvious enough to justify thinking. The number is **not** "how many times to run the optimizer" — it's the expected number of times each opcode will run over the contract's lifetime. Higher values trade deploy-time bytecode size for cheaper runtime gas. Defaults:
99
+ - `200` (Solidity default) — balanced; right for most contracts including infrequently-called governance
100
+ - `1_000_000` — protocol contracts with hot paths (AMMs, lending pools, perp engines); pay extra deploy cost once, save gas on every swap forever
101
+ - `1` — one-shot contracts (deploy scripts, factories that deploy once and self-destruct conceptually); minimize deploy cost
102
+
103
+ The `ci` profile overrides fuzz runs to 10k for thorough coverage on PRs; locally 256 keeps `forge test` snappy.
104
+
105
+ ### Test naming
106
+
107
+ Foundry's runner discovers tests by function-name prefix. Three prefixes matter, and the convention is load-bearing — auditors read intent off the name:
108
+
109
+ - `test_<unit>_<behavior>` — concrete unit tests; e.g. `test_transfer_revertsWhenInsufficientBalance`
110
+ - `testFuzz_<unit>_<property>` — property tests with fuzzed inputs; e.g. `testFuzz_deposit_creditsExactAmount(uint256 amount)`
111
+ - `invariant_<property>` — invariant tests run by the invariant engine over a stateful handler; e.g. `invariant_totalSupplyEqualsSumOfBalances`
112
+
113
+ File mirror: a contract `src/Vault.sol` gets its tests at `test/unit/Vault.t.sol` (the `.t.sol` suffix is convention, not required, but make it consistent across the repo). Invariant suites live in `test/invariant/Vault.invariants.t.sol` with their `Handler` contract alongside.
114
+
115
+ ```solidity
116
+ // test/unit/Vault.t.sol
117
+ contract VaultTest is Test {
118
+ Vault vault;
119
+
120
+ function setUp() public {
121
+ vault = new Vault();
122
+ }
123
+
124
+ function test_deposit_creditsBalance() public {
125
+ vault.deposit{value: 1 ether}();
126
+ assertEq(vault.balanceOf(address(this)), 1 ether);
127
+ }
128
+
129
+ function testFuzz_deposit_creditsExactAmount(uint96 amount) public {
130
+ vm.deal(address(this), amount);
131
+ vault.deposit{value: amount}();
132
+ assertEq(vault.balanceOf(address(this)), amount);
133
+ }
134
+ }
135
+ ```
136
+
137
+ Use `uint96` (or another bounded type) for fuzz parameters tied to ETH amounts — full `uint256` blows the available balance and triggers `max_test_rejects` exhaustion.
138
+
139
+ ### Deploy scripts and `broadcast/`
140
+
141
+ Deploy scripts inherit `forge-std/Script.sol` and read environment-specific addresses via `vm.envAddress`. Hard-coded addresses in script bodies are a category of bug — they survive a `git mv` from staging to mainnet and you find out at $4 gwei.
142
+
143
+ ```solidity
144
+ // script/Deploy.s.sol
145
+ import {Script} from "forge-std/Script.sol";
146
+ import {Vault} from "../src/Vault.sol";
147
+
148
+ contract Deploy is Script {
149
+ function run() external returns (Vault vault) {
150
+ address admin = vm.envAddress("ADMIN_ADDRESS");
151
+ uint256 pk = vm.envUint("DEPLOYER_PK");
152
+
153
+ vm.startBroadcast(pk);
154
+ vault = new Vault(admin);
155
+ vm.stopBroadcast();
156
+ }
157
+ }
158
+ ```
159
+
160
+ Running `forge script script/Deploy.s.sol --rpc-url $RPC --broadcast --verify` writes a receipt to `broadcast/Deploy.s.sol/<chainId>/run-latest.json` and a timestamped copy. That JSON contains the deployed address, transaction hash, block number, constructor args, and a SHA tying it back to the commit. **This is the provenance artifact.** Keep it for canonical chains; without it, "which commit deployed the mainnet Vault at 0xabc..." becomes archaeology. Verify on Etherscan in the same command (`--verify`) so the audit trail extends to the public block explorer.
161
+
162
+ ### `lib/` and remappings
163
+
164
+ Solidity dependencies are installed via `forge install`, which adds the upstream repo as a git submodule under `lib/`:
165
+
166
+ ```bash
167
+ forge install OpenZeppelin/openzeppelin-contracts@v5.0.0
168
+ forge install transmissions11/solmate
169
+ forge install foundry-rs/forge-std
170
+ ```
171
+
172
+ Pin to a tag (`@v5.0.0`), never to `main`. Submodule SHAs are recorded in `.gitmodules` and the commit, so a fresh `forge install` after clone produces the same dependency set.
173
+
174
+ Imports through `lib/openzeppelin-contracts/contracts/...` are ugly. `remappings.txt` aliases them:
175
+
176
+ ```
177
+ @openzeppelin/=lib/openzeppelin-contracts/
178
+ @openzeppelin-upgradeable/=lib/openzeppelin-contracts-upgradeable/
179
+ solmate/=lib/solmate/src/
180
+ forge-std/=lib/forge-std/src/
181
+ ```
182
+
183
+ Then `import "@openzeppelin/contracts/token/ERC20/ERC20.sol";` resolves cleanly. Keep `remappings.txt` checked in; some IDE plugins and `forge` itself read it.
184
+
185
+ ### `.gitignore`
186
+
187
+ ```gitignore
188
+ # Build artifacts
189
+ cache/
190
+ out/
191
+
192
+ # Coverage
193
+ lcov.info
194
+
195
+ # Node (Hardhat carryovers, JS tooling)
196
+ node_modules/
197
+
198
+ # Environment / secrets
199
+ .env
200
+ .env.*
201
+ !.env.example
202
+
203
+ # Anvil / ephemeral local chains — chain ID 31337
204
+ broadcast/*/31337/
205
+ broadcast/**/dry-run/
206
+
207
+ # Editor
208
+ .vscode/
209
+ .idea/
210
+ ```
211
+
212
+ The load-bearing line is `broadcast/*/31337/`. Anvil's chain ID is 31337 and every local script run leaves a receipt; those are noise. Mainnet (`1`), Optimism (`10`), Base (`8453`), Arbitrum (`42161`), and other canonical chain IDs are **not** in the ignore list because their broadcast artifacts are the on-chain deploy provenance. Treat them with the same care as the contracts themselves: commit, review, and tag at release.
@@ -0,0 +1,152 @@
1
+ ---
2
+ name: web3-requirements
3
+ description: Problem framing, invariants, threat model, trust assumptions, and success metrics for shipping smart contracts and protocols to EVM chains
4
+ topics: [web3, requirements, invariants, threat-model, security]
5
+ ---
6
+
7
+ A smart contract shipped to an EVM chain without a written invariant set, a threat model, and an explicit list of trust assumptions is a guessing game with adversarial counterparties and irreversible state. This document defines the acceptance spec for a contract or protocol going to Ethereum mainnet, an L2 (Optimism, Arbitrum, Base), or a compatible sidechain. The audience is a senior Solidity engineer or protocol architect who can ship code but has not yet hardened it against funded adversaries. The goal is to force, in writing, the questions an auditor will ask on day one.
8
+
9
+ ## Summary
10
+
11
+ A web3 requirements doc states what the contract does for which users (problem framing), enumerates the invariants that must hold across every reachable state, names the threat model in terms of capabilities and time horizon, lists each trust assumption as a documented failure mode, and defines success in concrete economic, gas, or capability terms. State invariants up front and write them as Foundry invariant tests before you finish the implementation. If you cannot enumerate your trust assumptions, you have not designed a protocol — you have written code that happens to compile.
12
+
13
+ ## Deep Guidance
14
+
15
+ ### Problem framing
16
+
17
+ The framing answers one question: what does this contract do for which users so that what becomes possible. Write it before you open `forge init`. The template below forces concrete nouns and verbs; if you find yourself writing "leverage" or "unlock value" you are not ready.
18
+
19
+ ```
20
+ This contract does <user-visible action> for <user class> so that <decision/outcome>.
21
+ ```
22
+
23
+ Worked example — a yield vault: "This contract lets monthly retail depositors deposit ETH and earn variable yield routed through Aave v3, so that they can hold a yield-bearing position without managing positions themselves." That sentence implies the whole surface area: deposit, withdraw, accounting for accrued yield, an external adapter to Aave, an admin for adapter rotation. Everything else is implementation.
24
+
25
+ Drop the framing into a `docs/spec.md` block at the top of the repo. Keep out-of-scope explicit — out-of-scope is where audits find missing checks.
26
+
27
+ ```markdown
28
+ # docs/spec.md
29
+ user_action: deposit ETH; withdraw ETH + accrued yield
30
+ user_class: retail depositors holding their own keys (EOAs and Safe multisigs)
31
+ outcome: passive yield routed through Aave v3 on Optimism
32
+ out_of_scope:
33
+ - non-ETH assets
34
+ - leveraged positions
35
+ - cross-chain bridging (vault is single-chain only)
36
+ - permissioned access (anyone with ETH can deposit)
37
+ ```
38
+
39
+ ### Invariants
40
+
41
+ Invariants are properties that must hold across every reachable state and every call sequence. They are not unit-test assertions about one transaction — they are global truths the protocol exposes. Write them in English first, then in a Foundry invariant test before you finish the implementation. If an invariant is hard to state in one sentence, decompose it; if it cannot be tested with a fuzzer, it is not really an invariant.
42
+
43
+ Opinionated defaults for a vault-style contract:
44
+
45
+ - **Conservation**: `totalAssets()` is greater than or equal to the sum of user-owed principal. The vault never owes more than it can pay.
46
+ - **Solvency**: at any block, a full sequential withdrawal of every depositor at their current share would not revert for accounting reasons.
47
+ - **Monotonicity of share price**: in the absence of admin-triggered loss recognition, `convertToAssets(1e18)` is non-decreasing. A surprise drop indicates a bug or an external loss the protocol failed to flag.
48
+ - **Access control**: only `admin` can call functions tagged `onlyAdmin`. Stated as `forall caller != admin, call to onlyAdmin function reverts`.
49
+ - **No reentrancy reachable state**: no external call to user-controlled code happens before state writes are finalized in any deposit/withdraw path.
50
+
51
+ Encode them as Foundry invariant tests. The handler is the load-bearing part — a weak handler proves nothing.
52
+
53
+ ```solidity
54
+ // test/invariant/VaultInvariants.t.sol
55
+ pragma solidity ^0.8.24;
56
+
57
+ import {Test} from "forge-std/Test.sol";
58
+ import {Vault} from "src/Vault.sol";
59
+ import {VaultHandler} from "./VaultHandler.sol";
60
+
61
+ contract VaultInvariants is Test {
62
+ Vault internal vault;
63
+ VaultHandler internal handler;
64
+
65
+ function setUp() public {
66
+ vault = new Vault();
67
+ handler = new VaultHandler(vault);
68
+ targetContract(address(handler));
69
+ }
70
+
71
+ /// Conservation: assets under management cover all outstanding shares.
72
+ function invariant_solvency() public view {
73
+ uint256 owed = vault.convertToAssets(vault.totalSupply());
74
+ assertGe(vault.totalAssets(), owed, "vault is insolvent");
75
+ }
76
+
77
+ /// Share price is monotonic in the absence of admin loss recognition.
78
+ function invariant_sharePriceMonotonic() public view {
79
+ assertGe(vault.convertToAssets(1e18), handler.lastSharePrice());
80
+ }
81
+ }
82
+ ```
83
+
84
+ Run with `forge test --match-contract VaultInvariants --fuzz-runs 50000`. A passing invariant suite at 50k runs is a precondition for audit submission — not a substitute for it.
85
+
86
+ ### Threat model
87
+
88
+ A threat model names who attacks, with what capabilities, on what time horizon, and — critically — who you do not defend against. Skipping the last part is how teams ship contracts that are "secure" against an unspecified adversary and break against a real one.
89
+
90
+ Rubric, in order of how often each is skipped:
91
+
92
+ - **Capabilities**: what can the attacker do on-chain. Concretely: deploy arbitrary contracts, hold arbitrary token balances, call any public function in any order, observe and reorder mempool transactions (front-running), pay arbitrary gas, hold flash-loan-scale capital (think hundreds of millions of dollars for a single transaction on mainnet), submit governance proposals if your protocol has on-chain governance, manipulate spot prices on thin DEX pools.
93
+ - **Time horizon**: are we defending across one block (atomic flash-loan attack), one transaction batch (sandwich), a governance timelock window (typically 24-72 hours), or months (slow-roll oracle drift). Different defenses apply at each horizon.
94
+ - **Out of scope**: name them. Common honest answers: nation-state actors capable of reorgs on L1, the chain itself going down or censoring, supply-chain compromise of a dependency outside the audit boundary, social-engineering of the multisig signers, an exploit in the underlying L2 sequencer.
95
+
96
+ Concrete attack scenarios to write down before audit:
97
+
98
+ - **Oracle manipulation**: an adversary moves the price reported by a feed (spot DEX TWAP on a thin pool, a stale Chainlink feed during a fast move) and exploits a function that trusts it. Defense: use a Chainlink feed with a heartbeat check and a deviation circuit-breaker, never a single-block DEX spot price.
99
+ - **Governance proposal abuse**: an adversary accumulates governance tokens (or buys voting power via a flash loan against a vulnerable token design) and pushes a malicious proposal — for example, upgrading the vault implementation to one that drains funds. Defense: timelock all upgrade and parameter changes; size the timelock to the off-chain alerting and response window.
100
+ - **Reentrancy via callbacks**: any function that makes an external call into user-controlled code before finalizing state is vulnerable. Defense: checks-effects-interactions, plus `nonReentrant` on every function that touches accounting. Treat ERC-777, ERC-1363, and any token with transfer hooks as user-controlled code for this purpose.
101
+ - **Front-running and MEV**: on a public mempool, any profitable action you broadcast is visible. Defense: commit-reveal for sensitive ordering, private mempools (Flashbots Protect) for admin actions, or making the action order-independent. Sandwich-resistance for swaps means a strict slippage cap on every quote, sourced from the caller and not from an oracle the attacker can move.
102
+ - **Donation / inflation attack**: an attacker deposits a tiny first share, then transfers a large amount directly to the vault address to inflate share price and round the next depositor's shares to zero. Defense: virtual shares / dead shares in the ERC-4626 implementation, or an initial deposit by the deployer that is permanently locked.
103
+
104
+ ### Trust assumptions
105
+
106
+ Every contract trusts something. The job here is to make each trust explicit, paired with the failure mode if the trust is misplaced. The list below is the minimum for a DeFi vault — extend it for your specific design.
107
+
108
+ - **Price oracle (Chainlink ETH/USD on the target chain)**: trusted to be correct within the configured deviation and heartbeat. If it fails — feed goes stale during a fast move, or the off-chain operators collude — the vault can mis-price deposits and withdrawals. Mitigation: pause-on-stale check (`updatedAt` is within the heartbeat window) and an emergency pause that the multisig can trigger.
109
+ - **Upgrade admin (3-of-5 Safe multisig)**: trusted to act in users' interest and to keep keys secure. If three signers are compromised, they can upgrade the vault to drain funds. Mitigation: 48-hour timelock on every upgrade; off-chain monitoring with on-call alerting; a separate guardian role with veto power on suspicious upgrades but no positive authority.
110
+ - **Underlying protocol (Aave v3)**: trusted to honor its own accounting and not to grief depositors. If Aave's pool is compromised, the vault inherits the loss. Mitigation: cap exposure per adapter; document the inherited risk in the user-facing README.
111
+ - **Solidity compiler and toolchain**: trusted to compile source to faithful bytecode. Mitigation: pin a specific `solc` version (e.g. `0.8.24`), reproducible builds via Foundry's `forge build --deterministic`, and verified source on Etherscan.
112
+ - **The chain itself**: trusted not to reorg meaningfully or to censor the contract. For L2s, also trusted to honor its withdrawal-window guarantees. Document the chain-specific risk (e.g. "this protocol is deployed on Base; a Base sequencer outage halts deposits and withdrawals until it recovers").
113
+
114
+ Each line is a documented risk. Surface them in the user-facing docs — not just in the audit report — so depositors can size their exposure.
115
+
116
+ ### Success metrics
117
+
118
+ State success in concrete numbers, written before launch. Vague goals ("be secure", "have lots of users") let the team declare victory after any outcome. Three categories to name:
119
+
120
+ - **Economic security**: a $-TVL bar paired with a no-exploit time horizon. Example: `$50M TVL with no successful exploit in the first 6 months post-launch`. The dollar figure forces the team to size the bug bounty (a useful default: 10% of TVL up to a cap), and the time horizon forces a monitoring commitment.
121
+ - **Gas budget**: per-function ceilings on the target chain, measured by `forge snapshot`. Example: `deposit costs less than 200k gas on Optimism; withdraw less than 250k`. Encode these as snapshot tests in CI so a regression breaks the build, not just makes things slightly more expensive.
122
+ - **Capability counts**: scale targets that drive design. Example: `supports 10,000 unique depositors without unbounded loops`, or `supports adapter rotation without migrating user balances`. Each capability target rules out a class of naive implementations (no `address[] depositors` you iterate over).
123
+
124
+ ```solidity
125
+ // test/gas/Snapshot.t.sol
126
+ pragma solidity ^0.8.24;
127
+ import {Test} from "forge-std/Test.sol";
128
+ import {Vault} from "src/Vault.sol";
129
+
130
+ contract GasSnapshot is Test {
131
+ Vault internal vault;
132
+ address internal user = address(0xBEEF);
133
+
134
+ function setUp() public { vault = new Vault(); vm.deal(user, 10 ether); }
135
+
136
+ function test_gas_deposit() public {
137
+ vm.prank(user);
138
+ uint256 g = gasleft();
139
+ vault.deposit{value: 1 ether}();
140
+ uint256 used = g - gasleft();
141
+ assertLt(used, 200_000, "deposit exceeds gas budget");
142
+ }
143
+ }
144
+ ```
145
+
146
+ A few more disciplines that pay off late but cost little up front:
147
+
148
+ - **Deployment script as code**: the canonical deployment lives in `script/Deploy.s.sol`, not in a one-off REPL session. Anyone should be able to reproduce mainnet bytecode from a tagged commit.
149
+ - **Pause-and-recover drill**: before mainnet, simulate the full incident response on a fork — the multisig signs, the contract pauses, an exploit is contained, an upgrade is queued through timelock, and depositors are made whole. If the runbook is not exercised, it does not exist.
150
+ - **Post-deploy verification**: verified source on Etherscan or the L2 explorer, a published address registry (one line per chain) committed to the repo, and a public read-only dashboard for the invariant metrics so anyone can re-derive solvency from on-chain state.
151
+
152
+ Taken together — framing, invariants, threat model, trust assumptions, success metrics — these five sections are what an auditor will ask for in the kickoff call. Write them before you ship, commit them next to the contracts, and treat any drift as a scope change that requires re-auditing.