slotdiff 0.1.0-alpha.1 → 0.2.0-alpha.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 (51) hide show
  1. package/CHANGELOG.md +16 -9
  2. package/README.md +196 -105
  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 +363 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/erc7201.d.ts +72 -0
  16. package/dist/erc7201.d.ts.map +1 -0
  17. package/dist/erc7201.js +340 -0
  18. package/dist/erc7201.js.map +1 -0
  19. package/dist/errors.d.ts +5 -0
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +8 -0
  22. package/dist/errors.js.map +1 -1
  23. package/dist/explorer.js +2 -2
  24. package/dist/explorer.js.map +1 -1
  25. package/dist/index.d.ts +3 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +3 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/reconstruct.d.ts +51 -3
  30. package/dist/reconstruct.d.ts.map +1 -1
  31. package/dist/reconstruct.js +37 -8
  32. package/dist/reconstruct.js.map +1 -1
  33. package/dist/report.d.ts +26 -0
  34. package/dist/report.d.ts.map +1 -0
  35. package/dist/report.js +109 -0
  36. package/dist/report.js.map +1 -0
  37. package/dist/resolve.d.ts +11 -0
  38. package/dist/resolve.d.ts.map +1 -1
  39. package/dist/resolve.js +11 -1
  40. package/dist/resolve.js.map +1 -1
  41. package/package.json +6 -3
  42. package/src/bin.ts +25 -0
  43. package/src/build-info.ts +405 -0
  44. package/src/cli.ts +423 -0
  45. package/src/erc7201.ts +412 -0
  46. package/src/errors.ts +9 -0
  47. package/src/explorer.ts +2 -2
  48. package/src/index.ts +18 -1
  49. package/src/reconstruct.ts +111 -10
  50. package/src/report.ts +155 -0
  51. package/src/resolve.ts +17 -2
package/src/erc7201.ts ADDED
@@ -0,0 +1,412 @@
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
+ // Not exported from the package index; the package has no exports map, and
15
+ // all exist since 1.46.0, the lowest version slotdiff accepts.
16
+ import { getStorageLocationAnnotation } from "@openzeppelin/upgrades-core/dist/storage/namespace.js";
17
+ import { calculateERC7201StorageLocation } from "@openzeppelin/upgrades-core/dist/utils/erc7201.js";
18
+ import { resolveBaseSlot } from "@openzeppelin/upgrades-core/dist/utils/erc7201-builtin.js";
19
+ import {
20
+ normalizeUint256Literal,
21
+ toBytes32Hex,
22
+ } from "@openzeppelin/upgrades-core/dist/utils/integer-literals.js";
23
+
24
+ export interface NamespaceSlot {
25
+ /** `erc7201:<id>`, as annotated. */
26
+ namespace: string;
27
+ /** The slot the annotation implies. */
28
+ expected: string;
29
+ /** The slot the code assigns, when it could be read and is unique. */
30
+ actual?: string;
31
+ /**
32
+ * `match`: the code uses the annotated slot. `mismatch`: it uses `actual`
33
+ * instead. `conflict`: it assigns different slots in different places.
34
+ * `unread`: no assignment was found, or one could not be evaluated.
35
+ */
36
+ status: "match" | "mismatch" | "conflict" | "unread";
37
+ }
38
+
39
+ interface AstNode {
40
+ nodeType?: string;
41
+ id?: number;
42
+ [key: string]: unknown;
43
+ }
44
+
45
+ /**
46
+ * How each of `namespaces` (`erc7201:<id>` keys of `contract`'s layout,
47
+ * `source.sol:Name`) is assigned in `output`. Only structs declared in the
48
+ * contract or its bases count, as for OpenZeppelin: a sibling contract in the
49
+ * same build (a V1 next to its V2, a test copy) may declare the same id.
50
+ */
51
+ export function namespaceSlots(
52
+ output: SolcOutput,
53
+ contract: string,
54
+ namespaces: string[],
55
+ ): NamespaceSlot[] {
56
+ const colon = contract.lastIndexOf(":");
57
+ // Most derived first, as solc lists them: the order virtual calls resolve in.
58
+ let linearized: number[] = [];
59
+ walk(output.sources?.[contract.slice(0, colon)]?.ast, (node) => {
60
+ if (node.nodeType === "ContractDefinition" && node.name === contract.slice(colon + 1)) {
61
+ linearized = node.linearizedBaseContracts as number[];
62
+ }
63
+ });
64
+ const bases = new Set(linearized);
65
+
66
+ // Only code the contract can run counts: its bases (minus the functions they
67
+ // override), libraries and free functions, not a sibling contract (a test
68
+ // harness) in the same files.
69
+ const byId = new Map<number, AstNode>();
70
+ const touched = new Set<number>();
71
+ const overridden = new Set<number>();
72
+ for (const source of Object.values(output.sources ?? {})) {
73
+ walk(source.ast as unknown as AstNode, (node) => {
74
+ if (typeof node.id === "number") byId.set(node.id, node);
75
+ // Any Solidity use of a local after its declaration, so a local is read
76
+ // only when nothing but assembly reads it.
77
+ if (node.nodeType === "Identifier") touched.add(node.referencedDeclaration as number);
78
+ if (node.nodeType === "FunctionDefinition" && bases.has(node.scope as number)) {
79
+ for (const base of (node.baseFunctions ?? []) as number[]) overridden.add(base);
80
+ }
81
+ });
82
+ }
83
+ const assemblies: AstNode[] = [];
84
+ for (const source of Object.values(output.sources ?? {})) {
85
+ walk(source.ast as unknown as AstNode, (node) => {
86
+ const sibling =
87
+ node.nodeType === "ContractDefinition" &&
88
+ node.contractKind !== "library" &&
89
+ !bases.has(node.id as number);
90
+ if (sibling || overridden.has(node.id as number)) return false;
91
+ if (node.nodeType === "InlineAssembly") assemblies.push(node);
92
+ });
93
+ }
94
+ const ctx = { byId, linearized, bases, touched };
95
+
96
+ // Struct id → every slot value assigned to a pointer of that struct (undefined: unreadable).
97
+ const assigned = new Map<number, Array<string | undefined>>();
98
+ for (const assembly of assemblies) {
99
+ const refs = (assembly.externalReferences ?? []) as Array<{
100
+ declaration: number;
101
+ src: string;
102
+ suffix?: string;
103
+ }>;
104
+ const declarationAt = (src: unknown) => refs.find((r) => r.src === src)?.declaration ?? -1;
105
+ walk(assembly.AST as AstNode, (node) => {
106
+ if (node.nodeType !== "YulAssignment") return;
107
+ for (const target of node.variableNames as AstNode[]) {
108
+ if (typeof target.name !== "string") continue;
109
+ if (!target.name.endsWith(".slot")) {
110
+ touched.add(declarationAt(target.src));
111
+ continue;
112
+ }
113
+ const pointer = byId.get(declarationAt(target.src));
114
+ const struct = (pointer?.typeName as AstNode | undefined)?.referencedDeclaration;
115
+ if (typeof struct !== "number") continue;
116
+ const value = node.value as AstNode;
117
+ const slot =
118
+ value.nodeType === "YulLiteral"
119
+ ? value.kind === "number"
120
+ ? normalize(value.value)
121
+ : undefined
122
+ : resolveDecl(ctx, byId.get(declarationAt(value.src)));
123
+ assigned.set(struct, [...(assigned.get(struct) ?? []), slot]);
124
+ }
125
+ });
126
+ }
127
+
128
+ return namespaces.map((namespace) => {
129
+ const expected = calculateERC7201StorageLocation(namespace.slice("erc7201:".length));
130
+ const slots = [...byId.values()]
131
+ .filter(
132
+ (n) =>
133
+ n.nodeType === "StructDefinition" &&
134
+ bases.has(n.scope as number) &&
135
+ getStorageLocationAnnotation(n as never) === namespace,
136
+ )
137
+ .flatMap((n) => assigned.get(n.id!) ?? []);
138
+ const known = [...new Set(slots.filter((s) => s !== undefined))];
139
+ if (known.length > 1) return { namespace, expected, status: "conflict" };
140
+ if (known.length === 0 || slots.includes(undefined)) {
141
+ return { namespace, expected, status: "unread" };
142
+ }
143
+ const actual = known[0];
144
+ return actual === expected
145
+ ? { namespace, expected, actual, status: "match" }
146
+ : { namespace, expected, actual, status: "mismatch" };
147
+ });
148
+ }
149
+
150
+ export interface NamespacePair {
151
+ old?: NamespaceSlot;
152
+ next?: NamespaceSlot;
153
+ /**
154
+ * Why this pair fails the check: `<new>` assigns several slots (`conflict`),
155
+ * leaves the old data behind (`moved`), or keeps the data in place under an
156
+ * annotation that does not match (`mismatch`). `unread`: a slot could not be
157
+ * read, so the pair could not be checked.
158
+ */
159
+ problem?: "conflict" | "moved" | "mismatch" | "unread";
160
+ }
161
+
162
+ export interface NamespaceCheck {
163
+ /**
164
+ * `old`'s layout with each paired namespace under its name in `<new>`, for
165
+ * `diffLayouts`: OpenZeppelin then compares the structs of the same storage.
166
+ * An unpaired namespace keeps its name, so one that is really gone is still
167
+ * reported as deleted.
168
+ */
169
+ layout: StorageLayout;
170
+ /** Every namespace of either side, old and new paired when they are the same storage. */
171
+ pairs: NamespacePair[];
172
+ /** `fail` when any pair has a problem other than `unread`; `unread` when a slot could not be read. */
173
+ result: "pass" | "fail" | "unread";
174
+ }
175
+
176
+ /**
177
+ * Checks ERC-7201 namespaces by the slot the code uses, not the annotation
178
+ * OpenZeppelin goes by. An old and a new namespace are the same storage when
179
+ * the code uses the same slot (under the same annotation first), failing that
180
+ * when they share an annotation, and failing that when the new annotation
181
+ * names the slot where the old data is.
182
+ * Pass `layout` to `diffLayouts` in place of `old.layout`.
183
+ */
184
+ export function checkNamespaces(
185
+ old: { layout: StorageLayout; namespaces: NamespaceSlot[] },
186
+ next: { namespaces: NamespaceSlot[] },
187
+ ): NamespaceCheck {
188
+ const unpaired = new Set(old.namespaces);
189
+ const pairs: NamespacePair[] = next.namespaces.map((n) => ({ next: n }));
190
+ const sameSlot = (o: NamespaceSlot, n: NamespaceSlot) =>
191
+ o.actual !== undefined && n.actual !== undefined && same(o.actual, n.actual);
192
+ const rules: Array<(o: NamespaceSlot, n: NamespaceSlot) => boolean> = [
193
+ (o, n) => sameSlot(o, n) && o.namespace === n.namespace,
194
+ sameSlot,
195
+ (o, n) => o.namespace === n.namespace,
196
+ (o, n) => o.actual !== undefined && same(o.actual, n.expected),
197
+ ];
198
+ // Rule by rule over every namespace, so a weaker rule never takes a pair from a stronger one.
199
+ for (const rule of rules) {
200
+ for (const pair of pairs) {
201
+ const o = pair.old ?? [...unpaired].find((o) => rule(o, pair.next!));
202
+ if (o !== undefined && unpaired.delete(o)) pair.old = o;
203
+ }
204
+ }
205
+ for (const o of unpaired) pairs.push({ old: o });
206
+ for (const pair of pairs) pair.problem = problemOf(pair);
207
+
208
+ // Renamed all at once, so two namespaces can swap names.
209
+ const renames = new Map(
210
+ pairs.flatMap((p) =>
211
+ p.old !== undefined && p.next !== undefined ? [[p.old.namespace, p.next.namespace]] : [],
212
+ ),
213
+ );
214
+ const oldNames = Object.keys(old.layout.namespaces ?? {});
215
+ const renamed = oldNames.map((name) => renames.get(name) ?? name);
216
+ // On a clash (an old namespace that stays keeps the name another would take)
217
+ // nothing is renamed: OpenZeppelin then reports the moved one as deleted.
218
+ const layout =
219
+ new Set(renamed).size < renamed.length || renamed.every((to, i) => to === oldNames[i])
220
+ ? old.layout
221
+ : {
222
+ ...old.layout,
223
+ namespaces: Object.fromEntries(
224
+ oldNames.map((name, i) => [renamed[i], old.layout.namespaces![name]]),
225
+ ),
226
+ };
227
+
228
+ const problems = pairs.map((p) => p.problem);
229
+ return {
230
+ layout,
231
+ pairs,
232
+ result: problems.some((p) => p !== undefined && p !== "unread")
233
+ ? "fail"
234
+ : problems.includes("unread")
235
+ ? "unread"
236
+ : "pass",
237
+ };
238
+ }
239
+
240
+ function problemOf({ old, next }: NamespacePair): NamespacePair["problem"] {
241
+ if (next?.status === "conflict") return "conflict";
242
+ if (old?.actual !== undefined && next?.actual !== undefined && !same(old.actual, next.actual)) {
243
+ return "moved";
244
+ }
245
+ if (next?.status === "mismatch") return "mismatch";
246
+ if ((old !== undefined && old.actual === undefined) || next?.status === "unread") {
247
+ return "unread";
248
+ }
249
+ return undefined;
250
+ }
251
+
252
+ function same(a: string, b: string): boolean {
253
+ return BigInt(a) === BigInt(b);
254
+ }
255
+
256
+ // Visits every node; a visitor returning false skips the node's children.
257
+ function walk(node: unknown, visit: (node: AstNode) => boolean | void): void {
258
+ if (Array.isArray(node)) {
259
+ for (const child of node) walk(child, visit);
260
+ } else if (typeof node === "object" && node !== null) {
261
+ if (visit(node as AstNode) === false) return;
262
+ for (const child of Object.values(node)) walk(child, visit);
263
+ }
264
+ }
265
+
266
+ interface Context {
267
+ byId: Map<number, AstNode>;
268
+ linearized: number[];
269
+ bases: Set<number>;
270
+ /** Declarations that Solidity code uses, or assembly assigns. */
271
+ touched: Set<number>;
272
+ }
273
+
274
+ /**
275
+ * The value of a constant, an immutable set where it is declared, or a local
276
+ * that only assembly reads. A state variable's initial value is not read: it
277
+ * lives in the implementation's storage, not the proxy's.
278
+ */
279
+ function resolveDecl(ctx: Context, decl: AstNode | undefined, depth = 0): string | undefined {
280
+ if (decl === undefined) return undefined;
281
+ if (decl.stateVariable === true) {
282
+ if (decl.mutability !== "constant" && decl.mutability !== "immutable") return undefined;
283
+ return resolveExpr(ctx, decl.value as AstNode | undefined, depth);
284
+ }
285
+ if (ctx.touched.has(decl.id as number)) return undefined;
286
+ return resolveExpr(ctx, initialValue(ctx.byId, decl) as AstNode | undefined, depth);
287
+ }
288
+
289
+ /**
290
+ * The value of an expression: a reference to a constant or local, a call to a
291
+ * no-argument function that returns one (OpenZeppelin's Initializable reads
292
+ * its slot from `_initializableStorageSlot()`), the ERC-7201 formula, a
293
+ * number literal or `erc7201("id")`.
294
+ */
295
+ function resolveExpr(ctx: Context, e: AstNode | undefined, depth: number): string | undefined {
296
+ if (e === undefined || depth > 8) return undefined;
297
+ if (e.nodeType === "Identifier") {
298
+ return resolveDecl(ctx, ctx.byId.get(e.referencedDeclaration as number), depth + 1);
299
+ }
300
+ if (e.nodeType === "FunctionCall" && e.kind === "functionCall") {
301
+ const callee = e.expression as AstNode;
302
+ const fn = ctx.byId.get(callee.referencedDeclaration as number);
303
+ if (callee.nodeType !== "Identifier" || fn?.nodeType !== "FunctionDefinition") return undefined;
304
+ if ((e.arguments as unknown[]).length > 0) return undefined;
305
+ return resolveExpr(ctx, returned(override(ctx, fn)), depth + 1);
306
+ }
307
+ const id = formulaId(e);
308
+ if (id !== undefined) return calculateERC7201StorageLocation(id);
309
+ // OpenZeppelin would read any other literal, such as `hex"..."`, as a number.
310
+ if (e.nodeType === "Literal" && e.kind !== "number") return undefined;
311
+ try {
312
+ return resolveBaseSlot(e as never);
313
+ } catch {
314
+ return undefined;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * The implementation an internal call to `fn` reaches: the most derived
320
+ * override of a virtual function in the bases, or `fn` itself (a free or
321
+ * library function, which nothing overrides).
322
+ */
323
+ function override(ctx: Context, fn: AstNode): AstNode {
324
+ if (fn.virtual !== true || !ctx.bases.has(fn.scope as number)) return fn;
325
+ for (const contract of ctx.linearized) {
326
+ for (const node of ctx.byId.values()) {
327
+ if (
328
+ node.nodeType === "FunctionDefinition" &&
329
+ node.scope === contract &&
330
+ node.name === fn.name &&
331
+ (node.parameters as { parameters: unknown[] }).parameters.length === 0 &&
332
+ node.body
333
+ ) {
334
+ return node;
335
+ }
336
+ }
337
+ }
338
+ return fn;
339
+ }
340
+
341
+ // The expression of a function whose body is a single `return <expression>;`
342
+ // and that has no modifier, which could skip it.
343
+ function returned(fn: AstNode): AstNode | undefined {
344
+ if ((fn.modifiers as unknown[] | undefined)?.length) return undefined;
345
+ const statements = (fn.body as { statements?: AstNode[] } | null)?.statements;
346
+ return statements?.length === 1 && statements[0].nodeType === "Return"
347
+ ? (statements[0].expression as AstNode)
348
+ : undefined;
349
+ }
350
+
351
+ // A local variable's value is on the statement that declares it.
352
+ function initialValue(byId: Map<number, AstNode>, decl: AstNode): unknown {
353
+ for (const node of byId.values()) {
354
+ if (node.nodeType !== "VariableDeclarationStatement") continue;
355
+ const declarations = node.declarations as Array<AstNode | null>;
356
+ if (declarations.length === 1 && declarations[0]?.id === decl.id) return node.initialValue;
357
+ }
358
+ return undefined;
359
+ }
360
+
361
+ /**
362
+ * The id in the ERC-7201 formula written out in Solidity,
363
+ * `keccak256(abi.encode(uint256(keccak256("id")) - 1)) & ~bytes32(uint256(0xff))`,
364
+ * with an optional `bytes("id")`. Anything else is not recognized.
365
+ */
366
+ function formulaId(e: AstNode): string | undefined {
367
+ if (e.nodeType !== "BinaryOperation" || e.operator !== "&") return undefined;
368
+ const mask = e.rightExpression as AstNode;
369
+ const masked = arg(
370
+ arg(
371
+ mask.nodeType === "UnaryOperation" ? (mask.subExpression as AstNode) : undefined,
372
+ "bytes32",
373
+ ),
374
+ "uint256",
375
+ );
376
+ if (masked?.nodeType !== "Literal" || normalize(masked.value) !== toBytes32Hex(0xffn)) {
377
+ return undefined;
378
+ }
379
+ const minus = arg(arg(e.leftExpression as AstNode, "keccak256"), "abi.encode");
380
+ if (minus?.nodeType !== "BinaryOperation" || minus.operator !== "-") return undefined;
381
+ const one = minus.rightExpression as AstNode;
382
+ if (one.nodeType !== "Literal" || one.value !== "1") return undefined;
383
+ let id = arg(arg(minus.leftExpression as AstNode, "uint256"), "keccak256");
384
+ if (id?.nodeType === "FunctionCall") id = arg(id, "bytes");
385
+ return id?.nodeType === "Literal" && id.kind === "string" ? (id.value as string) : undefined;
386
+ }
387
+
388
+ // The single argument of a call to `name` (a function, `abi.encode`, or a type conversion).
389
+ function arg(call: AstNode | undefined, name: string): AstNode | undefined {
390
+ if (call?.nodeType !== "FunctionCall") return undefined;
391
+ const callee = call.expression as AstNode;
392
+ const calleeName =
393
+ callee.nodeType === "Identifier"
394
+ ? callee.name
395
+ : callee.nodeType === "MemberAccess"
396
+ ? `${(callee.expression as AstNode).name}.${callee.memberName}`
397
+ : callee.nodeType === "ElementaryTypeNameExpression"
398
+ ? (callee.typeName as AstNode).name
399
+ : undefined;
400
+ const args = call.arguments as AstNode[];
401
+ return calleeName === name && args.length === 1 ? args[0] : undefined;
402
+ }
403
+
404
+ // A number literal as a slot, or undefined when it is not one.
405
+ function normalize(value: unknown): string | undefined {
406
+ if (typeof value !== "string") return undefined;
407
+ try {
408
+ return normalizeUint256Literal(value);
409
+ } catch {
410
+ return undefined;
411
+ }
412
+ }
package/src/errors.ts CHANGED
@@ -22,3 +22,12 @@ export class LayoutIntegrityError extends Error {
22
22
  this.name = "LayoutIntegrityError";
23
23
  }
24
24
  }
25
+
26
+ /**
27
+ * A network error's code (ECONNREFUSED, ENOTFOUND), never its message: fetch
28
+ * quotes the URL in some messages, and URLs carry API keys.
29
+ */
30
+ export function errorCode(e: unknown): string {
31
+ const code = (e as { cause?: { code?: unknown } }).cause?.code;
32
+ return typeof code === "string" ? code : "network error";
33
+ }
package/src/explorer.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { SolcInput } from "@openzeppelin/upgrades-core";
11
- import { LayoutUnavailableError } from "./errors.js";
11
+ import { errorCode, LayoutUnavailableError } from "./errors.js";
12
12
 
13
13
  export const ETHERSCAN_V2_API_URL = "https://api.etherscan.io/v2/api";
14
14
 
@@ -87,7 +87,7 @@ async function requestWithRetry(url: string, fetchImpl: FetchLike): Promise<Sour
87
87
  res = await fetchImpl(url);
88
88
  } catch (e) {
89
89
  // Messages never include the URL, which carries the API key.
90
- throw new LayoutUnavailableError(`Explorer unreachable: ${(e as Error).message}`);
90
+ throw new LayoutUnavailableError(`Explorer unreachable (${errorCode(e)}).`);
91
91
  }
92
92
 
93
93
  if (res.status === 429 && retry) {
package/src/index.ts CHANGED
@@ -24,7 +24,24 @@ export {
24
24
  type VerifiedSource,
25
25
  } from "./explorer.js";
26
26
  export { getSolc, type SolcOptions, type SolcRunner } from "./solc.js";
27
- export { layoutFromSource } from "./reconstruct.js";
27
+ export { findBuildInfo, type BuildInfoOptions, type BuildInfoContract } from "./build-info.js";
28
+ export {
29
+ checkNamespaces,
30
+ type NamespaceCheck,
31
+ type NamespacePair,
32
+ type NamespaceSlot,
33
+ } from "./erc7201.js";
34
+ export {
35
+ layoutFromSource,
36
+ analyzeSource,
37
+ SAFETY_ERROR_KINDS,
38
+ type SourceAnalysis,
39
+ type SafetyOptions,
40
+ type SafetyReport,
41
+ type SafetyError,
42
+ type SafetyErrorKind,
43
+ type ProxyKind,
44
+ } from "./reconstruct.js";
28
45
  export {
29
46
  compareDeployedBytecode,
30
47
  isProvingMatch,
@@ -8,27 +8,35 @@
8
8
  */
9
9
 
10
10
  import {
11
+ getErrors,
11
12
  getStorageLayout,
13
+ inferProxyKind,
12
14
  isNamespaceSupported,
13
15
  makeNamespacedInput,
14
16
  solcInputOutputDecoder,
15
17
  trySanitizeNatSpec,
18
+ UpgradeableContractErrorReport,
16
19
  validate,
20
+ type ProxyDeployment,
17
21
  type SolcInput,
18
22
  type SolcOutput,
19
23
  type StorageLayout,
24
+ type ValidationRunData,
25
+ type Version,
20
26
  } from "@openzeppelin/upgrades-core";
27
+ // Not exported from the package index; the package has no exports map.
28
+ import { errorKinds, type ValidationError } from "@openzeppelin/upgrades-core/dist/validate/run.js";
21
29
  import { compareDeployedBytecode, isProvingMatch, type ProvingMatch } from "./bytecode.js";
30
+ import { namespaceSlots, type NamespaceSlot } from "./erc7201.js";
22
31
  import { LayoutIntegrityError } from "./errors.js";
23
32
  import type { VerifiedSource } from "./explorer.js";
24
33
  import type { SolcRunner } from "./solc.js";
25
34
 
26
- export interface ReconstructedLayout {
35
+ export interface ReconstructedLayout extends SourceAnalysis {
27
36
  /** Fully qualified name, `source.sol:Contract`. */
28
37
  contract: string;
29
38
  compiler: string;
30
39
  bytecodeMatch: ProvingMatch;
31
- layout: StorageLayout;
32
40
  }
33
41
 
34
42
  // validate() reads evm.bytecode for every contract, so codegen is requested
@@ -68,7 +76,7 @@ export async function reconstructLayout(
68
76
  contract,
69
77
  compiler: source.solcLongVersion,
70
78
  bytecodeMatch,
71
- layout: await extractLayout(input, output, solc, contract),
79
+ ...(await analyze(input, output, solc, contract)),
72
80
  };
73
81
  }
74
82
 
@@ -82,17 +90,109 @@ export async function layoutFromSource(
82
90
  solc: SolcRunner,
83
91
  contract: string,
84
92
  ): Promise<StorageLayout> {
93
+ return (await analyzeSource(input, solc, contract)).layout;
94
+ }
95
+
96
+ export type ProxyKind = ProxyDeployment["kind"];
97
+
98
+ /**
99
+ * OpenZeppelin's safety error kinds, without the deprecated struct-definition
100
+ * and enum-definition, which the check never reports.
101
+ */
102
+ export type SafetyErrorKind = Exclude<
103
+ ValidationError["kind"],
104
+ "struct-definition" | "enum-definition"
105
+ >;
106
+
107
+ export const SAFETY_ERROR_KINDS: readonly SafetyErrorKind[] = errorKinds.filter(
108
+ (k): k is SafetyErrorKind => k !== "struct-definition" && k !== "enum-definition",
109
+ );
110
+
111
+ export interface SafetyError {
112
+ /** OpenZeppelin's error kind, such as `constructor`. */
113
+ kind: SafetyErrorKind;
114
+ /** Where, as `file.sol:line`. */
115
+ src: string;
116
+ }
117
+
118
+ export interface SafetyOptions {
119
+ /**
120
+ * The proxy being upgraded; only `uups` requires an `upgradeToAndCall`.
121
+ * Required: the new contract's own `kind` would let a UUPS implementation
122
+ * that lost its upgrade function pass.
123
+ */
124
+ kind: ProxyKind;
125
+ /** Error kinds to accept anyway (OZ's `unsafeAllow`). Prefer NatSpec exceptions in the source. */
126
+ unsafeAllow?: SafetyErrorKind[];
127
+ }
128
+
129
+ export interface SafetyReport {
130
+ ok: boolean;
131
+ errors: SafetyError[];
132
+ /** OpenZeppelin's description of each error. */
133
+ explain(): string;
134
+ }
135
+
136
+ export interface SourceAnalysis {
137
+ layout: StorageLayout;
138
+ /** `uups` when the contract has `upgradeTo` or `upgradeToAndCall`, else `transparent`. */
139
+ kind: Exclude<ProxyKind, "beacon">;
140
+ /**
141
+ * The slot each ERC-7201 namespace really uses, read from the code
142
+ * (`$.slot := LOCATION`); OpenZeppelin trusts the annotation alone.
143
+ */
144
+ namespaces: NamespaceSlot[];
145
+ /**
146
+ * OpenZeppelin's safety check (constructors, `selfdestruct`,
147
+ * `delegatecall`, initial values, immutables, initializers), honoring
148
+ * `@custom:oz-upgrades-unsafe-allow` in the source.
149
+ */
150
+ safety(options: SafetyOptions): SafetyReport;
151
+ }
152
+
153
+ /** `layoutFromSource` plus OpenZeppelin's safety check, from one compile. */
154
+ export async function analyzeSource(
155
+ input: SolcInput,
156
+ solc: SolcRunner,
157
+ contract: string,
158
+ ): Promise<SourceAnalysis> {
85
159
  const selected = withOutputSelection(input, FULL_SELECTION);
86
- const output = await compileOrThrow(solc, selected, "source");
87
- return extractLayout(selected, output, solc, contract);
160
+ return analyze(selected, await compileOrThrow(solc, selected, "source"), solc, contract);
88
161
  }
89
162
 
90
- async function extractLayout(
163
+ async function analyze(
91
164
  input: SolcInput,
92
165
  output: SolcOutput,
93
166
  solc: SolcRunner,
94
167
  contract: string,
95
- ): Promise<StorageLayout> {
168
+ ): Promise<SourceAnalysis> {
169
+ const { data, version } = await validateContract(input, output, solc, contract);
170
+ const layout = getStorageLayout(data, version);
171
+ return {
172
+ layout,
173
+ // Typed as any ProxyKind, but only ever uups or transparent.
174
+ kind: inferProxyKind(data, version) as SourceAnalysis["kind"],
175
+ namespaces: namespaceSlots(output, contract, Object.keys(layout.namespaces ?? {})),
176
+ safety({ kind, unsafeAllow = [] }) {
177
+ // A copy: OpenZeppelin appends to the array it is given.
178
+ const opts = { kind, unsafeAllow: [...unsafeAllow] };
179
+ // The deprecated kinds are never reported, so every error has a SafetyErrorKind.
180
+ const errors = getErrors(data, version, opts) as Array<ValidationError & SafetyError>;
181
+ return {
182
+ ok: errors.length === 0,
183
+ errors,
184
+ explain: () => new UpgradeableContractErrorReport(errors).explain(),
185
+ };
186
+ },
187
+ };
188
+ }
189
+
190
+ async function validateContract(
191
+ input: SolcInput,
192
+ output: SolcOutput,
193
+ solc: SolcRunner,
194
+ contract: string,
195
+ ): Promise<{ data: ValidationRunData; version: Version }> {
96
196
  const solcVersion = solc.longVersion.split("+")[0];
97
197
 
98
198
  let namespacedOutput: SolcOutput | undefined;
@@ -116,13 +216,14 @@ async function extractLayout(
116
216
  const version = runData[contract]?.version;
117
217
  if (version === undefined) {
118
218
  throw new LayoutIntegrityError(
119
- `${contract} compiled to no bytecode; cannot derive its layout.`,
219
+ `${contract} compiled to no bytecode (an abstract contract or interface?); ` +
220
+ `cannot derive its layout.`,
120
221
  );
121
222
  }
122
223
  // OZ looks the contract up by bytecode version and takes the first match, so
123
224
  // two contracts with identical code (possible without a source hash) could
124
- // swap layouts. Listing the target first makes the lookup land on it.
125
- return getStorageLayout({ [contract]: runData[contract], ...runData }, version);
225
+ // swap layouts or safety errors. Listing the target first makes every lookup land on it.
226
+ return { data: { [contract]: runData[contract], ...runData }, version };
126
227
  }
127
228
 
128
229
  /**