diamond-detect 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jayesh Yadav
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,228 @@
1
+ # diamond-detect
2
+
3
+ A static analyzer for [EIP-2535 Diamond](https://eips.ethereum.org/EIPS/eip-2535) storage-slot collisions. Reads your Foundry build artifacts and reports cases where two facets would silently corrupt each other through the proxy's shared storage.
4
+
5
+ ```sh
6
+ npx diamond-detect .
7
+ ```
8
+
9
+ ## Why this tool exists
10
+
11
+ A Diamond proxy `delegatecall`s into many facet contracts, and **every facet shares the proxy's storage**. When two facets accidentally land at the same slot — by reusing a Diamond Storage namespace, by drifting AppStorage layouts, by reusing an EIP-7201 id, or by writing literal slots in inline assembly — the result is silent corruption: one facet's writes overwrite another's data with no error and no revert.
12
+
13
+ Slither catches general storage issues but doesn't speak Diamond. Most teams either hand-audit by spreadsheet or rely on a one-off script. `diamond-detect` is a focused, Diamond-specific analyzer you can drop into CI in three lines of YAML.
14
+
15
+ ## Should you use it?
16
+
17
+ You should use it if:
18
+
19
+ - Your project deploys an EIP-2535 Diamond, or treats some contracts as facets sharing a single proxy's storage.
20
+ - You use Foundry to build (`out/` artifacts).
21
+ - You want to catch namespace, AppStorage, EIP-7201, or inline-assembly slot collisions before they hit mainnet.
22
+
23
+ You probably don't need it if you have only a handful of facets that all consume one canonical `LibAppStorage` and you read every storage layout diff manually. Even then, it's a 5-minute install — worth running once.
24
+
25
+ ## Install
26
+
27
+ ```sh
28
+ npm install -g diamond-detect # global, then run `diamond-detect`
29
+ # or:
30
+ npx diamond-detect <path> # no install
31
+ ```
32
+
33
+ Requires Node 20+ and Foundry.
34
+
35
+ ## First run
36
+
37
+ ### 1. Configure Foundry to emit AST + storage layout
38
+
39
+ `diamond-detect` needs both. Easiest way is via `foundry.toml`:
40
+
41
+ ```toml
42
+ [profile.default]
43
+ ast = true
44
+ extra_output = ["storageLayout"]
45
+ ```
46
+
47
+ (Or pass `--ast --extra-output storageLayout` to `forge build` each time.)
48
+
49
+ ### 2. Build
50
+
51
+ ```sh
52
+ forge build
53
+ ```
54
+
55
+ This populates `out/` with artifact JSON files that include the AST and storage layout for every contract.
56
+
57
+ ### 3. Scan
58
+
59
+ ```sh
60
+ diamond-detect .
61
+ ```
62
+
63
+ If everything is fine you'll see:
64
+
65
+ ```
66
+ scanned 12 contract artifact(s)
67
+ ✓ no storage collisions detected
68
+ ```
69
+
70
+ If something is wrong you'll see one or more findings with the slot, the colliding contracts, and a hint at the cause:
71
+
72
+ ```
73
+ ERROR diamond-storage-namespace 0x84d86c34a05b71953e57fe7dafea685384b33934d9ddaebd0cf7709e74b71bab
74
+ Diamond Storage namespace "myapp.strategies" is declared in 2 different sources, all resolving to the same slot.
75
+ facets: LibStrategies, LibVaults
76
+ at src/LibStrategies.sol
77
+ at src/LibVaults.sol
78
+
79
+ 1 error(s), 0 warning(s)
80
+ ```
81
+
82
+ Exit code is `1` whenever a finding meets your `--severity` threshold (default `warn`), `0` otherwise, `2` on internal errors.
83
+
84
+ ## What it detects
85
+
86
+ Run [`examples/`](./examples/) to see each one in action — every example ships a buggy `before/` and a fixed `after/`.
87
+
88
+ | Kind | Severity | What it catches |
89
+ |---|---|---|
90
+ | `diamond-storage-namespace` | error | Two libraries declare `bytes32 constant POSITION = keccak256("...")` with the same string. ([01-namespace-collision](./examples/01-namespace-collision/)) |
91
+ | `appstorage-fingerprint` | error | The same fully-qualified struct (e.g. `struct LibAppStorage.AppStorage`) has different layouts across facets — the stale-artifact / forgot-to-rebuild bug. ([02-appstorage-shift](./examples/02-appstorage-shift/)) |
92
+ | `erc7201-namespace` | error | Two contracts annotate `@custom:storage-location erc7201:<id>` with the same id. ([03-erc7201-collision](./examples/03-erc7201-collision/)) |
93
+ | `inheritance-overlap` | warn | Two facets have state at the same slot whose `(label, type)` differ — e.g. `Ownable._owner` vs `MyOwnable.owner`. |
94
+ | `inline-assembly-slot` | info | A literal slot is written via `sstore(0x42, …)`. Usually intentional, but reported so you can confirm it doesn't overlap a computed Diamond Storage slot. |
95
+
96
+ A clean baseline that exercises every analyzer and produces no findings is in [`examples/04-clean/`](./examples/04-clean/).
97
+
98
+ ## Configuring for your project
99
+
100
+ ### Scope to your real facets with `--facets`
101
+
102
+ By default `diamond-detect` analyzes every contract in `src/`. Diamond projects often have non-facet contracts there too — registries, factories, libraries — and the inheritance-overlap analyzer can produce noisy advisories for them. Tell it where your facets actually live:
103
+
104
+ ```sh
105
+ diamond-detect --facets 'src/facets/**' .
106
+ ```
107
+
108
+ This restricts the facet-shared-storage analyzers (`inheritance-overlap`, `appstorage-fingerprint`) to that glob. Other analyzers still scan the whole project.
109
+
110
+ ### Default ignores
111
+
112
+ These paths are skipped automatically because they're never facets:
113
+
114
+ ```
115
+ lib/**
116
+ test/**
117
+ script/**
118
+ **/*.t.sol
119
+ **/*.s.sol
120
+ ```
121
+
122
+ Add your own with `--ignore <glob>` (repeatable). Disable the defaults entirely with `--no-default-ignore`.
123
+
124
+ ### Severity thresholds in CI
125
+
126
+ ```sh
127
+ diamond-detect --severity error . # exit 1 only on errors
128
+ diamond-detect --severity warn . # default: exit 1 on warns + errors
129
+ diamond-detect --severity info . # exit 1 on anything
130
+ ```
131
+
132
+ ## CLI reference
133
+
134
+ ```
135
+ diamond-detect <path> Foundry project root or src/ folder
136
+ --json Machine-readable JSON
137
+ --markdown GitHub-flavored Markdown (PR-friendly)
138
+ --severity <info|warn|error> Exit-code threshold (default: warn)
139
+ --ignore <glob> Skip source paths matching this glob (repeatable)
140
+ --no-default-ignore Don't skip lib/, test/, script/, *.t.sol, *.s.sol
141
+ --facets <glob> Restrict facet-shared-storage analyzers
142
+ (inheritance-overlap, appstorage-fingerprint)
143
+ to source paths matching this glob (repeatable)
144
+ ```
145
+
146
+ ## Output formats
147
+
148
+ - **Terminal** (default): coloured, one block per finding, summary footer.
149
+ - **JSON** (`--json`): a stable shape suitable for piping into other tools.
150
+
151
+ ```json
152
+ {
153
+ "summary": { "facetCount": 12, "errors": 1, "warnings": 0, "info": 0 },
154
+ "findings": [
155
+ {
156
+ "kind": "diamond-storage-namespace",
157
+ "severity": "error",
158
+ "slot": "0x...",
159
+ "message": "...",
160
+ "facets": ["LibStrategies", "LibVaults"],
161
+ "locations": [{ "file": "src/LibStrategies.sol" }],
162
+ "detail": { "namespaces": ["myapp.strategies"], "declarations": [...] }
163
+ }
164
+ ]
165
+ }
166
+ ```
167
+
168
+ - **Markdown** (`--markdown`): findings grouped by kind into severity-tagged `<details>` blocks. Designed for posting as a PR comment.
169
+
170
+ ## CI integration
171
+
172
+ Drop this into `.github/workflows/diamond-detect.yml`:
173
+
174
+ ```yaml
175
+ name: diamond-detect
176
+
177
+ on:
178
+ pull_request:
179
+
180
+ jobs:
181
+ scan:
182
+ runs-on: ubuntu-latest
183
+ steps:
184
+ - uses: actions/checkout@v4
185
+ - uses: foundry-rs/foundry-toolchain@v1
186
+ - run: forge build
187
+ - run: npx -y diamond-detect --markdown --facets 'src/facets/**' . > diamond-detect.md
188
+ - uses: marocchino/sticky-pull-request-comment@v2
189
+ with:
190
+ path: diamond-detect.md
191
+ ```
192
+
193
+ Tighten with `--severity error` if you only want to fail CI on hard collisions.
194
+
195
+ ## Troubleshooting
196
+
197
+ **"warning: no AST found in any artifact"** — your build didn't include AST output. Set `ast = true` in `foundry.toml` (under `[profile.default]`) and rebuild. Without AST, the namespace, EIP-7201, and inline-assembly analyzers can't run; only storage-layout-based ones (`appstorage-fingerprint`, `inheritance-overlap`) will fire.
198
+
199
+ **"Foundry out/ directory not found"** — you haven't run `forge build` yet, or you pointed `diamond-detect` at the wrong directory. Pass either the project root (the directory with `foundry.toml`) or any subdirectory of it.
200
+
201
+ **Scans `0` artifacts** — the loader is filtering everything. If your facets live under non-standard paths (e.g. `src/diamond/**` and you also have files in `lib/diamond-3-hardhat/`), check whether the default-ignore is hiding them. Use `--no-default-ignore` to confirm, then add narrower `--ignore` patterns.
202
+
203
+ **Lots of `inheritance-overlap` warnings on registries / factories** — those are non-facet contracts. Scope the analyzer with `--facets 'src/facets/**'` (or wherever your facets live).
204
+
205
+ **Findings only when I rebuild?** — `forge build` is incremental. If you change a struct definition but don't touch the consumers, their artifacts stay stale and the analyzer doesn't see the new layout. Wipe with `forge clean && forge build` if you suspect drift.
206
+
207
+ ## Comparison
208
+
209
+ | Tool | Diamond Storage namespaces | EIP-7201 ids | AppStorage drift | Hardcoded sstore slots |
210
+ |---|---|---|---|---|
211
+ | Slither | partial — general slot detector, not Diamond-aware | no | no | yes (separate detector) |
212
+ | Hand-audit / spreadsheet | yes, manually | yes, manually | hard to spot | yes |
213
+ | `diamond-detect` | yes | yes | yes | yes |
214
+
215
+ Slither remains excellent for general Solidity static analysis. Use both.
216
+
217
+ ## Roadmap
218
+
219
+ - **Onchain mode**: point at a deployed Diamond address; resolve facets through the [Diamond Loupe](https://eips.ethereum.org/EIPS/eip-2535#diamond-loupe), pull source from Etherscan, and run the same checks against what's actually live.
220
+ - **Facet auto-detection**: infer the facet set by walking deployment scripts or naming conventions, so `--facets` becomes optional.
221
+ - **Slither plugin**: surface findings inside an existing Slither pipeline.
222
+ - **VS Code extension**: inline diagnostics on save.
223
+
224
+ Issues and PRs welcome at the [repo](https://github.com/jayeshy14/Diamond-Storage-Detector).
225
+
226
+ ## License
227
+
228
+ MIT. See [LICENSE](./LICENSE).
package/dist/cli.js ADDED
@@ -0,0 +1,875 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+ import pc2 from "picocolors";
6
+
7
+ // src/detector/parseArtifacts.ts
8
+ import { promises as fs } from "fs";
9
+ import path from "path";
10
+ async function fileExists(p) {
11
+ try {
12
+ await fs.access(p);
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+ async function resolveFoundryRoot(input) {
19
+ const abs = path.resolve(input);
20
+ const stat = await fs.stat(abs);
21
+ const candidate = stat.isDirectory() ? abs : path.dirname(abs);
22
+ let cur = candidate;
23
+ for (let i = 0; i < 6; i++) {
24
+ if (await fileExists(path.join(cur, "foundry.toml"))) {
25
+ return { root: cur, outDir: path.join(cur, "out") };
26
+ }
27
+ const parent = path.dirname(cur);
28
+ if (parent === cur) break;
29
+ cur = parent;
30
+ }
31
+ if (await fileExists(path.join(candidate, "out"))) {
32
+ return { root: candidate, outDir: path.join(candidate, "out") };
33
+ }
34
+ throw new Error(
35
+ `Could not locate a Foundry project root from "${input}". Run \`forge build\` first, or pass the project root.`
36
+ );
37
+ }
38
+ async function walkJson(dir, acc = []) {
39
+ let entries;
40
+ try {
41
+ entries = await fs.readdir(dir, { withFileTypes: true });
42
+ } catch {
43
+ return acc;
44
+ }
45
+ for (const e of entries) {
46
+ const full = path.join(dir, e.name);
47
+ if (e.isDirectory()) await walkJson(full, acc);
48
+ else if (e.isFile() && e.name.endsWith(".json") && !e.name.endsWith(".metadata.json")) {
49
+ acc.push(full);
50
+ }
51
+ }
52
+ return acc;
53
+ }
54
+ function extractContractName(artifactPath) {
55
+ return path.basename(artifactPath, ".json");
56
+ }
57
+ function extractSourcePath(artifactPath, parsed) {
58
+ if (typeof parsed.metadata === "object" && parsed.metadata?.settings?.compilationTarget) {
59
+ const targets = parsed.metadata.settings.compilationTarget;
60
+ const first = Object.keys(targets)[0];
61
+ if (first) return first;
62
+ }
63
+ if (typeof parsed.metadata === "string") {
64
+ try {
65
+ const md = JSON.parse(parsed.metadata);
66
+ if (typeof md === "object" && md?.settings?.compilationTarget) {
67
+ const first = Object.keys(md.settings.compilationTarget)[0];
68
+ if (first) return first;
69
+ }
70
+ } catch {
71
+ }
72
+ }
73
+ return path.basename(path.dirname(artifactPath));
74
+ }
75
+ async function loadFoundryArtifacts(inputPath, opts = {}) {
76
+ const { outDir } = await resolveFoundryRoot(inputPath);
77
+ if (!await fileExists(outDir)) {
78
+ throw new Error(
79
+ `Foundry out/ directory not found at ${outDir}. Run \`forge build\` first, with \`ast = true\` and \`extra_output = ["storageLayout"]\` in foundry.toml (or pass \`--ast\` to forge).`
80
+ );
81
+ }
82
+ const files = await walkJson(outDir);
83
+ const artifacts = [];
84
+ for (const file of files) {
85
+ const text = await fs.readFile(file, "utf8");
86
+ let parsed;
87
+ try {
88
+ parsed = JSON.parse(text);
89
+ } catch {
90
+ continue;
91
+ }
92
+ if (!parsed.ast && !parsed.storageLayout) continue;
93
+ const contractName = extractContractName(file);
94
+ const sourcePath = extractSourcePath(file, parsed);
95
+ if (opts.ignoreSourcePath?.(sourcePath)) continue;
96
+ artifacts.push({
97
+ contractName,
98
+ sourcePath,
99
+ artifactPath: file,
100
+ storageLayout: parsed.storageLayout ?? null,
101
+ ast: parsed.ast,
102
+ bytecodeHash: parsed.bytecode?.object ? parsed.bytecode.object.slice(0, 18) : void 0
103
+ });
104
+ }
105
+ return artifacts;
106
+ }
107
+
108
+ // src/detector/index.ts
109
+ var DEFAULT_IGNORE_GLOBS = [
110
+ "lib/**",
111
+ "test/**",
112
+ "script/**",
113
+ "**/*.t.sol",
114
+ "**/*.s.sol"
115
+ ];
116
+ function compilePatterns(globs) {
117
+ return globs.map((g) => {
118
+ const escaped = g.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "::DOUBLESTAR::").replace(/\*/g, "[^/]*").replace(/::DOUBLESTAR::/g, ".*").replace(/\?/g, ".");
119
+ return new RegExp(`^${escaped}$`);
120
+ });
121
+ }
122
+ function buildIgnore(userGlobs, noDefault) {
123
+ const globs = [
124
+ ...noDefault ? [] : DEFAULT_IGNORE_GLOBS,
125
+ ...userGlobs ?? []
126
+ ];
127
+ if (globs.length === 0) return void 0;
128
+ const patterns = compilePatterns(globs);
129
+ return (sourcePath) => patterns.some((p) => p.test(sourcePath));
130
+ }
131
+ function buildIsFacet(globs) {
132
+ if (!globs || globs.length === 0) return void 0;
133
+ const patterns = compilePatterns(globs);
134
+ return (artifact) => patterns.some((p) => p.test(artifact.sourcePath));
135
+ }
136
+ async function detect(options, analyzers) {
137
+ const artifacts = await loadFoundryArtifacts(options.path, {
138
+ ignoreSourcePath: buildIgnore(options.ignoreGlobs, options.noDefaultIgnore)
139
+ });
140
+ const ctx = {
141
+ artifacts,
142
+ rawSources: /* @__PURE__ */ new Map(),
143
+ isFacet: buildIsFacet(options.facetGlobs)
144
+ };
145
+ const findings = [];
146
+ for (const analyzer of analyzers) {
147
+ const out = await analyzer.run(ctx);
148
+ findings.push(...out);
149
+ }
150
+ return { artifacts, findings };
151
+ }
152
+
153
+ // src/detector/analyzers/diamondStorage.ts
154
+ import { keccak_256 } from "@noble/hashes/sha3";
155
+ function keccak256Hex(input) {
156
+ const bytes = keccak_256(new TextEncoder().encode(input));
157
+ let out = "0x";
158
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
159
+ return out;
160
+ }
161
+ function isBytes32Constant(node) {
162
+ if (node.nodeType !== "VariableDeclaration") return false;
163
+ if (node.constant !== true) return false;
164
+ const typeName = node.typeName;
165
+ return typeName?.nodeType === "ElementaryTypeName" && typeName.name === "bytes32";
166
+ }
167
+ function extractKeccakStringArg(value) {
168
+ if (!value || typeof value !== "object") return null;
169
+ const v = value;
170
+ if (v.nodeType !== "FunctionCall") return null;
171
+ const expr = v.expression;
172
+ if (expr?.nodeType !== "Identifier" || expr.name !== "keccak256") return null;
173
+ const args = v.arguments;
174
+ if (!Array.isArray(args) || args.length !== 1) return null;
175
+ const arg = args[0];
176
+ if (arg.nodeType !== "Literal" || arg.kind !== "string") return null;
177
+ return typeof arg.value === "string" ? arg.value : null;
178
+ }
179
+ function lineFromSrc(src, sourceText) {
180
+ if (typeof src !== "string" || !sourceText) return void 0;
181
+ const [startStr] = src.split(":");
182
+ const start = Number(startStr);
183
+ if (!Number.isFinite(start)) return void 0;
184
+ let line = 1;
185
+ for (let i = 0; i < start && i < sourceText.length; i++) {
186
+ if (sourceText.charCodeAt(i) === 10) line++;
187
+ }
188
+ return line;
189
+ }
190
+ function walkAst(ast, visit, parents = []) {
191
+ if (!ast || typeof ast !== "object") return;
192
+ const node = ast;
193
+ if (typeof node.nodeType === "string") visit(node, parents);
194
+ const nextParents = typeof node.nodeType === "string" ? [...parents, node] : parents;
195
+ for (const key of Object.keys(node)) {
196
+ const value = node[key];
197
+ if (Array.isArray(value)) {
198
+ for (const child of value) walkAst(child, visit, nextParents);
199
+ } else if (value && typeof value === "object") {
200
+ walkAst(value, visit, nextParents);
201
+ }
202
+ }
203
+ }
204
+ function declaringContract(parents) {
205
+ for (let i = parents.length - 1; i >= 0; i--) {
206
+ const p = parents[i];
207
+ if (p.nodeType === "ContractDefinition") return p.name ?? null;
208
+ }
209
+ return null;
210
+ }
211
+ function collectSlotConstants(ctx) {
212
+ const seen = /* @__PURE__ */ new Set();
213
+ const out = [];
214
+ for (const artifact of ctx.artifacts) {
215
+ if (!artifact.ast) continue;
216
+ walkAst(artifact.ast, (node, parents) => {
217
+ if (!isBytes32Constant(node)) return;
218
+ const namespace = extractKeccakStringArg(node.value);
219
+ if (namespace === null) return;
220
+ const variableName = node.name ?? "<anon>";
221
+ const contract = declaringContract(parents) ?? artifact.contractName;
222
+ const src = node.src;
223
+ const dedupeKey = `${artifact.sourcePath}::${contract}::${variableName}::${src ?? ""}`;
224
+ if (seen.has(dedupeKey)) return;
225
+ seen.add(dedupeKey);
226
+ out.push({
227
+ variableName,
228
+ namespace,
229
+ slot: keccak256Hex(namespace),
230
+ contract,
231
+ sourcePath: artifact.sourcePath,
232
+ src
233
+ });
234
+ });
235
+ }
236
+ return out;
237
+ }
238
+ var diamondStorageAnalyzer = {
239
+ name: "diamond-storage-namespace",
240
+ run(ctx) {
241
+ const constants = collectSlotConstants(ctx);
242
+ const bySlot = /* @__PURE__ */ new Map();
243
+ for (const c of constants) {
244
+ const list = bySlot.get(c.slot) ?? [];
245
+ list.push(c);
246
+ bySlot.set(c.slot, list);
247
+ }
248
+ const findings = [];
249
+ for (const [slot, group] of bySlot) {
250
+ const distinctSources = new Set(group.map((g) => g.sourcePath));
251
+ if (distinctSources.size < 2) continue;
252
+ const namespaces = Array.from(new Set(group.map((g) => g.namespace)));
253
+ const facets = Array.from(new Set(group.map((g) => g.contract)));
254
+ const locations = group.map((g) => {
255
+ const sourceText = ctx.rawSources.get(g.sourcePath);
256
+ return { file: g.sourcePath, line: lineFromSrc(g.src, sourceText) };
257
+ });
258
+ findings.push({
259
+ kind: "diamond-storage-namespace",
260
+ severity: "error",
261
+ slot,
262
+ message: namespaces.length === 1 ? `Diamond Storage namespace "${namespaces[0]}" is declared in ${distinctSources.size} different sources, all resolving to the same slot.` : `Distinct namespaces ${namespaces.map((n) => `"${n}"`).join(", ")} hash to the same slot.`,
263
+ facets,
264
+ locations,
265
+ detail: { namespaces, declarations: group }
266
+ });
267
+ }
268
+ return findings;
269
+ }
270
+ };
271
+
272
+ // src/lib/eip7201.ts
273
+ import { keccak_256 as keccak_2562 } from "@noble/hashes/sha3";
274
+ var MASK_LAST_BYTE = (() => {
275
+ const m = new Uint8Array(32).fill(255);
276
+ m[31] = 0;
277
+ return m;
278
+ })();
279
+ function utf8(s) {
280
+ return new TextEncoder().encode(s);
281
+ }
282
+ function toHex(bytes) {
283
+ let out = "0x";
284
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
285
+ return out;
286
+ }
287
+ function subOne(bytes) {
288
+ const out = new Uint8Array(bytes);
289
+ for (let i = out.length - 1; i >= 0; i--) {
290
+ if (out[i] > 0) {
291
+ out[i] = out[i] - 1;
292
+ return out;
293
+ }
294
+ out[i] = 255;
295
+ }
296
+ return out;
297
+ }
298
+ function maskLastByte(bytes) {
299
+ const out = new Uint8Array(32);
300
+ for (let i = 0; i < 32; i++) out[i] = bytes[i] & MASK_LAST_BYTE[i];
301
+ return out;
302
+ }
303
+ function erc7201Slot(namespaceId) {
304
+ const inner = keccak_2562(utf8(namespaceId));
305
+ const decremented = subOne(inner);
306
+ const outer = keccak_2562(decremented);
307
+ return toHex(maskLastByte(outer));
308
+ }
309
+ var ERC7201_PREFIX = "erc7201:";
310
+ function parseErc7201Annotation(text) {
311
+ const idx = text.indexOf(ERC7201_PREFIX);
312
+ if (idx === -1) return null;
313
+ const rest = text.slice(idx + ERC7201_PREFIX.length);
314
+ const match = rest.match(/^[A-Za-z0-9_.\-]+/);
315
+ return match ? match[0] : null;
316
+ }
317
+
318
+ // src/detector/analyzers/erc7201.ts
319
+ var NAMED_NODE_TYPES = /* @__PURE__ */ new Set([
320
+ "ContractDefinition",
321
+ "StructDefinition",
322
+ "FunctionDefinition",
323
+ "VariableDeclaration",
324
+ "ErrorDefinition",
325
+ "EventDefinition",
326
+ "ModifierDefinition",
327
+ "EnumDefinition"
328
+ ]);
329
+ function getDocText(node) {
330
+ const doc = node.documentation;
331
+ if (!doc) return null;
332
+ if (typeof doc === "string") return doc;
333
+ if (typeof doc === "object" && doc !== null) {
334
+ const text = doc.text;
335
+ if (typeof text === "string") return text;
336
+ }
337
+ return null;
338
+ }
339
+ function nearestContract(parents) {
340
+ for (let i = parents.length - 1; i >= 0; i--) {
341
+ const p = parents[i];
342
+ if (p.nodeType === "ContractDefinition") return p.name ?? null;
343
+ }
344
+ return null;
345
+ }
346
+ function walkAst2(ast, visit, parents = []) {
347
+ if (!ast || typeof ast !== "object") return;
348
+ const node = ast;
349
+ if (typeof node.nodeType === "string") visit(node, parents);
350
+ const nextParents = typeof node.nodeType === "string" ? [...parents, node] : parents;
351
+ for (const key of Object.keys(node)) {
352
+ const value = node[key];
353
+ if (Array.isArray(value)) {
354
+ for (const child of value) walkAst2(child, visit, nextParents);
355
+ } else if (value && typeof value === "object") {
356
+ walkAst2(value, visit, nextParents);
357
+ }
358
+ }
359
+ }
360
+ function collectErc7201Annotations(artifacts) {
361
+ const seen = /* @__PURE__ */ new Set();
362
+ const out = [];
363
+ for (const artifact of artifacts) {
364
+ if (!artifact.ast) continue;
365
+ walkAst2(artifact.ast, (node, parents) => {
366
+ if (!node.nodeType || !NAMED_NODE_TYPES.has(node.nodeType)) return;
367
+ const text = getDocText(node);
368
+ if (!text || !text.includes("erc7201:")) return;
369
+ const namespaceId = parseErc7201Annotation(text);
370
+ if (!namespaceId) return;
371
+ const attachedTo = `${node.nodeType}:${node.name ?? "<anon>"}`;
372
+ const contract = node.nodeType === "ContractDefinition" ? node.name ?? artifact.contractName : nearestContract(parents) ?? artifact.contractName;
373
+ const src = node.src;
374
+ const dedupeKey = `${artifact.sourcePath}::${attachedTo}::${src ?? ""}`;
375
+ if (seen.has(dedupeKey)) return;
376
+ seen.add(dedupeKey);
377
+ out.push({
378
+ namespaceId,
379
+ slot: erc7201Slot(namespaceId),
380
+ attachedTo,
381
+ contract,
382
+ sourcePath: artifact.sourcePath,
383
+ src
384
+ });
385
+ });
386
+ }
387
+ return out;
388
+ }
389
+ var erc7201Analyzer = {
390
+ name: "erc7201-namespace",
391
+ run(ctx) {
392
+ const annotations = collectErc7201Annotations(ctx.artifacts);
393
+ const bySlot = /* @__PURE__ */ new Map();
394
+ for (const a of annotations) {
395
+ const list = bySlot.get(a.slot) ?? [];
396
+ list.push(a);
397
+ bySlot.set(a.slot, list);
398
+ }
399
+ const findings = [];
400
+ for (const [slot, group] of bySlot) {
401
+ const distinctSources = new Set(group.map((g) => g.sourcePath));
402
+ if (distinctSources.size < 2) continue;
403
+ const ids = Array.from(new Set(group.map((g) => g.namespaceId)));
404
+ const facets = Array.from(new Set(group.map((g) => g.contract)));
405
+ const locations = group.map((g) => ({ file: g.sourcePath }));
406
+ findings.push({
407
+ kind: "erc7201-namespace",
408
+ severity: "error",
409
+ slot,
410
+ message: ids.length === 1 ? `EIP-7201 namespace "${ids[0]}" is declared in ${distinctSources.size} different sources, all resolving to the same slot.` : `Distinct EIP-7201 namespaces ${ids.map((n) => `"${n}"`).join(", ")} hash to the same slot.`,
411
+ facets,
412
+ locations,
413
+ detail: { namespaceIds: ids, annotations: group }
414
+ });
415
+ }
416
+ return findings;
417
+ }
418
+ };
419
+
420
+ // src/detector/analyzers/appStorage.ts
421
+ function memberFingerprint(m) {
422
+ return { label: m.label, offset: m.offset, slot: m.slot, type: m.type };
423
+ }
424
+ function structFingerprint(label, numberOfBytes, members) {
425
+ const ordered = [...members].map(memberFingerprint).sort((a, b) => {
426
+ if (a.slot !== b.slot) return a.slot.localeCompare(b.slot);
427
+ return a.offset - b.offset;
428
+ });
429
+ return { label, numberOfBytes, members: ordered };
430
+ }
431
+ function hashFingerprint(fp) {
432
+ return JSON.stringify(fp);
433
+ }
434
+ function collectStructFingerprints(artifacts) {
435
+ const byLabel = /* @__PURE__ */ new Map();
436
+ for (const artifact of artifacts) {
437
+ const types = artifact.storageLayout?.types;
438
+ if (!types) continue;
439
+ for (const entry of Object.values(types)) {
440
+ if (!entry.members || entry.members.length === 0) continue;
441
+ if (entry.encoding !== "inplace") continue;
442
+ const fingerprint = structFingerprint(entry.label, entry.numberOfBytes, entry.members);
443
+ const hash = hashFingerprint(fingerprint);
444
+ const list = byLabel.get(entry.label) ?? [];
445
+ list.push({ fingerprint, hash, artifact });
446
+ byLabel.set(entry.label, list);
447
+ }
448
+ }
449
+ return byLabel;
450
+ }
451
+ function dedupeByHash(items) {
452
+ const seen = /* @__PURE__ */ new Map();
453
+ for (const item of items) {
454
+ const key = `${item.hash}::${item.artifact.sourcePath}`;
455
+ if (!seen.has(key)) seen.set(key, item);
456
+ }
457
+ return Array.from(seen.values());
458
+ }
459
+ function diffSummary(a, b) {
460
+ const aFields = new Set(a.members.map((m) => `${m.label}@${m.slot}+${m.offset}:${m.type}`));
461
+ const bFields = new Set(b.members.map((m) => `${m.label}@${m.slot}+${m.offset}:${m.type}`));
462
+ const onlyA = [...aFields].filter((f) => !bFields.has(f));
463
+ const onlyB = [...bFields].filter((f) => !aFields.has(f));
464
+ const parts = [];
465
+ if (onlyA.length > 0) parts.push(`only in version A: ${onlyA.join(", ")}`);
466
+ if (onlyB.length > 0) parts.push(`only in version B: ${onlyB.join(", ")}`);
467
+ if (a.numberOfBytes !== b.numberOfBytes) {
468
+ parts.push(`size differs (${a.numberOfBytes} vs ${b.numberOfBytes} bytes)`);
469
+ }
470
+ return parts.join("; ");
471
+ }
472
+ var appStorageAnalyzer = {
473
+ name: "appstorage-fingerprint",
474
+ run(ctx) {
475
+ const findings = [];
476
+ const scoped = ctx.isFacet ? ctx.artifacts.filter(ctx.isFacet) : ctx.artifacts;
477
+ const grouped = collectStructFingerprints(scoped);
478
+ for (const [label, items] of grouped) {
479
+ const variants = /* @__PURE__ */ new Map();
480
+ for (const item of dedupeByHash(items)) {
481
+ const list = variants.get(item.hash) ?? [];
482
+ list.push(item);
483
+ variants.set(item.hash, list);
484
+ }
485
+ if (variants.size < 2) continue;
486
+ const variantArr = [...variants.values()];
487
+ const sources = /* @__PURE__ */ new Set();
488
+ const facets = /* @__PURE__ */ new Set();
489
+ const locations = [];
490
+ for (const v of variantArr) {
491
+ for (const item of v) {
492
+ sources.add(item.artifact.sourcePath);
493
+ facets.add(item.artifact.contractName);
494
+ locations.push({ file: item.artifact.sourcePath });
495
+ }
496
+ }
497
+ const a = variantArr[0][0].fingerprint;
498
+ const b = variantArr[1][0].fingerprint;
499
+ const summary = diffSummary(a, b);
500
+ findings.push({
501
+ kind: "appstorage-fingerprint",
502
+ severity: "error",
503
+ slot: "n/a",
504
+ message: `${label} has ${variants.size} divergent layouts across ${sources.size} sources \u2014 ${summary || "member ordering differs"}.`,
505
+ facets: [...facets],
506
+ locations,
507
+ detail: {
508
+ structLabel: label,
509
+ variants: variantArr.map((v) => ({
510
+ fingerprint: v[0].fingerprint,
511
+ artifacts: v.map((x) => ({
512
+ contractName: x.artifact.contractName,
513
+ sourcePath: x.artifact.sourcePath
514
+ }))
515
+ }))
516
+ }
517
+ });
518
+ }
519
+ return findings;
520
+ }
521
+ };
522
+
523
+ // src/detector/analyzers/inlineAssembly.ts
524
+ function walkAst3(ast, visit) {
525
+ if (!ast || typeof ast !== "object") return;
526
+ const node = ast;
527
+ if (typeof node.nodeType === "string") visit(node);
528
+ for (const key of Object.keys(node)) {
529
+ const value = node[key];
530
+ if (Array.isArray(value)) {
531
+ for (const child of value) walkAst3(child, visit);
532
+ } else if (value && typeof value === "object") {
533
+ walkAst3(value, visit);
534
+ }
535
+ }
536
+ }
537
+ function normalizeSlot(value) {
538
+ const trimmed = value.trim();
539
+ let big;
540
+ try {
541
+ if (trimmed.startsWith("0x") || trimmed.startsWith("0X")) {
542
+ big = BigInt(trimmed);
543
+ } else {
544
+ big = BigInt(trimmed);
545
+ }
546
+ } catch {
547
+ return trimmed;
548
+ }
549
+ return "0x" + big.toString(16).padStart(64, "0");
550
+ }
551
+ function collectSstoreLiterals(artifacts) {
552
+ const out = [];
553
+ for (const artifact of artifacts) {
554
+ if (!artifact.ast) continue;
555
+ walkAst3(artifact.ast, (node) => {
556
+ if (node.nodeType !== "YulFunctionCall") return;
557
+ const fnName = node.functionName?.name;
558
+ if (fnName !== "sstore") return;
559
+ const args = node.arguments;
560
+ if (!Array.isArray(args) || args.length < 2) return;
561
+ const arg0 = args[0];
562
+ if (arg0?.nodeType !== "YulLiteral" || arg0.kind !== "number") return;
563
+ const rawValue = String(arg0.value ?? "");
564
+ out.push({
565
+ slot: normalizeSlot(rawValue),
566
+ rawValue,
567
+ artifact,
568
+ src: node.src
569
+ });
570
+ });
571
+ }
572
+ return out;
573
+ }
574
+ var inlineAssemblyAnalyzer = {
575
+ name: "inline-assembly-slot",
576
+ run(ctx) {
577
+ const literals = collectSstoreLiterals(ctx.artifacts);
578
+ return literals.map((lit) => ({
579
+ kind: "inline-assembly-slot",
580
+ severity: "info",
581
+ slot: lit.slot,
582
+ message: `inline assembly writes to a hardcoded slot (sstore(${lit.rawValue}, \u2026)) \u2014 confirm no overlap with computed storage slots.`,
583
+ facets: [lit.artifact.contractName],
584
+ locations: [{ file: lit.artifact.sourcePath }],
585
+ detail: { rawValue: lit.rawValue, src: lit.src }
586
+ }));
587
+ }
588
+ };
589
+
590
+ // src/detector/analyzers/inheritance.ts
591
+ function keyOf(s) {
592
+ return `${s.slot}@${s.offset}`;
593
+ }
594
+ function declarationKey(s) {
595
+ return `${s.label}::${s.type}`;
596
+ }
597
+ function collectSlotEntries(artifacts) {
598
+ const byKey = /* @__PURE__ */ new Map();
599
+ for (const artifact of artifacts) {
600
+ const layout = artifact.storageLayout?.storage;
601
+ if (!layout || layout.length === 0) continue;
602
+ for (const slot of layout) {
603
+ const k = keyOf(slot);
604
+ const list = byKey.get(k) ?? [];
605
+ list.push({ artifact, slot });
606
+ byKey.set(k, list);
607
+ }
608
+ }
609
+ return byKey;
610
+ }
611
+ var inheritanceAnalyzer = {
612
+ name: "inheritance-overlap",
613
+ run(ctx) {
614
+ const findings = [];
615
+ const scoped = ctx.isFacet ? ctx.artifacts.filter(ctx.isFacet) : ctx.artifacts;
616
+ const grouped = collectSlotEntries(scoped);
617
+ for (const [_slotKey, entries] of grouped) {
618
+ const facetNames = new Set(entries.map((e) => e.artifact.contractName));
619
+ if (facetNames.size < 2) continue;
620
+ const declarations = /* @__PURE__ */ new Map();
621
+ for (const e of entries) {
622
+ const k = declarationKey(e.slot);
623
+ const list = declarations.get(k) ?? [];
624
+ list.push(e);
625
+ declarations.set(k, list);
626
+ }
627
+ if (declarations.size < 2) continue;
628
+ const sample = entries[0].slot;
629
+ const slotHex = "0x" + BigInt(sample.slot).toString(16).padStart(64, "0");
630
+ const facets = Array.from(facetNames).sort();
631
+ const declarationsForMessage = Array.from(declarations.entries()).map(
632
+ ([_, items]) => {
633
+ const sample2 = items[0].slot;
634
+ return `${sample2.label}:${sample2.type} (declared in ${sample2.contract})`;
635
+ }
636
+ );
637
+ const locations = entries.map((e) => ({
638
+ file: e.artifact.sourcePath
639
+ }));
640
+ findings.push({
641
+ kind: "inheritance-overlap",
642
+ severity: "warn",
643
+ slot: slotHex,
644
+ message: `Slot ${sample.slot}+${sample.offset} is occupied by ${declarations.size} different declarations across ${facetNames.size} contracts: ${declarationsForMessage.join(" vs ")}. If these are Diamond facets sharing the proxy's storage, this is a collision; if they are independent contracts, ignore.`,
645
+ facets,
646
+ locations,
647
+ detail: {
648
+ slot: sample.slot,
649
+ offset: sample.offset,
650
+ declarations: Array.from(declarations.entries()).map(([_, items]) => ({
651
+ contract: items[0].slot.contract,
652
+ label: items[0].slot.label,
653
+ type: items[0].slot.type,
654
+ facets: items.map((i) => i.artifact.contractName)
655
+ }))
656
+ }
657
+ });
658
+ }
659
+ return findings;
660
+ }
661
+ };
662
+
663
+ // src/detector/analyzers/index.ts
664
+ var defaultAnalyzers = [
665
+ diamondStorageAnalyzer,
666
+ erc7201Analyzer,
667
+ appStorageAnalyzer,
668
+ inlineAssemblyAnalyzer,
669
+ inheritanceAnalyzer
670
+ ];
671
+
672
+ // src/reporter/terminal.ts
673
+ import pc from "picocolors";
674
+ var SEVERITY_RANK = { info: 0, warn: 1, error: 2 };
675
+ function colorSeverity(sev) {
676
+ if (sev === "error") return pc.red(pc.bold("ERROR"));
677
+ if (sev === "warn") return pc.yellow("WARN ");
678
+ return pc.cyan("INFO ");
679
+ }
680
+ function renderTerminal(findings, facetCount) {
681
+ const lines = [];
682
+ lines.push(pc.dim(`scanned ${facetCount} contract artifact(s)`));
683
+ if (findings.length === 0) {
684
+ lines.push(pc.green("\u2713 no storage collisions detected"));
685
+ return lines.join("\n");
686
+ }
687
+ const sorted = [...findings].sort(
688
+ (a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]
689
+ );
690
+ for (const f of sorted) {
691
+ lines.push("");
692
+ lines.push(`${colorSeverity(f.severity)} ${pc.bold(f.kind)} ${pc.dim(f.slot)}`);
693
+ lines.push(` ${f.message}`);
694
+ if (f.facets.length > 0) {
695
+ lines.push(` ${pc.dim("facets:")} ${f.facets.join(", ")}`);
696
+ }
697
+ for (const loc of f.locations) {
698
+ const where = loc.line ? `${loc.file}:${loc.line}` : loc.file;
699
+ lines.push(` ${pc.dim("at")} ${where}`);
700
+ }
701
+ }
702
+ const errors = findings.filter((f) => f.severity === "error").length;
703
+ const warns = findings.filter((f) => f.severity === "warn").length;
704
+ lines.push("");
705
+ lines.push(pc.bold(`${errors} error(s), ${warns} warning(s)`));
706
+ return lines.join("\n");
707
+ }
708
+
709
+ // src/reporter/json.ts
710
+ function renderJson(findings, facetCount) {
711
+ return JSON.stringify(
712
+ {
713
+ summary: {
714
+ facetCount,
715
+ errors: findings.filter((f) => f.severity === "error").length,
716
+ warnings: findings.filter((f) => f.severity === "warn").length,
717
+ info: findings.filter((f) => f.severity === "info").length
718
+ },
719
+ findings
720
+ },
721
+ null,
722
+ 2
723
+ );
724
+ }
725
+
726
+ // src/reporter/markdown.ts
727
+ var SEVERITY_LABEL = {
728
+ error: "\u{1F534} error",
729
+ warn: "\u{1F7E1} warn",
730
+ info: "\u{1F535} info"
731
+ };
732
+ var SEVERITY_RANK2 = { info: 0, warn: 1, error: 2 };
733
+ var KIND_TITLE = {
734
+ "diamond-storage-namespace": "Diamond Storage namespace collisions",
735
+ "appstorage-fingerprint": "AppStorage struct drift",
736
+ "erc7201-namespace": "EIP-7201 namespace collisions",
737
+ "inline-assembly-slot": "Inline-assembly hardcoded sstore slots",
738
+ "inheritance-overlap": "Inheritance / cross-contract slot overlap",
739
+ "mapping-overlap": "Mapping / Diamond Storage overlap"
740
+ };
741
+ function escape(text) {
742
+ return text.replace(/\|/g, "\\|").replace(/`/g, "\\`");
743
+ }
744
+ function renderFinding(f) {
745
+ const lines = [];
746
+ lines.push(`- **\`${f.slot}\`** \u2014 ${SEVERITY_LABEL[f.severity]}`);
747
+ lines.push(` - ${escape(f.message)}`);
748
+ if (f.facets.length > 0) {
749
+ lines.push(` - facets: ${f.facets.map((x) => `\`${x}\``).join(", ")}`);
750
+ }
751
+ if (f.locations.length > 0) {
752
+ const locs = Array.from(new Set(f.locations.map((l) => l.file))).slice(0, 8);
753
+ lines.push(` - sources: ${locs.map((l) => `\`${l}\``).join(", ")}`);
754
+ }
755
+ return lines.join("\n");
756
+ }
757
+ function groupByKind(findings) {
758
+ const map = /* @__PURE__ */ new Map();
759
+ for (const f of findings) {
760
+ const list = map.get(f.kind) ?? [];
761
+ list.push(f);
762
+ map.set(f.kind, list);
763
+ }
764
+ return map;
765
+ }
766
+ function maxSeverity(findings) {
767
+ let max = "info";
768
+ for (const f of findings) {
769
+ if (SEVERITY_RANK2[f.severity] > SEVERITY_RANK2[max]) max = f.severity;
770
+ }
771
+ return max;
772
+ }
773
+ function renderMarkdown(findings, facetCount) {
774
+ const errs = findings.filter((f) => f.severity === "error").length;
775
+ const warns = findings.filter((f) => f.severity === "warn").length;
776
+ const infos = findings.filter((f) => f.severity === "info").length;
777
+ if (findings.length === 0) {
778
+ return `### diamond-detect
779
+
780
+ \u2705 No storage collisions detected across ${facetCount} contract(s).`;
781
+ }
782
+ const lines = [];
783
+ lines.push(`### diamond-detect`);
784
+ lines.push("");
785
+ lines.push(
786
+ `Scanned **${facetCount}** contract(s). Found **${errs}** error(s), **${warns}** warning(s), **${infos}** info(s).`
787
+ );
788
+ const grouped = groupByKind(findings);
789
+ const orderedKinds = [
790
+ "diamond-storage-namespace",
791
+ "erc7201-namespace",
792
+ "appstorage-fingerprint",
793
+ "inheritance-overlap",
794
+ "inline-assembly-slot",
795
+ "mapping-overlap"
796
+ ];
797
+ for (const kind of orderedKinds) {
798
+ const items = grouped.get(kind);
799
+ if (!items || items.length === 0) continue;
800
+ const title = KIND_TITLE[kind];
801
+ const max = maxSeverity(items);
802
+ const open = max === "error" ? " open" : "";
803
+ lines.push("");
804
+ lines.push(
805
+ `<details${open}><summary>${SEVERITY_LABEL[max]} \u2014 ${title} (${items.length})</summary>`
806
+ );
807
+ lines.push("");
808
+ for (const f of items) lines.push(renderFinding(f));
809
+ lines.push("");
810
+ lines.push("</details>");
811
+ }
812
+ return lines.join("\n");
813
+ }
814
+
815
+ // src/cli.ts
816
+ var SEVERITY_RANK3 = { info: 0, warn: 1, error: 2 };
817
+ async function run(target, opts) {
818
+ const result = await detect(
819
+ {
820
+ path: target,
821
+ ignoreGlobs: opts.ignore,
822
+ noDefaultIgnore: opts.noDefaultIgnore,
823
+ facetGlobs: opts.facets.length > 0 ? opts.facets : void 0
824
+ },
825
+ defaultAnalyzers
826
+ );
827
+ const withAst = result.artifacts.filter((a) => a.ast).length;
828
+ if (result.artifacts.length > 0 && withAst === 0 && !opts.json) {
829
+ process.stderr.write(
830
+ pc2.yellow(
831
+ "warning: no AST found in any artifact. Set `ast = true` in foundry.toml (or pass `--ast` to forge) and rebuild \u2014 AST-based analyzers depend on it.\n"
832
+ )
833
+ );
834
+ }
835
+ const output = opts.json ? renderJson(result.findings, result.artifacts.length) : opts.markdown ? renderMarkdown(result.findings, result.artifacts.length) : renderTerminal(result.findings, result.artifacts.length);
836
+ process.stdout.write(output + "\n");
837
+ const threshold = SEVERITY_RANK3[opts.severity];
838
+ const hit = result.findings.some((f) => SEVERITY_RANK3[f.severity] >= threshold);
839
+ process.exit(hit ? 1 : 0);
840
+ }
841
+ var program = new Command();
842
+ program.name("diamond-detect").description("Static analyzer for EIP-2535 Diamond storage-slot collisions").argument("<path>", "Foundry project root or src/ folder").option("--json", "Emit machine-readable JSON").option("--markdown", "Emit GitHub-flavored Markdown (PR-friendly)").option(
843
+ "--severity <level>",
844
+ "Exit-code threshold: info | warn | error",
845
+ (v) => {
846
+ if (v !== "info" && v !== "warn" && v !== "error") {
847
+ throw new Error(`invalid severity: ${v}`);
848
+ }
849
+ return v;
850
+ },
851
+ "warn"
852
+ ).option(
853
+ "--ignore <glob>",
854
+ "Skip source files matching this glob. Repeat for multiple. Defaults: lib/**, test/**, script/**, **/*.t.sol, **/*.s.sol",
855
+ (v, prev = []) => prev.concat(v),
856
+ []
857
+ ).option(
858
+ "--no-default-ignore",
859
+ "Disable the built-in lib/test/script ignore list and scan everything in out/"
860
+ ).option(
861
+ "--facets <glob>",
862
+ "Restrict facet-shared-storage analyzers (inheritance-overlap, appstorage-fingerprint) to source paths matching this glob. Repeat for multiple.",
863
+ (v, prev = []) => prev.concat(v),
864
+ []
865
+ ).action(async (target, opts) => {
866
+ try {
867
+ await run(target, opts);
868
+ } catch (err) {
869
+ const msg = err instanceof Error ? err.message : String(err);
870
+ process.stderr.write(pc2.red(`diamond-detect: ${msg}
871
+ `));
872
+ process.exit(2);
873
+ }
874
+ });
875
+ program.parseAsync(process.argv);
package/dist/index.js ADDED
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/detector/parseArtifacts.ts
4
+ import { promises as fs } from "fs";
5
+ import path from "path";
6
+ async function fileExists(p) {
7
+ try {
8
+ await fs.access(p);
9
+ return true;
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+ async function resolveFoundryRoot(input) {
15
+ const abs = path.resolve(input);
16
+ const stat = await fs.stat(abs);
17
+ const candidate = stat.isDirectory() ? abs : path.dirname(abs);
18
+ let cur = candidate;
19
+ for (let i = 0; i < 6; i++) {
20
+ if (await fileExists(path.join(cur, "foundry.toml"))) {
21
+ return { root: cur, outDir: path.join(cur, "out") };
22
+ }
23
+ const parent = path.dirname(cur);
24
+ if (parent === cur) break;
25
+ cur = parent;
26
+ }
27
+ if (await fileExists(path.join(candidate, "out"))) {
28
+ return { root: candidate, outDir: path.join(candidate, "out") };
29
+ }
30
+ throw new Error(
31
+ `Could not locate a Foundry project root from "${input}". Run \`forge build\` first, or pass the project root.`
32
+ );
33
+ }
34
+ async function walkJson(dir, acc = []) {
35
+ let entries;
36
+ try {
37
+ entries = await fs.readdir(dir, { withFileTypes: true });
38
+ } catch {
39
+ return acc;
40
+ }
41
+ for (const e of entries) {
42
+ const full = path.join(dir, e.name);
43
+ if (e.isDirectory()) await walkJson(full, acc);
44
+ else if (e.isFile() && e.name.endsWith(".json") && !e.name.endsWith(".metadata.json")) {
45
+ acc.push(full);
46
+ }
47
+ }
48
+ return acc;
49
+ }
50
+ function extractContractName(artifactPath) {
51
+ return path.basename(artifactPath, ".json");
52
+ }
53
+ function extractSourcePath(artifactPath, parsed) {
54
+ if (typeof parsed.metadata === "object" && parsed.metadata?.settings?.compilationTarget) {
55
+ const targets = parsed.metadata.settings.compilationTarget;
56
+ const first = Object.keys(targets)[0];
57
+ if (first) return first;
58
+ }
59
+ if (typeof parsed.metadata === "string") {
60
+ try {
61
+ const md = JSON.parse(parsed.metadata);
62
+ if (typeof md === "object" && md?.settings?.compilationTarget) {
63
+ const first = Object.keys(md.settings.compilationTarget)[0];
64
+ if (first) return first;
65
+ }
66
+ } catch {
67
+ }
68
+ }
69
+ return path.basename(path.dirname(artifactPath));
70
+ }
71
+ async function loadFoundryArtifacts(inputPath, opts = {}) {
72
+ const { outDir } = await resolveFoundryRoot(inputPath);
73
+ if (!await fileExists(outDir)) {
74
+ throw new Error(
75
+ `Foundry out/ directory not found at ${outDir}. Run \`forge build\` first, with \`ast = true\` and \`extra_output = ["storageLayout"]\` in foundry.toml (or pass \`--ast\` to forge).`
76
+ );
77
+ }
78
+ const files = await walkJson(outDir);
79
+ const artifacts = [];
80
+ for (const file of files) {
81
+ const text = await fs.readFile(file, "utf8");
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(text);
85
+ } catch {
86
+ continue;
87
+ }
88
+ if (!parsed.ast && !parsed.storageLayout) continue;
89
+ const contractName = extractContractName(file);
90
+ const sourcePath = extractSourcePath(file, parsed);
91
+ if (opts.ignoreSourcePath?.(sourcePath)) continue;
92
+ artifacts.push({
93
+ contractName,
94
+ sourcePath,
95
+ artifactPath: file,
96
+ storageLayout: parsed.storageLayout ?? null,
97
+ ast: parsed.ast,
98
+ bytecodeHash: parsed.bytecode?.object ? parsed.bytecode.object.slice(0, 18) : void 0
99
+ });
100
+ }
101
+ return artifacts;
102
+ }
103
+
104
+ // src/detector/index.ts
105
+ var DEFAULT_IGNORE_GLOBS = [
106
+ "lib/**",
107
+ "test/**",
108
+ "script/**",
109
+ "**/*.t.sol",
110
+ "**/*.s.sol"
111
+ ];
112
+ function compilePatterns(globs) {
113
+ return globs.map((g) => {
114
+ const escaped = g.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "::DOUBLESTAR::").replace(/\*/g, "[^/]*").replace(/::DOUBLESTAR::/g, ".*").replace(/\?/g, ".");
115
+ return new RegExp(`^${escaped}$`);
116
+ });
117
+ }
118
+ function buildIgnore(userGlobs, noDefault) {
119
+ const globs = [
120
+ ...noDefault ? [] : DEFAULT_IGNORE_GLOBS,
121
+ ...userGlobs ?? []
122
+ ];
123
+ if (globs.length === 0) return void 0;
124
+ const patterns = compilePatterns(globs);
125
+ return (sourcePath) => patterns.some((p) => p.test(sourcePath));
126
+ }
127
+ function buildIsFacet(globs) {
128
+ if (!globs || globs.length === 0) return void 0;
129
+ const patterns = compilePatterns(globs);
130
+ return (artifact) => patterns.some((p) => p.test(artifact.sourcePath));
131
+ }
132
+ async function detect(options, analyzers) {
133
+ const artifacts = await loadFoundryArtifacts(options.path, {
134
+ ignoreSourcePath: buildIgnore(options.ignoreGlobs, options.noDefaultIgnore)
135
+ });
136
+ const ctx = {
137
+ artifacts,
138
+ rawSources: /* @__PURE__ */ new Map(),
139
+ isFacet: buildIsFacet(options.facetGlobs)
140
+ };
141
+ const findings = [];
142
+ for (const analyzer of analyzers) {
143
+ const out = await analyzer.run(ctx);
144
+ findings.push(...out);
145
+ }
146
+ return { artifacts, findings };
147
+ }
148
+
149
+ // src/lib/eip7201.ts
150
+ import { keccak_256 } from "@noble/hashes/sha3";
151
+ var MASK_LAST_BYTE = (() => {
152
+ const m = new Uint8Array(32).fill(255);
153
+ m[31] = 0;
154
+ return m;
155
+ })();
156
+ function utf8(s) {
157
+ return new TextEncoder().encode(s);
158
+ }
159
+ function toHex(bytes) {
160
+ let out = "0x";
161
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
162
+ return out;
163
+ }
164
+ function subOne(bytes) {
165
+ const out = new Uint8Array(bytes);
166
+ for (let i = out.length - 1; i >= 0; i--) {
167
+ if (out[i] > 0) {
168
+ out[i] = out[i] - 1;
169
+ return out;
170
+ }
171
+ out[i] = 255;
172
+ }
173
+ return out;
174
+ }
175
+ function maskLastByte(bytes) {
176
+ const out = new Uint8Array(32);
177
+ for (let i = 0; i < 32; i++) out[i] = bytes[i] & MASK_LAST_BYTE[i];
178
+ return out;
179
+ }
180
+ function erc7201Slot(namespaceId) {
181
+ const inner = keccak_256(utf8(namespaceId));
182
+ const decremented = subOne(inner);
183
+ const outer = keccak_256(decremented);
184
+ return toHex(maskLastByte(outer));
185
+ }
186
+ var ERC7201_PREFIX = "erc7201:";
187
+ function parseErc7201Annotation(text) {
188
+ const idx = text.indexOf(ERC7201_PREFIX);
189
+ if (idx === -1) return null;
190
+ const rest = text.slice(idx + ERC7201_PREFIX.length);
191
+ const match = rest.match(/^[A-Za-z0-9_.\-]+/);
192
+ return match ? match[0] : null;
193
+ }
194
+ export {
195
+ detect,
196
+ erc7201Slot,
197
+ loadFoundryArtifacts,
198
+ parseErc7201Annotation
199
+ };
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "diamond-detect",
3
+ "version": "0.1.0",
4
+ "description": "Static analyzer for EIP-2535 Diamond storage-slot collisions across facets",
5
+ "homepage": "https://github.com/jayeshy14/Diamond-Storage-Detector#readme",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/jayeshy14/Diamond-Storage-Detector.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/jayeshy14/Diamond-Storage-Detector/issues"
12
+ },
13
+ "keywords": [
14
+ "solidity",
15
+ "diamond-proxy",
16
+ "eip-2535",
17
+ "eip-7201",
18
+ "static-analysis",
19
+ "foundry",
20
+ "smart-contracts",
21
+ "security-tools"
22
+ ],
23
+ "license": "MIT",
24
+ "author": "Jayesh Yadav",
25
+ "type": "module",
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "bin": {
30
+ "diamond-detect": "dist/cli.js"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsup",
39
+ "dev": "tsup --watch",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest",
42
+ "typecheck": "tsc --noEmit",
43
+ "prepublishOnly": "npm run build && npm test"
44
+ },
45
+ "dependencies": {
46
+ "commander": "^12.1.0",
47
+ "picocolors": "^1.1.1",
48
+ "@noble/hashes": "^1.7.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^20.11.0",
52
+ "tsup": "^8.3.5",
53
+ "typescript": "^5.6.3",
54
+ "vitest": "^2.1.8"
55
+ }
56
+ }