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.
- package/CHANGELOG.md +34 -0
- package/README.md +55 -38
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +59 -33
- package/dist/cli.js.map +1 -1
- package/dist/erc7201.d.ts +27 -11
- package/dist/erc7201.d.ts.map +1 -1
- package/dist/erc7201.js +182 -13
- package/dist/erc7201.js.map +1 -1
- package/dist/errors.d.ts +14 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +20 -0
- package/dist/errors.js.map +1 -1
- package/dist/explorer.d.ts.map +1 -1
- package/dist/explorer.js +28 -9
- package/dist/explorer.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/reconstruct.d.ts +30 -15
- package/dist/reconstruct.d.ts.map +1 -1
- package/dist/reconstruct.js +140 -32
- package/dist/reconstruct.js.map +1 -1
- package/dist/record.d.ts +13 -0
- package/dist/record.d.ts.map +1 -1
- package/dist/record.js +8 -0
- package/dist/record.js.map +1 -1
- package/dist/report.d.ts +4 -1
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +37 -8
- package/dist/report.js.map +1 -1
- package/dist/resolve.d.ts +6 -1
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +6 -1
- package/dist/resolve.js.map +1 -1
- package/dist/solc.js +20 -4
- package/dist/solc.js.map +1 -1
- package/dist/upgrade.d.ts +48 -0
- package/dist/upgrade.d.ts.map +1 -0
- package/dist/upgrade.js +36 -0
- package/dist/upgrade.js.map +1 -0
- package/package.json +4 -4
- package/src/cli.ts +69 -33
- package/src/erc7201.ts +217 -26
- package/src/errors.ts +22 -0
- package/src/explorer.ts +39 -10
- package/src/index.ts +11 -3
- package/src/reconstruct.ts +206 -57
- package/src/record.ts +18 -0
- package/src/report.ts +44 -13
- package/src/resolve.ts +12 -2
- package/src/solc.ts +20 -4
- 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
|
-
* `
|
|
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
|
|
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")
|
|
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
|
|
131
|
-
|
|
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 (
|
|
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`),
|
|
156
|
-
* annotation that does not match (`mismatch`)
|
|
157
|
-
*
|
|
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
|
-
/**
|
|
173
|
-
|
|
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
|
|
186
|
-
next: {
|
|
251
|
+
old: { layout: StorageLayout; namespaces?: NamespaceSlot[] },
|
|
252
|
+
next: { layout: StorageLayout; namespaces?: NamespaceSlot[] },
|
|
187
253
|
): NamespaceCheck {
|
|
188
|
-
const unpaired = new Set(old
|
|
189
|
-
const pairs: NamespacePair[] = next.
|
|
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
|
-
? "
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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}";
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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";
|