slotdiff 0.1.0-alpha.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +50 -9
  2. package/README.md +219 -111
  3. package/dist/bin.d.ts +3 -0
  4. package/dist/bin.d.ts.map +1 -0
  5. package/dist/bin.js +22 -0
  6. package/dist/bin.js.map +1 -0
  7. package/dist/build-info.d.ts +45 -0
  8. package/dist/build-info.d.ts.map +1 -0
  9. package/dist/build-info.js +319 -0
  10. package/dist/build-info.js.map +1 -0
  11. package/dist/cli.d.ts +25 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +389 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/erc7201.d.ts +88 -0
  16. package/dist/erc7201.d.ts.map +1 -0
  17. package/dist/erc7201.js +509 -0
  18. package/dist/erc7201.js.map +1 -0
  19. package/dist/errors.d.ts +19 -0
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +28 -0
  22. package/dist/errors.js.map +1 -1
  23. package/dist/explorer.d.ts.map +1 -1
  24. package/dist/explorer.js +29 -10
  25. package/dist/explorer.js.map +1 -1
  26. package/dist/index.d.ts +6 -3
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +5 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/reconstruct.d.ts +70 -7
  31. package/dist/reconstruct.d.ts.map +1 -1
  32. package/dist/reconstruct.js +157 -20
  33. package/dist/reconstruct.js.map +1 -1
  34. package/dist/record.d.ts +13 -0
  35. package/dist/record.d.ts.map +1 -1
  36. package/dist/record.js +8 -0
  37. package/dist/record.js.map +1 -1
  38. package/dist/report.d.ts +29 -0
  39. package/dist/report.d.ts.map +1 -0
  40. package/dist/report.js +138 -0
  41. package/dist/report.js.map +1 -0
  42. package/dist/resolve.d.ts +16 -0
  43. package/dist/resolve.d.ts.map +1 -1
  44. package/dist/resolve.js +17 -2
  45. package/dist/resolve.js.map +1 -1
  46. package/dist/solc.js +20 -4
  47. package/dist/solc.js.map +1 -1
  48. package/dist/upgrade.d.ts +48 -0
  49. package/dist/upgrade.d.ts.map +1 -0
  50. package/dist/upgrade.js +36 -0
  51. package/dist/upgrade.js.map +1 -0
  52. package/package.json +9 -6
  53. package/src/bin.ts +25 -0
  54. package/src/build-info.ts +405 -0
  55. package/src/cli.ts +459 -0
  56. package/src/erc7201.ts +603 -0
  57. package/src/errors.ts +31 -0
  58. package/src/explorer.ts +40 -11
  59. package/src/index.ts +28 -3
  60. package/src/reconstruct.ts +279 -29
  61. package/src/record.ts +18 -0
  62. package/src/report.ts +186 -0
  63. package/src/resolve.ts +28 -3
  64. package/src/solc.ts +20 -4
  65. package/src/upgrade.ts +91 -0
package/src/erc7201.ts ADDED
@@ -0,0 +1,603 @@
1
+ /**
2
+ * ERC-7201 namespaces by the slot the code really uses.
3
+ *
4
+ * OpenZeppelin matches namespaces by their `@custom:storage-location
5
+ * erc7201:<id>` annotation and never checks the `bytes32` slot the code assigns
6
+ * (`assembly { $.slot := LOCATION }`). The two can disagree. A changed
7
+ * constant behind an unchanged annotation moves the storage while the layout
8
+ * check passes; a corrected annotation over an unchanged constant leaves the
9
+ * storage in place while the check reports a deleted namespace. Reading the
10
+ * slot from the AST catches the first and fixes the second.
11
+ */
12
+
13
+ import type { SolcOutput, StorageLayout } from "@openzeppelin/upgrades-core";
14
+ import { styleText } from "node:util";
15
+ import { LayoutIntegrityError, LayoutUnavailableError } from "./errors.js";
16
+ import { namespaceLines, plain } from "./report.js";
17
+ // Not exported from the package index; the package has no exports map, and
18
+ // all exist since 1.46.0, the lowest version slotdiff accepts.
19
+ import { getStorageLocationAnnotation } from "@openzeppelin/upgrades-core/dist/storage/namespace.js";
20
+ import { calculateERC7201StorageLocation } from "@openzeppelin/upgrades-core/dist/utils/erc7201.js";
21
+ import { resolveBaseSlot } from "@openzeppelin/upgrades-core/dist/utils/erc7201-builtin.js";
22
+ import {
23
+ normalizeUint256Literal,
24
+ toBytes32Hex,
25
+ } from "@openzeppelin/upgrades-core/dist/utils/integer-literals.js";
26
+
27
+ export interface NamespaceSlot {
28
+ /** `erc7201:<id>`, as annotated. */
29
+ namespace: string;
30
+ /** The slot the annotation implies. */
31
+ expected: string;
32
+ /** The slot the code assigns, when it could be read and is unique. */
33
+ actual?: string;
34
+ /**
35
+ * `match`: the code uses the annotated slot. `mismatch`: it uses `actual`
36
+ * instead. `conflict`: it assigns different slots in different places.
37
+ * `unused`: no code assigns its slot (a namespace kept reserved).
38
+ * `unread`: an assignment could not be evaluated.
39
+ */
40
+ status: "match" | "mismatch" | "conflict" | "unused" | "unread";
41
+ }
42
+
43
+ interface AstNode {
44
+ nodeType?: string;
45
+ id?: number;
46
+ [key: string]: unknown;
47
+ }
48
+
49
+ /**
50
+ * How each of `namespaces` (`erc7201:<id>` keys of `contract`'s layout,
51
+ * `source.sol:Name`) is assigned in `output`. Only structs declared in the
52
+ * contract or its bases count, as for OpenZeppelin: a sibling contract in the
53
+ * same build (a V1 next to its V2, a test copy) may declare the same id.
54
+ */
55
+ export function namespaceSlots(
56
+ output: SolcOutput,
57
+ contract: string,
58
+ namespaces: string[],
59
+ ): NamespaceSlot[] {
60
+ const colon = contract.lastIndexOf(":");
61
+ // Most derived first, as solc lists them: the order virtual calls resolve in.
62
+ let linearized: number[] = [];
63
+ walk(output.sources?.[contract.slice(0, colon)]?.ast, (node) => {
64
+ if (node.nodeType === "ContractDefinition" && node.name === contract.slice(colon + 1)) {
65
+ linearized = node.linearizedBaseContracts as number[];
66
+ }
67
+ });
68
+ const bases = new Set(linearized);
69
+
70
+ // Only code the contract can run counts: its bases (minus the functions they
71
+ // override), libraries and free functions, not a sibling contract (a test
72
+ // harness) in the same files.
73
+ const byId = new Map<number, AstNode>();
74
+ const touched = new Set<number>();
75
+ const overridden = new Set<number>();
76
+ for (const source of Object.values(output.sources ?? {})) {
77
+ walk(source.ast as unknown as AstNode, (node) => {
78
+ if (typeof node.id === "number") byId.set(node.id, node);
79
+ // Any Solidity use of a local after its declaration, so a local is read
80
+ // only when nothing but assembly reads it.
81
+ if (node.nodeType === "Identifier") touched.add(node.referencedDeclaration as number);
82
+ if (node.nodeType === "FunctionDefinition" && bases.has(node.scope as number)) {
83
+ for (const base of (node.baseFunctions ?? []) as number[]) overridden.add(base);
84
+ }
85
+ });
86
+ }
87
+ // An overridden function can still run (through `super` or `Base.f()`), so
88
+ // its assembly is set aside: it never gives a slot, but it keeps a namespace
89
+ // it assigns from passing as unused.
90
+ const assemblies: AstNode[] = [];
91
+ const setAside: AstNode[] = [];
92
+ for (const source of Object.values(output.sources ?? {})) {
93
+ walk(source.ast as unknown as AstNode, (node) => {
94
+ const sibling =
95
+ node.nodeType === "ContractDefinition" &&
96
+ node.contractKind !== "library" &&
97
+ !bases.has(node.id as number);
98
+ if (sibling) return false;
99
+ if (overridden.has(node.id as number)) {
100
+ walk(node, (inner) => {
101
+ if (inner.nodeType === "InlineAssembly") setAside.push(inner);
102
+ });
103
+ return false;
104
+ }
105
+ if (node.nodeType === "InlineAssembly") assemblies.push(node);
106
+ });
107
+ }
108
+ const ctx = { byId, linearized, bases, touched };
109
+
110
+ // Struct id → every slot value assigned to a pointer of that struct (undefined: unreadable).
111
+ const assigned = new Map<number, Array<string | undefined>>();
112
+ // Structs assigned a slot only in set-aside code, and whether any pointer
113
+ // that is not a struct (a mapping or an array) is assigned one.
114
+ const hidden = new Set<number>();
115
+ let untyped = false;
116
+ const unannotated = (struct: number) => {
117
+ const node = byId.get(struct);
118
+ return node === undefined || getStorageLocationAnnotation(node as never) === undefined;
119
+ };
120
+ for (const assembly of setAside) {
121
+ forEachSlotAssignment(assembly, byId, (struct) => {
122
+ if (struct === undefined) untyped = true;
123
+ else hidden.add(struct);
124
+ });
125
+ }
126
+ for (const assembly of assemblies) {
127
+ const refs = (assembly.externalReferences ?? []) as Array<{
128
+ declaration: number;
129
+ src: string;
130
+ suffix?: string;
131
+ }>;
132
+ const declarationAt = (src: unknown) => refs.find((r) => r.src === src)?.declaration ?? -1;
133
+ walk(assembly.AST as AstNode, (node) => {
134
+ if (node.nodeType !== "YulAssignment") return;
135
+ for (const target of node.variableNames as AstNode[]) {
136
+ if (typeof target.name !== "string") continue;
137
+ if (!target.name.endsWith(".slot")) {
138
+ touched.add(declarationAt(target.src));
139
+ continue;
140
+ }
141
+ const pointer = byId.get(declarationAt(target.src));
142
+ const struct = (pointer?.typeName as AstNode | undefined)?.referencedDeclaration;
143
+ if (typeof struct !== "number") {
144
+ untyped = true;
145
+ continue;
146
+ }
147
+ const value = node.value as AstNode;
148
+ const slot =
149
+ value.nodeType === "YulLiteral"
150
+ ? value.kind === "number"
151
+ ? normalize(value.value)
152
+ : undefined
153
+ : resolveDecl(ctx, byId.get(declarationAt(value.src)));
154
+ assigned.set(struct, [...(assigned.get(struct) ?? []), slot]);
155
+ }
156
+ });
157
+ }
158
+
159
+ const structsOf = (namespace: string) =>
160
+ [...byId.values()].filter(
161
+ (n) =>
162
+ n.nodeType === "StructDefinition" &&
163
+ bases.has(n.scope as number) &&
164
+ getStorageLocationAnnotation(n as never) === namespace,
165
+ );
166
+ // Storage placed through any other struct (a library's Layout, annotated or
167
+ // not, or a struct with no annotation) is in no layout OpenZeppelin builds,
168
+ // so a change to it would pass unseen. A slot passed in (StorageSlot) is not known.
169
+ const namespaced = new Set(namespaces.flatMap((n) => structsOf(n).map((s) => s.id!)));
170
+ for (const [id, slots] of assigned) {
171
+ if (namespaced.has(id) || slots.every((s) => s === undefined)) continue;
172
+ const struct = byId.get(id);
173
+ const scope = byId.get(struct?.scope as number)?.name;
174
+ throw new LayoutUnavailableError(
175
+ `${contract} keeps storage at a fixed slot through ${[scope, struct?.name].filter(Boolean).join(".")}, ` +
176
+ "which is not an ERC-7201 namespace of the contract or its bases (a @custom:storage-location " +
177
+ "struct declared there): its fields are in no layout slotdiff compares, so it cannot check them.",
178
+ );
179
+ }
180
+
181
+ return namespaces.map((namespace) => {
182
+ const expected = calculateERC7201StorageLocation(namespace.slice("erc7201:".length));
183
+ const structs = structsOf(namespace);
184
+ const slots = structs.flatMap((n) => assigned.get(n.id!) ?? []);
185
+ const known = [...new Set(slots.filter((s) => s !== undefined))];
186
+ if (slots.length === 0) {
187
+ // Unused only when nothing could be placing it: no assignment in
188
+ // set-aside code, none to a struct nested in it, none to an untyped pointer.
189
+ const nested = new Set(structs.flatMap((n) => [...nestedStructs(n, byId)]));
190
+ const own = new Set(structs.map((n) => n.id!));
191
+ const placed =
192
+ untyped ||
193
+ [...hidden].some(unannotated) ||
194
+ [...own, ...nested].some((id) => hidden.has(id)) ||
195
+ [...nested].some((id) => !own.has(id) && assigned.has(id));
196
+ return { namespace, expected, status: placed ? "unread" : "unused" };
197
+ }
198
+ if (known.length > 1) return { namespace, expected, status: "conflict" };
199
+ if (slots.includes(undefined)) {
200
+ return { namespace, expected, status: "unread" };
201
+ }
202
+ const actual = known[0];
203
+ return actual === expected
204
+ ? { namespace, expected, actual, status: "match" }
205
+ : { namespace, expected, actual, status: "mismatch" };
206
+ });
207
+ }
208
+
209
+ export interface NamespacePair {
210
+ old?: NamespaceSlot;
211
+ next?: NamespaceSlot;
212
+ /**
213
+ * Why this pair fails the check: `<new>` assigns several slots (`conflict`),
214
+ * leaves the old data behind (`moved`), keeps the data in place under an
215
+ * annotation that does not match (`mismatch`), or stops assigning a namespace
216
+ * whose deployed annotation does not match its data (`dropped`). `unread`: a
217
+ * slot could not be read, so the pair could not be checked.
218
+ */
219
+ problem?: "conflict" | "moved" | "mismatch" | "dropped" | "unread";
220
+ }
221
+
222
+ export interface NamespaceCheck {
223
+ /**
224
+ * `old`'s layout with each paired namespace under its name in `<new>`, for
225
+ * `diffLayouts`: OpenZeppelin then compares the structs of the same storage.
226
+ * An unpaired namespace keeps its name, so one that is really gone is still
227
+ * reported as deleted.
228
+ */
229
+ layout: StorageLayout;
230
+ /** Every namespace of either side, old and new paired when they are the same storage. */
231
+ pairs: NamespacePair[];
232
+ /**
233
+ * `fail` when any pair has a problem other than `unread`; `unchecked` when a
234
+ * slot could not be read (a pair's problem is `unread`), as in `UpgradeCheck`.
235
+ */
236
+ result: "pass" | "fail" | "unchecked";
237
+ /** The CLI's namespace grid, with what each problem means; empty when nothing is worth showing. */
238
+ explain(color?: boolean): string;
239
+ }
240
+
241
+ /**
242
+ * Checks ERC-7201 namespaces by the slot the code uses, not the annotation
243
+ * OpenZeppelin goes by. An old and a new namespace are the same storage when
244
+ * the code uses the same slot (under the same annotation first), failing that
245
+ * when they share an annotation, and failing that when the new annotation
246
+ * names the slot where the old data is. A namespace of either layout missing
247
+ * from that side's `namespaces` (or all of them, when it is absent) is unread.
248
+ * Pass `layout` to `diffLayouts` in place of `old.layout`.
249
+ */
250
+ export function checkNamespaces(
251
+ old: { layout: StorageLayout; namespaces?: NamespaceSlot[] },
252
+ next: { layout: StorageLayout; namespaces?: NamespaceSlot[] },
253
+ ): NamespaceCheck {
254
+ const unpaired = new Set(slotsOf(old, "old"));
255
+ const pairs: NamespacePair[] = slotsOf(next, "new").map((n) => ({ next: n }));
256
+ const sameSlot = (o: NamespaceSlot, n: NamespaceSlot) =>
257
+ o.actual !== undefined && n.actual !== undefined && same(o.actual, n.actual);
258
+ const rules: Array<(o: NamespaceSlot, n: NamespaceSlot) => boolean> = [
259
+ (o, n) => sameSlot(o, n) && o.namespace === n.namespace,
260
+ sameSlot,
261
+ (o, n) => o.namespace === n.namespace,
262
+ (o, n) => o.actual !== undefined && same(o.actual, n.expected),
263
+ ];
264
+ // Rule by rule over every namespace, so a weaker rule never takes a pair from a stronger one.
265
+ for (const rule of rules) {
266
+ for (const pair of pairs) {
267
+ const o = pair.old ?? [...unpaired].find((o) => rule(o, pair.next!));
268
+ if (o !== undefined && unpaired.delete(o)) pair.old = o;
269
+ }
270
+ }
271
+ for (const o of unpaired) pairs.push({ old: o });
272
+ for (const pair of pairs) pair.problem = problemOf(pair);
273
+
274
+ // Renamed all at once, so two namespaces can swap names.
275
+ const renames = new Map(
276
+ pairs.flatMap((p) =>
277
+ p.old !== undefined && p.next !== undefined ? [[p.old.namespace, p.next.namespace]] : [],
278
+ ),
279
+ );
280
+ const oldNames = Object.keys(old.layout.namespaces ?? {});
281
+ const renamed = oldNames.map((name) => renames.get(name) ?? name);
282
+ // On a clash (an old namespace that stays keeps the name another would take)
283
+ // nothing is renamed: OpenZeppelin then reports the moved one as deleted.
284
+ const layout =
285
+ new Set(renamed).size < renamed.length || renamed.every((to, i) => to === oldNames[i])
286
+ ? old.layout
287
+ : {
288
+ ...old.layout,
289
+ namespaces: Object.fromEntries(
290
+ oldNames.map((name, i) => [renamed[i], old.layout.namespaces![name]]),
291
+ ),
292
+ };
293
+
294
+ const problems = pairs.map((p) => p.problem);
295
+ return {
296
+ layout,
297
+ pairs,
298
+ result: problems.some((p) => p !== undefined && p !== "unread")
299
+ ? "fail"
300
+ : problems.includes("unread")
301
+ ? "unchecked"
302
+ : "pass",
303
+ // Colored by default, as OpenZeppelin's reports are.
304
+ explain: (color = true) =>
305
+ namespaceLines(
306
+ pairs,
307
+ color ? (format, text) => styleText(format, text, { validateStream: false }) : plain,
308
+ ).join("\n"),
309
+ };
310
+ }
311
+
312
+ /**
313
+ * One slot per namespace of `side.layout`. A namespace with no slot (a record
314
+ * from before slots were recorded) is unread, never left to OpenZeppelin's
315
+ * annotation-only match.
316
+ */
317
+ function slotsOf(
318
+ side: { layout: StorageLayout; namespaces?: NamespaceSlot[] },
319
+ which: "old" | "new",
320
+ ): NamespaceSlot[] {
321
+ const misfit = slotsMisfit(side.layout, side.namespaces);
322
+ if (misfit !== undefined) {
323
+ throw new LayoutIntegrityError(
324
+ `The ${which} namespace slots do not fit its layout: ${misfit}.`,
325
+ );
326
+ }
327
+ const given = side.namespaces ?? [];
328
+ const seen = new Set(given.map((n) => n.namespace));
329
+ return [
330
+ ...given,
331
+ ...Object.keys(side.layout.namespaces ?? {})
332
+ .filter((namespace) => !seen.has(namespace))
333
+ .map((namespace): NamespaceSlot => ({
334
+ namespace,
335
+ expected: calculateERC7201StorageLocation(namespace.slice("erc7201:".length)),
336
+ status: "unread",
337
+ })),
338
+ ];
339
+ }
340
+
341
+ // `<formula>:<id>`, as OpenZeppelin keys namespaces. Never a name like
342
+ // `__proto__`, which a plain object lookup would resolve on its prototype.
343
+ const NAMESPACE_KEY = /^[\w.-]+:\S/;
344
+ const SLOT = /^0x[0-9a-f]{64}$/i;
345
+
346
+ /**
347
+ * Why `namespaces` cannot be the slots of `layout`'s namespaces, or undefined
348
+ * when they can (or are absent). A slot for a namespace the layout lacks, or a
349
+ * second one for the same namespace, could take another's place when
350
+ * namespaces are paired, so either is refused. Internal, shared with records.
351
+ */
352
+ export function slotsMisfit(layout: StorageLayout, namespaces: unknown): string | undefined {
353
+ const names = Object.keys(layout.namespaces ?? {});
354
+ const odd = names.find((name) => !NAMESPACE_KEY.test(name));
355
+ if (odd !== undefined) return `${JSON.stringify(odd)} is not a storage location`;
356
+ if (namespaces === undefined) return undefined;
357
+ if (!Array.isArray(namespaces)) return "they are not a list";
358
+ const seen = new Set<string>();
359
+ for (const slot of namespaces) {
360
+ if (!isSlot(slot)) return "a slot is malformed";
361
+ if (!names.includes(slot.namespace)) return `${slot.namespace} is not in the layout`;
362
+ if (seen.has(slot.namespace)) return `${slot.namespace} appears twice`;
363
+ seen.add(slot.namespace);
364
+ }
365
+ return undefined;
366
+ }
367
+
368
+ // Well formed, and consistent with its status.
369
+ function isSlot(value: unknown): value is NamespaceSlot {
370
+ const n = value as Partial<NamespaceSlot> | null;
371
+ if (
372
+ typeof n !== "object" ||
373
+ n === null ||
374
+ typeof n.namespace !== "string" ||
375
+ typeof n.expected !== "string" ||
376
+ !SLOT.test(n.expected) ||
377
+ (n.actual !== undefined && (typeof n.actual !== "string" || !SLOT.test(n.actual)))
378
+ ) {
379
+ return false;
380
+ }
381
+ const read = n.actual !== undefined;
382
+ const same = read && BigInt(n.actual!) === BigInt(n.expected);
383
+ return n.status === "match"
384
+ ? same
385
+ : n.status === "mismatch"
386
+ ? read && !same
387
+ : (n.status === "unread" || n.status === "conflict" || n.status === "unused") && !read;
388
+ }
389
+
390
+ function problemOf({ old, next }: NamespacePair): NamespacePair["problem"] {
391
+ if (next?.status === "conflict") return "conflict";
392
+ if (old?.actual !== undefined && next?.actual !== undefined && !same(old.actual, next.actual)) {
393
+ return "moved";
394
+ }
395
+ if (next?.status === "mismatch") return "mismatch";
396
+ // Only the deployed code knows where the data really is; once no code assigns
397
+ // the namespace, a later version that follows the wrong annotation would pass.
398
+ if (old?.status === "mismatch" && next?.status === "unused") return "dropped";
399
+ // An unused namespace has no data of that version to move: compared by annotation, as OpenZeppelin does.
400
+ const unknown = (s?: NamespaceSlot) =>
401
+ s !== undefined && s.actual === undefined && s.status !== "unused";
402
+ if (unknown(old) || next?.status === "unread") {
403
+ return "unread";
404
+ }
405
+ return undefined;
406
+ }
407
+
408
+ // Calls `found` with the struct id of each pointer assigned a `.slot` in
409
+ // `assembly`, or undefined for a pointer that is not a struct.
410
+ function forEachSlotAssignment(
411
+ assembly: AstNode,
412
+ byId: Map<number, AstNode>,
413
+ found: (struct: number | undefined) => void,
414
+ ): void {
415
+ const refs = (assembly.externalReferences ?? []) as Array<{ declaration: number; src: string }>;
416
+ walk(assembly.AST as AstNode, (node) => {
417
+ if (node.nodeType !== "YulAssignment") return;
418
+ for (const target of node.variableNames as AstNode[]) {
419
+ if (typeof target.name !== "string" || !target.name.endsWith(".slot")) continue;
420
+ const pointer = byId.get(refs.find((r) => r.src === target.src)?.declaration ?? -1);
421
+ const struct = (pointer?.typeName as AstNode | undefined)?.referencedDeclaration;
422
+ found(typeof struct === "number" ? struct : undefined);
423
+ }
424
+ });
425
+ }
426
+
427
+ // Every struct that `struct` holds, at any depth (in members, mappings and arrays).
428
+ function nestedStructs(struct: AstNode, byId: Map<number, AstNode>): Set<number> {
429
+ const found = new Set<number>();
430
+ const visit = (node: AstNode) =>
431
+ walk(node, (n) => {
432
+ if (n.nodeType !== "UserDefinedTypeName") return;
433
+ const target = byId.get(n.referencedDeclaration as number);
434
+ if (target?.nodeType === "StructDefinition" && !found.has(target.id!)) {
435
+ found.add(target.id!);
436
+ visit(target);
437
+ }
438
+ });
439
+ visit(struct);
440
+ return found;
441
+ }
442
+
443
+ function same(a: string, b: string): boolean {
444
+ return BigInt(a) === BigInt(b);
445
+ }
446
+
447
+ // Visits every node; a visitor returning false skips the node's children.
448
+ function walk(node: unknown, visit: (node: AstNode) => boolean | void): void {
449
+ if (Array.isArray(node)) {
450
+ for (const child of node) walk(child, visit);
451
+ } else if (typeof node === "object" && node !== null) {
452
+ if (visit(node as AstNode) === false) return;
453
+ for (const child of Object.values(node)) walk(child, visit);
454
+ }
455
+ }
456
+
457
+ interface Context {
458
+ byId: Map<number, AstNode>;
459
+ linearized: number[];
460
+ bases: Set<number>;
461
+ /** Declarations that Solidity code uses, or assembly assigns. */
462
+ touched: Set<number>;
463
+ }
464
+
465
+ /**
466
+ * The value of a constant, an immutable set where it is declared, or a local
467
+ * that only assembly reads. A state variable's initial value is not read: it
468
+ * lives in the implementation's storage, not the proxy's.
469
+ */
470
+ function resolveDecl(ctx: Context, decl: AstNode | undefined, depth = 0): string | undefined {
471
+ if (decl === undefined) return undefined;
472
+ if (decl.stateVariable === true) {
473
+ if (decl.mutability !== "constant" && decl.mutability !== "immutable") return undefined;
474
+ return resolveExpr(ctx, decl.value as AstNode | undefined, depth);
475
+ }
476
+ if (ctx.touched.has(decl.id as number)) return undefined;
477
+ return resolveExpr(ctx, initialValue(ctx.byId, decl) as AstNode | undefined, depth);
478
+ }
479
+
480
+ /**
481
+ * The value of an expression: a reference to a constant or local, a call to a
482
+ * no-argument function that returns one (OpenZeppelin's Initializable reads
483
+ * its slot from `_initializableStorageSlot()`), the ERC-7201 formula, a
484
+ * number literal or `erc7201("id")`.
485
+ */
486
+ function resolveExpr(ctx: Context, e: AstNode | undefined, depth: number): string | undefined {
487
+ if (e === undefined || depth > 8) return undefined;
488
+ if (e.nodeType === "Identifier") {
489
+ return resolveDecl(ctx, ctx.byId.get(e.referencedDeclaration as number), depth + 1);
490
+ }
491
+ if (e.nodeType === "FunctionCall" && e.kind === "functionCall") {
492
+ const callee = e.expression as AstNode;
493
+ const fn = ctx.byId.get(callee.referencedDeclaration as number);
494
+ if (callee.nodeType !== "Identifier" || fn?.nodeType !== "FunctionDefinition") return undefined;
495
+ if ((e.arguments as unknown[]).length > 0) return undefined;
496
+ return resolveExpr(ctx, returned(override(ctx, fn)), depth + 1);
497
+ }
498
+ const id = formulaId(e);
499
+ if (id !== undefined) return calculateERC7201StorageLocation(id);
500
+ // OpenZeppelin would read any other literal, such as `hex"..."`, as a number.
501
+ if (e.nodeType === "Literal" && e.kind !== "number") return undefined;
502
+ try {
503
+ return resolveBaseSlot(e as never);
504
+ } catch {
505
+ return undefined;
506
+ }
507
+ }
508
+
509
+ /**
510
+ * The implementation an internal call to `fn` reaches: the most derived
511
+ * override of a virtual function in the bases, or `fn` itself (a free or
512
+ * library function, which nothing overrides).
513
+ */
514
+ function override(ctx: Context, fn: AstNode): AstNode {
515
+ if (fn.virtual !== true || !ctx.bases.has(fn.scope as number)) return fn;
516
+ for (const contract of ctx.linearized) {
517
+ for (const node of ctx.byId.values()) {
518
+ if (
519
+ node.nodeType === "FunctionDefinition" &&
520
+ node.scope === contract &&
521
+ node.name === fn.name &&
522
+ (node.parameters as { parameters: unknown[] }).parameters.length === 0 &&
523
+ node.body
524
+ ) {
525
+ return node;
526
+ }
527
+ }
528
+ }
529
+ return fn;
530
+ }
531
+
532
+ // The expression of a function whose body is a single `return <expression>;`
533
+ // and that has no modifier, which could skip it.
534
+ function returned(fn: AstNode): AstNode | undefined {
535
+ if ((fn.modifiers as unknown[] | undefined)?.length) return undefined;
536
+ const statements = (fn.body as { statements?: AstNode[] } | null)?.statements;
537
+ return statements?.length === 1 && statements[0].nodeType === "Return"
538
+ ? (statements[0].expression as AstNode)
539
+ : undefined;
540
+ }
541
+
542
+ // A local variable's value is on the statement that declares it.
543
+ function initialValue(byId: Map<number, AstNode>, decl: AstNode): unknown {
544
+ for (const node of byId.values()) {
545
+ if (node.nodeType !== "VariableDeclarationStatement") continue;
546
+ const declarations = node.declarations as Array<AstNode | null>;
547
+ if (declarations.length === 1 && declarations[0]?.id === decl.id) return node.initialValue;
548
+ }
549
+ return undefined;
550
+ }
551
+
552
+ /**
553
+ * The id in the ERC-7201 formula written out in Solidity,
554
+ * `keccak256(abi.encode(uint256(keccak256("id")) - 1)) & ~bytes32(uint256(0xff))`,
555
+ * with an optional `bytes("id")`. Anything else is not recognized.
556
+ */
557
+ function formulaId(e: AstNode): string | undefined {
558
+ if (e.nodeType !== "BinaryOperation" || e.operator !== "&") return undefined;
559
+ const mask = e.rightExpression as AstNode;
560
+ const masked = arg(
561
+ arg(
562
+ mask.nodeType === "UnaryOperation" ? (mask.subExpression as AstNode) : undefined,
563
+ "bytes32",
564
+ ),
565
+ "uint256",
566
+ );
567
+ if (masked?.nodeType !== "Literal" || normalize(masked.value) !== toBytes32Hex(0xffn)) {
568
+ return undefined;
569
+ }
570
+ const minus = arg(arg(e.leftExpression as AstNode, "keccak256"), "abi.encode");
571
+ if (minus?.nodeType !== "BinaryOperation" || minus.operator !== "-") return undefined;
572
+ const one = minus.rightExpression as AstNode;
573
+ if (one.nodeType !== "Literal" || one.value !== "1") return undefined;
574
+ let id = arg(arg(minus.leftExpression as AstNode, "uint256"), "keccak256");
575
+ if (id?.nodeType === "FunctionCall") id = arg(id, "bytes");
576
+ return id?.nodeType === "Literal" && id.kind === "string" ? (id.value as string) : undefined;
577
+ }
578
+
579
+ // The single argument of a call to `name` (a function, `abi.encode`, or a type conversion).
580
+ function arg(call: AstNode | undefined, name: string): AstNode | undefined {
581
+ if (call?.nodeType !== "FunctionCall") return undefined;
582
+ const callee = call.expression as AstNode;
583
+ const calleeName =
584
+ callee.nodeType === "Identifier"
585
+ ? callee.name
586
+ : callee.nodeType === "MemberAccess"
587
+ ? `${(callee.expression as AstNode).name}.${callee.memberName}`
588
+ : callee.nodeType === "ElementaryTypeNameExpression"
589
+ ? (callee.typeName as AstNode).name
590
+ : undefined;
591
+ const args = call.arguments as AstNode[];
592
+ return calleeName === name && args.length === 1 ? args[0] : undefined;
593
+ }
594
+
595
+ // A number literal as a slot, or undefined when it is not one.
596
+ function normalize(value: unknown): string | undefined {
597
+ if (typeof value !== "string") return undefined;
598
+ try {
599
+ return normalizeUint256Literal(value);
600
+ } catch {
601
+ return undefined;
602
+ }
603
+ }
package/src/errors.ts CHANGED
@@ -22,3 +22,34 @@ export class LayoutIntegrityError extends Error {
22
22
  this.name = "LayoutIntegrityError";
23
23
  }
24
24
  }
25
+
26
+ /**
27
+ * A build is missing output the analysis needs (an AST, metadata, a storage
28
+ * layout or bytecode). Yours to fix, by compiling again with them selected
29
+ * (`hardhat compile --force` once the outputs are configured, say).
30
+ */
31
+ export class IncompleteBuildError extends Error {
32
+ constructor(message: string) {
33
+ super(message);
34
+ this.name = "IncompleteBuildError";
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Text from outside (an explorer, an RPC endpoint, solc quoting verified
40
+ * source) without control characters, so it cannot move the cursor or recolor
41
+ * a terminal. Tabs and line breaks stay.
42
+ */
43
+ export function printable(text: string): string {
44
+ // eslint-disable-next-line no-control-regex
45
+ return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, " ");
46
+ }
47
+
48
+ /**
49
+ * A network error's code (ECONNREFUSED, ENOTFOUND), never its message: fetch
50
+ * quotes the URL in some messages, and URLs carry API keys.
51
+ */
52
+ export function errorCode(e: unknown): string {
53
+ const code = (e as { cause?: { code?: unknown } }).cause?.code;
54
+ return typeof code === "string" ? code : "network error";
55
+ }