diamond-detect 0.1.1 → 0.2.1

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 (4) hide show
  1. package/README.md +38 -21
  2. package/dist/cli.js +466 -119
  3. package/dist/index.js +496 -44
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -8,7 +8,7 @@ npx diamond-detect .
8
8
 
9
9
  ## Why this tool exists
10
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.
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, whether by reusing a Diamond Storage namespace string, by hardcoding the same precomputed slot, by computing the same ERC-7201 namespace inline, by drifting AppStorage layouts, by reusing an EIP-7201 id, or by writing a literal slot directly in inline assembly, the result is silent corruption where one facet's writes overwrite another's data with no error and no revert.
12
12
 
13
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
14
 
@@ -60,23 +60,40 @@ This populates `out/` with artifact JSON files that include the AST and storage
60
60
  diamond-detect .
61
61
  ```
62
62
 
63
- If everything is fine you'll see:
63
+ If everything is fine you'll see a confirmation, plus every storage region the tool verified so you can see it actually inspected each one and that they all sit on distinct slots:
64
64
 
65
65
  ```
66
- scanned 12 contract artifact(s)
67
- ✓ no storage collisions detected
66
+ no storage collisions detected · 8 artifacts scanned
67
+
68
+ Verified 4 storage regions, each on its own slot:
69
+
70
+ • myapp.vaults erc7201 0x84d86c…b71bab LibVaults src/LibVaults.sol:12
71
+ • myapp.strategies namespace 0xa1b2c3…445566 LibStrategies src/LibStrategies.sol:9
72
+ • AAVE_STORAGE_SLOT precomputed 0x340080…215700 AaveFacet src/facets/AaveFacet.sol:28
73
+ • diamond.standard.diamond.storage namespace 0xc8fcad…2c131c LibDiamond src/libraries/LibDiamond.sol:8
74
+
75
+ Every facet keeps to its own namespace, and no two regions share a slot. Nicely done.
68
76
  ```
69
77
 
70
- If something is wrong you'll see one or more findings with the slot, the colliding contracts, and a hint at the cause:
78
+ If something is wrong you'll get one diagnostic per collision, with a code frame pointing at the exact line in every colliding file, the shared slot, and a hint at the cause:
71
79
 
72
80
  ```
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
81
+ error[diamond-storage-namespace]: Diamond Storage namespace "myapp.strategies" is declared in 2 different sources, all resolving to the same slot.
82
+ ╭─[src/LibStrategies.sol:5:5]
83
+
84
+ 5 │ bytes32 internal constant POSITION = keccak256("myapp.strategies");
85
+ · ────────────────────────────────────────────────────────────────── slot 0x84d86c…b71bab
86
+ ╰─
87
+ ╭─[src/LibVaults.sol:8:5]
88
+
89
+ 8 │ bytes32 internal constant POSITION = keccak256("myapp.strategies");
90
+ · ────────────────────────────────────────────────────────────────── same slot here
91
+ ╰─
92
+ = facets: LibStrategies, LibVaults
93
+ = slot: 0x84d86c34a05b71953e57fe7dafea685384b33934d9ddaebd0cf7709e74b71bab
94
+ = help: give every facet a unique storage seed; never reuse a namespace string, precomputed slot, or formula across facets
78
95
 
79
- 1 error(s), 0 warning(s)
96
+ 1 error · 2 artifacts scanned
80
97
  ```
81
98
 
82
99
  Exit code is `1` whenever a finding meets your `--severity` threshold (default `warn`), `0` otherwise, `2` on internal errors.
@@ -87,7 +104,7 @@ Run [`examples/`](./examples/) to see each one in action — every example ships
87
104
 
88
105
  | Kind | Severity | What it catches |
89
106
  |---|---|---|
90
- | `diamond-storage-namespace` | error | Two libraries declare `bytes32 constant POSITION = keccak256("...")` with the same string. ([01-namespace-collision](./examples/01-namespace-collision/)) |
107
+ | `diamond-storage-namespace` | error | Two facets resolve to the same Diamond Storage slot, whether the slot comes from `keccak256("...")`, a hardcoded precomputed literal (`bytes32 constant S = 0x..`), the inline ERC-7201 formula written without an annotation, or a direct `assembly { x.slot := <literal> }`. All four representations are compared in one space, so a literal in one facet that matches a formula or namespace in another is caught too. ([01-namespace-collision](./examples/01-namespace-collision/)) |
91
108
  | `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
109
  | `erc7201-namespace` | error | Two contracts annotate `@custom:storage-location erc7201:<id>` with the same id. ([03-erc7201-collision](./examples/03-erc7201-collision/)) |
93
110
  | `inheritance-overlap` | warn | Two facets have state at the same slot whose `(label, type)` differ — e.g. `Ownable._owner` vs `MyOwnable.owner`. |
@@ -145,7 +162,7 @@ diamond-detect <path> Foundry project root or src/ folder
145
162
 
146
163
  ## Output formats
147
164
 
148
- - **Terminal** (default): coloured, one block per finding, summary footer.
165
+ - **Terminal** (default): a code-frame diagnostic per collision that underlines the exact slot declaration in every colliding file, with `= facets / = slot / = help` notes and a coloured summary footer. A clean run instead lists every storage region it verified, with its slot and location, so you can confirm nothing was skipped. Colour is auto-disabled when the output is piped or running in CI.
149
166
  - **JSON** (`--json`): a stable shape suitable for piping into other tools.
150
167
 
151
168
  ```json
@@ -158,8 +175,8 @@ diamond-detect <path> Foundry project root or src/ folder
158
175
  "slot": "0x...",
159
176
  "message": "...",
160
177
  "facets": ["LibStrategies", "LibVaults"],
161
- "locations": [{ "file": "src/LibStrategies.sol" }],
162
- "detail": { "namespaces": ["myapp.strategies"], "declarations": [...] }
178
+ "locations": [{ "file": "src/LibStrategies.sol", "line": 5, "src": "120:54:0" }],
179
+ "detail": { "namespaces": ["myapp.strategies"], "variableNames": ["POSITION"], "declarations": [...] }
163
180
  }
164
181
  ]
165
182
  }
@@ -206,13 +223,13 @@ Tighten with `--severity error` if you only want to fail CI on hard collisions.
206
223
 
207
224
  ## Comparison
208
225
 
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 |
226
+ | Tool | Diamond Storage namespaces | Precomputed / inline-formula slots | EIP-7201 ids | AppStorage drift | Hardcoded assembly slots |
227
+ |---|---|---|---|---|---|
228
+ | Slither | partial, a general slot detector that is not Diamond-aware | no | no | no | partial, raw `sstore` only |
229
+ | Hand-audit / spreadsheet | yes, manually | error-prone by hand | yes, manually | hard to spot | yes, manually |
230
+ | `diamond-detect` | yes | yes | yes | yes | yes |
214
231
 
215
- Slither remains excellent for general Solidity static analysis. Use both.
232
+ Slither's storage layout does not model Diamond namespaced storage, which lives at hashed slots reached through assembly, so it cannot see a Diamond storage collision at all. It remains excellent for general Solidity static analysis, so run both.
216
233
 
217
234
  ## Roadmap
218
235
 
package/dist/cli.js CHANGED
@@ -72,6 +72,18 @@ function extractSourcePath(artifactPath, parsed) {
72
72
  }
73
73
  return path.basename(path.dirname(artifactPath));
74
74
  }
75
+ async function loadRawSources(inputPath, sourcePaths) {
76
+ const { root } = await resolveFoundryRoot(inputPath);
77
+ const out = /* @__PURE__ */ new Map();
78
+ for (const sourcePath of new Set(sourcePaths)) {
79
+ if (out.has(sourcePath)) continue;
80
+ try {
81
+ out.set(sourcePath, await fs.readFile(path.join(root, sourcePath), "utf8"));
82
+ } catch {
83
+ }
84
+ }
85
+ return out;
86
+ }
75
87
  async function loadFoundryArtifacts(inputPath, opts = {}) {
76
88
  const { outDir } = await resolveFoundryRoot(inputPath);
77
89
  if (!await fileExists(outDir)) {
@@ -105,55 +117,58 @@ async function loadFoundryArtifacts(inputPath, opts = {}) {
105
117
  return artifacts;
106
118
  }
107
119
 
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));
120
+ // src/detector/analyzers/diamondStorage.ts
121
+ import { keccak_256 as keccak_2562 } from "@noble/hashes/sha3";
122
+
123
+ // src/lib/eip7201.ts
124
+ import { keccak_256 } from "@noble/hashes/sha3";
125
+ var MASK_LAST_BYTE = (() => {
126
+ const m = new Uint8Array(32).fill(255);
127
+ m[31] = 0;
128
+ return m;
129
+ })();
130
+ function utf8(s) {
131
+ return new TextEncoder().encode(s);
130
132
  }
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));
133
+ function toHex(bytes) {
134
+ let out = "0x";
135
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
136
+ return out;
135
137
  }
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);
138
+ function subOne(bytes) {
139
+ const out = new Uint8Array(bytes);
140
+ for (let i = out.length - 1; i >= 0; i--) {
141
+ if (out[i] > 0) {
142
+ out[i] = out[i] - 1;
143
+ return out;
144
+ }
145
+ out[i] = 255;
149
146
  }
150
- return { artifacts, findings };
147
+ return out;
148
+ }
149
+ function maskLastByte(bytes) {
150
+ const out = new Uint8Array(32);
151
+ for (let i = 0; i < 32; i++) out[i] = bytes[i] & MASK_LAST_BYTE[i];
152
+ return out;
153
+ }
154
+ function erc7201Slot(namespaceId) {
155
+ const inner = keccak_256(utf8(namespaceId));
156
+ const decremented = subOne(inner);
157
+ const outer = keccak_256(decremented);
158
+ return toHex(maskLastByte(outer));
159
+ }
160
+ var ERC7201_PREFIX = "erc7201:";
161
+ function parseErc7201Annotation(text) {
162
+ const idx = text.indexOf(ERC7201_PREFIX);
163
+ if (idx === -1) return null;
164
+ const rest = text.slice(idx + ERC7201_PREFIX.length);
165
+ const match = rest.match(/^[A-Za-z0-9_.\-]+/);
166
+ return match ? match[0] : null;
151
167
  }
152
168
 
153
169
  // src/detector/analyzers/diamondStorage.ts
154
- import { keccak_256 } from "@noble/hashes/sha3";
155
170
  function keccak256Hex(input) {
156
- const bytes = keccak_256(new TextEncoder().encode(input));
171
+ const bytes = keccak_2562(new TextEncoder().encode(input));
157
172
  let out = "0x";
158
173
  for (const b of bytes) out += b.toString(16).padStart(2, "0");
159
174
  return out;
@@ -176,6 +191,61 @@ function extractKeccakStringArg(value) {
176
191
  if (arg.nodeType !== "Literal" || arg.kind !== "string") return null;
177
192
  return typeof arg.value === "string" ? arg.value : null;
178
193
  }
194
+ function extractBytes32HexLiteral(value) {
195
+ if (!value || typeof value !== "object") return null;
196
+ const v = value;
197
+ if (v.nodeType !== "Literal" || v.kind !== "number") return null;
198
+ if (typeof v.value !== "string") return null;
199
+ try {
200
+ const big = BigInt(v.value);
201
+ if (big < 0n) return null;
202
+ return "0x" + big.toString(16).padStart(64, "0");
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+ function findKeccakStringArg(node) {
208
+ if (!node || typeof node !== "object") return null;
209
+ const v = node;
210
+ if (v.nodeType !== "FunctionCall") return null;
211
+ const expr = v.expression;
212
+ if (expr?.nodeType !== "Identifier" || expr.name !== "keccak256") return null;
213
+ const args = v.arguments;
214
+ if (!Array.isArray(args) || args.length !== 1) return null;
215
+ const arg = args[0];
216
+ if (arg.nodeType !== "Literal" || arg.kind !== "string") return null;
217
+ return typeof arg.value === "string" ? arg.value : null;
218
+ }
219
+ function someNode(node, pred) {
220
+ if (!node || typeof node !== "object") return false;
221
+ const n = node;
222
+ if (typeof n.nodeType === "string" && pred(n)) return true;
223
+ for (const key of Object.keys(n)) {
224
+ const value = n[key];
225
+ if (Array.isArray(value)) {
226
+ if (value.some((c) => someNode(c, pred))) return true;
227
+ } else if (value && typeof value === "object") {
228
+ if (someNode(value, pred)) return true;
229
+ }
230
+ }
231
+ return false;
232
+ }
233
+ function extractErc7201FormulaNamespace(value) {
234
+ const strings = [];
235
+ someNode(value, (n) => {
236
+ const s = findKeccakStringArg(n);
237
+ if (s !== null) strings.push(s);
238
+ return false;
239
+ });
240
+ if (strings.length !== 1) return null;
241
+ const hasSubOne = someNode(
242
+ value,
243
+ (n) => n.nodeType === "BinaryOperation" && n.operator === "-" && n.rightExpression?.nodeType === "Literal" && n.rightExpression.value === "1"
244
+ );
245
+ const hasMask = someNode(value, (n) => n.nodeType === "BinaryOperation" && n.operator === "&");
246
+ if (!hasSubOne || !hasMask) return null;
247
+ return strings[0];
248
+ }
179
249
  function lineFromSrc(src, sourceText) {
180
250
  if (typeof src !== "string" || !sourceText) return void 0;
181
251
  const [startStr] = src.split(":");
@@ -237,6 +307,105 @@ function collectSlotConstants(ctx) {
237
307
  }
238
308
  return out;
239
309
  }
310
+ function collectLiteralSlotConstants(ctx) {
311
+ const seen = /* @__PURE__ */ new Set();
312
+ const out = [];
313
+ for (const artifact of ctx.artifacts) {
314
+ if (!artifact.ast) continue;
315
+ walkAst(artifact.ast, (node, parents) => {
316
+ if (!isBytes32Constant(node)) return;
317
+ if (extractKeccakStringArg(node.value) !== null) return;
318
+ const slot = extractBytes32HexLiteral(node.value);
319
+ if (slot === null) return;
320
+ const declarationId = typeof node.id === "number" ? node.id : -1;
321
+ const variableName = node.name ?? "<anon>";
322
+ const contract = declaringContract(parents) ?? artifact.contractName;
323
+ const src = node.src;
324
+ const dedupeKey = `${artifact.sourcePath}::${contract}::${variableName}::${src ?? ""}`;
325
+ if (seen.has(dedupeKey)) return;
326
+ seen.add(dedupeKey);
327
+ out.push({
328
+ declarationId,
329
+ variableName,
330
+ namespace: null,
331
+ slot,
332
+ contract,
333
+ sourcePath: artifact.sourcePath,
334
+ src
335
+ });
336
+ });
337
+ }
338
+ return out;
339
+ }
340
+ function collectFormulaSlotConstants(ctx) {
341
+ const seen = /* @__PURE__ */ new Set();
342
+ const out = [];
343
+ for (const artifact of ctx.artifacts) {
344
+ if (!artifact.ast) continue;
345
+ walkAst(artifact.ast, (node, parents) => {
346
+ if (!isBytes32Constant(node)) return;
347
+ if (extractKeccakStringArg(node.value) !== null) return;
348
+ const namespace = extractErc7201FormulaNamespace(node.value);
349
+ if (namespace === null) return;
350
+ const declarationId = typeof node.id === "number" ? node.id : -1;
351
+ const variableName = node.name ?? "<anon>";
352
+ const contract = declaringContract(parents) ?? artifact.contractName;
353
+ const src = node.src;
354
+ const dedupeKey = `${artifact.sourcePath}::${contract}::${variableName}::${src ?? ""}`;
355
+ if (seen.has(dedupeKey)) return;
356
+ seen.add(dedupeKey);
357
+ out.push({
358
+ declarationId,
359
+ variableName,
360
+ namespace,
361
+ slot: erc7201Slot(namespace),
362
+ contract,
363
+ sourcePath: artifact.sourcePath,
364
+ src
365
+ });
366
+ });
367
+ }
368
+ return out;
369
+ }
370
+ function collectAssemblyLiteralSlots(artifacts) {
371
+ const seen = /* @__PURE__ */ new Set();
372
+ const out = [];
373
+ for (const artifact of artifacts) {
374
+ if (!artifact.ast) continue;
375
+ walkAst(artifact.ast, (node, parents) => {
376
+ if (node.nodeType !== "YulAssignment") return;
377
+ const targets = node.variableNames;
378
+ const targetsSlot = Array.isArray(targets) && targets.some((t) => typeof t.name === "string" && t.name.endsWith(".slot"));
379
+ if (!targetsSlot) return;
380
+ const value = node.value;
381
+ if (value?.nodeType !== "YulLiteral" || value.kind !== "number") return;
382
+ if (typeof value.value !== "string") return;
383
+ let slot;
384
+ try {
385
+ const big = BigInt(value.value);
386
+ if (big < 0n) return;
387
+ slot = "0x" + big.toString(16).padStart(64, "0");
388
+ } catch {
389
+ return;
390
+ }
391
+ const contract = declaringContract(parents) ?? artifact.contractName;
392
+ const src = node.src;
393
+ const dedupeKey = `${artifact.sourcePath}::${contract}::${slot}::${src ?? ""}`;
394
+ if (seen.has(dedupeKey)) return;
395
+ seen.add(dedupeKey);
396
+ out.push({
397
+ declarationId: -1,
398
+ variableName: "<assembly literal>",
399
+ namespace: null,
400
+ slot,
401
+ contract,
402
+ sourcePath: artifact.sourcePath,
403
+ src
404
+ });
405
+ });
406
+ }
407
+ return out;
408
+ }
240
409
  function collectSlotAssignmentValueSrcs(yulNode, srcs) {
241
410
  if (!yulNode || typeof yulNode !== "object") return;
242
411
  const node = yulNode;
@@ -308,15 +477,23 @@ function isUsedAsSlot(constantId, slotUsedIds, aliases) {
308
477
  }
309
478
  return false;
310
479
  }
480
+ function collectGatedSlotConstants(ctx) {
481
+ const constants = [
482
+ ...collectSlotConstants(ctx),
483
+ ...collectLiteralSlotConstants(ctx),
484
+ ...collectFormulaSlotConstants(ctx)
485
+ ];
486
+ const slotUsedIds = collectSlotUsedDeclarationIds(ctx.artifacts);
487
+ const aliases = collectAliases(ctx.artifacts);
488
+ return [
489
+ ...constants.filter((c) => isUsedAsSlot(c.declarationId, slotUsedIds, aliases)),
490
+ ...collectAssemblyLiteralSlots(ctx.artifacts)
491
+ ];
492
+ }
311
493
  var diamondStorageAnalyzer = {
312
494
  name: "diamond-storage-namespace",
313
495
  run(ctx) {
314
- const constants = collectSlotConstants(ctx);
315
- const slotUsedIds = collectSlotUsedDeclarationIds(ctx.artifacts);
316
- const aliases = collectAliases(ctx.artifacts);
317
- const slotConstants = constants.filter(
318
- (c) => isUsedAsSlot(c.declarationId, slotUsedIds, aliases)
319
- );
496
+ const slotConstants = collectGatedSlotConstants(ctx);
320
497
  const bySlot = /* @__PURE__ */ new Map();
321
498
  for (const c of slotConstants) {
322
499
  const list = bySlot.get(c.slot) ?? [];
@@ -327,72 +504,38 @@ var diamondStorageAnalyzer = {
327
504
  for (const [slot, group] of bySlot) {
328
505
  const distinctSources = new Set(group.map((g) => g.sourcePath));
329
506
  if (distinctSources.size < 2) continue;
330
- const namespaces = Array.from(new Set(group.map((g) => g.namespace)));
507
+ const namespaces = Array.from(
508
+ new Set(group.map((g) => g.namespace).filter((n) => n !== null))
509
+ );
510
+ const variableNames = Array.from(new Set(group.map((g) => g.variableName)));
511
+ const hasLiteral = group.some((g) => g.namespace === null);
331
512
  const facets = Array.from(new Set(group.map((g) => g.contract)));
332
513
  const locations = group.map((g) => {
333
514
  const sourceText = ctx.rawSources.get(g.sourcePath);
334
- return { file: g.sourcePath, line: lineFromSrc(g.src, sourceText) };
515
+ return { file: g.sourcePath, line: lineFromSrc(g.src, sourceText), src: g.src };
335
516
  });
517
+ let message;
518
+ if (!hasLiteral) {
519
+ 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.`;
520
+ } else if (namespaces.length === 0) {
521
+ message = `Hardcoded storage slot ${slot} is used as a Diamond Storage pointer by ${distinctSources.size} different sources (${variableNames.join(", ")}), so distinct facets share the same slot.`;
522
+ } else {
523
+ message = `Hardcoded slot ${slot} collides with namespace(s) ${namespaces.map((n) => `"${n}"`).join(", ")} \u2014 they resolve to the same storage slot.`;
524
+ }
336
525
  findings.push({
337
526
  kind: "diamond-storage-namespace",
338
527
  severity: "error",
339
528
  slot,
340
- 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.`,
529
+ message,
341
530
  facets,
342
531
  locations,
343
- detail: { namespaces, declarations: group }
532
+ detail: { namespaces, variableNames, declarations: group }
344
533
  });
345
534
  }
346
535
  return findings;
347
536
  }
348
537
  };
349
538
 
350
- // src/lib/eip7201.ts
351
- import { keccak_256 as keccak_2562 } from "@noble/hashes/sha3";
352
- var MASK_LAST_BYTE = (() => {
353
- const m = new Uint8Array(32).fill(255);
354
- m[31] = 0;
355
- return m;
356
- })();
357
- function utf8(s) {
358
- return new TextEncoder().encode(s);
359
- }
360
- function toHex(bytes) {
361
- let out = "0x";
362
- for (const b of bytes) out += b.toString(16).padStart(2, "0");
363
- return out;
364
- }
365
- function subOne(bytes) {
366
- const out = new Uint8Array(bytes);
367
- for (let i = out.length - 1; i >= 0; i--) {
368
- if (out[i] > 0) {
369
- out[i] = out[i] - 1;
370
- return out;
371
- }
372
- out[i] = 255;
373
- }
374
- return out;
375
- }
376
- function maskLastByte(bytes) {
377
- const out = new Uint8Array(32);
378
- for (let i = 0; i < 32; i++) out[i] = bytes[i] & MASK_LAST_BYTE[i];
379
- return out;
380
- }
381
- function erc7201Slot(namespaceId) {
382
- const inner = keccak_2562(utf8(namespaceId));
383
- const decremented = subOne(inner);
384
- const outer = keccak_2562(decremented);
385
- return toHex(maskLastByte(outer));
386
- }
387
- var ERC7201_PREFIX = "erc7201:";
388
- function parseErc7201Annotation(text) {
389
- const idx = text.indexOf(ERC7201_PREFIX);
390
- if (idx === -1) return null;
391
- const rest = text.slice(idx + ERC7201_PREFIX.length);
392
- const match = rest.match(/^[A-Za-z0-9_.\-]+/);
393
- return match ? match[0] : null;
394
- }
395
-
396
539
  // src/detector/analyzers/erc7201.ts
397
540
  var NAMED_NODE_TYPES = /* @__PURE__ */ new Set([
398
541
  "ContractDefinition",
@@ -480,7 +623,7 @@ var erc7201Analyzer = {
480
623
  if (distinctSources.size < 2) continue;
481
624
  const ids = Array.from(new Set(group.map((g) => g.namespaceId)));
482
625
  const facets = Array.from(new Set(group.map((g) => g.contract)));
483
- const locations = group.map((g) => ({ file: g.sourcePath }));
626
+ const locations = group.map((g) => ({ file: g.sourcePath, src: g.src }));
484
627
  findings.push({
485
628
  kind: "erc7201-namespace",
486
629
  severity: "error",
@@ -495,6 +638,102 @@ var erc7201Analyzer = {
495
638
  }
496
639
  };
497
640
 
641
+ // src/detector/inventory.ts
642
+ function lineOf(src, text) {
643
+ if (!src || !text) return void 0;
644
+ const [startStr] = src.split(":");
645
+ const start = Number(startStr);
646
+ if (!Number.isFinite(start)) return void 0;
647
+ let line = 1;
648
+ for (let i = 0; i < start && i < text.length; i++) {
649
+ if (text.charCodeAt(i) === 10) line++;
650
+ }
651
+ return line;
652
+ }
653
+ var PRIORITY = { erc7201: 0, namespace: 1, hardcoded: 2 };
654
+ function buildInventory(ctx) {
655
+ const bySlot = /* @__PURE__ */ new Map();
656
+ const add = (region) => {
657
+ const existing = bySlot.get(region.slot);
658
+ if (!existing || PRIORITY[region.kind] < PRIORITY[existing.kind]) {
659
+ bySlot.set(region.slot, region);
660
+ }
661
+ };
662
+ for (const c of collectGatedSlotConstants(ctx)) {
663
+ add({
664
+ slot: c.slot,
665
+ label: c.namespace ?? c.variableName,
666
+ kind: c.namespace ? "namespace" : "hardcoded",
667
+ contract: c.contract,
668
+ file: c.sourcePath,
669
+ line: lineOf(c.src, ctx.rawSources.get(c.sourcePath))
670
+ });
671
+ }
672
+ for (const a of collectErc7201Annotations(ctx.artifacts)) {
673
+ add({
674
+ slot: a.slot,
675
+ label: a.namespaceId,
676
+ kind: "erc7201",
677
+ contract: a.contract,
678
+ file: a.sourcePath,
679
+ line: lineOf(a.src, ctx.rawSources.get(a.sourcePath))
680
+ });
681
+ }
682
+ return [...bySlot.values()].sort(
683
+ (x, y) => x.file.localeCompare(y.file) || x.slot.localeCompare(y.slot)
684
+ );
685
+ }
686
+
687
+ // src/detector/index.ts
688
+ var DEFAULT_IGNORE_GLOBS = [
689
+ "lib/**",
690
+ "test/**",
691
+ "script/**",
692
+ "**/*.t.sol",
693
+ "**/*.s.sol"
694
+ ];
695
+ function compilePatterns(globs) {
696
+ return globs.map((g) => {
697
+ const escaped = g.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "::DOUBLESTAR::").replace(/\*/g, "[^/]*").replace(/::DOUBLESTAR::/g, ".*").replace(/\?/g, ".");
698
+ return new RegExp(`^${escaped}$`);
699
+ });
700
+ }
701
+ function buildIgnore(userGlobs, noDefault) {
702
+ const globs = [
703
+ ...noDefault ? [] : DEFAULT_IGNORE_GLOBS,
704
+ ...userGlobs ?? []
705
+ ];
706
+ if (globs.length === 0) return void 0;
707
+ const patterns = compilePatterns(globs);
708
+ return (sourcePath) => patterns.some((p) => p.test(sourcePath));
709
+ }
710
+ function buildIsFacet(globs) {
711
+ if (!globs || globs.length === 0) return void 0;
712
+ const patterns = compilePatterns(globs);
713
+ return (artifact) => patterns.some((p) => p.test(artifact.sourcePath));
714
+ }
715
+ async function detect(options, analyzers) {
716
+ const artifacts = await loadFoundryArtifacts(options.path, {
717
+ ignoreSourcePath: buildIgnore(options.ignoreGlobs, options.noDefaultIgnore)
718
+ });
719
+ const rawSources = await loadRawSources(
720
+ options.path,
721
+ artifacts.map((a) => a.sourcePath)
722
+ );
723
+ const ctx = {
724
+ artifacts,
725
+ rawSources,
726
+ isFacet: buildIsFacet(options.facetGlobs)
727
+ };
728
+ const findings = [];
729
+ for (const analyzer of analyzers) {
730
+ const out = await analyzer.run(ctx);
731
+ findings.push(...out);
732
+ }
733
+ const inventory = buildInventory(ctx);
734
+ return { artifacts, findings, rawSources, inventory };
735
+ }
736
+
498
737
  // src/detector/analyzers/appStorage.ts
499
738
  function memberFingerprint(m) {
500
739
  return { label: m.label, offset: m.offset, slot: m.slot, type: m.type };
@@ -659,7 +898,7 @@ var inlineAssemblyAnalyzer = {
659
898
  slot: lit.slot,
660
899
  message: `inline assembly writes to a hardcoded slot (sstore(${lit.rawValue}, \u2026)) \u2014 confirm no overlap with computed storage slots.`,
661
900
  facets: [lit.artifact.contractName],
662
- locations: [{ file: lit.artifact.sourcePath }],
901
+ locations: [{ file: lit.artifact.sourcePath, src: lit.src }],
663
902
  detail: { rawValue: lit.rawValue, src: lit.src }
664
903
  }));
665
904
  }
@@ -750,37 +989,140 @@ var defaultAnalyzers = [
750
989
  // src/reporter/terminal.ts
751
990
  import pc from "picocolors";
752
991
  var SEVERITY_RANK = { info: 0, warn: 1, error: 2 };
753
- function colorSeverity(sev) {
754
- if (sev === "error") return pc.red(pc.bold("ERROR"));
755
- if (sev === "warn") return pc.yellow("WARN ");
756
- return pc.cyan("INFO ");
992
+ var SEV = {
993
+ error: { label: "error", glyph: "\u2716", paint: (s) => pc.red(s) },
994
+ warn: { label: "warning", glyph: "\u26A0", paint: (s) => pc.yellow(s) },
995
+ info: { label: "note", glyph: "\u25CF", paint: (s) => pc.cyan(s) }
996
+ };
997
+ var HELP = {
998
+ "diamond-storage-namespace": "give every facet a unique storage seed; never reuse a namespace string, precomputed slot, or formula across facets",
999
+ "erc7201-namespace": "use a distinct erc7201 namespace id per facet",
1000
+ "appstorage-fingerprint": "keep the shared struct layout identical across all facets; append fields, never reorder or insert",
1001
+ "inheritance-overlap": "facets must not declare sequential state variables; move state into namespaced Diamond Storage",
1002
+ "inline-assembly-slot": "confirm this hardcoded slot cannot overlap any namespaced storage region",
1003
+ "mapping-overlap": "ensure mapping base slots are derived from distinct, collision-resistant seeds"
1004
+ };
1005
+ function shortSlot(slot) {
1006
+ if (!slot.startsWith("0x") || slot.length <= 14) return slot;
1007
+ return `${slot.slice(0, 8)}\u2026${slot.slice(-6)}`;
1008
+ }
1009
+ function expandTabs(s) {
1010
+ return s.replace(/\t/g, " ");
1011
+ }
1012
+ function resolveSpan(loc, sourceText) {
1013
+ if (!sourceText || !loc.src) return null;
1014
+ const [offStr, lenStr] = loc.src.split(":");
1015
+ const offset = Number(offStr);
1016
+ const length = Number(lenStr);
1017
+ if (!Number.isFinite(offset) || offset < 0 || offset > sourceText.length) return null;
1018
+ let line = 1;
1019
+ let lineStart = 0;
1020
+ for (let i = 0; i < offset; i++) {
1021
+ if (sourceText.charCodeAt(i) === 10) {
1022
+ line++;
1023
+ lineStart = i + 1;
1024
+ }
1025
+ }
1026
+ const nl = sourceText.indexOf("\n", lineStart);
1027
+ const lineEnd = nl === -1 ? sourceText.length : nl;
1028
+ const rawLine = sourceText.slice(lineStart, lineEnd);
1029
+ const rawPrefix = sourceText.slice(lineStart, offset);
1030
+ const column = expandTabs(rawPrefix).length + 1;
1031
+ const visibleLen = expandTabs(rawLine).length;
1032
+ const rawCaret = Number.isFinite(length) && length > 0 ? length : 1;
1033
+ const caretLen = Math.max(1, Math.min(rawCaret, visibleLen - (column - 1)));
1034
+ return { line, column, lineText: expandTabs(rawLine), caretLen };
1035
+ }
1036
+ function renderFrame(loc, span, sev, note) {
1037
+ const gutter = String(span.line);
1038
+ const pad = " ".repeat(gutter.length);
1039
+ const bar = pc.dim("\u2502");
1040
+ const arrow = pc.dim("\u256D\u2500[");
1041
+ const close = pc.dim("]");
1042
+ const caret = sev.paint("\u2500".repeat(span.caretLen));
1043
+ const caretPad = " ".repeat(span.column - 1);
1044
+ const label = note ? " " + sev.paint(note) : "";
1045
+ return [
1046
+ ` ${arrow}${pc.cyan(`${loc.file}:${span.line}:${span.column}`)}${close}`,
1047
+ ` ${pad} ${bar}`,
1048
+ ` ${pc.dim(gutter)} ${bar} ${span.lineText}`,
1049
+ ` ${pad} ${pc.dim("\xB7")} ${caretPad}${caret}${label}`,
1050
+ ` ${pad} ${pc.dim("\u2570\u2500")}`
1051
+ ];
757
1052
  }
758
- function renderTerminal(findings, facetCount) {
1053
+ var KIND_TAG = {
1054
+ erc7201: "erc7201",
1055
+ namespace: "namespace",
1056
+ hardcoded: "precomputed"
1057
+ };
1058
+ function renderInventory(inventory) {
759
1059
  const lines = [];
760
- lines.push(pc.dim(`scanned ${facetCount} contract artifact(s)`));
761
- if (findings.length === 0) {
762
- lines.push(pc.green("\u2713 no storage collisions detected"));
763
- return lines.join("\n");
1060
+ const shown = inventory.slice(0, 60);
1061
+ const labelW = Math.max(...shown.map((r) => r.label.length), 0);
1062
+ const tagW = Math.max(...shown.map((r) => KIND_TAG[r.kind].length), 0);
1063
+ const contractW = Math.max(...shown.map((r) => r.contract.length), 0);
1064
+ lines.push("");
1065
+ lines.push(
1066
+ pc.bold(`Verified ${inventory.length} storage region${inventory.length === 1 ? "" : "s"}`) + pc.dim(", each on its own slot:")
1067
+ );
1068
+ lines.push("");
1069
+ for (const r of shown) {
1070
+ const where = r.line ? `${r.file}:${r.line}` : r.file;
1071
+ lines.push(
1072
+ ` ${pc.green("\u2022")} ${pc.green(r.label.padEnd(labelW))} ${pc.dim(KIND_TAG[r.kind].padEnd(tagW))} ${pc.cyan(shortSlot(r.slot))} ${r.contract.padEnd(contractW)} ${pc.dim(where)}`
1073
+ );
1074
+ }
1075
+ if (inventory.length > shown.length) {
1076
+ lines.push(` ${pc.dim(`\u2026 and ${inventory.length - shown.length} more`)}`);
764
1077
  }
765
- const sorted = [...findings].sort(
766
- (a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]
1078
+ lines.push("");
1079
+ lines.push(
1080
+ pc.green("Every facet keeps to its own namespace, and no two regions share a slot. ") + pc.green(pc.bold("Nicely done."))
767
1081
  );
1082
+ return lines;
1083
+ }
1084
+ function renderTerminal(findings, facetCount, rawSources, inventory = []) {
1085
+ const artifactsNote = pc.dim(`${facetCount} artifact${facetCount === 1 ? "" : "s"} scanned`);
1086
+ if (findings.length === 0) {
1087
+ const header = `${pc.green(pc.bold("\u2714 no storage collisions detected"))} ${pc.dim("\xB7")} ${artifactsNote}`;
1088
+ if (inventory.length === 0) return header;
1089
+ return [header, ...renderInventory(inventory)].join("\n");
1090
+ }
1091
+ const sorted = [...findings].sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]);
1092
+ const lines = [];
768
1093
  for (const f of sorted) {
1094
+ const sev = SEV[f.severity];
1095
+ const tag = `${pc.bold(sev.paint(sev.label))}${pc.dim(`[${f.kind}]`)}`;
769
1096
  lines.push("");
770
- lines.push(`${colorSeverity(f.severity)} ${pc.bold(f.kind)} ${pc.dim(f.slot)}`);
771
- lines.push(` ${f.message}`);
1097
+ lines.push(`${tag}: ${pc.bold(f.message)}`);
1098
+ f.locations.forEach((loc, i) => {
1099
+ const span = resolveSpan(loc, rawSources?.get(loc.file));
1100
+ const note = i === 0 ? `slot ${shortSlot(f.slot)}` : "same slot here";
1101
+ if (span) {
1102
+ lines.push(...renderFrame(loc, span, sev, f.slot === "n/a" ? "here" : note));
1103
+ } else {
1104
+ const where = loc.line ? `${loc.file}:${loc.line}` : loc.file;
1105
+ lines.push(` ${pc.dim("\u256D\u2500[")}${pc.cyan(where)}${pc.dim("]")}`);
1106
+ }
1107
+ });
772
1108
  if (f.facets.length > 0) {
773
- lines.push(` ${pc.dim("facets:")} ${f.facets.join(", ")}`);
1109
+ lines.push(` ${pc.dim("= facets:")} ${f.facets.join(pc.dim(", "))}`);
774
1110
  }
775
- for (const loc of f.locations) {
776
- const where = loc.line ? `${loc.file}:${loc.line}` : loc.file;
777
- lines.push(` ${pc.dim("at")} ${where}`);
1111
+ if (f.slot && f.slot !== "n/a") {
1112
+ lines.push(` ${pc.dim("= slot: ")} ${pc.dim(f.slot)}`);
778
1113
  }
1114
+ lines.push(` ${pc.dim("= help: ")} ${pc.dim(HELP[f.kind] ?? "")}`);
779
1115
  }
780
1116
  const errors = findings.filter((f) => f.severity === "error").length;
781
1117
  const warns = findings.filter((f) => f.severity === "warn").length;
1118
+ const notes = findings.filter((f) => f.severity === "info").length;
1119
+ const parts = [];
1120
+ if (errors > 0) parts.push(pc.red(`${SEV.error.glyph} ${errors} error${errors === 1 ? "" : "s"}`));
1121
+ if (warns > 0)
1122
+ parts.push(pc.yellow(`${SEV.warn.glyph} ${warns} warning${warns === 1 ? "" : "s"}`));
1123
+ if (notes > 0) parts.push(pc.cyan(`${SEV.info.glyph} ${notes} note${notes === 1 ? "" : "s"}`));
782
1124
  lines.push("");
783
- lines.push(pc.bold(`${errors} error(s), ${warns} warning(s)`));
1125
+ lines.push(`${parts.join(pc.dim(" \xB7 "))} ${pc.dim("\xB7")} ${artifactsNote}`);
784
1126
  return lines.join("\n");
785
1127
  }
786
1128
 
@@ -910,7 +1252,12 @@ async function run(target, opts) {
910
1252
  )
911
1253
  );
912
1254
  }
913
- const output = opts.json ? renderJson(result.findings, result.artifacts.length) : opts.markdown ? renderMarkdown(result.findings, result.artifacts.length) : renderTerminal(result.findings, result.artifacts.length);
1255
+ const output = opts.json ? renderJson(result.findings, result.artifacts.length) : opts.markdown ? renderMarkdown(result.findings, result.artifacts.length) : renderTerminal(
1256
+ result.findings,
1257
+ result.artifacts.length,
1258
+ result.rawSources,
1259
+ result.inventory
1260
+ );
914
1261
  process.stdout.write(output + "\n");
915
1262
  const threshold = SEVERITY_RANK3[opts.severity];
916
1263
  const hit = result.findings.some((f) => SEVERITY_RANK3[f.severity] >= threshold);
package/dist/index.js CHANGED
@@ -68,6 +68,18 @@ function extractSourcePath(artifactPath, parsed) {
68
68
  }
69
69
  return path.basename(path.dirname(artifactPath));
70
70
  }
71
+ async function loadRawSources(inputPath, sourcePaths) {
72
+ const { root } = await resolveFoundryRoot(inputPath);
73
+ const out = /* @__PURE__ */ new Map();
74
+ for (const sourcePath of new Set(sourcePaths)) {
75
+ if (out.has(sourcePath)) continue;
76
+ try {
77
+ out.set(sourcePath, await fs.readFile(path.join(root, sourcePath), "utf8"));
78
+ } catch {
79
+ }
80
+ }
81
+ return out;
82
+ }
71
83
  async function loadFoundryArtifacts(inputPath, opts = {}) {
72
84
  const { outDir } = await resolveFoundryRoot(inputPath);
73
85
  if (!await fileExists(outDir)) {
@@ -101,50 +113,8 @@ async function loadFoundryArtifacts(inputPath, opts = {}) {
101
113
  return artifacts;
102
114
  }
103
115
 
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
- }
116
+ // src/detector/analyzers/diamondStorage.ts
117
+ import { keccak_256 as keccak_2562 } from "@noble/hashes/sha3";
148
118
 
149
119
  // src/lib/eip7201.ts
150
120
  import { keccak_256 } from "@noble/hashes/sha3";
@@ -191,6 +161,488 @@ function parseErc7201Annotation(text) {
191
161
  const match = rest.match(/^[A-Za-z0-9_.\-]+/);
192
162
  return match ? match[0] : null;
193
163
  }
164
+
165
+ // src/detector/analyzers/diamondStorage.ts
166
+ function keccak256Hex(input) {
167
+ const bytes = keccak_2562(new TextEncoder().encode(input));
168
+ let out = "0x";
169
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
170
+ return out;
171
+ }
172
+ function isBytes32Constant(node) {
173
+ if (node.nodeType !== "VariableDeclaration") return false;
174
+ if (node.constant !== true) return false;
175
+ const typeName = node.typeName;
176
+ return typeName?.nodeType === "ElementaryTypeName" && typeName.name === "bytes32";
177
+ }
178
+ function extractKeccakStringArg(value) {
179
+ if (!value || typeof value !== "object") return null;
180
+ const v = value;
181
+ if (v.nodeType !== "FunctionCall") return null;
182
+ const expr = v.expression;
183
+ if (expr?.nodeType !== "Identifier" || expr.name !== "keccak256") return null;
184
+ const args = v.arguments;
185
+ if (!Array.isArray(args) || args.length !== 1) return null;
186
+ const arg = args[0];
187
+ if (arg.nodeType !== "Literal" || arg.kind !== "string") return null;
188
+ return typeof arg.value === "string" ? arg.value : null;
189
+ }
190
+ function extractBytes32HexLiteral(value) {
191
+ if (!value || typeof value !== "object") return null;
192
+ const v = value;
193
+ if (v.nodeType !== "Literal" || v.kind !== "number") return null;
194
+ if (typeof v.value !== "string") return null;
195
+ try {
196
+ const big = BigInt(v.value);
197
+ if (big < 0n) return null;
198
+ return "0x" + big.toString(16).padStart(64, "0");
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+ function findKeccakStringArg(node) {
204
+ if (!node || typeof node !== "object") return null;
205
+ const v = node;
206
+ if (v.nodeType !== "FunctionCall") return null;
207
+ const expr = v.expression;
208
+ if (expr?.nodeType !== "Identifier" || expr.name !== "keccak256") return null;
209
+ const args = v.arguments;
210
+ if (!Array.isArray(args) || args.length !== 1) return null;
211
+ const arg = args[0];
212
+ if (arg.nodeType !== "Literal" || arg.kind !== "string") return null;
213
+ return typeof arg.value === "string" ? arg.value : null;
214
+ }
215
+ function someNode(node, pred) {
216
+ if (!node || typeof node !== "object") return false;
217
+ const n = node;
218
+ if (typeof n.nodeType === "string" && pred(n)) return true;
219
+ for (const key of Object.keys(n)) {
220
+ const value = n[key];
221
+ if (Array.isArray(value)) {
222
+ if (value.some((c) => someNode(c, pred))) return true;
223
+ } else if (value && typeof value === "object") {
224
+ if (someNode(value, pred)) return true;
225
+ }
226
+ }
227
+ return false;
228
+ }
229
+ function extractErc7201FormulaNamespace(value) {
230
+ const strings = [];
231
+ someNode(value, (n) => {
232
+ const s = findKeccakStringArg(n);
233
+ if (s !== null) strings.push(s);
234
+ return false;
235
+ });
236
+ if (strings.length !== 1) return null;
237
+ const hasSubOne = someNode(
238
+ value,
239
+ (n) => n.nodeType === "BinaryOperation" && n.operator === "-" && n.rightExpression?.nodeType === "Literal" && n.rightExpression.value === "1"
240
+ );
241
+ const hasMask = someNode(value, (n) => n.nodeType === "BinaryOperation" && n.operator === "&");
242
+ if (!hasSubOne || !hasMask) return null;
243
+ return strings[0];
244
+ }
245
+ function walkAst(ast, visit, parents = []) {
246
+ if (!ast || typeof ast !== "object") return;
247
+ const node = ast;
248
+ if (typeof node.nodeType === "string") visit(node, parents);
249
+ const nextParents = typeof node.nodeType === "string" ? [...parents, node] : parents;
250
+ for (const key of Object.keys(node)) {
251
+ const value = node[key];
252
+ if (Array.isArray(value)) {
253
+ for (const child of value) walkAst(child, visit, nextParents);
254
+ } else if (value && typeof value === "object") {
255
+ walkAst(value, visit, nextParents);
256
+ }
257
+ }
258
+ }
259
+ function declaringContract(parents) {
260
+ for (let i = parents.length - 1; i >= 0; i--) {
261
+ const p = parents[i];
262
+ if (p.nodeType === "ContractDefinition") return p.name ?? null;
263
+ }
264
+ return null;
265
+ }
266
+ function collectSlotConstants(ctx) {
267
+ const seen = /* @__PURE__ */ new Set();
268
+ const out = [];
269
+ for (const artifact of ctx.artifacts) {
270
+ if (!artifact.ast) continue;
271
+ walkAst(artifact.ast, (node, parents) => {
272
+ if (!isBytes32Constant(node)) return;
273
+ const namespace = extractKeccakStringArg(node.value);
274
+ if (namespace === null) return;
275
+ const declarationId = typeof node.id === "number" ? node.id : -1;
276
+ const variableName = node.name ?? "<anon>";
277
+ const contract = declaringContract(parents) ?? artifact.contractName;
278
+ const src = node.src;
279
+ const dedupeKey = `${artifact.sourcePath}::${contract}::${variableName}::${src ?? ""}`;
280
+ if (seen.has(dedupeKey)) return;
281
+ seen.add(dedupeKey);
282
+ out.push({
283
+ declarationId,
284
+ variableName,
285
+ namespace,
286
+ slot: keccak256Hex(namespace),
287
+ contract,
288
+ sourcePath: artifact.sourcePath,
289
+ src
290
+ });
291
+ });
292
+ }
293
+ return out;
294
+ }
295
+ function collectLiteralSlotConstants(ctx) {
296
+ const seen = /* @__PURE__ */ new Set();
297
+ const out = [];
298
+ for (const artifact of ctx.artifacts) {
299
+ if (!artifact.ast) continue;
300
+ walkAst(artifact.ast, (node, parents) => {
301
+ if (!isBytes32Constant(node)) return;
302
+ if (extractKeccakStringArg(node.value) !== null) return;
303
+ const slot = extractBytes32HexLiteral(node.value);
304
+ if (slot === null) return;
305
+ const declarationId = typeof node.id === "number" ? node.id : -1;
306
+ const variableName = node.name ?? "<anon>";
307
+ const contract = declaringContract(parents) ?? artifact.contractName;
308
+ const src = node.src;
309
+ const dedupeKey = `${artifact.sourcePath}::${contract}::${variableName}::${src ?? ""}`;
310
+ if (seen.has(dedupeKey)) return;
311
+ seen.add(dedupeKey);
312
+ out.push({
313
+ declarationId,
314
+ variableName,
315
+ namespace: null,
316
+ slot,
317
+ contract,
318
+ sourcePath: artifact.sourcePath,
319
+ src
320
+ });
321
+ });
322
+ }
323
+ return out;
324
+ }
325
+ function collectFormulaSlotConstants(ctx) {
326
+ const seen = /* @__PURE__ */ new Set();
327
+ const out = [];
328
+ for (const artifact of ctx.artifacts) {
329
+ if (!artifact.ast) continue;
330
+ walkAst(artifact.ast, (node, parents) => {
331
+ if (!isBytes32Constant(node)) return;
332
+ if (extractKeccakStringArg(node.value) !== null) return;
333
+ const namespace = extractErc7201FormulaNamespace(node.value);
334
+ if (namespace === null) return;
335
+ const declarationId = typeof node.id === "number" ? node.id : -1;
336
+ const variableName = node.name ?? "<anon>";
337
+ const contract = declaringContract(parents) ?? artifact.contractName;
338
+ const src = node.src;
339
+ const dedupeKey = `${artifact.sourcePath}::${contract}::${variableName}::${src ?? ""}`;
340
+ if (seen.has(dedupeKey)) return;
341
+ seen.add(dedupeKey);
342
+ out.push({
343
+ declarationId,
344
+ variableName,
345
+ namespace,
346
+ slot: erc7201Slot(namespace),
347
+ contract,
348
+ sourcePath: artifact.sourcePath,
349
+ src
350
+ });
351
+ });
352
+ }
353
+ return out;
354
+ }
355
+ function collectAssemblyLiteralSlots(artifacts) {
356
+ const seen = /* @__PURE__ */ new Set();
357
+ const out = [];
358
+ for (const artifact of artifacts) {
359
+ if (!artifact.ast) continue;
360
+ walkAst(artifact.ast, (node, parents) => {
361
+ if (node.nodeType !== "YulAssignment") return;
362
+ const targets = node.variableNames;
363
+ const targetsSlot = Array.isArray(targets) && targets.some((t) => typeof t.name === "string" && t.name.endsWith(".slot"));
364
+ if (!targetsSlot) return;
365
+ const value = node.value;
366
+ if (value?.nodeType !== "YulLiteral" || value.kind !== "number") return;
367
+ if (typeof value.value !== "string") return;
368
+ let slot;
369
+ try {
370
+ const big = BigInt(value.value);
371
+ if (big < 0n) return;
372
+ slot = "0x" + big.toString(16).padStart(64, "0");
373
+ } catch {
374
+ return;
375
+ }
376
+ const contract = declaringContract(parents) ?? artifact.contractName;
377
+ const src = node.src;
378
+ const dedupeKey = `${artifact.sourcePath}::${contract}::${slot}::${src ?? ""}`;
379
+ if (seen.has(dedupeKey)) return;
380
+ seen.add(dedupeKey);
381
+ out.push({
382
+ declarationId: -1,
383
+ variableName: "<assembly literal>",
384
+ namespace: null,
385
+ slot,
386
+ contract,
387
+ sourcePath: artifact.sourcePath,
388
+ src
389
+ });
390
+ });
391
+ }
392
+ return out;
393
+ }
394
+ function collectSlotAssignmentValueSrcs(yulNode, srcs) {
395
+ if (!yulNode || typeof yulNode !== "object") return;
396
+ const node = yulNode;
397
+ if (node.nodeType === "YulAssignment") {
398
+ const targets = node.variableNames;
399
+ const targetsSlot = Array.isArray(targets) && targets.some((t) => typeof t.name === "string" && t.name.endsWith(".slot"));
400
+ if (targetsSlot) {
401
+ const value = node.value;
402
+ if (value?.nodeType === "YulIdentifier" && typeof value.src === "string") {
403
+ srcs.add(value.src);
404
+ }
405
+ }
406
+ }
407
+ for (const key of Object.keys(node)) {
408
+ const v = node[key];
409
+ if (Array.isArray(v)) {
410
+ for (const child of v) collectSlotAssignmentValueSrcs(child, srcs);
411
+ } else if (v && typeof v === "object") {
412
+ collectSlotAssignmentValueSrcs(v, srcs);
413
+ }
414
+ }
415
+ }
416
+ function collectSlotUsedDeclarationIds(artifacts) {
417
+ const ids = /* @__PURE__ */ new Set();
418
+ for (const artifact of artifacts) {
419
+ if (!artifact.ast) continue;
420
+ walkAst(artifact.ast, (node) => {
421
+ if (node.nodeType !== "InlineAssembly") return;
422
+ const yulAst = node.AST;
423
+ if (!yulAst) return;
424
+ const slotValueSrcs = /* @__PURE__ */ new Set();
425
+ collectSlotAssignmentValueSrcs(yulAst, slotValueSrcs);
426
+ if (slotValueSrcs.size === 0) return;
427
+ const refs = node.externalReferences;
428
+ if (!Array.isArray(refs)) return;
429
+ for (const ref of refs) {
430
+ if (typeof ref.src === "string" && slotValueSrcs.has(ref.src) && typeof ref.declaration === "number") {
431
+ ids.add(ref.declaration);
432
+ }
433
+ }
434
+ });
435
+ }
436
+ return ids;
437
+ }
438
+ function collectAliases(artifacts) {
439
+ const aliases = /* @__PURE__ */ new Map();
440
+ for (const artifact of artifacts) {
441
+ if (!artifact.ast) continue;
442
+ walkAst(artifact.ast, (node) => {
443
+ if (node.nodeType !== "VariableDeclarationStatement") return;
444
+ const initialValue = node.initialValue;
445
+ if (initialValue?.nodeType !== "Identifier") return;
446
+ const referenced = initialValue.referencedDeclaration;
447
+ if (typeof referenced !== "number") return;
448
+ const decls = node.declarations;
449
+ if (!Array.isArray(decls)) return;
450
+ for (const d of decls) {
451
+ if (typeof d?.id === "number") aliases.set(d.id, referenced);
452
+ }
453
+ });
454
+ }
455
+ return aliases;
456
+ }
457
+ function isUsedAsSlot(constantId, slotUsedIds, aliases) {
458
+ if (constantId < 0) return false;
459
+ if (slotUsedIds.has(constantId)) return true;
460
+ for (const [aliasId, refId] of aliases) {
461
+ if (refId === constantId && slotUsedIds.has(aliasId)) return true;
462
+ }
463
+ return false;
464
+ }
465
+ function collectGatedSlotConstants(ctx) {
466
+ const constants = [
467
+ ...collectSlotConstants(ctx),
468
+ ...collectLiteralSlotConstants(ctx),
469
+ ...collectFormulaSlotConstants(ctx)
470
+ ];
471
+ const slotUsedIds = collectSlotUsedDeclarationIds(ctx.artifacts);
472
+ const aliases = collectAliases(ctx.artifacts);
473
+ return [
474
+ ...constants.filter((c) => isUsedAsSlot(c.declarationId, slotUsedIds, aliases)),
475
+ ...collectAssemblyLiteralSlots(ctx.artifacts)
476
+ ];
477
+ }
478
+
479
+ // src/detector/analyzers/erc7201.ts
480
+ var NAMED_NODE_TYPES = /* @__PURE__ */ new Set([
481
+ "ContractDefinition",
482
+ "StructDefinition",
483
+ "FunctionDefinition",
484
+ "VariableDeclaration",
485
+ "ErrorDefinition",
486
+ "EventDefinition",
487
+ "ModifierDefinition",
488
+ "EnumDefinition"
489
+ ]);
490
+ function getDocText(node) {
491
+ const doc = node.documentation;
492
+ if (!doc) return null;
493
+ if (typeof doc === "string") return doc;
494
+ if (typeof doc === "object" && doc !== null) {
495
+ const text = doc.text;
496
+ if (typeof text === "string") return text;
497
+ }
498
+ return null;
499
+ }
500
+ function nearestContract(parents) {
501
+ for (let i = parents.length - 1; i >= 0; i--) {
502
+ const p = parents[i];
503
+ if (p.nodeType === "ContractDefinition") return p.name ?? null;
504
+ }
505
+ return null;
506
+ }
507
+ function walkAst2(ast, visit, parents = []) {
508
+ if (!ast || typeof ast !== "object") return;
509
+ const node = ast;
510
+ if (typeof node.nodeType === "string") visit(node, parents);
511
+ const nextParents = typeof node.nodeType === "string" ? [...parents, node] : parents;
512
+ for (const key of Object.keys(node)) {
513
+ const value = node[key];
514
+ if (Array.isArray(value)) {
515
+ for (const child of value) walkAst2(child, visit, nextParents);
516
+ } else if (value && typeof value === "object") {
517
+ walkAst2(value, visit, nextParents);
518
+ }
519
+ }
520
+ }
521
+ function collectErc7201Annotations(artifacts) {
522
+ const seen = /* @__PURE__ */ new Set();
523
+ const out = [];
524
+ for (const artifact of artifacts) {
525
+ if (!artifact.ast) continue;
526
+ walkAst2(artifact.ast, (node, parents) => {
527
+ if (!node.nodeType || !NAMED_NODE_TYPES.has(node.nodeType)) return;
528
+ const text = getDocText(node);
529
+ if (!text || !text.includes("erc7201:")) return;
530
+ const namespaceId = parseErc7201Annotation(text);
531
+ if (!namespaceId) return;
532
+ const attachedTo = `${node.nodeType}:${node.name ?? "<anon>"}`;
533
+ const contract = node.nodeType === "ContractDefinition" ? node.name ?? artifact.contractName : nearestContract(parents) ?? artifact.contractName;
534
+ const src = node.src;
535
+ const dedupeKey = `${artifact.sourcePath}::${attachedTo}::${src ?? ""}`;
536
+ if (seen.has(dedupeKey)) return;
537
+ seen.add(dedupeKey);
538
+ out.push({
539
+ namespaceId,
540
+ slot: erc7201Slot(namespaceId),
541
+ attachedTo,
542
+ contract,
543
+ sourcePath: artifact.sourcePath,
544
+ src
545
+ });
546
+ });
547
+ }
548
+ return out;
549
+ }
550
+
551
+ // src/detector/inventory.ts
552
+ function lineOf(src, text) {
553
+ if (!src || !text) return void 0;
554
+ const [startStr] = src.split(":");
555
+ const start = Number(startStr);
556
+ if (!Number.isFinite(start)) return void 0;
557
+ let line = 1;
558
+ for (let i = 0; i < start && i < text.length; i++) {
559
+ if (text.charCodeAt(i) === 10) line++;
560
+ }
561
+ return line;
562
+ }
563
+ var PRIORITY = { erc7201: 0, namespace: 1, hardcoded: 2 };
564
+ function buildInventory(ctx) {
565
+ const bySlot = /* @__PURE__ */ new Map();
566
+ const add = (region) => {
567
+ const existing = bySlot.get(region.slot);
568
+ if (!existing || PRIORITY[region.kind] < PRIORITY[existing.kind]) {
569
+ bySlot.set(region.slot, region);
570
+ }
571
+ };
572
+ for (const c of collectGatedSlotConstants(ctx)) {
573
+ add({
574
+ slot: c.slot,
575
+ label: c.namespace ?? c.variableName,
576
+ kind: c.namespace ? "namespace" : "hardcoded",
577
+ contract: c.contract,
578
+ file: c.sourcePath,
579
+ line: lineOf(c.src, ctx.rawSources.get(c.sourcePath))
580
+ });
581
+ }
582
+ for (const a of collectErc7201Annotations(ctx.artifacts)) {
583
+ add({
584
+ slot: a.slot,
585
+ label: a.namespaceId,
586
+ kind: "erc7201",
587
+ contract: a.contract,
588
+ file: a.sourcePath,
589
+ line: lineOf(a.src, ctx.rawSources.get(a.sourcePath))
590
+ });
591
+ }
592
+ return [...bySlot.values()].sort(
593
+ (x, y) => x.file.localeCompare(y.file) || x.slot.localeCompare(y.slot)
594
+ );
595
+ }
596
+
597
+ // src/detector/index.ts
598
+ var DEFAULT_IGNORE_GLOBS = [
599
+ "lib/**",
600
+ "test/**",
601
+ "script/**",
602
+ "**/*.t.sol",
603
+ "**/*.s.sol"
604
+ ];
605
+ function compilePatterns(globs) {
606
+ return globs.map((g) => {
607
+ const escaped = g.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "::DOUBLESTAR::").replace(/\*/g, "[^/]*").replace(/::DOUBLESTAR::/g, ".*").replace(/\?/g, ".");
608
+ return new RegExp(`^${escaped}$`);
609
+ });
610
+ }
611
+ function buildIgnore(userGlobs, noDefault) {
612
+ const globs = [
613
+ ...noDefault ? [] : DEFAULT_IGNORE_GLOBS,
614
+ ...userGlobs ?? []
615
+ ];
616
+ if (globs.length === 0) return void 0;
617
+ const patterns = compilePatterns(globs);
618
+ return (sourcePath) => patterns.some((p) => p.test(sourcePath));
619
+ }
620
+ function buildIsFacet(globs) {
621
+ if (!globs || globs.length === 0) return void 0;
622
+ const patterns = compilePatterns(globs);
623
+ return (artifact) => patterns.some((p) => p.test(artifact.sourcePath));
624
+ }
625
+ async function detect(options, analyzers) {
626
+ const artifacts = await loadFoundryArtifacts(options.path, {
627
+ ignoreSourcePath: buildIgnore(options.ignoreGlobs, options.noDefaultIgnore)
628
+ });
629
+ const rawSources = await loadRawSources(
630
+ options.path,
631
+ artifacts.map((a) => a.sourcePath)
632
+ );
633
+ const ctx = {
634
+ artifacts,
635
+ rawSources,
636
+ isFacet: buildIsFacet(options.facetGlobs)
637
+ };
638
+ const findings = [];
639
+ for (const analyzer of analyzers) {
640
+ const out = await analyzer.run(ctx);
641
+ findings.push(...out);
642
+ }
643
+ const inventory = buildInventory(ctx);
644
+ return { artifacts, findings, rawSources, inventory };
645
+ }
194
646
  export {
195
647
  detect,
196
648
  erc7201Slot,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diamond-detect",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Static analyzer for EIP-2535 Diamond storage-slot collisions across facets",
5
5
  "homepage": "https://github.com/jayeshy14/Diamond-Storage-Detector#readme",
6
6
  "repository": {