slotdiff 0.1.0-alpha.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -9
- package/README.md +219 -111
- 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 +389 -0
- package/dist/cli.js.map +1 -0
- package/dist/erc7201.d.ts +88 -0
- package/dist/erc7201.d.ts.map +1 -0
- package/dist/erc7201.js +509 -0
- package/dist/erc7201.js.map +1 -0
- package/dist/errors.d.ts +19 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +28 -0
- package/dist/errors.js.map +1 -1
- package/dist/explorer.d.ts.map +1 -1
- package/dist/explorer.js +29 -10
- package/dist/explorer.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/reconstruct.d.ts +70 -7
- package/dist/reconstruct.d.ts.map +1 -1
- package/dist/reconstruct.js +157 -20
- package/dist/reconstruct.js.map +1 -1
- package/dist/record.d.ts +13 -0
- package/dist/record.d.ts.map +1 -1
- package/dist/record.js +8 -0
- package/dist/record.js.map +1 -1
- package/dist/report.d.ts +29 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +138 -0
- package/dist/report.js.map +1 -0
- package/dist/resolve.d.ts +16 -0
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +17 -2
- package/dist/resolve.js.map +1 -1
- package/dist/solc.js +20 -4
- package/dist/solc.js.map +1 -1
- package/dist/upgrade.d.ts +48 -0
- package/dist/upgrade.d.ts.map +1 -0
- package/dist/upgrade.js +36 -0
- package/dist/upgrade.js.map +1 -0
- package/package.json +9 -6
- package/src/bin.ts +25 -0
- package/src/build-info.ts +405 -0
- package/src/cli.ts +459 -0
- package/src/erc7201.ts +603 -0
- package/src/errors.ts +31 -0
- package/src/explorer.ts +40 -11
- package/src/index.ts +28 -3
- package/src/reconstruct.ts +279 -29
- package/src/record.ts +18 -0
- package/src/report.ts +186 -0
- package/src/resolve.ts +28 -3
- package/src/solc.ts +20 -4
- package/src/upgrade.ts +91 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `slotdiff <old> <new>`: each side is a contract in the project's build info
|
|
3
|
+
* or an address: a proxy (read as the implementation it runs now) or an
|
|
4
|
+
* implementation.
|
|
5
|
+
*
|
|
6
|
+
* Exit codes: 0 every check ran and passed (or was skipped with --no-safety),
|
|
7
|
+
* 1 a check failed, 2 something could not be checked. Failing to check is
|
|
8
|
+
* never reported as compatible.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
import { relative, resolve } from "node:path";
|
|
13
|
+
import { parseArgs, styleText } from "node:util";
|
|
14
|
+
import { findBuildInfo } from "./build-info.js";
|
|
15
|
+
import { readCode, readProxy, type EthProvider } from "./chain.js";
|
|
16
|
+
import {
|
|
17
|
+
checkLines,
|
|
18
|
+
namespaceLines,
|
|
19
|
+
plain,
|
|
20
|
+
unassignedCount,
|
|
21
|
+
type Check,
|
|
22
|
+
type Paint,
|
|
23
|
+
} from "./report.js";
|
|
24
|
+
import { errorCode, LayoutUnavailableError, printable } from "./errors.js";
|
|
25
|
+
import { canQueryExplorer } from "./explorer.js";
|
|
26
|
+
import { rebuild } from "./resolve.js";
|
|
27
|
+
import {
|
|
28
|
+
analyzeSource,
|
|
29
|
+
SAFETY_ERROR_KINDS,
|
|
30
|
+
type ProxyKind,
|
|
31
|
+
type SafetyErrorKind,
|
|
32
|
+
type SourceAnalysis,
|
|
33
|
+
} from "./reconstruct.js";
|
|
34
|
+
import { getSolc } from "./solc.js";
|
|
35
|
+
import { checkUpgrade } from "./upgrade.js";
|
|
36
|
+
|
|
37
|
+
const USAGE = `Usage: slotdiff <old> <new> [options]
|
|
38
|
+
|
|
39
|
+
Checks that <new> can replace <old> behind a proxy: OpenZeppelin's storage layout
|
|
40
|
+
rules, that each ERC-7201 namespace stays in the slot the code really uses, and
|
|
41
|
+
OpenZeppelin's safety rules for <new> (constructors, initializers, selfdestruct...).
|
|
42
|
+
Each side is either:
|
|
43
|
+
a contract in this project's build info src/Vault.sol:Vault, or Vault when unique
|
|
44
|
+
an address a proxy (the implementation it runs now),
|
|
45
|
+
or for <new>, an implementation
|
|
46
|
+
|
|
47
|
+
Options:
|
|
48
|
+
--rpc <url> JSON-RPC endpoint, required when a side is an address
|
|
49
|
+
--root <dir> Project root (default: the current directory)
|
|
50
|
+
--build-info <file> Read local sides from this build-info file, as is
|
|
51
|
+
(not compared with the sources on disk)
|
|
52
|
+
--explorer-url <url> Another Etherscan-compatible API, such as Blockscout
|
|
53
|
+
--unsafe-allow-renames Accept renames without @custom:oz-renamed-from
|
|
54
|
+
--unsafe-allow <kinds> Comma-separated safety errors to accept, e.g. constructor;
|
|
55
|
+
prefer @custom:oz-upgrades-unsafe-allow in the source
|
|
56
|
+
--no-safety Skip the safety rules
|
|
57
|
+
--old-implementation <old> is an implementation address, not a proxy; also
|
|
58
|
+
for a proxy slotdiff cannot read (pass its implementation)
|
|
59
|
+
-h, --help Show this help
|
|
60
|
+
-v, --version Show the version
|
|
61
|
+
|
|
62
|
+
An address needs verified source: set ETHERSCAN_API_KEY once (one key covers every
|
|
63
|
+
chain), or use --explorer-url, with that explorer's key in its query if it needs one
|
|
64
|
+
(ETHERSCAN_API_KEY is sent only to Etherscan). --rpc picks the chain for each run.
|
|
65
|
+
|
|
66
|
+
Exit codes: 0 every check passed (or was skipped with --no-safety), 1 a check
|
|
67
|
+
failed, 2 something could not be checked.
|
|
68
|
+
|
|
69
|
+
A pass means these checks found no problem, not that an upgrade is safe in every
|
|
70
|
+
way. No warranty; see LICENSE.`;
|
|
71
|
+
|
|
72
|
+
const HELP_HINT = "Run slotdiff --help for usage.";
|
|
73
|
+
|
|
74
|
+
// "slot" and "diff" in block letters, split where the two words meet.
|
|
75
|
+
const BANNER: Array<[string, string]> = [
|
|
76
|
+
[" _ _ ", " _ _ __ __"],
|
|
77
|
+
[" ___| | ___ | |_", " __| (_)/ _|/ _|"],
|
|
78
|
+
["/ __| |/ _ \\| __", "/ _` | | |_| |_"],
|
|
79
|
+
["\\__ \\ | (_) | |", "| (_| | | _| _|"],
|
|
80
|
+
["|___/_|\\___/ \\__", "\\__,_|_|_| |_|"],
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
|
|
84
|
+
|
|
85
|
+
type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
|
|
86
|
+
|
|
87
|
+
export interface CliIo {
|
|
88
|
+
stdout: (line: string) => void;
|
|
89
|
+
stderr: (line: string) => void;
|
|
90
|
+
cwd?: string;
|
|
91
|
+
env?: Record<string, string | undefined>;
|
|
92
|
+
fetch?: FetchLike;
|
|
93
|
+
/** stdout is an interactive terminal: show the banner. */
|
|
94
|
+
tty?: boolean;
|
|
95
|
+
/** stdout takes colors (Node's rules: a terminal, `NO_COLOR`, `FORCE_COLOR`). */
|
|
96
|
+
color?: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface Side {
|
|
100
|
+
/** Two lines: what it is, then where it came from. Addresses in full, to copy. */
|
|
101
|
+
description: [string, string];
|
|
102
|
+
/** Inferred from the implementation's code, or `beacon` for a beacon proxy. */
|
|
103
|
+
kind: ProxyKind;
|
|
104
|
+
analysis: SourceAnalysis;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Runs the CLI and resolves to its exit code. */
|
|
108
|
+
export async function run(argv: string[], io: CliIo): Promise<number> {
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
parsed = parseArgs({
|
|
112
|
+
args: argv,
|
|
113
|
+
allowPositionals: true,
|
|
114
|
+
options: {
|
|
115
|
+
rpc: { type: "string" },
|
|
116
|
+
root: { type: "string" },
|
|
117
|
+
"build-info": { type: "string" },
|
|
118
|
+
"explorer-url": { type: "string" },
|
|
119
|
+
"unsafe-allow-renames": { type: "boolean" },
|
|
120
|
+
"unsafe-allow": { type: "string" },
|
|
121
|
+
"no-safety": { type: "boolean" },
|
|
122
|
+
"old-implementation": { type: "boolean" },
|
|
123
|
+
help: { type: "boolean", short: "h" },
|
|
124
|
+
version: { type: "boolean", short: "v" },
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
} catch (e) {
|
|
128
|
+
// Node's text for an unknown option runs on about `--`; the option is enough.
|
|
129
|
+
const message = (e as Error).message;
|
|
130
|
+
const unknown = /^Unknown option '([^']+)'/.exec(message)?.[1];
|
|
131
|
+
const text = unknown !== undefined ? `Unknown option ${unknown}` : message;
|
|
132
|
+
io.stderr(`${text.replace(/\.?$/, ".")} ${HELP_HINT}`);
|
|
133
|
+
return 2;
|
|
134
|
+
}
|
|
135
|
+
const { values, positionals } = parsed;
|
|
136
|
+
const paint: Paint = io.color
|
|
137
|
+
? (format, text) => styleText(format, text, { validateStream: false })
|
|
138
|
+
: plain;
|
|
139
|
+
// Only for a person at a terminal: never in a pipe, a log or CI.
|
|
140
|
+
const banner = async () =>
|
|
141
|
+
[
|
|
142
|
+
...BANNER.map(([slot, diff]) => paint("blueBright", slot) + paint("magenta", diff)),
|
|
143
|
+
` ${paint("dim", `storage layout diff for upgradeable contracts · ${await packageVersion()}`)}`,
|
|
144
|
+
].join("\n");
|
|
145
|
+
if (values.help) {
|
|
146
|
+
io.stdout(io.tty ? `${await banner()}\n\n${USAGE}` : USAGE);
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
if (values.version) {
|
|
150
|
+
io.stdout(io.tty ? await banner() : await packageVersion());
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
if (positionals.length !== 2) {
|
|
154
|
+
io.stderr(`Expected <old> and <new>, got ${positionals.length} argument(s). ${HELP_HINT}`);
|
|
155
|
+
return 2;
|
|
156
|
+
}
|
|
157
|
+
if (positionals.some((p) => p.includes("://"))) {
|
|
158
|
+
// Not echoed: an RPC URL usually carries an API key.
|
|
159
|
+
io.stderr("A side looks like a URL. Pass the endpoint with --rpc <url>.");
|
|
160
|
+
return 2;
|
|
161
|
+
}
|
|
162
|
+
const short = positionals.find((p) => /^0x[0-9a-fA-F]*$/.test(p) && !ADDRESS.test(p));
|
|
163
|
+
if (short !== undefined) {
|
|
164
|
+
io.stderr(`${short} is not an address: an address is 0x and 40 hex digits.`);
|
|
165
|
+
return 2;
|
|
166
|
+
}
|
|
167
|
+
const hasAddress = positionals.some((p) => ADDRESS.test(p));
|
|
168
|
+
if (values.rpc === undefined && hasAddress) {
|
|
169
|
+
io.stderr("An address needs --rpc <url>.");
|
|
170
|
+
return 2;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const oldImplementation = values["old-implementation"] === true;
|
|
174
|
+
if (oldImplementation && !ADDRESS.test(positionals[0])) {
|
|
175
|
+
io.stderr("--old-implementation needs <old> to be an address.");
|
|
176
|
+
return 2;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const safety = !values["no-safety"];
|
|
180
|
+
if (!safety && values["unsafe-allow"] !== undefined) {
|
|
181
|
+
io.stderr("--unsafe-allow does nothing with --no-safety.");
|
|
182
|
+
return 2;
|
|
183
|
+
}
|
|
184
|
+
const unsafeAllow = (values["unsafe-allow"]?.split(",") ?? [])
|
|
185
|
+
.map((k) => k.trim())
|
|
186
|
+
.filter((k) => k !== "") as SafetyErrorKind[];
|
|
187
|
+
const unknownKind = unsafeAllow.find(
|
|
188
|
+
(k) => !(SAFETY_ERROR_KINDS as readonly string[]).includes(k),
|
|
189
|
+
);
|
|
190
|
+
if (unknownKind !== undefined) {
|
|
191
|
+
io.stderr(
|
|
192
|
+
`--unsafe-allow: unknown error kind "${unknownKind}". Known: ${SAFETY_ERROR_KINDS.join(", ")}.`,
|
|
193
|
+
);
|
|
194
|
+
return 2;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Neither URL is ever echoed: endpoints often carry an API key.
|
|
198
|
+
let rpcUrl: URL | undefined;
|
|
199
|
+
try {
|
|
200
|
+
rpcUrl = values.rpc !== undefined ? httpUrl(values.rpc, "--rpc") : undefined;
|
|
201
|
+
const explorerUrl = values["explorer-url"];
|
|
202
|
+
if (explorerUrl !== undefined && hasCredentials(httpUrl(explorerUrl, "--explorer-url"))) {
|
|
203
|
+
throw new Error("--explorer-url must not include a user name or password.");
|
|
204
|
+
}
|
|
205
|
+
} catch (e) {
|
|
206
|
+
io.stderr((e as Error).message);
|
|
207
|
+
return 2;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const cwd = io.cwd ?? process.cwd();
|
|
211
|
+
const root = resolve(cwd, values.root ?? ".");
|
|
212
|
+
const buildInfoFile =
|
|
213
|
+
values["build-info"] !== undefined ? resolve(cwd, values["build-info"]) : undefined;
|
|
214
|
+
const env = io.env ?? process.env;
|
|
215
|
+
// The Etherscan key goes to Etherscan only, never to another explorer's URL.
|
|
216
|
+
const explorer =
|
|
217
|
+
values["explorer-url"] !== undefined
|
|
218
|
+
? { apiUrl: values["explorer-url"] }
|
|
219
|
+
: { apiKey: env.ETHERSCAN_API_KEY };
|
|
220
|
+
const provider = rpcUrl !== undefined ? jsonRpcProvider(rpcUrl, io.fetch ?? fetch) : undefined;
|
|
221
|
+
|
|
222
|
+
if (io.tty) io.stdout(`${await banner()}\n`);
|
|
223
|
+
|
|
224
|
+
const readSide = async (spec: string, side: "old" | "new"): Promise<Side> => {
|
|
225
|
+
if (ADDRESS.test(spec)) {
|
|
226
|
+
// A proxy stands for what it runs now. <new> may be an implementation, but
|
|
227
|
+
// <old> only when said so: a proxy of a kind slotdiff cannot read (a
|
|
228
|
+
// diamond, a custom slot) would otherwise be compared as its own code.
|
|
229
|
+
const proxy = await readProxy(provider!, spec);
|
|
230
|
+
if (side === "old" && (proxy === undefined) !== oldImplementation) {
|
|
231
|
+
// An address with no code is reported as such.
|
|
232
|
+
if (proxy === undefined) await readCode(provider!, spec);
|
|
233
|
+
throw new Error(
|
|
234
|
+
proxy === undefined
|
|
235
|
+
? `${spec.toLowerCase()} is not a proxy slotdiff can read (no ERC-1967 implementation ` +
|
|
236
|
+
"or beacon slot is set). If it is an implementation, or a proxy of another kind " +
|
|
237
|
+
"given by its implementation's address, pass --old-implementation."
|
|
238
|
+
: `${spec.toLowerCase()} is a proxy, not an implementation: drop --old-implementation.`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const implementation = proxy?.implementation ?? spec.toLowerCase();
|
|
242
|
+
if (!canQueryExplorer(explorer)) {
|
|
243
|
+
await readCode(provider!, implementation); // a wrong address is reported as such first
|
|
244
|
+
throw new Error(
|
|
245
|
+
`${implementation} needs an explorer key to fetch its verified source: set ` +
|
|
246
|
+
"ETHERSCAN_API_KEY (or use --explorer-url for another explorer).",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const { record, analysis } = await rebuild(provider!, implementation, { explorer });
|
|
250
|
+
return {
|
|
251
|
+
description: [
|
|
252
|
+
proxy === undefined
|
|
253
|
+
? `${implementation} (implementation)`
|
|
254
|
+
: `${spec.toLowerCase()} → implementation ${implementation}` +
|
|
255
|
+
(proxy.beacon !== undefined ? ` (via beacon ${proxy.beacon})` : ""),
|
|
256
|
+
`${record.contract} (verified source, ${record.bytecodeMatch} match)`,
|
|
257
|
+
],
|
|
258
|
+
kind: proxy?.beacon !== undefined ? "beacon" : analysis.kind,
|
|
259
|
+
analysis,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const build = await findBuildInfo(root, spec, { file: buildInfoFile });
|
|
263
|
+
const solc = await getSolc(build.solcVersion);
|
|
264
|
+
const analysis = await analyzeSource(build.input, solc, build.contract);
|
|
265
|
+
return {
|
|
266
|
+
description: [
|
|
267
|
+
build.contract,
|
|
268
|
+
relative(root, build.file) +
|
|
269
|
+
(buildInfoFile !== undefined ? " (as given, not compared with the sources on disk)" : ""),
|
|
270
|
+
],
|
|
271
|
+
kind: analysis.kind,
|
|
272
|
+
analysis,
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
let old: Side;
|
|
277
|
+
let next: Side;
|
|
278
|
+
try {
|
|
279
|
+
old = await readSide(positionals[0], "old");
|
|
280
|
+
// The same side twice (e.g. a proxy against itself) is fetched and compiled once.
|
|
281
|
+
next = positionals[1] === positionals[0] ? old : await readSide(positionals[1], "new");
|
|
282
|
+
} catch (e) {
|
|
283
|
+
// Messages can quote an explorer or verified source.
|
|
284
|
+
io.stderr(`Could not check: ${printable((e as Error).message)}`);
|
|
285
|
+
return 2;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (const [label, side] of [
|
|
289
|
+
["old", old],
|
|
290
|
+
["new", next],
|
|
291
|
+
] as const) {
|
|
292
|
+
io.stdout(
|
|
293
|
+
`${paint("bold", label)} ${side.description[0]}\n ${paint("dim", side.description[1])}`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The kind comes from <old>, the proxy being upgraded: inferring it from <new>
|
|
298
|
+
// would miss a UUPS implementation that lost its upgrade function.
|
|
299
|
+
const upgrade = checkUpgrade({ ...old.analysis, kind: old.kind }, next.analysis, {
|
|
300
|
+
unsafeAllowRenames: values["unsafe-allow-renames"],
|
|
301
|
+
unsafeAllow,
|
|
302
|
+
skipSafety: !safety,
|
|
303
|
+
});
|
|
304
|
+
const { layout: report, namespaces, safety: safetyCheck } = upgrade;
|
|
305
|
+
const grid = namespaceLines(namespaces.pairs, paint);
|
|
306
|
+
if (grid.length > 0) io.stdout(`\n${grid.join("\n")}`);
|
|
307
|
+
|
|
308
|
+
const renames = values["unsafe-allow-renames"] ? " (renames accepted)" : "";
|
|
309
|
+
const checks: Check[] = [
|
|
310
|
+
report.ok
|
|
311
|
+
? { name: "Storage layout", status: "pass", summary: `compatible${renames}` }
|
|
312
|
+
: {
|
|
313
|
+
name: "Storage layout",
|
|
314
|
+
status: "fail",
|
|
315
|
+
summary: `incompatible${renames}`,
|
|
316
|
+
details: report.explain(),
|
|
317
|
+
},
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
if (namespaces.pairs.length > 0) {
|
|
321
|
+
checks.push({
|
|
322
|
+
name: "ERC-7201 slots",
|
|
323
|
+
...(namespaces.result === "fail"
|
|
324
|
+
? { status: "fail", summary: "problems found in <new> (see above)" }
|
|
325
|
+
: namespaces.result === "unchecked"
|
|
326
|
+
? { status: "skip", summary: "not checked: some slots could not be read (see above)" }
|
|
327
|
+
: {
|
|
328
|
+
status: "pass",
|
|
329
|
+
summary:
|
|
330
|
+
(grid.length > 0
|
|
331
|
+
? "compared by the slot in code (see above)"
|
|
332
|
+
: "match their annotations") + unassigned(unassignedCount(namespaces.pairs)),
|
|
333
|
+
}),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const accepted = unsafeAllow.length > 0 ? `; accepted: ${unsafeAllow.join(", ")}` : "";
|
|
338
|
+
checks.push(
|
|
339
|
+
safetyCheck === undefined
|
|
340
|
+
? { name: "Safety rules", status: "skip", summary: "skipped (--no-safety)" }
|
|
341
|
+
: safetyCheck.ok
|
|
342
|
+
? {
|
|
343
|
+
name: "Safety rules",
|
|
344
|
+
status: "pass",
|
|
345
|
+
summary: `no problems found (${old.kind} proxy${accepted})`,
|
|
346
|
+
}
|
|
347
|
+
: {
|
|
348
|
+
name: "Safety rules",
|
|
349
|
+
status: "fail",
|
|
350
|
+
summary: `problems found (${old.kind} proxy${accepted})`,
|
|
351
|
+
details: safetyCheck.explain(),
|
|
352
|
+
},
|
|
353
|
+
);
|
|
354
|
+
io.stdout(`\n${checkLines(checks, paint).join("\n")}`);
|
|
355
|
+
|
|
356
|
+
// One verdict, so a passing check next to a failing one is never read as safe.
|
|
357
|
+
if (upgrade.result === "fail") {
|
|
358
|
+
const problems = [
|
|
359
|
+
...(report.ok ? [] : ["the storage layout is incompatible"]),
|
|
360
|
+
...(namespaces.result === "fail" ? ["an ERC-7201 namespace fails the slot check"] : []),
|
|
361
|
+
...(safetyCheck?.ok === false ? ["<new> breaks the safety rules"] : []),
|
|
362
|
+
];
|
|
363
|
+
io.stdout(`\n${paint(["bold", "red"], "Not safe to upgrade:")} ${problems.join(" and ")}.`);
|
|
364
|
+
return 1;
|
|
365
|
+
}
|
|
366
|
+
if (upgrade.result === "unchecked") {
|
|
367
|
+
io.stdout(
|
|
368
|
+
`\n${paint(["bold", "yellow"], "Could not check:")} some ERC-7201 slots could not be read.`,
|
|
369
|
+
);
|
|
370
|
+
return 2;
|
|
371
|
+
}
|
|
372
|
+
io.stdout(
|
|
373
|
+
safetyCheck === undefined
|
|
374
|
+
? `\n${paint("yellow", "Storage layout compatible; safety rules not checked (--no-safety).")}`
|
|
375
|
+
: `\n${paint(["bold", "green"], "Safe to upgrade.")}`,
|
|
376
|
+
);
|
|
377
|
+
return 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function unassigned(count: number): string {
|
|
381
|
+
if (count === 0) return "";
|
|
382
|
+
return `; ${count} not assigned in code, compared by annotation`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function httpUrl(value: string, flag: string): URL {
|
|
386
|
+
let url: URL;
|
|
387
|
+
try {
|
|
388
|
+
url = new URL(value);
|
|
389
|
+
} catch {
|
|
390
|
+
throw new Error(`${flag} is not a valid URL; it needs a scheme, such as https://.`);
|
|
391
|
+
}
|
|
392
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
393
|
+
throw new Error(`${flag} must be an http or https URL.`);
|
|
394
|
+
}
|
|
395
|
+
return url;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function hasCredentials(url: URL): boolean {
|
|
399
|
+
return url.username !== "" || url.password !== "";
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* A minimal JSON-RPC client. Errors never include the URL: endpoints often
|
|
404
|
+
* carry an API key in the path or query. Credentials in the URL, which fetch
|
|
405
|
+
* refuses, are sent as basic auth instead.
|
|
406
|
+
*/
|
|
407
|
+
function jsonRpcProvider(url: URL, fetchImpl: FetchLike): EthProvider {
|
|
408
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
409
|
+
if (hasCredentials(url)) {
|
|
410
|
+
const user = `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`;
|
|
411
|
+
headers.authorization = `Basic ${Buffer.from(user).toString("base64")}`;
|
|
412
|
+
url = new URL(url);
|
|
413
|
+
url.username = "";
|
|
414
|
+
url.password = "";
|
|
415
|
+
}
|
|
416
|
+
const endpoint = url.href;
|
|
417
|
+
let id = 0;
|
|
418
|
+
return {
|
|
419
|
+
async send(method, params) {
|
|
420
|
+
let response: Response;
|
|
421
|
+
try {
|
|
422
|
+
response = await fetchImpl(endpoint, {
|
|
423
|
+
method: "POST",
|
|
424
|
+
headers,
|
|
425
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }),
|
|
426
|
+
});
|
|
427
|
+
} catch (e) {
|
|
428
|
+
throw new LayoutUnavailableError(`Cannot reach the RPC endpoint (${errorCode(e)}).`);
|
|
429
|
+
}
|
|
430
|
+
if (!response.ok) {
|
|
431
|
+
throw new LayoutUnavailableError(`The RPC endpoint answered HTTP ${response.status}.`);
|
|
432
|
+
}
|
|
433
|
+
let body: { result?: unknown; error?: { message?: string } };
|
|
434
|
+
try {
|
|
435
|
+
body = (await response.json()) as typeof body;
|
|
436
|
+
} catch {
|
|
437
|
+
body = {};
|
|
438
|
+
}
|
|
439
|
+
if (typeof body !== "object" || body === null || !("result" in body || "error" in body)) {
|
|
440
|
+
throw new LayoutUnavailableError(`The RPC endpoint did not answer JSON-RPC.`);
|
|
441
|
+
}
|
|
442
|
+
if (body.error !== undefined) {
|
|
443
|
+
// The server's text, without control characters (terminal escapes) and capped.
|
|
444
|
+
const message = printable(String(body.error.message ?? "unknown error"))
|
|
445
|
+
.replace(/\s/g, " ")
|
|
446
|
+
.slice(0, 300);
|
|
447
|
+
throw new Error(`RPC ${method} failed: ${message}`);
|
|
448
|
+
}
|
|
449
|
+
return body.result;
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function packageVersion(): Promise<string> {
|
|
455
|
+
const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as {
|
|
456
|
+
version: string;
|
|
457
|
+
};
|
|
458
|
+
return pkg.version;
|
|
459
|
+
}
|