slotdiff 0.2.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 (54) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +55 -38
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +59 -33
  5. package/dist/cli.js.map +1 -1
  6. package/dist/erc7201.d.ts +27 -11
  7. package/dist/erc7201.d.ts.map +1 -1
  8. package/dist/erc7201.js +182 -13
  9. package/dist/erc7201.js.map +1 -1
  10. package/dist/errors.d.ts +14 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js +20 -0
  13. package/dist/errors.js.map +1 -1
  14. package/dist/explorer.d.ts.map +1 -1
  15. package/dist/explorer.js +28 -9
  16. package/dist/explorer.js.map +1 -1
  17. package/dist/index.d.ts +4 -3
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/reconstruct.d.ts +30 -15
  22. package/dist/reconstruct.d.ts.map +1 -1
  23. package/dist/reconstruct.js +140 -32
  24. package/dist/reconstruct.js.map +1 -1
  25. package/dist/record.d.ts +13 -0
  26. package/dist/record.d.ts.map +1 -1
  27. package/dist/record.js +8 -0
  28. package/dist/record.js.map +1 -1
  29. package/dist/report.d.ts +4 -1
  30. package/dist/report.d.ts.map +1 -1
  31. package/dist/report.js +37 -8
  32. package/dist/report.js.map +1 -1
  33. package/dist/resolve.d.ts +6 -1
  34. package/dist/resolve.d.ts.map +1 -1
  35. package/dist/resolve.js +6 -1
  36. package/dist/resolve.js.map +1 -1
  37. package/dist/solc.js +20 -4
  38. package/dist/solc.js.map +1 -1
  39. package/dist/upgrade.d.ts +48 -0
  40. package/dist/upgrade.d.ts.map +1 -0
  41. package/dist/upgrade.js +36 -0
  42. package/dist/upgrade.js.map +1 -0
  43. package/package.json +4 -4
  44. package/src/cli.ts +69 -33
  45. package/src/erc7201.ts +217 -26
  46. package/src/errors.ts +22 -0
  47. package/src/explorer.ts +39 -10
  48. package/src/index.ts +11 -3
  49. package/src/reconstruct.ts +206 -57
  50. package/src/record.ts +18 -0
  51. package/src/report.ts +44 -13
  52. package/src/resolve.ts +12 -2
  53. package/src/solc.ts +20 -4
  54. package/src/upgrade.ts +91 -0
package/src/erc7201.ts CHANGED
@@ -11,6 +11,9 @@
11
11
  */
12
12
 
13
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";
14
17
  // Not exported from the package index; the package has no exports map, and
15
18
  // all exist since 1.46.0, the lowest version slotdiff accepts.
16
19
  import { getStorageLocationAnnotation } from "@openzeppelin/upgrades-core/dist/storage/namespace.js";
@@ -31,9 +34,10 @@ export interface NamespaceSlot {
31
34
  /**
32
35
  * `match`: the code uses the annotated slot. `mismatch`: it uses `actual`
33
36
  * instead. `conflict`: it assigns different slots in different places.
34
- * `unread`: no assignment was found, or one could not be evaluated.
37
+ * `unused`: no code assigns its slot (a namespace kept reserved).
38
+ * `unread`: an assignment could not be evaluated.
35
39
  */
36
- status: "match" | "mismatch" | "conflict" | "unread";
40
+ status: "match" | "mismatch" | "conflict" | "unused" | "unread";
37
41
  }
38
42
 
39
43
  interface AstNode {
@@ -80,14 +84,24 @@ export function namespaceSlots(
80
84
  }
81
85
  });
82
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.
83
90
  const assemblies: AstNode[] = [];
91
+ const setAside: AstNode[] = [];
84
92
  for (const source of Object.values(output.sources ?? {})) {
85
93
  walk(source.ast as unknown as AstNode, (node) => {
86
94
  const sibling =
87
95
  node.nodeType === "ContractDefinition" &&
88
96
  node.contractKind !== "library" &&
89
97
  !bases.has(node.id as number);
90
- if (sibling || overridden.has(node.id as number)) return false;
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
+ }
91
105
  if (node.nodeType === "InlineAssembly") assemblies.push(node);
92
106
  });
93
107
  }
@@ -95,6 +109,20 @@ export function namespaceSlots(
95
109
 
96
110
  // Struct id → every slot value assigned to a pointer of that struct (undefined: unreadable).
97
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
+ }
98
126
  for (const assembly of assemblies) {
99
127
  const refs = (assembly.externalReferences ?? []) as Array<{
100
128
  declaration: number;
@@ -112,7 +140,10 @@ export function namespaceSlots(
112
140
  }
113
141
  const pointer = byId.get(declarationAt(target.src));
114
142
  const struct = (pointer?.typeName as AstNode | undefined)?.referencedDeclaration;
115
- if (typeof struct !== "number") continue;
143
+ if (typeof struct !== "number") {
144
+ untyped = true;
145
+ continue;
146
+ }
116
147
  const value = node.value as AstNode;
117
148
  const slot =
118
149
  value.nodeType === "YulLiteral"
@@ -125,19 +156,47 @@ export function namespaceSlots(
125
156
  });
126
157
  }
127
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
+
128
181
  return namespaces.map((namespace) => {
129
182
  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!) ?? []);
183
+ const structs = structsOf(namespace);
184
+ const slots = structs.flatMap((n) => assigned.get(n.id!) ?? []);
138
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
+ }
139
198
  if (known.length > 1) return { namespace, expected, status: "conflict" };
140
- if (known.length === 0 || slots.includes(undefined)) {
199
+ if (slots.includes(undefined)) {
141
200
  return { namespace, expected, status: "unread" };
142
201
  }
143
202
  const actual = known[0];
@@ -152,11 +211,12 @@ export interface NamespacePair {
152
211
  next?: NamespaceSlot;
153
212
  /**
154
213
  * 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.
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.
158
218
  */
159
- problem?: "conflict" | "moved" | "mismatch" | "unread";
219
+ problem?: "conflict" | "moved" | "mismatch" | "dropped" | "unread";
160
220
  }
161
221
 
162
222
  export interface NamespaceCheck {
@@ -169,8 +229,13 @@ export interface NamespaceCheck {
169
229
  layout: StorageLayout;
170
230
  /** Every namespace of either side, old and new paired when they are the same storage. */
171
231
  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";
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;
174
239
  }
175
240
 
176
241
  /**
@@ -178,15 +243,16 @@ export interface NamespaceCheck {
178
243
  * OpenZeppelin goes by. An old and a new namespace are the same storage when
179
244
  * the code uses the same slot (under the same annotation first), failing that
180
245
  * when they share an annotation, and failing that when the new annotation
181
- * names the slot where the old data is.
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.
182
248
  * Pass `layout` to `diffLayouts` in place of `old.layout`.
183
249
  */
184
250
  export function checkNamespaces(
185
- old: { layout: StorageLayout; namespaces: NamespaceSlot[] },
186
- next: { namespaces: NamespaceSlot[] },
251
+ old: { layout: StorageLayout; namespaces?: NamespaceSlot[] },
252
+ next: { layout: StorageLayout; namespaces?: NamespaceSlot[] },
187
253
  ): NamespaceCheck {
188
- const unpaired = new Set(old.namespaces);
189
- const pairs: NamespacePair[] = next.namespaces.map((n) => ({ next: n }));
254
+ const unpaired = new Set(slotsOf(old, "old"));
255
+ const pairs: NamespacePair[] = slotsOf(next, "new").map((n) => ({ next: n }));
190
256
  const sameSlot = (o: NamespaceSlot, n: NamespaceSlot) =>
191
257
  o.actual !== undefined && n.actual !== undefined && same(o.actual, n.actual);
192
258
  const rules: Array<(o: NamespaceSlot, n: NamespaceSlot) => boolean> = [
@@ -232,23 +298,148 @@ export function checkNamespaces(
232
298
  result: problems.some((p) => p !== undefined && p !== "unread")
233
299
  ? "fail"
234
300
  : problems.includes("unread")
235
- ? "unread"
301
+ ? "unchecked"
236
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"),
237
309
  };
238
310
  }
239
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
+
240
390
  function problemOf({ old, next }: NamespacePair): NamespacePair["problem"] {
241
391
  if (next?.status === "conflict") return "conflict";
242
392
  if (old?.actual !== undefined && next?.actual !== undefined && !same(old.actual, next.actual)) {
243
393
  return "moved";
244
394
  }
245
395
  if (next?.status === "mismatch") return "mismatch";
246
- if ((old !== undefined && old.actual === undefined) || next?.status === "unread") {
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") {
247
403
  return "unread";
248
404
  }
249
405
  return undefined;
250
406
  }
251
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
+
252
443
  function same(a: string, b: string): boolean {
253
444
  return BigInt(a) === BigInt(b);
254
445
  }
package/src/errors.ts CHANGED
@@ -23,6 +23,28 @@ export class LayoutIntegrityError extends Error {
23
23
  }
24
24
  }
25
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
+
26
48
  /**
27
49
  * A network error's code (ECONNREFUSED, ENOTFOUND), never its message: fetch
28
50
  * quotes the URL in some messages, and URLs carry API keys.
package/src/explorer.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { SolcInput } from "@openzeppelin/upgrades-core";
11
- import { errorCode, LayoutUnavailableError } from "./errors.js";
11
+ import { errorCode, LayoutUnavailableError, printable } from "./errors.js";
12
12
 
13
13
  export const ETHERSCAN_V2_API_URL = "https://api.etherscan.io/v2/api";
14
14
 
@@ -115,8 +115,12 @@ async function requestWithRetry(url: string, fetchImpl: FetchLike): Promise<Sour
115
115
  return entry as SourceCodeEntry;
116
116
  }
117
117
 
118
- const detail =
119
- typeof body.result === "string" ? body.result : (body.message ?? "unknown error");
118
+ // One line: a line break could draw a verdict of its own under the error.
119
+ const detail = printable(
120
+ String(typeof body.result === "string" ? body.result : (body.message ?? "unknown error")),
121
+ )
122
+ .replace(/\s+/g, " ")
123
+ .slice(0, 300);
120
124
  // Before the key check: Etherscan's rate-limit message mentions the API key.
121
125
  if (RATE_LIMIT.test(detail)) {
122
126
  if (retry) {
@@ -134,6 +138,10 @@ function backoff(attempt: number): Promise<void> {
134
138
  return new Promise((r) => setTimeout(r, 1000 * attempt));
135
139
  }
136
140
 
141
+ // Control characters other than tabs and line breaks (\v, \f and \r included).
142
+ // eslint-disable-next-line no-control-regex
143
+ const CONTROL = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f]/;
144
+
137
145
  /** Exported for tests. */
138
146
  export function parseSourceCodeEntry(entry: SourceCodeEntry, address: string): VerifiedSource {
139
147
  const raw = entry.SourceCode ?? "";
@@ -144,7 +152,8 @@ export function parseSourceCodeEntry(entry: SourceCodeEntry, address: string): V
144
152
  const compilerVersion = entry.CompilerVersion ?? "";
145
153
  if (!compilerVersion.startsWith("v0.")) {
146
154
  throw new LayoutUnavailableError(
147
- `Contract ${address} was verified with "${compilerVersion}"; only Solidity is supported.`,
155
+ `Contract ${address} was verified with "${printable(compilerVersion).slice(0, 80)}"; ` +
156
+ "only Solidity is supported.",
148
157
  );
149
158
  }
150
159
 
@@ -154,7 +163,8 @@ export function parseSourceCodeEntry(entry: SourceCodeEntry, address: string): V
154
163
  )
155
164
  ) {
156
165
  throw new LayoutUnavailableError(
157
- `Contract ${address} reports an unrecognized compiler version "${compilerVersion}".`,
166
+ `Contract ${address} reports an unrecognized compiler version ` +
167
+ `"${printable(compilerVersion).slice(0, 80)}".`,
158
168
  );
159
169
  }
160
170
 
@@ -166,11 +176,30 @@ export function parseSourceCodeEntry(entry: SourceCodeEntry, address: string): V
166
176
  );
167
177
  }
168
178
 
169
- return {
170
- input,
171
- solcLongVersion: compilerVersion.slice(1),
172
- contractName: entry.ContractName ?? "",
173
- };
179
+ // Names, paths and source text (annotations, quoted by solc) reach the
180
+ // screen; real source holds no control characters but tabs and line breaks.
181
+ const contractName = entry.ContractName ?? "";
182
+ const sources = Object.values(input.sources) as Array<{ content?: unknown }>;
183
+ // Every source inline: solc would fetch `urls` itself.
184
+ if (sources.some((s) => typeof s?.content !== "string")) {
185
+ throw new LayoutUnavailableError(
186
+ `Contract ${address} is verified with sources that are not inline (urls); only content is read.`,
187
+ );
188
+ }
189
+ const contents = sources.map((s) => s.content as string);
190
+ if (
191
+ // Names and paths are printed on one line: no whitespace but a space.
192
+ [contractName, ...Object.keys(input.sources)].some(
193
+ (s) => printable(s) !== s || /[^\S ]/.test(s),
194
+ ) ||
195
+ contents.some((s) => CONTROL.test(s))
196
+ ) {
197
+ throw new LayoutUnavailableError(
198
+ `Contract ${address} is verified with control characters in its name, paths or source.`,
199
+ );
200
+ }
201
+
202
+ return { input, solcLongVersion: compilerVersion.slice(1), contractName };
174
203
  }
175
204
 
176
205
  // Etherscan wraps standard-json in an extra pair of braces (`{{...}}`); some
package/src/index.ts CHANGED
@@ -32,8 +32,16 @@ export {
32
32
  type NamespaceSlot,
33
33
  } from "./erc7201.js";
34
34
  export {
35
- layoutFromSource,
35
+ checkUpgrade,
36
+ type UpgradeCheck,
37
+ type UpgradeOptions,
38
+ type UpgradeSide,
39
+ } from "./upgrade.js";
40
+ export {
36
41
  analyzeSource,
42
+ analyzeBuild,
43
+ type BuildAnalysis,
44
+ type BuildCompiler,
37
45
  SAFETY_ERROR_KINDS,
38
46
  type SourceAnalysis,
39
47
  type SafetyOptions,
@@ -49,5 +57,5 @@ export {
49
57
  type ProvingMatch,
50
58
  type ImmutableReferences,
51
59
  } from "./bytecode.js";
52
- export { LayoutIntegrityError, LayoutUnavailableError } from "./errors.js";
53
- export type { SolcInput, StorageLayout } from "@openzeppelin/upgrades-core";
60
+ export { IncompleteBuildError, LayoutIntegrityError, LayoutUnavailableError } from "./errors.js";
61
+ export type { SolcInput, SolcOutput, StorageLayout } from "@openzeppelin/upgrades-core";