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.
- package/CHANGELOG.md +16 -9
- package/README.md +196 -105
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +22 -0
- package/dist/bin.js.map +1 -0
- package/dist/build-info.d.ts +45 -0
- package/dist/build-info.d.ts.map +1 -0
- package/dist/build-info.js +319 -0
- package/dist/build-info.js.map +1 -0
- package/dist/cli.d.ts +25 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +363 -0
- package/dist/cli.js.map +1 -0
- package/dist/erc7201.d.ts +72 -0
- package/dist/erc7201.d.ts.map +1 -0
- package/dist/erc7201.js +340 -0
- package/dist/erc7201.js.map +1 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +8 -0
- package/dist/errors.js.map +1 -1
- package/dist/explorer.js +2 -2
- package/dist/explorer.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/reconstruct.d.ts +51 -3
- package/dist/reconstruct.d.ts.map +1 -1
- package/dist/reconstruct.js +37 -8
- package/dist/reconstruct.js.map +1 -1
- package/dist/report.d.ts +26 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +109 -0
- package/dist/report.js.map +1 -0
- package/dist/resolve.d.ts +11 -0
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +11 -1
- package/dist/resolve.js.map +1 -1
- package/package.json +6 -3
- package/src/bin.ts +25 -0
- package/src/build-info.ts +405 -0
- package/src/cli.ts +423 -0
- package/src/erc7201.ts +412 -0
- package/src/errors.ts +9 -0
- package/src/explorer.ts +2 -2
- package/src/index.ts +18 -1
- package/src/reconstruct.ts +111 -10
- package/src/report.ts +155 -0
- 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
|
|
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 {
|
|
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,
|
package/src/reconstruct.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
163
|
+
async function analyze(
|
|
91
164
|
input: SolcInput,
|
|
92
165
|
output: SolcOutput,
|
|
93
166
|
solc: SolcRunner,
|
|
94
167
|
contract: string,
|
|
95
|
-
): Promise<
|
|
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
|
|
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
|
|
125
|
-
return
|
|
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
|
/**
|