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.
- package/CHANGELOG.md +26 -0
- package/LICENSE +10 -0
- package/README.md +304 -2
- 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/bytecode.d.ts +46 -0
- package/dist/bytecode.d.ts.map +1 -0
- package/dist/bytecode.js +101 -0
- package/dist/bytecode.js.map +1 -0
- package/dist/chain.d.ts +20 -0
- package/dist/chain.d.ts.map +1 -0
- package/dist/chain.js +52 -0
- package/dist/chain.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/diff.d.ts +15 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +11 -0
- package/dist/diff.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 +23 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +32 -0
- package/dist/errors.js.map +1 -0
- package/dist/explorer.d.ts +42 -0
- package/dist/explorer.d.ts.map +1 -0
- package/dist/explorer.js +131 -0
- package/dist/explorer.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/reconstruct.d.ts +75 -0
- package/dist/reconstruct.d.ts.map +1 -0
- package/dist/reconstruct.js +169 -0
- package/dist/reconstruct.js.map +1 -0
- package/dist/record.d.ts +48 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +91 -0
- package/dist/record.js.map +1 -0
- 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 +65 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +90 -0
- package/dist/resolve.js.map +1 -0
- package/dist/solc.d.ts +38 -0
- package/dist/solc.d.ts.map +1 -0
- package/dist/solc.js +194 -0
- package/dist/solc.js.map +1 -0
- package/package.json +73 -4
- package/src/bin.ts +25 -0
- package/src/build-info.ts +405 -0
- package/src/bytecode.ts +141 -0
- package/src/chain.ts +75 -0
- package/src/cli.ts +423 -0
- package/src/diff.ts +31 -0
- package/src/erc7201.ts +412 -0
- package/src/errors.ts +33 -0
- package/src/explorer.ts +190 -0
- package/src/index.ts +53 -0
- package/src/reconstruct.ts +329 -0
- package/src/record.ts +127 -0
- package/src/report.ts +155 -0
- package/src/resolve.ts +153 -0
- package/src/solc.ts +268 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rebuilds the storage layout of deployed code from its verified source, and
|
|
3
|
+
* proves the source is the deployed code before trusting the layout.
|
|
4
|
+
*
|
|
5
|
+
* The layout comes out of the same oz-core pipeline the compile hook runs for
|
|
6
|
+
* the local build (full compile, namespaced second pass, `validate()`,
|
|
7
|
+
* `getStorageLayout`), so both sides of a comparison are extracted identically.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
getErrors,
|
|
12
|
+
getStorageLayout,
|
|
13
|
+
inferProxyKind,
|
|
14
|
+
isNamespaceSupported,
|
|
15
|
+
makeNamespacedInput,
|
|
16
|
+
solcInputOutputDecoder,
|
|
17
|
+
trySanitizeNatSpec,
|
|
18
|
+
UpgradeableContractErrorReport,
|
|
19
|
+
validate,
|
|
20
|
+
type ProxyDeployment,
|
|
21
|
+
type SolcInput,
|
|
22
|
+
type SolcOutput,
|
|
23
|
+
type StorageLayout,
|
|
24
|
+
type ValidationRunData,
|
|
25
|
+
type Version,
|
|
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
|
+
import { compareDeployedBytecode, isProvingMatch, type ProvingMatch } from "./bytecode.js";
|
|
30
|
+
import { namespaceSlots, type NamespaceSlot } from "./erc7201.js";
|
|
31
|
+
import { LayoutIntegrityError } from "./errors.js";
|
|
32
|
+
import type { VerifiedSource } from "./explorer.js";
|
|
33
|
+
import type { SolcRunner } from "./solc.js";
|
|
34
|
+
|
|
35
|
+
export interface ReconstructedLayout extends SourceAnalysis {
|
|
36
|
+
/** Fully qualified name, `source.sol:Contract`. */
|
|
37
|
+
contract: string;
|
|
38
|
+
compiler: string;
|
|
39
|
+
bytecodeMatch: ProvingMatch;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// validate() reads evm.bytecode for every contract, so codegen is requested
|
|
43
|
+
// for all of them. A verified input holds only the target's dependency graph.
|
|
44
|
+
const FULL_SELECTION = {
|
|
45
|
+
"*": {
|
|
46
|
+
"*": [
|
|
47
|
+
"storageLayout",
|
|
48
|
+
"evm.bytecode.object",
|
|
49
|
+
"evm.bytecode.linkReferences",
|
|
50
|
+
"evm.deployedBytecode.object",
|
|
51
|
+
"evm.deployedBytecode.immutableReferences",
|
|
52
|
+
],
|
|
53
|
+
"": ["ast"],
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// The namespaced pass only needs layouts and ASTs; skipping codegen keeps it cheap.
|
|
58
|
+
const LAYOUT_SELECTION = { "*": { "*": ["storageLayout"], "": ["ast"] } };
|
|
59
|
+
|
|
60
|
+
export async function reconstructLayout(
|
|
61
|
+
source: VerifiedSource,
|
|
62
|
+
solc: SolcRunner,
|
|
63
|
+
onchainCode: string,
|
|
64
|
+
): Promise<ReconstructedLayout> {
|
|
65
|
+
const input = withOutputSelection(source.input, FULL_SELECTION);
|
|
66
|
+
const output = await compileOrThrow(solc, input, "verified source");
|
|
67
|
+
|
|
68
|
+
const { contract, bytecodeMatch } = proveDeployedCode(
|
|
69
|
+
output,
|
|
70
|
+
source.contractName,
|
|
71
|
+
onchainCode,
|
|
72
|
+
embedsSourceHash(source.input),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
contract,
|
|
77
|
+
compiler: source.solcLongVersion,
|
|
78
|
+
bytecodeMatch,
|
|
79
|
+
...(await analyze(input, output, solc, contract)),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
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
|
+
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> {
|
|
159
|
+
const selected = withOutputSelection(input, FULL_SELECTION);
|
|
160
|
+
return analyze(selected, await compileOrThrow(solc, selected, "source"), solc, contract);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function analyze(
|
|
164
|
+
input: SolcInput,
|
|
165
|
+
output: SolcOutput,
|
|
166
|
+
solc: SolcRunner,
|
|
167
|
+
contract: string,
|
|
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 }> {
|
|
196
|
+
const solcVersion = solc.longVersion.split("+")[0];
|
|
197
|
+
|
|
198
|
+
let namespacedOutput: SolcOutput | undefined;
|
|
199
|
+
if (isNamespaceSupported(solcVersion)) {
|
|
200
|
+
let namespacedInput = makeNamespacedInput(input, output, solcVersion);
|
|
201
|
+
namespacedInput = await trySanitizeNatSpec(namespacedInput, solcVersion);
|
|
202
|
+
namespacedOutput = await compileOrThrow(
|
|
203
|
+
solc,
|
|
204
|
+
withOutputSelection(namespacedInput, LAYOUT_SELECTION),
|
|
205
|
+
"namespaced pass",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const runData = validate(
|
|
210
|
+
output,
|
|
211
|
+
solcInputOutputDecoder(input, output),
|
|
212
|
+
solcVersion,
|
|
213
|
+
input,
|
|
214
|
+
namespacedOutput,
|
|
215
|
+
);
|
|
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
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Finds the compiled contract that matches the deployed code. The verified
|
|
231
|
+
* name narrows the search, but two files may define a contract with the same
|
|
232
|
+
* name, so the bytecode decides, and the strongest match wins. Only a proving
|
|
233
|
+
* match is accepted: `metadata-only` can hide a different storage layout.
|
|
234
|
+
*/
|
|
235
|
+
function proveDeployedCode(
|
|
236
|
+
output: SolcOutput,
|
|
237
|
+
contractName: string,
|
|
238
|
+
onchainCode: string,
|
|
239
|
+
hashed: boolean,
|
|
240
|
+
): { contract: string; bytecodeMatch: ProvingMatch } {
|
|
241
|
+
let candidates = 0;
|
|
242
|
+
let best: { contract: string; bytecodeMatch: ProvingMatch } | undefined;
|
|
243
|
+
let weak: "metadata-only" | "code-only" | undefined;
|
|
244
|
+
for (const [sourcePath, contracts] of Object.entries(output.contracts ?? {})) {
|
|
245
|
+
const compiled = contracts[contractName] as
|
|
246
|
+
| {
|
|
247
|
+
evm?: {
|
|
248
|
+
deployedBytecode?: {
|
|
249
|
+
object?: string;
|
|
250
|
+
immutableReferences?: Record<string, Array<{ start: number; length: number }>>;
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
| undefined;
|
|
255
|
+
const deployed = compiled?.evm?.deployedBytecode;
|
|
256
|
+
if (deployed?.object === undefined) continue;
|
|
257
|
+
candidates++;
|
|
258
|
+
const compared = compareDeployedBytecode(
|
|
259
|
+
onchainCode,
|
|
260
|
+
deployed.object,
|
|
261
|
+
deployed.immutableReferences,
|
|
262
|
+
);
|
|
263
|
+
const match = !hashed && isProvingMatch(compared) ? "code-only" : compared;
|
|
264
|
+
if (match === "metadata-only" || match === "code-only") weak ??= match;
|
|
265
|
+
if (!isProvingMatch(match)) continue;
|
|
266
|
+
if (best === undefined || (match === "exact" && best.bytecodeMatch !== "exact")) {
|
|
267
|
+
best = { contract: `${sourcePath}:${contractName}`, bytecodeMatch: match };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (best !== undefined) return best;
|
|
271
|
+
|
|
272
|
+
if (candidates === 0) {
|
|
273
|
+
throw new LayoutIntegrityError(`Verified source has no contract named "${contractName}".`);
|
|
274
|
+
}
|
|
275
|
+
// Neither weak match proves the layout: unread variables and gap sizes never reach the bytecode.
|
|
276
|
+
throw new LayoutIntegrityError(
|
|
277
|
+
weak === "metadata-only"
|
|
278
|
+
? `Verified source for "${contractName}" matches the deployed code only after stripping ` +
|
|
279
|
+
`metadata, so the compiler input differed. That does not prove the storage layout ` +
|
|
280
|
+
`(unread variables and gap sizes never reach the bytecode). Refusing to trust it.`
|
|
281
|
+
: weak === "code-only"
|
|
282
|
+
? `Verified source for "${contractName}" compiles to the deployed code, but the build ` +
|
|
283
|
+
`embeds no source hash (bytecodeHash "none" or appendCBOR false), so the code does ` +
|
|
284
|
+
`not prove the source or its storage layout. Refusing to trust it.`
|
|
285
|
+
: `Verified source for "${contractName}" does not compile to the deployed code, ` +
|
|
286
|
+
`even with immutables masked. Refusing to trust its layout.`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Whether the verified settings embed a metadata hash. The bytecode check alone
|
|
292
|
+
* can be fooled: without CBOR (`appendCBOR: false`) the code can end in a data
|
|
293
|
+
* literal shaped like solc's metadata. The settings cannot lie, since the code
|
|
294
|
+
* only matches when compiled with the settings that were really used.
|
|
295
|
+
*/
|
|
296
|
+
function embedsSourceHash(input: SolcInput): boolean {
|
|
297
|
+
const metadata = (
|
|
298
|
+
input.settings as { metadata?: { appendCBOR?: boolean; bytecodeHash?: string } }
|
|
299
|
+
).metadata;
|
|
300
|
+
return metadata?.appendCBOR !== false && metadata?.bytecodeHash !== "none";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function compileOrThrow(
|
|
304
|
+
solc: SolcRunner,
|
|
305
|
+
input: SolcInput,
|
|
306
|
+
what: string,
|
|
307
|
+
): Promise<SolcOutput> {
|
|
308
|
+
const output = await solc.compile(input);
|
|
309
|
+
const errors = (output.errors ?? []).filter((e) => e.severity === "error");
|
|
310
|
+
if (errors.length > 0) {
|
|
311
|
+
throw new LayoutIntegrityError(
|
|
312
|
+
`solc ${solc.longVersion} failed to compile the ${what}: ${errors[0].formattedMessage}`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
return output;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Only solc's standard-json keys: build tools add their own (Foundry's build-info
|
|
319
|
+
// input carries `version`, `allowPaths`, `basePath`, `includePaths`), and solc
|
|
320
|
+
// rejects any key it does not know.
|
|
321
|
+
function withOutputSelection(input: SolcInput, outputSelection: unknown): SolcInput {
|
|
322
|
+
// upgrades-core's SolcInput type omits `language`, which standard json requires.
|
|
323
|
+
const { language } = input as SolcInput & { language?: string };
|
|
324
|
+
return {
|
|
325
|
+
language,
|
|
326
|
+
sources: input.sources,
|
|
327
|
+
settings: { ...input.settings, outputSelection },
|
|
328
|
+
} as SolcInput;
|
|
329
|
+
}
|
package/src/record.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout records: the proven storage layout of the code at one address on one
|
|
3
|
+
* chain. slotdiff never writes them anywhere. A record is plain JSON, so the
|
|
4
|
+
* caller saves it however it likes and loads it back through `parseRecord`
|
|
5
|
+
* (offline) or `verifyRecord` (against the chain):
|
|
6
|
+
*
|
|
7
|
+
* await writeFile(path, JSON.stringify(record, null, 2));
|
|
8
|
+
* const record = await verifyRecord(provider, address, JSON.parse(await readFile(path, "utf8")));
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { StorageLayout } from "@openzeppelin/upgrades-core";
|
|
12
|
+
import { isProvingMatch, type ProvingMatch } from "./bytecode.js";
|
|
13
|
+
import { codeSha256, readChainId, readCode, type EthProvider } from "./chain.js";
|
|
14
|
+
import { LayoutIntegrityError, LayoutUnavailableError } from "./errors.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Deployed code never changes, so a record cannot go stale for its chain and
|
|
18
|
+
* address. `codeSha256` binds it to the exact runtime code it was proven
|
|
19
|
+
* against; `verifyRecord` refuses it for any other code or chain.
|
|
20
|
+
*/
|
|
21
|
+
export interface LayoutRecord {
|
|
22
|
+
format: 1;
|
|
23
|
+
/** Lowercase. */
|
|
24
|
+
address: string;
|
|
25
|
+
chainId: number;
|
|
26
|
+
codeSha256: string;
|
|
27
|
+
/** Fully qualified name, `source.sol:Contract`. */
|
|
28
|
+
contract: string;
|
|
29
|
+
/** solc long version, when known. */
|
|
30
|
+
compiler?: string;
|
|
31
|
+
bytecodeMatch: ProvingMatch;
|
|
32
|
+
/** `explorer`: rebuilt from verified source. `local-compile`: proven against a local build. */
|
|
33
|
+
source: "explorer" | "local-compile";
|
|
34
|
+
recordedAt: string;
|
|
35
|
+
layout: StorageLayout;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ADDRESS = /^0x[0-9a-f]{40}$/;
|
|
39
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Checks a loaded record without touching the chain: its format, its shape,
|
|
43
|
+
* and that it rests on a match that proves a layout. Pass `address` to also
|
|
44
|
+
* require that it describes that address. Use `verifyRecord` whenever an RPC
|
|
45
|
+
* is available; this alone cannot tell that the code at the address changed
|
|
46
|
+
* or that the record came from another chain.
|
|
47
|
+
*/
|
|
48
|
+
export function parseRecord(value: unknown, address?: string): LayoutRecord {
|
|
49
|
+
if (typeof value !== "object" || value === null) {
|
|
50
|
+
throw new LayoutIntegrityError("A layout record must be a JSON object.");
|
|
51
|
+
}
|
|
52
|
+
const r = value as Partial<LayoutRecord>;
|
|
53
|
+
if (r.format !== 1) {
|
|
54
|
+
throw new Error(`Unsupported layout record format ${String(r.format)}; upgrade slotdiff.`);
|
|
55
|
+
}
|
|
56
|
+
const who = typeof r.address === "string" ? r.address : "an unknown address";
|
|
57
|
+
const malformed = (field: string) =>
|
|
58
|
+
new LayoutIntegrityError(`The layout record for ${who} has a missing or malformed ${field}.`);
|
|
59
|
+
|
|
60
|
+
if (typeof r.address !== "string" || !ADDRESS.test(r.address)) throw malformed("address");
|
|
61
|
+
if (!Number.isSafeInteger(r.chainId) || (r.chainId as number) <= 0) throw malformed("chainId");
|
|
62
|
+
if (typeof r.codeSha256 !== "string" || !SHA256.test(r.codeSha256)) {
|
|
63
|
+
throw malformed("codeSha256");
|
|
64
|
+
}
|
|
65
|
+
if (typeof r.contract !== "string" || !r.contract.includes(":")) throw malformed("contract");
|
|
66
|
+
if (r.compiler !== undefined && typeof r.compiler !== "string") throw malformed("compiler");
|
|
67
|
+
if (r.source !== "explorer" && r.source !== "local-compile") throw malformed("source");
|
|
68
|
+
if (typeof r.recordedAt !== "string") throw malformed("recordedAt");
|
|
69
|
+
if (
|
|
70
|
+
typeof r.layout !== "object" ||
|
|
71
|
+
r.layout === null ||
|
|
72
|
+
!Array.isArray(r.layout.storage) ||
|
|
73
|
+
typeof r.layout.types !== "object" ||
|
|
74
|
+
r.layout.types === null
|
|
75
|
+
) {
|
|
76
|
+
throw malformed("layout");
|
|
77
|
+
}
|
|
78
|
+
if (typeof r.bytecodeMatch !== "string" || !isProvingMatch(r.bytecodeMatch)) {
|
|
79
|
+
throw new LayoutIntegrityError(
|
|
80
|
+
`The layout record for ${who} rests on a "${String(r.bytecodeMatch)}" match, which does ` +
|
|
81
|
+
`not prove a storage layout.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (address !== undefined && r.address !== address.toLowerCase()) {
|
|
85
|
+
throw new LayoutIntegrityError(
|
|
86
|
+
`The layout record for ${r.address} was loaded for ${address.toLowerCase()}.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return r as LayoutRecord;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `parseRecord`, then checks the record against the chain: it must have been
|
|
94
|
+
* made on this chain, and the code at `address` must be the code it was
|
|
95
|
+
* proven against. Returns the record, ready to diff.
|
|
96
|
+
*/
|
|
97
|
+
export async function verifyRecord(
|
|
98
|
+
provider: EthProvider,
|
|
99
|
+
address: string,
|
|
100
|
+
value: unknown,
|
|
101
|
+
): Promise<LayoutRecord> {
|
|
102
|
+
const record = parseRecord(value, address);
|
|
103
|
+
const chainId = await readChainId(provider);
|
|
104
|
+
if (record.chainId !== chainId) {
|
|
105
|
+
throw new LayoutIntegrityError(
|
|
106
|
+
`The layout record for ${record.address} was made on chain ${record.chainId}, not on ` +
|
|
107
|
+
`chain ${chainId}.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
let code: string;
|
|
111
|
+
try {
|
|
112
|
+
code = await readCode(provider, record.address);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
// The code the record was proven against is gone; never a reason to fall back.
|
|
115
|
+
if (!(e instanceof LayoutUnavailableError)) throw e;
|
|
116
|
+
throw new LayoutIntegrityError(
|
|
117
|
+
`The layout record for ${record.address} describes code that is no longer at that address.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (codeSha256(code) !== record.codeSha256) {
|
|
121
|
+
throw new LayoutIntegrityError(
|
|
122
|
+
`The layout record for ${record.address} was proven against different code than this ` +
|
|
123
|
+
`chain has at that address.`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return record;
|
|
127
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's report: the ERC-7201 namespace grid and the check list. Pure
|
|
3
|
+
* functions from results to lines, so the layout is tested as text. Colors
|
|
4
|
+
* come from `paint`, applied after alignment so they never shift a column.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { NamespacePair, NamespaceSlot } from "./erc7201.js";
|
|
8
|
+
|
|
9
|
+
/** Node's `util.styleText`, or `plain` when colors are off. */
|
|
10
|
+
export type Paint = (
|
|
11
|
+
format: Parameters<typeof import("node:util").styleText>[0],
|
|
12
|
+
text: string,
|
|
13
|
+
) => string;
|
|
14
|
+
|
|
15
|
+
export const plain: Paint = (_, text) => text;
|
|
16
|
+
|
|
17
|
+
export type CheckStatus = "pass" | "fail" | "skip";
|
|
18
|
+
|
|
19
|
+
export interface Check {
|
|
20
|
+
name: string;
|
|
21
|
+
status: CheckStatus;
|
|
22
|
+
summary: string;
|
|
23
|
+
/** Multi-line explanation shown under the check, such as OpenZeppelin's report. */
|
|
24
|
+
details?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MARK: Record<CheckStatus, string> = { pass: "✓", fail: "✗", skip: "-" };
|
|
28
|
+
const TONE = { pass: "green", fail: "red", skip: "yellow" } as const;
|
|
29
|
+
|
|
30
|
+
/** One line per check, names aligned, details indented under their check. */
|
|
31
|
+
export function checkLines(checks: Check[], paint: Paint = plain): string[] {
|
|
32
|
+
const width = Math.max(...checks.map((c) => c.name.length));
|
|
33
|
+
return checks.flatMap((c) => [
|
|
34
|
+
`${paint(TONE[c.status], MARK[c.status])} ${paint("bold", c.name.padEnd(width))} ${c.summary}`,
|
|
35
|
+
...(c.details?.split("\n").map((line) => ` ${line}`) ?? []),
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A grid for each namespace whose slot is in question on either side: the
|
|
41
|
+
* 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.
|
|
43
|
+
*/
|
|
44
|
+
export function namespaceLines(pairs: NamespacePair[], paint: Paint = plain): string[] {
|
|
45
|
+
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
|
+
)
|
|
52
|
+
.map((p) => {
|
|
53
|
+
const fmt = formatter([p.old, p.next]);
|
|
54
|
+
return {
|
|
55
|
+
rows: [
|
|
56
|
+
...(p.old !== undefined ? [row("old", p.old, fmt)] : []),
|
|
57
|
+
...(p.next !== undefined ? [row("new", p.next, fmt)] : []),
|
|
58
|
+
],
|
|
59
|
+
meaning: meaning(p, fmt),
|
|
60
|
+
tone: (p.problem === undefined
|
|
61
|
+
? "dim"
|
|
62
|
+
: p.problem === "unread"
|
|
63
|
+
? "yellow"
|
|
64
|
+
: "red") as Row["tone"],
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
if (blocks.length === 0) return [];
|
|
68
|
+
|
|
69
|
+
const header = {
|
|
70
|
+
side: "",
|
|
71
|
+
id: "annotation",
|
|
72
|
+
implied: "slot from annotation",
|
|
73
|
+
inCode: "slot in code",
|
|
74
|
+
mark: "",
|
|
75
|
+
tone: "dim",
|
|
76
|
+
} as Row;
|
|
77
|
+
const all = [header, ...blocks.flatMap((b) => b.rows)];
|
|
78
|
+
const width = (key: "id" | "implied" | "inCode") => Math.max(...all.map((r) => r[key].length));
|
|
79
|
+
const cells = (r: Row) =>
|
|
80
|
+
` ${r.side.padEnd(5)}${r.id.padEnd(width("id"))} ${r.implied.padEnd(width("implied"))} ` +
|
|
81
|
+
r.inCode.padEnd(width("inCode"));
|
|
82
|
+
const line = (r: Row) => `${cells(r)} ${paint(r.tone, r.mark)}`;
|
|
83
|
+
|
|
84
|
+
return [
|
|
85
|
+
paint("bold", "ERC-7201 namespaces"),
|
|
86
|
+
paint("dim", cells(header).trimEnd()),
|
|
87
|
+
...blocks.flatMap((b) => [...b.rows.map(line), ` ${paint(b.tone, b.meaning)}`]),
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface Row {
|
|
92
|
+
side: string;
|
|
93
|
+
id: string;
|
|
94
|
+
implied: string;
|
|
95
|
+
inCode: string;
|
|
96
|
+
mark: string;
|
|
97
|
+
tone: "green" | "red" | "yellow" | "dim";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function row(side: string, s: NamespaceSlot, fmt: (slot: string) => string): Row {
|
|
101
|
+
return {
|
|
102
|
+
side,
|
|
103
|
+
id: s.namespace.slice("erc7201:".length),
|
|
104
|
+
implied: fmt(s.expected),
|
|
105
|
+
inCode:
|
|
106
|
+
s.status === "conflict" ? "several" : s.actual !== undefined ? fmt(s.actual) : "not read",
|
|
107
|
+
mark: {
|
|
108
|
+
match: "✓",
|
|
109
|
+
mismatch: "✗ annotation does not match code",
|
|
110
|
+
conflict: "✗ code uses more than one slot",
|
|
111
|
+
unread: "? slot in code not read",
|
|
112
|
+
}[s.status],
|
|
113
|
+
tone: ({ match: "green", mismatch: "red", conflict: "red", unread: "yellow" } as const)[
|
|
114
|
+
s.status
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function meaning({ old, next, problem }: NamespacePair, fmt: (slot: string) => string): string {
|
|
120
|
+
switch (problem) {
|
|
121
|
+
case "conflict":
|
|
122
|
+
return "The new code assigns more than one slot to this namespace; it must use one.";
|
|
123
|
+
case "moved":
|
|
124
|
+
return (
|
|
125
|
+
`The new code moves this namespace from ${fmt(old!.actual!)} to ${fmt(next!.actual!)}: ` +
|
|
126
|
+
"its data would be left behind. Keep the slot the deployed code uses, under an annotation that matches it."
|
|
127
|
+
);
|
|
128
|
+
case "mismatch":
|
|
129
|
+
return old === undefined
|
|
130
|
+
? "The new code keeps this namespace in a different slot than its annotation says. Fix one of the two before deploying."
|
|
131
|
+
: "The storage did not move, but the new annotation does not match its code. Fix it before deploying.";
|
|
132
|
+
case "unread":
|
|
133
|
+
return "The slot in code could not be read (computed at run time?), so this namespace could not be checked.";
|
|
134
|
+
}
|
|
135
|
+
return next !== undefined
|
|
136
|
+
? "Same slot in code on both sides: the storage did not move. Compared as one namespace."
|
|
137
|
+
: "The deployed annotation does not match its code, and no namespace in the new code uses its slot.";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Short slots for a block, unless two different slots in it would then look
|
|
142
|
+
* the same: a hand-written constant can share both ends with another.
|
|
143
|
+
*/
|
|
144
|
+
function formatter(sides: Array<NamespaceSlot | undefined>): (slot: string) => string {
|
|
145
|
+
const slots = new Set(
|
|
146
|
+
sides.flatMap((s) => (s === undefined ? [] : [s.expected, s.actual ?? s.expected])),
|
|
147
|
+
);
|
|
148
|
+
const shorts = new Set([...slots].map(short));
|
|
149
|
+
return shorts.size === slots.size ? short : (slot) => slot;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** `0x5ea2…5c00`: enough to tell slots apart; slotdiff does the comparing. */
|
|
153
|
+
function short(slot: string): string {
|
|
154
|
+
return `${slot.slice(0, 6)}…${slot.slice(-4)}`;
|
|
155
|
+
}
|