slotdiff 0.0.0-stage → 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 (79) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +10 -0
  3. package/README.md +304 -2
  4. package/dist/bin.d.ts +3 -0
  5. package/dist/bin.d.ts.map +1 -0
  6. package/dist/bin.js +22 -0
  7. package/dist/bin.js.map +1 -0
  8. package/dist/build-info.d.ts +45 -0
  9. package/dist/build-info.d.ts.map +1 -0
  10. package/dist/build-info.js +319 -0
  11. package/dist/build-info.js.map +1 -0
  12. package/dist/bytecode.d.ts +46 -0
  13. package/dist/bytecode.d.ts.map +1 -0
  14. package/dist/bytecode.js +101 -0
  15. package/dist/bytecode.js.map +1 -0
  16. package/dist/chain.d.ts +20 -0
  17. package/dist/chain.d.ts.map +1 -0
  18. package/dist/chain.js +52 -0
  19. package/dist/chain.js.map +1 -0
  20. package/dist/cli.d.ts +25 -0
  21. package/dist/cli.d.ts.map +1 -0
  22. package/dist/cli.js +363 -0
  23. package/dist/cli.js.map +1 -0
  24. package/dist/diff.d.ts +15 -0
  25. package/dist/diff.d.ts.map +1 -0
  26. package/dist/diff.js +11 -0
  27. package/dist/diff.js.map +1 -0
  28. package/dist/erc7201.d.ts +72 -0
  29. package/dist/erc7201.d.ts.map +1 -0
  30. package/dist/erc7201.js +340 -0
  31. package/dist/erc7201.js.map +1 -0
  32. package/dist/errors.d.ts +23 -0
  33. package/dist/errors.d.ts.map +1 -0
  34. package/dist/errors.js +32 -0
  35. package/dist/errors.js.map +1 -0
  36. package/dist/explorer.d.ts +42 -0
  37. package/dist/explorer.d.ts.map +1 -0
  38. package/dist/explorer.js +131 -0
  39. package/dist/explorer.js.map +1 -0
  40. package/dist/index.d.ts +13 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +12 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/reconstruct.d.ts +75 -0
  45. package/dist/reconstruct.d.ts.map +1 -0
  46. package/dist/reconstruct.js +169 -0
  47. package/dist/reconstruct.js.map +1 -0
  48. package/dist/record.d.ts +48 -0
  49. package/dist/record.d.ts.map +1 -0
  50. package/dist/record.js +91 -0
  51. package/dist/record.js.map +1 -0
  52. package/dist/report.d.ts +26 -0
  53. package/dist/report.d.ts.map +1 -0
  54. package/dist/report.js +109 -0
  55. package/dist/report.js.map +1 -0
  56. package/dist/resolve.d.ts +65 -0
  57. package/dist/resolve.d.ts.map +1 -0
  58. package/dist/resolve.js +90 -0
  59. package/dist/resolve.js.map +1 -0
  60. package/dist/solc.d.ts +38 -0
  61. package/dist/solc.d.ts.map +1 -0
  62. package/dist/solc.js +194 -0
  63. package/dist/solc.js.map +1 -0
  64. package/package.json +73 -4
  65. package/src/bin.ts +25 -0
  66. package/src/build-info.ts +405 -0
  67. package/src/bytecode.ts +141 -0
  68. package/src/chain.ts +75 -0
  69. package/src/cli.ts +423 -0
  70. package/src/diff.ts +31 -0
  71. package/src/erc7201.ts +412 -0
  72. package/src/errors.ts +33 -0
  73. package/src/explorer.ts +190 -0
  74. package/src/index.ts +53 -0
  75. package/src/reconstruct.ts +329 -0
  76. package/src/record.ts +127 -0
  77. package/src/report.ts +155 -0
  78. package/src/resolve.ts +153 -0
  79. package/src/solc.ts +268 -0
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Reads a project's own build info: Foundry, Hardhat 2 and Hardhat 3.
3
+ *
4
+ * The build is chosen by content, never by modification time or file count.
5
+ * Foundry keeps every build-info file it ever wrote, so after a few edits
6
+ * several hold the target, most of them describing older sources. Only a build
7
+ * whose sources still match the files on disk is used.
8
+ */
9
+
10
+ import type { SolcInput } from "@openzeppelin/upgrades-core";
11
+ import { readFile, readdir, stat } from "node:fs/promises";
12
+ import { isAbsolute, join, relative, resolve } from "node:path";
13
+ import type { ImmutableReferences } from "./bytecode.js";
14
+
15
+ export interface BuildInfoOptions {
16
+ /**
17
+ * One build-info file to use as is, relative to the project root. Its
18
+ * sources are not compared with the files on disk, so it may come from
19
+ * another checkout, such as a CI artifact.
20
+ */
21
+ file?: string;
22
+ }
23
+
24
+ export interface BuildInfoContract {
25
+ /** The build-info file it was read from. */
26
+ file: string;
27
+ /**
28
+ * Fully qualified name as solc saw it, `source.sol:Name`. Hardhat 3 prefixes
29
+ * project sources with `project/`.
30
+ */
31
+ contract: string;
32
+ /** As recorded: a long version, or a release version for Foundry. Both work with `getSolc`. */
33
+ solcVersion: string;
34
+ /**
35
+ * The compiler input, reduced to the target and what it imports, so that a
36
+ * layout compiles only those sources rather than the whole project.
37
+ */
38
+ input: SolcInput;
39
+ /** Absent when the build info holds no compiler output. */
40
+ deployedBytecode?: string;
41
+ immutableReferences?: ImmutableReferences;
42
+ }
43
+
44
+ // Foundry, then Hardhat.
45
+ const DIRS = ["out/build-info", "artifacts/build-info"];
46
+
47
+ const NO_INPUT_HINT =
48
+ " Foundry writes the compiler input only with `build_info = true` in foundry.toml or " +
49
+ "`forge build --build-info`.";
50
+
51
+ interface RawBuildInfo {
52
+ _format?: string;
53
+ solcLongVersion?: string;
54
+ input?: SolcInput & { sources?: Record<string, { content?: string }> };
55
+ output?: {
56
+ contracts?: Record<string, Record<string, CompiledContract>>;
57
+ sources?: Record<
58
+ string,
59
+ { ast?: { nodes?: Array<{ nodeType?: string; absolutePath?: string }> } }
60
+ >;
61
+ };
62
+ userSourceNameMap?: Record<string, string>;
63
+ source_id_to_path?: unknown;
64
+ }
65
+
66
+ interface CompiledContract {
67
+ evm?: { deployedBytecode?: { object?: string; immutableReferences?: ImmutableReferences } };
68
+ }
69
+
70
+ interface Candidate {
71
+ file: string;
72
+ info: RawBuildInfo;
73
+ contract: string;
74
+ }
75
+
76
+ /**
77
+ * Finds `contract` in the build info under `root` and returns what a layout
78
+ * or a bytecode proof needs. `contract` is `path/File.sol:Name`, with the path
79
+ * as written in the project (Hardhat 3's `project/` prefix is optional), or a
80
+ * bare `Name` when only one contract has it.
81
+ */
82
+ export async function findBuildInfo(
83
+ root: string,
84
+ contract: string,
85
+ options: BuildInfoOptions = {},
86
+ ): Promise<BuildInfoContract> {
87
+ const target = parseTarget(contract);
88
+ if (target.path !== undefined && isAbsolute(target.path)) {
89
+ target.path = relative(root, target.path);
90
+ }
91
+
92
+ if (options.file !== undefined) {
93
+ const file = resolve(root, options.file);
94
+ let text: string;
95
+ try {
96
+ text = await readFile(file, "utf8");
97
+ } catch (e) {
98
+ throw new Error(`Cannot read ${options.file} (${(e as NodeJS.ErrnoException).code}).`, {
99
+ cause: e,
100
+ });
101
+ }
102
+ const info = await readBuildInfo(file, text);
103
+ if (info === undefined)
104
+ throw new Error(`${options.file} holds no compiler input.${NO_INPUT_HINT}`);
105
+ const matches = findContract(info, target);
106
+ if (matches.length === 0) throw new Error(`${options.file} has no contract ${contract}.`);
107
+ if (matches.length > 1) throw ambiguousName(contract, matches);
108
+ return toResult({ file, info, contract: matches[0] });
109
+ }
110
+
111
+ const files = await listBuildInfoFiles(root);
112
+ if (files.length === 0) {
113
+ const isDir = await stat(root).then(
114
+ (st) => st.isDirectory(),
115
+ () => false,
116
+ );
117
+ if (!isDir) throw new Error(`The project root ${root} is not a directory.`);
118
+ throw new Error(
119
+ `No build info found in ${DIRS.join(" or ")} under ${root}. Run from the project root ` +
120
+ `(or pass its path as the root), after a build.${NO_INPUT_HINT}`,
121
+ );
122
+ }
123
+
124
+ const holding: Candidate[] = [];
125
+ let withoutInput = false;
126
+ for (const file of files) {
127
+ // Cheap text check first: build-info files can be large, and most do not hold the target.
128
+ const text = await readFile(file, "utf8");
129
+ if (target.path !== undefined && !text.includes(`${target.path}"`)) continue;
130
+ const info = await readBuildInfo(file, text);
131
+ if (info === undefined) {
132
+ withoutInput = true;
133
+ continue;
134
+ }
135
+ for (const name of findContract(info, target)) holding.push({ file, info, contract: name });
136
+ }
137
+ if (holding.length === 0) {
138
+ throw new Error(
139
+ `No build info holds ${contract}. ` +
140
+ (withoutInput
141
+ ? `Some hold no compiler input.${NO_INPUT_HINT}`
142
+ : "Check the name (it is case-sensitive) and give the source path as the compiler " +
143
+ "saw it (for a remapped import, the path under lib/), or just the contract name. " +
144
+ "Or build first."),
145
+ );
146
+ }
147
+
148
+ // A build is fresh when the target's file exists and nothing it imports has changed.
149
+ const fresh: Candidate[] = [];
150
+ const stale: Array<{ candidate: Candidate; target: string | undefined }> = [];
151
+ for (const candidate of holding) {
152
+ const disk = await checkDisk(root, candidate);
153
+ if (disk === "gone") continue;
154
+ if (disk.fresh) fresh.push(candidate);
155
+ else stale.push({ candidate, target: disk.target });
156
+ }
157
+
158
+ // A bare name is ambiguous across every build that holds it, not only the
159
+ // fresh ones: an incremental build holds just the files it recompiled, so the
160
+ // freshest build can miss a second definition. A stale definition counts only
161
+ // while its file still declares the name (it may have been renamed since).
162
+ const name = target.name.replace(/\$/g, "\\$");
163
+ const declares = new RegExp(`\\b(?:contract|interface|library)\\s+${name}(?![\\w$])`);
164
+ const present = [
165
+ ...fresh,
166
+ ...stale
167
+ .filter(({ target }) => target === undefined || declares.test(target))
168
+ .map(({ candidate }) => candidate),
169
+ ];
170
+ const names = [...new Set(present.map((c) => c.contract))];
171
+ if (names.length > 1) throw ambiguousName(contract, names);
172
+
173
+ if (fresh.length === 0) {
174
+ throw new Error(
175
+ stale.length > 0
176
+ ? `Every build info holding ${contract} is older than the sources on disk. Build again.`
177
+ : `${contract} is no longer on disk (moved or deleted). Build again.`,
178
+ );
179
+ }
180
+
181
+ // Several fresh builds of the same sources are interchangeable only when the
182
+ // compiler and its settings agree (Foundry profiles can differ in both).
183
+ const byBuild = new Map<string, Candidate>();
184
+ for (const candidate of fresh.sort((a, b) => a.file.localeCompare(b.file))) {
185
+ const key = buildKey(candidate.info);
186
+ if (!byBuild.has(key)) byBuild.set(key, candidate);
187
+ }
188
+ if (byBuild.size > 1) {
189
+ const files = [...byBuild.values()].map((c) => relative(root, c.file));
190
+ throw new Error(
191
+ `${contract} was built with different compilers or settings in ${files.join(", ")} ` +
192
+ `(a project with several solc versions compiles shared files once per version). ` +
193
+ `Pick one with the \`file\` option (\`--build-info\` on the command line); ` +
194
+ `\`forge clean\` drops leftover builds.`,
195
+ );
196
+ }
197
+ return toResult([...byBuild.values()][0]);
198
+ }
199
+
200
+ interface Target {
201
+ /** Source path as given, without Hardhat 3's `project/` prefix. */
202
+ path?: string;
203
+ name: string;
204
+ }
205
+
206
+ function parseTarget(contract: string): Target {
207
+ const colon = contract.lastIndexOf(":");
208
+ const name = contract.slice(colon + 1);
209
+ if (!/^[A-Za-z_$][\w$]*$/.test(name)) {
210
+ // Not quoted: a URL passed here by mistake can carry an API key.
211
+ throw new Error("Expected a contract name or `path/File.sol:Name`.");
212
+ }
213
+ if (colon === -1) return { name };
214
+ const path = contract.slice(0, colon).replace(/^\.\//, "");
215
+ return { path: path.replace(/^project\//, ""), name };
216
+ }
217
+
218
+ async function listBuildInfoFiles(root: string): Promise<string[]> {
219
+ const files: string[] = [];
220
+ for (const dir of DIRS) {
221
+ let entries: string[];
222
+ try {
223
+ entries = await readdir(resolve(root, dir));
224
+ } catch (e) {
225
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") continue;
226
+ throw e;
227
+ }
228
+ for (const entry of entries.sort()) {
229
+ // Hardhat 3 keeps the compiler output next to the build info, in `<id>.output.json`.
230
+ if (entry.endsWith(".json") && !entry.endsWith(".output.json")) {
231
+ files.push(join(resolve(root, dir), entry));
232
+ }
233
+ }
234
+ }
235
+ return files;
236
+ }
237
+
238
+ /**
239
+ * Undefined for a file with no compiler input: Foundry writes one per build
240
+ * (only `id` and `source_id_to_path`) unless asked for full build info.
241
+ */
242
+ async function readBuildInfo(file: string, text: string): Promise<RawBuildInfo | undefined> {
243
+ let info: RawBuildInfo;
244
+ try {
245
+ info = JSON.parse(text) as RawBuildInfo;
246
+ } catch (e) {
247
+ throw new Error(`${file} is not JSON: ${(e as Error).message}`, { cause: e });
248
+ }
249
+ if (info?.input === undefined && info?.source_id_to_path !== undefined) return undefined;
250
+ if (
251
+ typeof info?.solcLongVersion !== "string" ||
252
+ typeof info.input?.sources !== "object" ||
253
+ info.input.sources === null
254
+ ) {
255
+ throw new Error(`${file} is not a build-info file (no solcLongVersion or input sources).`);
256
+ }
257
+ if (info.output === undefined) {
258
+ const outputFile = file.replace(/\.json$/, ".output.json");
259
+ try {
260
+ info.output = (JSON.parse(await readFile(outputFile, "utf8")) as RawBuildInfo).output;
261
+ } catch (e) {
262
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
263
+ throw new Error(`Cannot read ${outputFile}: ${(e as Error).message}`, { cause: e });
264
+ }
265
+ }
266
+ }
267
+ return info;
268
+ }
269
+
270
+ // Fully qualified names in `info` that match the target.
271
+ function findContract(info: RawBuildInfo, target: Target): string[] {
272
+ const sources = Object.keys(info.input!.sources);
273
+ if (target.path !== undefined) {
274
+ const path = target.path;
275
+ const source = sources.find((s) => s === path || s === `project/${path}`);
276
+ if (source === undefined) return [];
277
+ const compiled = info.output?.contracts?.[source];
278
+ // Without output the name cannot be checked here; solc checks it when the layout is built.
279
+ if (compiled !== undefined && !Object.hasOwn(compiled, target.name)) return [];
280
+ return [`${source}:${target.name}`];
281
+ }
282
+ if (info.output?.contracts === undefined) return [];
283
+ return Object.entries(info.output.contracts)
284
+ .filter(([, contracts]) => Object.hasOwn(contracts, target.name))
285
+ .map(([source]) => `${source}:${target.name}`);
286
+ }
287
+
288
+ /**
289
+ * Compares the target and every source it imports, directly or not, with the
290
+ * disk. A changed base contract changes the target's layout; a changed
291
+ * unrelated file does not, and must not make a build stale: an incremental
292
+ * build holds only the files it recompiled, so the build that holds the target
293
+ * is often older than files the target never imports. `gone` when the target's
294
+ * file no longer exists; a dependency not found on disk (hoisted node_modules,
295
+ * say) is not checked. `target` is the target's text on disk, when found.
296
+ */
297
+ async function checkDisk(
298
+ root: string,
299
+ { info, contract }: Candidate,
300
+ ): Promise<"gone" | { fresh: boolean; target: string | undefined }> {
301
+ const targetSource = sourceOf(contract);
302
+ let target: string | undefined;
303
+ let fresh = true;
304
+ // The target comes first.
305
+ for (const source of dependencies(info, targetSource)) {
306
+ const onDisk = await readSource(root, info, source);
307
+ if (source === targetSource) {
308
+ if (onDisk === null) return "gone";
309
+ target = onDisk;
310
+ }
311
+ const content = info.input!.sources[source]?.content;
312
+ if (typeof onDisk === "string" && typeof content === "string" && onDisk !== content) {
313
+ fresh = false;
314
+ }
315
+ }
316
+ return { fresh, target };
317
+ }
318
+
319
+ /**
320
+ * `source` and everything it imports, from the import directives in the
321
+ * output's ASTs. Without them, every source in the build (the cautious answer).
322
+ */
323
+ function dependencies(info: RawBuildInfo, source: string): string[] {
324
+ // The target first, as checkDisk expects.
325
+ const all = [source, ...Object.keys(info.input!.sources).filter((s) => s !== source)];
326
+ const asts = info.output?.sources;
327
+ const seen = new Set([source]);
328
+ const queue = [source];
329
+ while (queue.length > 0) {
330
+ const nodes = asts?.[queue.pop()!]?.ast?.nodes;
331
+ if (nodes === undefined) return all;
332
+ for (const node of nodes) {
333
+ const imported = node.nodeType === "ImportDirective" ? node.absolutePath : undefined;
334
+ if (imported !== undefined && !seen.has(imported)) {
335
+ seen.add(imported);
336
+ queue.push(imported);
337
+ }
338
+ }
339
+ }
340
+ return [...seen];
341
+ }
342
+
343
+ /**
344
+ * The file on disk behind a build's source name: undefined when it cannot be
345
+ * located by name (Hardhat 3's versioned `npm/` paths), null when it should be
346
+ * there and is not.
347
+ */
348
+ async function readSource(
349
+ root: string,
350
+ info: RawBuildInfo,
351
+ source: string,
352
+ ): Promise<string | null | undefined> {
353
+ const user = Object.entries(info.userSourceNameMap ?? {}).find(([, s]) => s === source)?.[0];
354
+ if (user === undefined && source.startsWith("npm/")) return undefined;
355
+ const names =
356
+ user !== undefined
357
+ ? [user]
358
+ : [source, ...(source.startsWith("project/") ? [source.slice("project/".length)] : [])];
359
+ const paths = names.flatMap((name) => [resolve(root, name), resolve(root, "node_modules", name)]);
360
+ return (await readFirst(paths)) ?? null;
361
+ }
362
+
363
+ async function readFirst(paths: string[]): Promise<string | undefined> {
364
+ for (const path of paths) {
365
+ try {
366
+ return await readFile(path, "utf8");
367
+ } catch (e) {
368
+ const code = (e as NodeJS.ErrnoException).code;
369
+ if (code !== "ENOENT" && code !== "ENOTDIR") throw e;
370
+ }
371
+ }
372
+ return undefined;
373
+ }
374
+
375
+ function buildKey(info: RawBuildInfo): string {
376
+ const settings = { ...(info.input!.settings as Record<string, unknown> | undefined) };
377
+ delete settings.outputSelection;
378
+ return JSON.stringify([info.solcLongVersion, settings]);
379
+ }
380
+
381
+ function ambiguousName(contract: string, names: string[]): Error {
382
+ return new Error(`${contract} is ambiguous: ${names.join(", ")}. Use \`path/File.sol:Name\`.`);
383
+ }
384
+
385
+ function sourceOf(contract: string): string {
386
+ return contract.slice(0, contract.lastIndexOf(":"));
387
+ }
388
+
389
+ function toResult({ file, info, contract }: Candidate): BuildInfoContract {
390
+ const source = sourceOf(contract);
391
+ const deployed =
392
+ info.output?.contracts?.[source]?.[contract.slice(source.length + 1)]?.evm?.deployedBytecode;
393
+ const sources = info.input!.sources;
394
+ return {
395
+ file,
396
+ contract,
397
+ solcVersion: info.solcLongVersion!,
398
+ input: {
399
+ ...info.input!,
400
+ sources: Object.fromEntries(dependencies(info, source).map((s) => [s, sources[s]])),
401
+ },
402
+ deployedBytecode: deployed?.object,
403
+ immutableReferences: deployed?.immutableReferences,
404
+ };
405
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Bytecode comparison utilities.
3
+ *
4
+ * Solidity appends a CBOR-encoded metadata hash to the end of every compiled
5
+ * contract's bytecode. The last two bytes encode the length of that metadata
6
+ * section. Stripping it allows comparing contracts that are logically identical
7
+ * but were compiled in different environments (different metadata).
8
+ */
9
+
10
+ export function stripBytecodeMetadata(bytecode: string): string {
11
+ const buf = toBuffer(bytecode);
12
+ const start = cborStart(buf);
13
+ if (start === undefined) return bytecode;
14
+ return "0x" + buf.subarray(0, start).toString("hex");
15
+ }
16
+
17
+ function toBuffer(bytecode: string): Buffer {
18
+ return Buffer.from(bytecode.startsWith("0x") ? bytecode.slice(2) : bytecode, "hex");
19
+ }
20
+
21
+ // Where solc's CBOR metadata starts, or undefined when the tail is not CBOR.
22
+ function cborStart(buf: Buffer): number | undefined {
23
+ if (buf.length < 2) return undefined;
24
+ const metadataLength = buf.readUInt16BE(buf.length - 2);
25
+ if (metadataLength === 0 || metadataLength + 2 > buf.length) return undefined;
26
+ // solc's metadata is a CBOR map (major type 5). Without that marker the
27
+ // tail is code (e.g. compiled with appendCBOR: false), and stripping it
28
+ // would hide real differences.
29
+ const start = buf.length - 2 - metadataLength;
30
+ return buf[start] >> 5 === 5 ? start : undefined;
31
+ }
32
+
33
+ // The CBOR keys solc uses for the metadata hash, each with its byte-string header.
34
+ const SOURCE_HASH_KEYS = [
35
+ Buffer.from("6469706673" + "5822", "hex"), // "ipfs", 34 bytes
36
+ Buffer.from("65627a7a7230" + "5820", "hex"), // "bzzr0", 32 bytes
37
+ Buffer.from("65627a7a7231" + "5820", "hex"), // "bzzr1", 32 bytes
38
+ ];
39
+
40
+ /**
41
+ * Whether compiled code embeds the hash of its metadata, which covers every
42
+ * source file and the settings. Only then does matching code also mean
43
+ * matching source. `bytecodeHash: "none"` and `appendCBOR: false` drop it.
44
+ */
45
+ function hasSourceHash(bytecode: string): boolean {
46
+ const buf = toBuffer(bytecode);
47
+ const start = cborStart(buf);
48
+ if (start === undefined) return false;
49
+ const cbor = buf.subarray(start, buf.length - 2);
50
+ return SOURCE_HASH_KEYS.some((key) => cbor.includes(key));
51
+ }
52
+
53
+ function normalize(s: string): string {
54
+ return s.toLowerCase().replace(/^0x/, "");
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Compiled-vs-on-chain comparison
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * solc's `evm.deployedBytecode.immutableReferences`: AST id to the byte spans
63
+ * (relative to the deployed bytecode) that the constructor fills in.
64
+ */
65
+ export type ImmutableReferences = Record<string, ReadonlyArray<{ start: number; length: number }>>;
66
+
67
+ /**
68
+ * - `exact`: byte-identical.
69
+ * - `immutables-only`: identical once immutable (and library-link) spans are
70
+ * masked. Same code, deployment-specific values. Every UUPS implementation
71
+ * lands here because `UUPSUpgradeable.__self` is immutable.
72
+ * - `metadata-only`: identical after masking and stripping the CBOR metadata,
73
+ * so the compiler input was not byte-identical (source hashes or settings).
74
+ * NOT proof of storage layout: variables no code reads, gap sizes and field
75
+ * names do not reach the bytecode, so different layouts can share code.
76
+ * - `code-only`: identical (exactly or once masked), but the build embeds no
77
+ * source hash (`bytecodeHash: "none"` or `appendCBOR: false`), so nothing
78
+ * ties the code to its source. NOT proof of layout, for the same reason.
79
+ * - `none`: different code.
80
+ *
81
+ * `exact` and `immutables-only` therefore always mean the source is proven too.
82
+ */
83
+ export type DeployedBytecodeMatch =
84
+ "exact" | "immutables-only" | "metadata-only" | "code-only" | "none";
85
+
86
+ /** The matches that prove a source is the deployed code, and so prove its layout. */
87
+ export type ProvingMatch = "exact" | "immutables-only";
88
+
89
+ export function isProvingMatch(match: DeployedBytecodeMatch): match is ProvingMatch {
90
+ return match === "exact" || match === "immutables-only";
91
+ }
92
+
93
+ // solc leaves `__$<34 hex>$__` (20 bytes) where an external library address is linked.
94
+ const LINK_PLACEHOLDER = /__\$[0-9a-fA-F]{34}\$__/g;
95
+
96
+ /**
97
+ * Compares runtime code read with `eth_getCode` against compiler output.
98
+ *
99
+ * On-chain code has immutables filled in while compiler output has them
100
+ * zeroed, and the difference sits in the code body where metadata stripping
101
+ * cannot reach, so the spans are zeroed on both sides before comparing.
102
+ */
103
+ export function compareDeployedBytecode(
104
+ onchain: string,
105
+ compiled: string,
106
+ immutableReferences: ImmutableReferences = {},
107
+ ): DeployedBytecodeMatch {
108
+ const live = normalize(onchain);
109
+ const built = normalize(compiled);
110
+ if (live === "" || built === "") return "none";
111
+ if (live === built) return hasSourceHash(built) ? "exact" : "code-only";
112
+
113
+ const spans: Array<{ start: number; length: number }> = Object.values(immutableReferences).flat();
114
+ for (const m of built.matchAll(LINK_PLACEHOLDER)) {
115
+ spans.push({ start: m.index / 2, length: 20 });
116
+ }
117
+ const builtHex = built.replace(LINK_PLACEHOLDER, "0".repeat(40));
118
+
119
+ const liveMasked = maskSpans(live, spans);
120
+ const builtMasked = maskSpans(builtHex, spans);
121
+ if (liveMasked === undefined || builtMasked === undefined) return "none";
122
+ if (liveMasked === builtMasked) return hasSourceHash(builtHex) ? "immutables-only" : "code-only";
123
+ if (stripBytecodeMetadata(liveMasked) === stripBytecodeMetadata(builtMasked)) {
124
+ return "metadata-only";
125
+ }
126
+ return "none";
127
+ }
128
+
129
+ // Returns undefined when the hex is malformed or a span falls outside the code.
130
+ function maskSpans(
131
+ hex: string,
132
+ spans: ReadonlyArray<{ start: number; length: number }>,
133
+ ): string | undefined {
134
+ const buf = Buffer.from(hex, "hex");
135
+ if (buf.length * 2 !== hex.length) return undefined;
136
+ for (const { start, length } of spans) {
137
+ if (start + length > buf.length) return undefined;
138
+ buf.fill(0, start, start + length);
139
+ }
140
+ return buf.toString("hex");
141
+ }
package/src/chain.ts ADDED
@@ -0,0 +1,75 @@
1
+ import {
2
+ EIP1967BeaconNotFound,
3
+ EIP1967ImplementationNotFound,
4
+ getBeaconAddress,
5
+ getImplementationAddress,
6
+ getImplementationAddressFromBeacon,
7
+ type EthereumProvider,
8
+ } from "@openzeppelin/upgrades-core";
9
+ import { createHash } from "node:crypto";
10
+ import { LayoutUnavailableError } from "./errors.js";
11
+
12
+ /** The one JSON-RPC method used; Hardhat's network provider and ethers' JsonRpcProvider both fit. */
13
+ export interface EthProvider {
14
+ send(method: string, params: unknown[]): Promise<unknown>;
15
+ }
16
+
17
+ export interface ProxyState {
18
+ implementation: string;
19
+ /** Set for a beacon proxy; `implementation` is then the beacon's. */
20
+ beacon?: string;
21
+ }
22
+
23
+ /**
24
+ * Reads what a proxy runs right now: the ERC-1967 implementation slot, or the
25
+ * beacon's implementation for a beacon proxy. Undefined when neither slot is
26
+ * set, i.e. the address is not a proxy this tool understands.
27
+ */
28
+ export async function readProxy(
29
+ provider: EthProvider,
30
+ address: string,
31
+ ): Promise<ProxyState | undefined> {
32
+ const oz = provider as unknown as EthereumProvider;
33
+ try {
34
+ return { implementation: (await getImplementationAddress(oz, address)).toLowerCase() };
35
+ } catch (e) {
36
+ if (!(e instanceof EIP1967ImplementationNotFound)) throw e;
37
+ }
38
+ let beacon: string;
39
+ try {
40
+ beacon = (await getBeaconAddress(oz, address)).toLowerCase();
41
+ } catch (e) {
42
+ if (e instanceof EIP1967BeaconNotFound) return undefined;
43
+ throw e;
44
+ }
45
+ const implementation = (await getImplementationAddressFromBeacon(oz, beacon)).toLowerCase();
46
+ return { implementation, beacon };
47
+ }
48
+
49
+ export async function readImplementation(provider: EthProvider, proxy: string): Promise<string> {
50
+ const state = await readProxy(provider, proxy);
51
+ if (state === undefined) {
52
+ throw new LayoutUnavailableError(
53
+ `${proxy} has no ERC-1967 implementation or beacon slot set on this chain.`,
54
+ );
55
+ }
56
+ return state.implementation;
57
+ }
58
+
59
+ export async function readCode(provider: EthProvider, address: string): Promise<string> {
60
+ const code = (await provider.send("eth_getCode", [address, "latest"])) as string;
61
+ if (code === "0x" || code === "") {
62
+ throw new LayoutUnavailableError(`No code at ${address} on this chain.`);
63
+ }
64
+ return code.toLowerCase();
65
+ }
66
+
67
+ export async function readChainId(provider: EthProvider): Promise<number> {
68
+ return Number(BigInt((await provider.send("eth_chainId", [])) as string));
69
+ }
70
+
71
+ export function codeSha256(code: string): string {
72
+ return createHash("sha256")
73
+ .update(Buffer.from(code.replace(/^0x/, ""), "hex"))
74
+ .digest("hex");
75
+ }