slotdiff 0.2.0-alpha.1 → 0.3.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 (58) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +55 -38
  3. package/dist/build-info.d.ts.map +1 -1
  4. package/dist/build-info.js +9 -3
  5. package/dist/build-info.js.map +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +59 -33
  8. package/dist/cli.js.map +1 -1
  9. package/dist/erc7201.d.ts +27 -11
  10. package/dist/erc7201.d.ts.map +1 -1
  11. package/dist/erc7201.js +182 -13
  12. package/dist/erc7201.js.map +1 -1
  13. package/dist/errors.d.ts +14 -0
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +20 -0
  16. package/dist/errors.js.map +1 -1
  17. package/dist/explorer.d.ts.map +1 -1
  18. package/dist/explorer.js +28 -9
  19. package/dist/explorer.js.map +1 -1
  20. package/dist/index.d.ts +4 -3
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +3 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/reconstruct.d.ts +30 -15
  25. package/dist/reconstruct.d.ts.map +1 -1
  26. package/dist/reconstruct.js +140 -32
  27. package/dist/reconstruct.js.map +1 -1
  28. package/dist/record.d.ts +13 -0
  29. package/dist/record.d.ts.map +1 -1
  30. package/dist/record.js +8 -0
  31. package/dist/record.js.map +1 -1
  32. package/dist/report.d.ts +4 -1
  33. package/dist/report.d.ts.map +1 -1
  34. package/dist/report.js +37 -8
  35. package/dist/report.js.map +1 -1
  36. package/dist/resolve.d.ts +6 -1
  37. package/dist/resolve.d.ts.map +1 -1
  38. package/dist/resolve.js +6 -1
  39. package/dist/resolve.js.map +1 -1
  40. package/dist/solc.js +20 -4
  41. package/dist/solc.js.map +1 -1
  42. package/dist/upgrade.d.ts +48 -0
  43. package/dist/upgrade.d.ts.map +1 -0
  44. package/dist/upgrade.js +36 -0
  45. package/dist/upgrade.js.map +1 -0
  46. package/package.json +5 -5
  47. package/src/build-info.ts +9 -3
  48. package/src/cli.ts +69 -33
  49. package/src/erc7201.ts +217 -26
  50. package/src/errors.ts +22 -0
  51. package/src/explorer.ts +39 -10
  52. package/src/index.ts +11 -3
  53. package/src/reconstruct.ts +206 -57
  54. package/src/record.ts +18 -0
  55. package/src/report.ts +44 -13
  56. package/src/resolve.ts +12 -2
  57. package/src/solc.ts +20 -4
  58. package/src/upgrade.ts +91 -0
@@ -10,6 +10,7 @@
10
10
  import {
11
11
  getErrors,
12
12
  getStorageLayout,
13
+ hashBytecode,
13
14
  inferProxyKind,
14
15
  isNamespaceSupported,
15
16
  makeNamespacedInput,
@@ -22,13 +23,10 @@ import {
22
23
  type SolcOutput,
23
24
  type StorageLayout,
24
25
  type ValidationRunData,
25
- type Version,
26
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";
29
27
  import { compareDeployedBytecode, isProvingMatch, type ProvingMatch } from "./bytecode.js";
30
28
  import { namespaceSlots, type NamespaceSlot } from "./erc7201.js";
31
- import { LayoutIntegrityError } from "./errors.js";
29
+ import { IncompleteBuildError, LayoutIntegrityError, printable } from "./errors.js";
32
30
  import type { VerifiedSource } from "./explorer.js";
33
31
  import type { SolcRunner } from "./solc.js";
34
32
 
@@ -49,6 +47,7 @@ const FULL_SELECTION = {
49
47
  "evm.bytecode.linkReferences",
50
48
  "evm.deployedBytecode.object",
51
49
  "evm.deployedBytecode.immutableReferences",
50
+ "metadata",
52
51
  ],
53
52
  "": ["ast"],
54
53
  },
@@ -76,37 +75,36 @@ export async function reconstructLayout(
76
75
  contract,
77
76
  compiler: source.solcLongVersion,
78
77
  bytecodeMatch,
79
- ...(await analyze(input, output, solc, contract)),
78
+ ...(await analyzeBuild(input, output, solc)).contract(contract),
80
79
  };
81
80
  }
82
81
 
83
- /**
84
- * The layout of `contract` (`source.sol:Name`) in `input`, with no bytecode
85
- * proof. Only for sources whose provenance is already known, such as a
86
- * project's own build-info.
87
- */
88
- export async function layoutFromSource(
89
- input: SolcInput,
90
- solc: SolcRunner,
91
- contract: string,
92
- ): Promise<StorageLayout> {
93
- return (await analyzeSource(input, solc, contract)).layout;
94
- }
95
-
96
82
  export type ProxyKind = ProxyDeployment["kind"];
97
83
 
84
+ type ValidationError = ReturnType<typeof getErrors>[number];
85
+
98
86
  /**
99
87
  * OpenZeppelin's safety error kinds, without the deprecated struct-definition
100
- * and enum-definition, which the check never reports.
88
+ * and enum-definition, which the check never reports. Listed here, not taken
89
+ * from OpenZeppelin, so its releases cannot change this API unseen; a test
90
+ * compares the two.
101
91
  */
102
- export type SafetyErrorKind = Exclude<
103
- ValidationError["kind"],
104
- "struct-definition" | "enum-definition"
105
- >;
92
+ export const SAFETY_ERROR_KINDS = [
93
+ "state-variable-assignment",
94
+ "state-variable-immutable",
95
+ "external-library-linking",
96
+ "constructor",
97
+ "delegatecall",
98
+ "selfdestruct",
99
+ "missing-public-upgradeto",
100
+ "internal-function-storage",
101
+ "missing-initializer",
102
+ "missing-initializer-call",
103
+ "duplicate-initializer-call",
104
+ "incorrect-initializer-order",
105
+ ] as const;
106
106
 
107
- export const SAFETY_ERROR_KINDS: readonly SafetyErrorKind[] = errorKinds.filter(
108
- (k): k is SafetyErrorKind => k !== "struct-definition" && k !== "enum-definition",
109
- );
107
+ export type SafetyErrorKind = (typeof SAFETY_ERROR_KINDS)[number];
110
108
 
111
109
  export interface SafetyError {
112
110
  /** OpenZeppelin's error kind, such as `constructor`. */
@@ -129,8 +127,8 @@ export interface SafetyOptions {
129
127
  export interface SafetyReport {
130
128
  ok: boolean;
131
129
  errors: SafetyError[];
132
- /** OpenZeppelin's description of each error. */
133
- explain(): string;
130
+ /** OpenZeppelin's description of each error; `color` (default true) adds terminal colors. */
131
+ explain(color?: boolean): string;
134
132
  }
135
133
 
136
134
  export interface SourceAnalysis {
@@ -150,30 +148,193 @@ export interface SourceAnalysis {
150
148
  safety(options: SafetyOptions): SafetyReport;
151
149
  }
152
150
 
153
- /** `layoutFromSource` plus OpenZeppelin's safety check, from one compile. */
151
+ /**
152
+ * The layout of `contract` (`source.sol:Name`) in `input`, its ERC-7201 slots
153
+ * and OpenZeppelin's safety check, from one compile. No bytecode proof: only
154
+ * for sources whose provenance is already known, such as a project's own build info.
155
+ */
154
156
  export async function analyzeSource(
155
157
  input: SolcInput,
156
- solc: SolcRunner,
158
+ solc: BuildCompiler,
157
159
  contract: string,
158
160
  ): Promise<SourceAnalysis> {
159
161
  const selected = withOutputSelection(input, FULL_SELECTION);
160
- return analyze(selected, await compileOrThrow(solc, selected, "source"), solc, contract);
162
+ const output = await compileOrThrow(solc, selected, "source");
163
+ return (await analyzeBuild(selected, output, solc)).contract(contract);
164
+ }
165
+
166
+ /** The compiler for OpenZeppelin's namespaced pass: a `SolcRunner`, or a build tool's. */
167
+ export type BuildCompiler = Pick<SolcRunner, "longVersion" | "compile">;
168
+
169
+ export interface BuildAnalysis {
170
+ /** The analysis of one contract in the build, `source.sol:Name`. */
171
+ contract(contract: string): SourceAnalysis;
161
172
  }
162
173
 
163
- async function analyze(
174
+ /**
175
+ * `analyzeSource` for a build you already compiled, such as a build tool's,
176
+ * without compiling it again: one namespaced pass and one validation serve
177
+ * every contract in it. `output` must hold every source's `ast` and, for each
178
+ * contract, its `metadata`, `storageLayout` (an interface or library may lack
179
+ * it) and `evm.bytecode` (`object`, `linkReferences`); without them it throws
180
+ * `IncompleteBuildError`. The metadata proves `output` was compiled from
181
+ * `input`'s sources by `compiler`'s version (not with its settings).
182
+ */
183
+ export async function analyzeBuild(
164
184
  input: SolcInput,
165
185
  output: SolcOutput,
166
- solc: SolcRunner,
186
+ compiler: BuildCompiler,
187
+ ): Promise<BuildAnalysis> {
188
+ checkBuild(input, output, compiler.longVersion);
189
+ const runData = await validateBuild(input, output, compiler);
190
+ return { contract: (contract) => analyzeContract(runData, output, contract) };
191
+ }
192
+
193
+ interface AstNode {
194
+ nodeType?: string;
195
+ name?: unknown;
196
+ contractKind?: unknown;
197
+ }
198
+
199
+ interface CompiledContract {
200
+ metadata?: unknown;
201
+ storageLayout?: unknown;
202
+ evm?: { bytecode?: { object?: unknown; linkReferences?: unknown } };
203
+ }
204
+
205
+ /**
206
+ * The analysis reads layouts from `output` and edits `input` at the offsets in
207
+ * `output`'s ASTs, so the two must be one build, complete: an output from other
208
+ * sources could pass a changed layout, and one without an AST would leave every
209
+ * namespace unassigned, compared by annotation alone. Anything else is refused.
210
+ */
211
+ function checkBuild(input: SolcInput, output: SolcOutput, longVersion: string): void {
212
+ if (output.sources === undefined && Object.hasOwn(output, "_format")) {
213
+ throw new Error(
214
+ "The compiler output is wrapped in a build info file; pass its output field " +
215
+ "(Hardhat 3 writes { _format, output } to <id>.output.json).",
216
+ );
217
+ }
218
+ const paths = Object.keys(input.sources);
219
+ const outputPaths = Object.keys(output.sources ?? {});
220
+ if (
221
+ paths.length !== outputPaths.length ||
222
+ paths.some((path) => !Object.hasOwn(output.sources, path))
223
+ ) {
224
+ throw new Error("The compiler output does not hold the same sources as the input.");
225
+ }
226
+ for (const path of paths) {
227
+ if (typeof (input.sources[path] as { content?: unknown }).content !== "string") {
228
+ throw new Error(`The compiler input has no content for ${path}.`);
229
+ }
230
+ const ast = output.sources[path].ast as { nodes?: AstNode[] } | undefined;
231
+ if (ast === undefined) {
232
+ throw new IncompleteBuildError(
233
+ `The compiler output has no ast for ${path}; it needs every source's ast.`,
234
+ );
235
+ }
236
+ for (const node of ast.nodes ?? []) {
237
+ if (node.nodeType !== "ContractDefinition") continue;
238
+ const name = `${path}:${String(node.name)}`;
239
+ const c = output.contracts?.[path]?.[String(node.name)] as CompiledContract | undefined;
240
+ if (c === undefined)
241
+ throw new IncompleteBuildError(`The compiler output has no contract output for ${name}.`);
242
+ // Neither can hold state, and some tools (Foundry) leave their layout out.
243
+ const stateless = node.contractKind === "interface" || node.contractKind === "library";
244
+ if (
245
+ typeof c.metadata !== "string" ||
246
+ (c.storageLayout === undefined && !stateless) ||
247
+ typeof c.evm?.bytecode?.object !== "string" ||
248
+ typeof c.evm.bytecode.linkReferences !== "object"
249
+ ) {
250
+ throw new IncompleteBuildError(
251
+ `The compiler output for ${name} lacks metadata, storageLayout or evm.bytecode ` +
252
+ `(object, linkReferences); compile with them in outputSelection.`,
253
+ );
254
+ }
255
+ proveSameBuild(input, path, name, c.metadata, longVersion);
256
+ }
257
+ }
258
+ }
259
+
260
+ function proveSameBuild(
261
+ input: SolcInput,
262
+ path: string,
263
+ name: string,
264
+ metadata: string,
265
+ longVersion: string,
266
+ ) {
267
+ let parsed: {
268
+ compiler?: { version?: unknown };
269
+ sources?: Record<string, { keccak256?: unknown }>;
270
+ };
271
+ try {
272
+ parsed = JSON.parse(metadata);
273
+ } catch {
274
+ throw new LayoutIntegrityError(`The metadata for ${name} is not JSON.`);
275
+ }
276
+ const [version, commit] = String(parsed.compiler?.version).split("+");
277
+ const [wanted, wantedCommit] = longVersion.split("+");
278
+ if (
279
+ version !== wanted ||
280
+ (commit !== undefined && wantedCommit !== undefined && commit !== wantedCommit)
281
+ ) {
282
+ throw new LayoutIntegrityError(
283
+ `${name} was compiled by solc ${String(parsed.compiler?.version)}, not ${longVersion}.`,
284
+ );
285
+ }
286
+ // Its own source at least: metadata that lists none would prove nothing.
287
+ const sources: Record<string, { keccak256?: unknown }> = { [path]: {}, ...parsed.sources };
288
+ for (const [source, { keccak256 }] of Object.entries(sources)) {
289
+ const content = (input.sources[source] as { content?: string } | undefined)?.content;
290
+ const hash =
291
+ content === undefined ? undefined : "0x" + hashBytecode(Buffer.from(content).toString("hex"));
292
+ if (hash !== keccak256) {
293
+ throw new LayoutIntegrityError(
294
+ `The compiler output for ${name} was not compiled from this input (${source} differs).`,
295
+ );
296
+ }
297
+ }
298
+ }
299
+
300
+ function analyzeContract(
301
+ runData: ValidationRunData,
302
+ output: SolcOutput,
167
303
  contract: string,
168
- ): Promise<SourceAnalysis> {
169
- const { data, version } = await validateContract(input, output, solc, contract);
170
- const layout = getStorageLayout(data, version);
304
+ ): SourceAnalysis {
305
+ const colon = contract.lastIndexOf(":");
306
+ if (output.contracts?.[contract.slice(0, colon)]?.[contract.slice(colon + 1)] === undefined) {
307
+ throw new Error(
308
+ `${contract} is not in the compiler input. Name it as source.sol:Contract, with the ` +
309
+ `source path exactly as the input has it (Hardhat 3 starts it with project/).`,
310
+ );
311
+ }
312
+ const version = runData[contract]?.version;
313
+ if (version === undefined) {
314
+ throw new LayoutIntegrityError(
315
+ `${contract} compiled to no bytecode (an abstract contract or interface?); ` +
316
+ `cannot derive its layout.`,
317
+ );
318
+ }
319
+ // OZ looks the contract up by bytecode version and takes the first match, so
320
+ // two contracts with identical code (possible without a source hash) could
321
+ // swap layouts or safety errors. Listing the target first makes every lookup land on it.
322
+ const data: ValidationRunData = { [contract]: runData[contract], ...runData };
323
+ // A copy: OpenZeppelin's layout points into the run data that every call shares.
324
+ const layout = structuredClone(getStorageLayout(data, version));
171
325
  return {
172
326
  layout,
173
327
  // Typed as any ProxyKind, but only ever uups or transparent.
174
328
  kind: inferProxyKind(data, version) as SourceAnalysis["kind"],
175
329
  namespaces: namespaceSlots(output, contract, Object.keys(layout.namespaces ?? {})),
176
330
  safety({ kind, unsafeAllow = [] }) {
331
+ // OpenZeppelin ignores a kind it does not know, so a typo would accept nothing silently.
332
+ const unknown = unsafeAllow.find((k) => !SAFETY_ERROR_KINDS.includes(k));
333
+ if (unknown !== undefined) {
334
+ throw new Error(
335
+ `Unknown unsafeAllow error kind "${String(unknown)}". Known: ${SAFETY_ERROR_KINDS.join(", ")}.`,
336
+ );
337
+ }
177
338
  // A copy: OpenZeppelin appends to the array it is given.
178
339
  const opts = { kind, unsafeAllow: [...unsafeAllow] };
179
340
  // The deprecated kinds are never reported, so every error has a SafetyErrorKind.
@@ -181,49 +342,37 @@ async function analyze(
181
342
  return {
182
343
  ok: errors.length === 0,
183
344
  errors,
184
- explain: () => new UpgradeableContractErrorReport(errors).explain(),
345
+ explain: (color?: boolean) => new UpgradeableContractErrorReport(errors).explain(color),
185
346
  };
186
347
  },
187
348
  };
188
349
  }
189
350
 
190
- async function validateContract(
351
+ async function validateBuild(
191
352
  input: SolcInput,
192
353
  output: SolcOutput,
193
- solc: SolcRunner,
194
- contract: string,
195
- ): Promise<{ data: ValidationRunData; version: Version }> {
196
- const solcVersion = solc.longVersion.split("+")[0];
354
+ compiler: BuildCompiler,
355
+ ): Promise<ValidationRunData> {
356
+ const solcVersion = compiler.longVersion.split("+")[0];
197
357
 
198
358
  let namespacedOutput: SolcOutput | undefined;
199
359
  if (isNamespaceSupported(solcVersion)) {
200
360
  let namespacedInput = makeNamespacedInput(input, output, solcVersion);
201
361
  namespacedInput = await trySanitizeNatSpec(namespacedInput, solcVersion);
202
362
  namespacedOutput = await compileOrThrow(
203
- solc,
363
+ compiler,
204
364
  withOutputSelection(namespacedInput, LAYOUT_SELECTION),
205
365
  "namespaced pass",
206
366
  );
207
367
  }
208
368
 
209
- const runData = validate(
369
+ return validate(
210
370
  output,
211
371
  solcInputOutputDecoder(input, output),
212
372
  solcVersion,
213
373
  input,
214
374
  namespacedOutput,
215
375
  );
216
- const version = runData[contract]?.version;
217
- if (version === undefined) {
218
- throw new LayoutIntegrityError(
219
- `${contract} compiled to no bytecode (an abstract contract or interface?); ` +
220
- `cannot derive its layout.`,
221
- );
222
- }
223
- // OZ looks the contract up by bytecode version and takes the first match, so
224
- // two contracts with identical code (possible without a source hash) could
225
- // swap layouts or safety errors. Listing the target first makes every lookup land on it.
226
- return { data: { [contract]: runData[contract], ...runData }, version };
227
376
  }
228
377
 
229
378
  /**
@@ -301,7 +450,7 @@ function embedsSourceHash(input: SolcInput): boolean {
301
450
  }
302
451
 
303
452
  async function compileOrThrow(
304
- solc: SolcRunner,
453
+ solc: BuildCompiler,
305
454
  input: SolcInput,
306
455
  what: string,
307
456
  ): Promise<SolcOutput> {
@@ -309,7 +458,7 @@ async function compileOrThrow(
309
458
  const errors = (output.errors ?? []).filter((e) => e.severity === "error");
310
459
  if (errors.length > 0) {
311
460
  throw new LayoutIntegrityError(
312
- `solc ${solc.longVersion} failed to compile the ${what}: ${errors[0].formattedMessage}`,
461
+ `solc ${solc.longVersion} failed to compile the ${what}: ${printable(String(errors[0].formattedMessage))}`,
313
462
  );
314
463
  }
315
464
  return output;
package/src/record.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  import type { StorageLayout } from "@openzeppelin/upgrades-core";
12
12
  import { isProvingMatch, type ProvingMatch } from "./bytecode.js";
13
13
  import { codeSha256, readChainId, readCode, type EthProvider } from "./chain.js";
14
+ import { slotsMisfit, type NamespaceSlot } from "./erc7201.js";
15
+ import type { SourceAnalysis } from "./reconstruct.js";
14
16
  import { LayoutIntegrityError, LayoutUnavailableError } from "./errors.js";
15
17
 
16
18
  /**
@@ -33,6 +35,17 @@ export interface LayoutRecord {
33
35
  source: "explorer" | "local-compile";
34
36
  recordedAt: string;
35
37
  layout: StorageLayout;
38
+ /**
39
+ * The proxy kind this code implies: `uups` when it has `upgradeTo` or
40
+ * `upgradeToAndCall`, else `transparent`. Pass `beacon` to the safety check
41
+ * instead when a beacon serves it. Absent in older records.
42
+ */
43
+ kind?: SourceAnalysis["kind"];
44
+ /**
45
+ * The slot each ERC-7201 namespace in `layout` really uses. Without it,
46
+ * `checkNamespaces` reports the record's namespaces as unread.
47
+ */
48
+ namespaces?: NamespaceSlot[];
36
49
  }
37
50
 
38
51
  const ADDRESS = /^0x[0-9a-f]{40}$/;
@@ -75,6 +88,11 @@ export function parseRecord(value: unknown, address?: string): LayoutRecord {
75
88
  ) {
76
89
  throw malformed("layout");
77
90
  }
91
+ if (r.kind !== undefined && r.kind !== "uups" && r.kind !== "transparent") {
92
+ throw malformed("kind");
93
+ }
94
+ if (slotsMisfit(r.layout, undefined) !== undefined) throw malformed("layout");
95
+ if (slotsMisfit(r.layout, r.namespaces) !== undefined) throw malformed("namespaces");
78
96
  if (typeof r.bytecodeMatch !== "string" || !isProvingMatch(r.bytecodeMatch)) {
79
97
  throw new LayoutIntegrityError(
80
98
  `The layout record for ${who} rests on a "${String(r.bytecodeMatch)}" match, which does ` +
package/src/report.ts CHANGED
@@ -36,19 +36,31 @@ export function checkLines(checks: Check[], paint: Paint = plain): string[] {
36
36
  ]);
37
37
  }
38
38
 
39
+ // A pair with nothing in question: no problem, and each side matches its
40
+ // annotation or is not assigned at all, except data the new code stops assigning.
41
+ // A side that is absent is not a problem here; OpenZeppelin reports a deleted namespace.
42
+ function quiet({ old, next, problem }: NamespacePair): boolean {
43
+ const plain = (s?: NamespaceSlot) =>
44
+ s === undefined || s.status === "match" || s.status === "unused";
45
+ const dropped = old?.actual !== undefined && next?.status === "unused";
46
+ return problem === undefined && plain(old) && plain(next) && !dropped;
47
+ }
48
+
49
+ /** How many namespaces are compared by annotation alone because no code assigns them. */
50
+ export function unassignedCount(pairs: NamespacePair[]): number {
51
+ return pairs.filter((p) => quiet(p) && [p.old, p.next].some((s) => s?.status === "unused"))
52
+ .length;
53
+ }
54
+
39
55
  /**
40
56
  * A grid for each namespace whose slot is in question on either side: the
41
57
  * annotation, the slot it implies and the slot the code uses, for old and new,
42
- * with a sentence saying what that means. Namespaces that line up are left out.
58
+ * with a sentence saying what that means. Namespaces that line up, or that no
59
+ * code assigns on either side, are left out.
43
60
  */
44
61
  export function namespaceLines(pairs: NamespacePair[], paint: Paint = plain): string[] {
45
62
  const blocks = pairs
46
- // A side that is absent is not a problem here; OpenZeppelin reports a deleted namespace.
47
- .filter(
48
- (p) =>
49
- p.problem !== undefined ||
50
- [p.old, p.next].some((s) => s !== undefined && s.status !== "match"),
51
- )
63
+ .filter((p) => !quiet(p))
52
64
  .map((p) => {
53
65
  const fmt = formatter([p.old, p.next]);
54
66
  return {
@@ -103,16 +115,23 @@ function row(side: string, s: NamespaceSlot, fmt: (slot: string) => string): Row
103
115
  id: s.namespace.slice("erc7201:".length),
104
116
  implied: fmt(s.expected),
105
117
  inCode:
106
- s.status === "conflict" ? "several" : s.actual !== undefined ? fmt(s.actual) : "not read",
118
+ s.status === "conflict"
119
+ ? "several"
120
+ : s.status === "unused"
121
+ ? "none"
122
+ : s.actual !== undefined
123
+ ? fmt(s.actual)
124
+ : "not read",
107
125
  mark: {
108
126
  match: "✓",
109
127
  mismatch: "✗ annotation does not match code",
110
128
  conflict: "✗ code uses more than one slot",
129
+ unused: "· not assigned in code",
111
130
  unread: "? slot in code not read",
112
131
  }[s.status],
113
- tone: ({ match: "green", mismatch: "red", conflict: "red", unread: "yellow" } as const)[
114
- s.status
115
- ],
132
+ tone: (
133
+ { match: "green", mismatch: "red", conflict: "red", unused: "dim", unread: "yellow" } as const
134
+ )[s.status],
116
135
  };
117
136
  }
118
137
 
@@ -126,11 +145,23 @@ function meaning({ old, next, problem }: NamespacePair, fmt: (slot: string) => s
126
145
  "its data would be left behind. Keep the slot the deployed code uses, under an annotation that matches it."
127
146
  );
128
147
  case "mismatch":
129
- return old === undefined
148
+ return old === undefined || old.status === "unused"
130
149
  ? "The new code keeps this namespace in a different slot than its annotation says. Fix one of the two before deploying."
131
150
  : "The storage did not move, but the new annotation does not match its code. Fix it before deploying.";
151
+ case "dropped":
152
+ return (
153
+ `The new code stops assigning this namespace while its data is at ${fmt(old!.actual!)}, ` +
154
+ `not at ${fmt(old!.expected)} where its annotation points: a later version that follows ` +
155
+ "the annotation would miss it. Correct the annotation to match the data first."
156
+ );
132
157
  case "unread":
133
- return "The slot in code could not be read (computed at run time?), so this namespace could not be checked.";
158
+ return old !== undefined && old.actual === undefined && old.status !== "unused"
159
+ ? "The old slot is not known (not read from its code, or missing from an older record), so this namespace could not be checked."
160
+ : "The slot in the new code could not be read (set from a value slotdiff does not evaluate), so this namespace could not be checked.";
161
+ }
162
+ // The only unused case shown without a problem: the new code stops assigning old data.
163
+ if (next?.status === "unused" && old?.actual !== undefined) {
164
+ return `The new code no longer assigns this namespace; its data stays at ${fmt(old.actual)}.`;
134
165
  }
135
166
  return next !== undefined
136
167
  ? "Same slot in code on both sides: the storage did not move. Compared as one namespace."
package/src/resolve.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  import { codeSha256, readChainId, readCode, readProxy, type EthProvider } from "./chain.js";
19
19
  import { LayoutUnavailableError } from "./errors.js";
20
20
  import { canQueryExplorer, fetchVerifiedSource, type ExplorerConfig } from "./explorer.js";
21
- import { reconstructLayout, type ReconstructedLayout } from "./reconstruct.js";
21
+ import { reconstructLayout, type ProxyKind, type ReconstructedLayout } from "./reconstruct.js";
22
22
  import { parseRecord, type LayoutRecord } from "./record.js";
23
23
  import { getSolc, type SolcOptions } from "./solc.js";
24
24
 
@@ -74,6 +74,8 @@ export async function rebuild(
74
74
  source: "explorer",
75
75
  recordedAt: new Date().toISOString(),
76
76
  layout: rebuilt.layout,
77
+ kind: rebuilt.kind,
78
+ namespaces: rebuilt.namespaces,
77
79
  };
78
80
  return { record, analysis: rebuilt };
79
81
  }
@@ -83,6 +85,8 @@ export interface ProxyLayout {
83
85
  implementation: string;
84
86
  /** Set for a beacon proxy. */
85
87
  beacon?: string;
88
+ /** The proxy's kind, for `checkUpgrade`: `beacon` behind a beacon, else the record's. */
89
+ kind: ProxyKind;
86
90
  record: LayoutRecord;
87
91
  }
88
92
 
@@ -99,7 +103,8 @@ export async function resolveProxyLayout(
99
103
  );
100
104
  }
101
105
  const record = await resolveLayout(provider, state.implementation, options);
102
- return { ...state, record };
106
+ // A rebuilt record always has its kind.
107
+ return { ...state, kind: state.beacon !== undefined ? "beacon" : record.kind!, record };
103
108
  }
104
109
 
105
110
  export interface LocalBuild {
@@ -109,6 +114,9 @@ export interface LocalBuild {
109
114
  layout: StorageLayout;
110
115
  deployedBytecode: string;
111
116
  immutableReferences?: ImmutableReferences;
117
+ /** From `analyzeSource`, so the record carries them (see `LayoutRecord`). */
118
+ kind?: LayoutRecord["kind"];
119
+ namespaces?: LayoutRecord["namespaces"];
112
120
  }
113
121
 
114
122
  export interface LocalBuildResult {
@@ -147,6 +155,8 @@ export async function proveLocalBuild(
147
155
  source: "local-compile",
148
156
  recordedAt: new Date().toISOString(),
149
157
  layout: build.layout,
158
+ ...(build.kind !== undefined ? { kind: build.kind } : {}),
159
+ ...(build.namespaces !== undefined ? { namespaces: build.namespaces } : {}),
150
160
  };
151
161
  // A record this returns must load back through parseRecord, so check it now.
152
162
  return { bytecodeMatch, record: parseRecord(JSON.parse(JSON.stringify(record)), lower) };
package/src/solc.ts CHANGED
@@ -14,9 +14,9 @@
14
14
 
15
15
  import { spawn } from "node:child_process";
16
16
  import { createHash, randomUUID } from "node:crypto";
17
- import { access, chmod, mkdir, readdir, rename, writeFile } from "node:fs/promises";
17
+ import { access, chmod, mkdir, mkdtemp, readdir, rename, rm, writeFile } from "node:fs/promises";
18
18
  import { constants } from "node:fs";
19
- import { homedir } from "node:os";
19
+ import { homedir, tmpdir } from "node:os";
20
20
  import { join } from "node:path";
21
21
  import type { SolcInput, SolcOutput } from "@openzeppelin/upgrades-core";
22
22
  import { LayoutIntegrityError, LayoutUnavailableError } from "./errors.js";
@@ -169,6 +169,10 @@ async function findBuild(
169
169
  if (build === undefined) {
170
170
  throw new LayoutUnavailableError(`solc ${version} has no native ${platform} build.`);
171
171
  }
172
+ // Both become paths: a release is matched by prefix, so its long version comes from the list.
173
+ if (!SOLC_VERSION.test(build.longVersion) || !/^[\w.+-]+$/.test(build.path)) {
174
+ throw new LayoutUnavailableError(`The solc build list has a malformed entry for ${version}.`);
175
+ }
172
176
  // Old Windows releases (up to 0.7.1) ship as zip archives, not executables.
173
177
  if (build.path.endsWith(".zip")) {
174
178
  throw new LayoutUnavailableError(
@@ -206,9 +210,21 @@ async function download(
206
210
  await rename(tmp, dest);
207
211
  }
208
212
 
209
- function runSolc(path: string, input: SolcInput): Promise<SolcOutput> {
213
+ // solc reads imports it is not given from its working directory, so it runs in
214
+ // an empty one: verified source could otherwise import, and quote in an error,
215
+ // any file under the caller's (an .env, say).
216
+ async function runSolc(path: string, input: SolcInput): Promise<SolcOutput> {
217
+ const cwd = await mkdtemp(join(tmpdir(), "slotdiff-solc-"));
218
+ try {
219
+ return await runSolcIn(cwd, path, input);
220
+ } finally {
221
+ await rm(cwd, { recursive: true, force: true });
222
+ }
223
+ }
224
+
225
+ function runSolcIn(cwd: string, path: string, input: SolcInput): Promise<SolcOutput> {
210
226
  return new Promise((resolve, reject) => {
211
- const child = spawn(path, ["--standard-json"], { stdio: ["pipe", "pipe", "pipe"] });
227
+ const child = spawn(path, ["--standard-json"], { cwd, stdio: ["pipe", "pipe", "pipe"] });
212
228
  const out: Buffer[] = [];
213
229
  const err: Buffer[] = [];
214
230
  child.stdout.on("data", (d: Buffer) => out.push(d));