@dusk-network/app-core 0.2.0 → 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.
@@ -0,0 +1,2429 @@
1
+ /* @ts-self-types="./duskevm-withdrawals.d.ts" */
2
+ /**
3
+ * A DuskEVM → Dusk withdrawal's life, signing aside: its start on DuskEVM,
4
+ * finding it again, where it is on Dusk, the game its proof goes against,
5
+ * and its proof's and claim's calls, checked before they're handed over.
6
+ * The app passes its clients, the data drivers it loaded and its caches;
7
+ * nothing is kept outside the objects the factory makes.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import { networkFor } from "@dusk-network/networks";
13
+ import { accountToRawKey } from "@dusk-network/networks/account";
14
+ import {
15
+ BRIDGE_FUNCTIONS,
16
+ GAME_STATUS,
17
+ claimFrom,
18
+ claimReadiness,
19
+ contractClock,
20
+ contractNow,
21
+ decodeBridgeCall,
22
+ decodeWithdrawal,
23
+ duskEvmContracts,
24
+ duskRecipientOf,
25
+ evmAddressOf,
26
+ isCandidateGame,
27
+ isEligibleGame,
28
+ wallTime,
29
+ } from "@dusk-network/networks/duskevm";
30
+ import { formatEvmAddress } from "@dusk-network/networks/format";
31
+ import { withdrawalGasLimit } from "@dusk-network/networks/gas";
32
+ import {
33
+ decodeAbiParameters,
34
+ decodeEventLog,
35
+ decodeFunctionData,
36
+ encodeAbiParameters,
37
+ encodeFunctionData,
38
+ fromRlp,
39
+ keccak256,
40
+ pad,
41
+ parseAbi,
42
+ toHex,
43
+ toRlp,
44
+ } from "viem";
45
+
46
+ import { addCoverage, topGap } from "./history.js";
47
+
48
+ /** @typedef {import("./duskevm-withdrawals.js").DataDriver} DataDriver */
49
+ /** @typedef {import("./duskevm-withdrawals.js").DriverSource} DriverSource */
50
+ /** @typedef {import("./duskevm-withdrawals.js").DuskEvmClient} DuskEvmClient */
51
+ /** @typedef {import("./duskevm-withdrawals.js").ExplorerWithdrawal} ExplorerWithdrawal */
52
+ /** @typedef {import("./duskevm-withdrawals.js").FactCache} FactCache */
53
+ /** @typedef {import("./duskevm-withdrawals.js").FoundWithdrawal} FoundWithdrawal */
54
+ /** @typedef {import("./duskevm-withdrawals.js").L2Withdrawal} L2Withdrawal */
55
+ /** @typedef {import("./duskevm-withdrawals.js").OutputProof} OutputProof */
56
+ /** @typedef {import("./duskevm-withdrawals.js").PortalCall} PortalCall */
57
+ /** @typedef {import("./duskevm-withdrawals.js").PreparedProof} PreparedProof */
58
+ /** @typedef {import("./duskevm-withdrawals.js").ReceiptWithdrawal} ReceiptWithdrawal */
59
+ /** @typedef {import("./duskevm-withdrawals.js").WaitReason} WaitReason */
60
+ /** @typedef {import("./duskevm-withdrawals.js").WithdrawalCallInput} WithdrawalCallInput */
61
+ /** @typedef {import("./duskevm-withdrawals.js").WithdrawalClaim} WithdrawalClaim */
62
+ /** @typedef {import("./duskevm-withdrawals.js").WithdrawalErrorCode} WithdrawalErrorCode */
63
+ /** @typedef {import("./duskevm-withdrawals.js").WithdrawalScan} WithdrawalScan */
64
+ /** @typedef {import("./duskevm-withdrawals.js").WithdrawalStage} WithdrawalStage */
65
+ /** @typedef {import("./duskevm-withdrawals.js").WithdrawalStatus} WithdrawalStatus */
66
+ /** @typedef {import("./duskevm-withdrawals.js").WithdrawalTiming} WithdrawalTiming */
67
+ /** @typedef {import("./duskevm-withdrawals.js").WithdrawalsOptions} WithdrawalsOptions */
68
+ /** @typedef {import("@dusk-network/networks").Network} Network */
69
+ /** @typedef {import("@dusk-network/networks").NetworkRef} NetworkRef */
70
+ /** @typedef {import("@dusk-network/networks/duskevm").ClaimDate} ClaimDate */
71
+ /** @typedef {import("@dusk-network/networks/duskevm").DuskRecipient} DuskRecipient */
72
+ /** @typedef {import("@dusk-network/networks/duskevm").DuskTip} DuskTip */
73
+ /** @typedef {import("@dusk-network/networks/duskevm").GameRules} GameRules */
74
+ /** @typedef {import("@dusk-network/networks/duskevm").WithdrawalFacts} WithdrawalFacts */
75
+ /**
76
+ * What the Portal and the factory said of a withdrawal, in one query.
77
+ *
78
+ * @typedef {{
79
+ * ahead: { game: string, reads: Promise<Array<{ value?: any, error?: Error }>> } | null,
80
+ * delays: { disputeGameFinalityDelaySeconds: bigint, proofMaturityDelaySeconds: bigint },
81
+ * finalized: boolean,
82
+ * gameCount: bigint,
83
+ * gameType: number,
84
+ * proofSubmitterCount: bigint,
85
+ * proven: { disputeGameProxy: `0x${string}`, timestamp: bigint },
86
+ * tip: Promise<DuskTip> | null,
87
+ * }} Snapshot
88
+ */
89
+
90
+ export const GAME_SEARCH_DEPTH = 64;
91
+ export const RESOLVED_PAGE = 32;
92
+ export const RESOLVED_PAGES = 3;
93
+ export const RESOLVER_LAG_SECONDS = 3_600;
94
+ export const MAX_BATCH = 32;
95
+ export const GAS_LIMIT_UPPER = 1_000_000_000n;
96
+ export const WITHDRAWAL_MIN_GAS_LIMIT = 200_000;
97
+ export const L2_WINDOW = 100_000;
98
+ export const L2_RUN_REQUESTS = 8;
99
+ export const UNFINALIZED_MARGIN = 1_800;
100
+ export const EXPLORER_PAGES = 10;
101
+ export const EXPLORER_ITEMS = 12;
102
+ export const MESSAGE_PASSED_TOPIC = "0x02a52367d10742d8032712c1bb8e0144ff1ec5ffda1ed7d70bb05a2744955054";
103
+ export const ETH_BRIDGE_INITIATED_TOPIC = "0x2849b43074093a05396b6f2a937dee8565b15a48a7b3d4bffb732a5017380af5";
104
+
105
+ export const STAGE = Object.freeze({
106
+ WAITING_FOR_OUTPUT: "waiting_for_output",
107
+ READY_TO_PROVE: "ready_to_prove",
108
+ PROVEN_WAITING: "proven_waiting",
109
+ READY_TO_FINALIZE: "ready_to_finalize",
110
+ FINALIZED: "finalized",
111
+ });
112
+
113
+ /** The latest games read for how often outputs come: five gaps. */
114
+ const OUTPUT_SAMPLE = 6n;
115
+ /** Games a scan for the newest covering game reads at once. */
116
+ const SCAN_PAGE = 32n;
117
+ /** Other accounts' proofs looked at, at most. */
118
+ const OTHER_PROOFS = 8n;
119
+
120
+ const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
121
+ /** @type {`0x${string}`} */
122
+ const ZERO_HASH = "0x0000000000000000000000000000000000000000000000000000000000000000";
123
+ const MESSAGE_PASSER = "0x4200000000000000000000000000000000000016";
124
+ const WEI_PER_LUX = 1_000_000_000n;
125
+ const U64_MAX = (1n << 64n) - 1n;
126
+ const U32_MAX = (1n << 32n) - 1n;
127
+ /** The low 240 bits of a withdrawal's nonce: its count, under its version. */
128
+ const NONCE_COUNT = (1n << 240n) - 1n;
129
+
130
+ // Answers a read is sent again for, a quarter second, then half a second later.
131
+ const RETRY_STATUSES = new Set([429, 502, 503, 504]);
132
+ const ATTEMPTS = 3;
133
+ const RETRY_MS = 250;
134
+
135
+ const PROVE = BRIDGE_FUNCTIONS.prove;
136
+ const CLAIM = BRIDGE_FUNCTIONS.claim;
137
+ /**
138
+ * A proof's archived arguments end with their root: the output root proof
139
+ * (version, state root, message passer storage root, block hash: 32 bytes
140
+ * each), the game's index (32), the withdrawal (144), and its proof's
141
+ * nodes, a Vec<Vec<u8>> (8), as the Portal's data driver writes them.
142
+ */
143
+ const PROVE_ROOT = 312;
144
+ const PROOF_NODES_AT = 304;
145
+
146
+ const MESSAGE_PASSED = parseAbi([
147
+ "event MessagePassed(uint256 indexed nonce, address indexed sender, address indexed target, uint256 value, uint256 gasLimit, bytes data, bytes32 withdrawalHash)",
148
+ ]);
149
+ const BRIDGE_ETH_TO = parseAbi(["function bridgeETHTo(address _to, uint32 _minGasLimit, bytes _extraData)"]);
150
+ const BRIDGE_ETH_TO_SELECTOR = "0xe11013dd";
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Refusals
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /** @type {Record<WithdrawalErrorCode, string>} */
157
+ const REFUSALS = {
158
+ "bad-hash": "Not a DuskEVM transaction hash",
159
+ "not-found": "DuskEVM has no receipt of this transaction",
160
+ failed: "The transaction failed on DuskEVM",
161
+ "not-a-withdrawal": "Not a DUSK withdrawal on the bridge's route",
162
+ gas: "The withdrawal's proof and claim need more gas than the bound",
163
+ claimed: "The withdrawal is claimed already",
164
+ proven: "This account has proved the withdrawal already",
165
+ "no-proof": "No dispute game covers the withdrawal yet",
166
+ "not-ready": "The withdrawal isn't ready to claim",
167
+ "bad-call": "The encoded call doesn't read back as what it was built from",
168
+ };
169
+
170
+ export class WithdrawalError extends Error {
171
+ /**
172
+ * @param {WithdrawalErrorCode} code
173
+ * @param {string} [message]
174
+ * @param {{ status?: WithdrawalStatus | null, reason?: WaitReason | null, cause?: unknown }} [options]
175
+ */
176
+ constructor(code, message, { cause, reason, status = null } = {}) {
177
+ super(message ?? REFUSALS[code] ?? String(code), cause === undefined ? undefined : { cause });
178
+ this.name = "WithdrawalError";
179
+ /** @type {WithdrawalErrorCode} */
180
+ this.code = code;
181
+ /** @type {WithdrawalStatus | null} */
182
+ this.status = status;
183
+ /** @type {WaitReason | null} */
184
+ this.reason = reason ?? status?.reason ?? null;
185
+ }
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Values
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /**
193
+ * @param {unknown} error
194
+ * @returns {Error}
195
+ */
196
+ const asError = (error) => (error instanceof Error ? error : new Error(String(error)));
197
+
198
+ /**
199
+ * @template T
200
+ * @param {Promise<T> | T} promise
201
+ * @returns {Promise<{ value?: T, error?: Error }>}
202
+ */
203
+ const settle = (promise) =>
204
+ Promise.resolve(promise).then(
205
+ (value) => ({ value }),
206
+ (error) => ({ error: asError(error) })
207
+ );
208
+
209
+ /** @param {number} ms */
210
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
211
+
212
+ /**
213
+ * Bytes as a driver gives or takes them: 0x hex, a Uint8Array, an
214
+ * ArrayBuffer, or an array of numbers.
215
+ *
216
+ * @param {unknown} value
217
+ * @returns {number[]}
218
+ */
219
+ function bytesOf(value) {
220
+ if (typeof value === "string") {
221
+ if (!/^0x(?:[0-9a-fA-F]{2})*$/.test(value)) throw new TypeError("Expected 0x-prefixed byte hex");
222
+
223
+ return Array.from({ length: (value.length - 2) / 2 }, (_, i) => parseInt(value.slice(2 + 2 * i, 4 + 2 * i), 16));
224
+ }
225
+
226
+ if (value instanceof Uint8Array) return Array.from(value);
227
+ if (value instanceof ArrayBuffer) return Array.from(new Uint8Array(value));
228
+
229
+ if (Array.isArray(value)) {
230
+ return value.map((byte) => {
231
+ const n = Number(byte);
232
+
233
+ if (!Number.isInteger(n) || n < 0 || n > 255) throw new TypeError("Expected bytes");
234
+
235
+ return n;
236
+ });
237
+ }
238
+
239
+ throw new TypeError("Expected bytes or byte hex");
240
+ }
241
+
242
+ /**
243
+ * @param {unknown} value
244
+ * @param {number} length
245
+ * @returns {number[]}
246
+ */
247
+ function fixedBytes(value, length) {
248
+ const bytes = bytesOf(value);
249
+
250
+ if (bytes.length !== length) throw new RangeError(`Expected ${length} bytes, got ${bytes.length}`);
251
+
252
+ return bytes;
253
+ }
254
+
255
+ /**
256
+ * @param {ArrayLike<number>} bytes
257
+ * @returns {`0x${string}`}
258
+ */
259
+ const hexOf = (bytes) => `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
260
+
261
+ /**
262
+ * A U256 as the contracts take it: 32 bytes, big-endian.
263
+ *
264
+ * @param {bigint} value
265
+ * @returns {number[]}
266
+ */
267
+ function u256Bytes(value) {
268
+ let rest = BigInt(value);
269
+
270
+ if (rest < 0n || rest >= 1n << 256n) throw new RangeError("Not a U256");
271
+
272
+ const out = new Array(32).fill(0);
273
+
274
+ for (let i = 31; i >= 0; i--) {
275
+ out[i] = Number(rest & 0xffn);
276
+ rest >>= 8n;
277
+ }
278
+
279
+ return out;
280
+ }
281
+
282
+ /**
283
+ * A number as a driver gives it: a bigint, a number, its text, or a U256's 32 bytes.
284
+ *
285
+ * @param {unknown} value
286
+ * @returns {bigint}
287
+ */
288
+ function bigOf(value) {
289
+ if (typeof value === "bigint") return value;
290
+ if (typeof value === "number" || typeof value === "string") return BigInt(value);
291
+
292
+ return fixedBytes(value, 32).reduce((n, byte) => (n << 8n) | BigInt(byte), 0n);
293
+ }
294
+
295
+ /**
296
+ * A struct's field, as a driver gives it by name or as a tuple by position.
297
+ *
298
+ * @param {any} value
299
+ * @param {string} key
300
+ * @param {number} index
301
+ */
302
+ const tupleValue = (value, key, index) => value?.[key] ?? value?.[index];
303
+
304
+ /**
305
+ * An address as the reads give it, 20 bytes or their hex, in lower case.
306
+ *
307
+ * @param {unknown} value
308
+ * @returns {`0x${string}`}
309
+ */
310
+ const addressHex = (value) => hexOf(fixedBytes(value, 20));
311
+
312
+ /**
313
+ * @param {string} address
314
+ * @returns {number[]}
315
+ */
316
+ const addressBytes = (address) => fixedBytes(address.toLowerCase(), 20);
317
+
318
+ /**
319
+ * A contract ID as a read gives it, 32 bytes or their hex, as lower-case hex.
320
+ *
321
+ * @param {unknown} value
322
+ * @returns {string}
323
+ */
324
+ function contractIdHex(value) {
325
+ const id =
326
+ typeof value === "string"
327
+ ? value.replace(/^0x/, "").toLowerCase()
328
+ : value instanceof Uint8Array || Array.isArray(value)
329
+ ? hexOf(fixedBytes(value, 32)).slice(2)
330
+ : "";
331
+
332
+ if (!/^[0-9a-f]{64}$/.test(id)) throw new RangeError("Expected a 32-byte contract ID");
333
+
334
+ return id;
335
+ }
336
+
337
+ /**
338
+ * An EVM address in lower case; a TypeError for anything else, and for a
339
+ * mixed-case one whose checksum is wrong.
340
+ *
341
+ * @param {unknown} value
342
+ * @param {string} what
343
+ * @returns {`0x${string}`}
344
+ */
345
+ function evmAddress(value, what) {
346
+ const text = typeof value === "string" ? value.trim() : "";
347
+
348
+ if (!/^0x[0-9a-fA-F]{40}$/.test(text)) throw new TypeError(`${what} isn't an EVM address`);
349
+
350
+ const digits = text.slice(2);
351
+
352
+ if (digits !== digits.toLowerCase() && digits !== digits.toUpperCase() && formatEvmAddress(text) !== text) {
353
+ throw new TypeError(`${what}'s checksum is wrong`);
354
+ }
355
+
356
+ return /** @type {`0x${string}`} */ (text.toLowerCase());
357
+ }
358
+
359
+ /**
360
+ * The Portal's address of the account whose proofs count: an EVM address,
361
+ * or the one DuskEVM gives a Dusk account; null for none.
362
+ *
363
+ * @param {unknown} value
364
+ * @returns {`0x${string}` | null}
365
+ */
366
+ function submitterOf(value) {
367
+ if (value === null || value === undefined || value === "") return null;
368
+
369
+ const text = typeof value === "string" ? value.trim() : "";
370
+
371
+ if (/^0x[0-9a-fA-F]{40}$/.test(text)) return evmAddress(text, "The submitter");
372
+ if (!accountToRawKey(text)) throw new TypeError("The submitter isn't a Dusk account or an EVM address");
373
+
374
+ return /** @type {`0x${string}`} */ (evmAddressOf(text).toLowerCase());
375
+ }
376
+
377
+ /**
378
+ * @param {unknown} value
379
+ * @returns {`0x${string}`}
380
+ */
381
+ function requireSubmitter(value) {
382
+ const submitter = submitterOf(value);
383
+
384
+ if (!submitter) throw new TypeError("A proof or a claim is for one account: pass its submitter");
385
+
386
+ return submitter;
387
+ }
388
+
389
+ /**
390
+ * The Portal's address of the account a withdrawal pays, which proves and
391
+ * claims it by default; null for a contract.
392
+ *
393
+ * @param {WithdrawalFacts} facts
394
+ * @returns {`0x${string}` | null}
395
+ */
396
+ const recipientSubmitter = (facts) =>
397
+ facts.recipient.kind === "account" ? /** @type {`0x${string}`} */ (evmAddressOf(facts.recipient.account).toLowerCase()) : null;
398
+
399
+ /**
400
+ * @param {unknown} value
401
+ * @param {string} what
402
+ * @returns {bigint}
403
+ */
404
+ function wholeOf(value, what) {
405
+ if (typeof value === "bigint") return value;
406
+
407
+ if ((typeof value === "number" && Number.isSafeInteger(value)) || (typeof value === "string" && /^\s*\d+\s*$/.test(value))) {
408
+ return BigInt(value);
409
+ }
410
+
411
+ throw new TypeError(`${what} is a whole number`);
412
+ }
413
+
414
+ /**
415
+ * The network a reference names; a TypeError for one there's no file for.
416
+ *
417
+ * @param {NetworkRef} network
418
+ * @returns {Network}
419
+ */
420
+ function networkOf(network) {
421
+ const net = network && typeof network === "object" ? network : networkFor(/** @type {number | bigint | string} */ (network));
422
+
423
+ if (!net || typeof net.chainId !== "number") throw new TypeError("Not one of the networks");
424
+
425
+ return net;
426
+ }
427
+
428
+ /**
429
+ * A data driver's input, as JSON: big integers as their text.
430
+ *
431
+ * @param {unknown} args
432
+ * @returns {string}
433
+ */
434
+ const driverJson = (args) =>
435
+ args === undefined || args === null ? "null" : JSON.stringify(args, (_key, item) => (typeof item === "bigint" ? item.toString() : item));
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // A withdrawal, as DuskEVM recorded it
439
+ // ---------------------------------------------------------------------------
440
+
441
+ /**
442
+ * @param {NetworkRef} network
443
+ * @param {any} receipt
444
+ * @returns {ReceiptWithdrawal}
445
+ */
446
+ export function parseWithdrawalReceipt(network, receipt) {
447
+ const net = networkOf(network);
448
+
449
+ if (!receipt) throw new WithdrawalError("not-found");
450
+ if (receipt.status === "0x0" || receipt.status === 0 || receipt.status === "reverted") throw new WithdrawalError("failed");
451
+
452
+ const passer = String(net.duskEvm?.predeploys?.l2ToL1MessagePasser ?? MESSAGE_PASSER).toLowerCase();
453
+
454
+ // The first MessagePassed of DuskEVM's message passer is the withdrawal.
455
+ for (const log of Array.isArray(receipt.logs) ? receipt.logs : []) {
456
+ if (String(log?.address ?? "").toLowerCase() !== passer) continue;
457
+ if (String(log?.topics?.[0] ?? "").toLowerCase() !== MESSAGE_PASSED_TOPIC) continue;
458
+
459
+ /** @type {any} */
460
+ let args;
461
+
462
+ try {
463
+ args = decodeEventLog({ abi: MESSAGE_PASSED, data: log.data, topics: log.topics }).args;
464
+ } catch (cause) {
465
+ throw new WithdrawalError("not-a-withdrawal", "Its MessagePassed event doesn't decode", { cause });
466
+ }
467
+
468
+ /** @type {L2Withdrawal} */
469
+ const withdrawal = {
470
+ data: /** @type {`0x${string}`} */ (String(args.data).toLowerCase()),
471
+ gasLimit: args.gasLimit,
472
+ nonce: args.nonce,
473
+ sender: args.sender,
474
+ target: args.target,
475
+ value: args.value,
476
+ };
477
+ const facts = decodeWithdrawal(net, withdrawal);
478
+
479
+ if (!facts) throw new WithdrawalError("not-a-withdrawal");
480
+
481
+ // The event names its own hash: one that isn't the withdrawal's isn't its record.
482
+ if (facts.hash !== String(args.withdrawalHash).toLowerCase()) {
483
+ throw new WithdrawalError("not-a-withdrawal", "Its MessagePassed event's hash isn't the withdrawal's");
484
+ }
485
+
486
+ const block = log.blockNumber ?? receipt.blockNumber;
487
+ const hash = String(log.transactionHash ?? receipt.transactionHash ?? "").toLowerCase();
488
+
489
+ if (block === undefined || block === null) throw new WithdrawalError("not-a-withdrawal", "Its receipt has no DuskEVM block");
490
+
491
+ return {
492
+ blockNumber: BigInt(block),
493
+ facts,
494
+ transactionHash: /** @type {`0x${string}`} */ (hash),
495
+ withdrawal,
496
+ withdrawalHash: facts.hash,
497
+ };
498
+ }
499
+
500
+ throw new WithdrawalError("not-a-withdrawal", "It records no MessagePassed event");
501
+ }
502
+
503
+ /**
504
+ * The withdrawal as the Portal's data driver takes it: its fields as bytes,
505
+ * the numbers as 32 big-endian bytes.
506
+ *
507
+ * @param {L2Withdrawal} withdrawal
508
+ */
509
+ const withdrawalInput = (withdrawal) => ({
510
+ data: bytesOf(withdrawal.data),
511
+ ["gas_limit"]: u256Bytes(withdrawal.gasLimit),
512
+ nonce: u256Bytes(withdrawal.nonce),
513
+ sender: addressBytes(withdrawal.sender),
514
+ target: addressBytes(withdrawal.target),
515
+ value: u256Bytes(withdrawal.value),
516
+ });
517
+
518
+ /**
519
+ * A proof's arguments: the withdrawal, the game's index, the output root
520
+ * proof and the proof's nodes.
521
+ *
522
+ * @param {L2Withdrawal} withdrawal
523
+ * @param {OutputProof} proof
524
+ */
525
+ const proveInput = (withdrawal, proof) => [
526
+ withdrawalInput(withdrawal),
527
+ u256Bytes(proof.disputeGameIndex),
528
+ {
529
+ ["latest_blockhash"]: fixedBytes(proof.outputRootProof.latestBlockhash, 32),
530
+ ["message_passer_storage_root"]: fixedBytes(proof.outputRootProof.messagePasserStorageRoot, 32),
531
+ ["state_root"]: fixedBytes(proof.outputRootProof.stateRoot, 32),
532
+ version: fixedBytes(proof.outputRootProof.version, 32),
533
+ },
534
+ proof.withdrawalProof.map((node) => bytesOf(node)),
535
+ ];
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // Proofs
539
+ // ---------------------------------------------------------------------------
540
+
541
+ /**
542
+ * Where the message passer keeps a withdrawal: its `sentMessages` slot.
543
+ *
544
+ * @param {`0x${string}`} withdrawalHash
545
+ * @returns {`0x${string}`}
546
+ */
547
+ const storageKey = (withdrawalHash) =>
548
+ keccak256(encodeAbiParameters([{ type: "bytes32" }, { type: "uint256" }], [withdrawalHash, 0n]));
549
+
550
+ /**
551
+ * An output's root: the hash of its version, state root, message passer
552
+ * storage root and block hash.
553
+ *
554
+ * @param {OutputProof["outputRootProof"]} proof
555
+ * @returns {`0x${string}`}
556
+ */
557
+ const outputRootOf = (proof) =>
558
+ keccak256(
559
+ encodeAbiParameters(
560
+ [{ type: "bytes32" }, { type: "bytes32" }, { type: "bytes32" }, { type: "bytes32" }],
561
+ [proof.version, proof.stateRoot, proof.messagePasserStorageRoot, proof.latestBlockhash]
562
+ )
563
+ );
564
+
565
+ /**
566
+ * @param {any} item
567
+ * @returns {{ node: any[], rlp: `0x${string}` } | null}
568
+ */
569
+ function embeddedNode(item) {
570
+ if (Array.isArray(item)) return { node: item, rlp: toRlp(item) };
571
+ if (typeof item !== "string" || !item.startsWith("0x") || item === "0x") return null;
572
+
573
+ try {
574
+ const decoded = fromRlp(/** @type {`0x${string}`} */ (item));
575
+
576
+ return Array.isArray(decoded) ? { node: decoded, rlp: /** @type {`0x${string}`} */ (item) } : null;
577
+ } catch {
578
+ return null;
579
+ }
580
+ }
581
+
582
+ /**
583
+ * A storage proof that ends in a branch node gets the terminal node the
584
+ * branch embeds for the key appended, as OP Stack clients do: the Portal's
585
+ * verifier walks the same trie.
586
+ *
587
+ * @param {`0x${string}`} path The hashed key
588
+ * @param {ReadonlyArray<`0x${string}`>} proof
589
+ * @returns {`0x${string}`[]}
590
+ */
591
+ function withEmbeddedNode(path, proof) {
592
+ const last = fromRlp(proof[proof.length - 1]);
593
+
594
+ if (!Array.isArray(last) || last.length !== 17) return [...proof];
595
+
596
+ const nodes = [...proof];
597
+
598
+ for (const item of last) {
599
+ const embedded = embeddedNode(item);
600
+ const suffix = embedded && typeof embedded.node[0] === "string" ? embedded.node[0].slice(3) : null;
601
+
602
+ if (embedded && suffix !== null && path.endsWith(suffix)) nodes.push(embedded.rlp);
603
+ }
604
+
605
+ return nodes;
606
+ }
607
+
608
+ /**
609
+ * @param {unknown} extraData
610
+ * @returns {bigint}
611
+ */
612
+ function l2SequenceOf(extraData) {
613
+ const bytes = bytesOf(extraData);
614
+
615
+ return bytes.length < 32 ? 0n : bigOf(bytes.slice(0, 32));
616
+ }
617
+
618
+ /**
619
+ * The DuskEVM block a game's metadata says its output is of.
620
+ *
621
+ * @param {any} metadata
622
+ * @returns {bigint}
623
+ */
624
+ function gameL2Block(metadata) {
625
+ const explicit = tupleValue(metadata, "l2SequenceNumber", 2);
626
+
627
+ return bigOf(explicit) === 0n ? l2SequenceOf(tupleValue(metadata, "extraData", 3)) : bigOf(explicit);
628
+ }
629
+
630
+ /**
631
+ * A game as a search of the factory lists it: its index, creation time,
632
+ * address, root claim and DuskEVM block.
633
+ *
634
+ * @param {any} result
635
+ */
636
+ function searchedGame(result) {
637
+ const metadata = fixedBytes(tupleValue(result, "metadata", 1), 32);
638
+
639
+ return {
640
+ createdAt: BigInt(tupleValue(result, "timestamp", 2)),
641
+ index: bigOf(tupleValue(result, "index", 0)),
642
+ l2BlockNumber: l2SequenceOf(tupleValue(result, "extra_data", 4)),
643
+ // A game ID packs its type, its creation time, then its address.
644
+ proxy: hexOf(metadata.slice(12)),
645
+ rootClaim: hexOf(fixedBytes(tupleValue(result, "root_claim", 3), 32)),
646
+ };
647
+ }
648
+
649
+ /**
650
+ * An archived Vec at `at`: a relative pointer and a length, its items
651
+ * before `end`, where the driver put them.
652
+ *
653
+ * @param {Uint8Array} bytes
654
+ * @param {number} at
655
+ * @param {number} end
656
+ * @param {number} itemSize
657
+ */
658
+ function archivedVec(bytes, at, end, itemSize) {
659
+ if (at < 0 || at + 8 > bytes.length) throw new WithdrawalError("bad-call");
660
+
661
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
662
+ const start = at + view.getInt32(at, true);
663
+ const length = view.getUint32(at + 4, true);
664
+
665
+ if (start < 0 || start + length * itemSize > end) throw new WithdrawalError("bad-call");
666
+
667
+ return { length, start };
668
+ }
669
+
670
+ /**
671
+ * @param {DuskRecipient} a
672
+ * @param {DuskRecipient} b
673
+ */
674
+ const sameRecipient = (a, b) =>
675
+ a.kind === b.kind && (a.kind === "account" ? a.account === /** @type {any} */ (b).account : a.contractId === /** @type {any} */ (b).contractId);
676
+
677
+ /**
678
+ * What a Portal call's arguments say, as Dusk Wallet reads them: the
679
+ * withdrawal they were built from, by its hash, on the bridge's route, and
680
+ * for a proof the game, the output root proof and the proof's nodes too.
681
+ * Anything else is no call to sign.
682
+ *
683
+ * @param {Network} net
684
+ * @param {string} portal
685
+ * @param {string} fnName
686
+ * @param {Uint8Array} fnArgs
687
+ * @param {{ facts: WithdrawalFacts, proof: OutputProof | null }} built
688
+ */
689
+ function checkedCall(net, portal, fnName, fnArgs, { facts, proof }) {
690
+ const call = decodeBridgeCall(net, { args: fnArgs, contractId: portal, fnName });
691
+
692
+ if (
693
+ !call ||
694
+ call.kind !== (proof ? "prove" : "claim") ||
695
+ call.hash !== facts.hash ||
696
+ call.nonce !== facts.nonce ||
697
+ call.amountWei !== facts.amountWei ||
698
+ call.gasLimit !== facts.gasLimit ||
699
+ call.from !== facts.from ||
700
+ call.target !== facts.target ||
701
+ !sameRecipient(call.recipient, facts.recipient) ||
702
+ "proofSubmitter" in call
703
+ ) {
704
+ throw new WithdrawalError("bad-call");
705
+ }
706
+
707
+ if (proof) {
708
+ if (call.kind !== "prove" || call.disputeGameIndex !== proof.disputeGameIndex || call.proofNodes !== proof.withdrawalProof.length) {
709
+ throw new WithdrawalError("bad-call");
710
+ }
711
+
712
+ const root = fnArgs.length - PROVE_ROOT;
713
+ const { latestBlockhash, messagePasserStorageRoot, stateRoot, version } = proof.outputRootProof;
714
+
715
+ [version, stateRoot, messagePasserStorageRoot, latestBlockhash].forEach((word, i) => {
716
+ if (hexOf(fnArgs.subarray(root + 32 * i, root + 32 * (i + 1))) !== word.toLowerCase()) throw new WithdrawalError("bad-call");
717
+ });
718
+
719
+ const nodes = archivedVec(fnArgs, root + PROOF_NODES_AT, root, 8);
720
+
721
+ proof.withdrawalProof.forEach((node, i) => {
722
+ const { length, start } = archivedVec(fnArgs, nodes.start + 8 * i, root, 1);
723
+
724
+ if (hexOf(fnArgs.subarray(start, start + length)) !== node.toLowerCase()) throw new WithdrawalError("bad-call");
725
+ });
726
+ }
727
+
728
+ return call;
729
+ }
730
+
731
+ // ---------------------------------------------------------------------------
732
+ // DuskEVM's RPC
733
+ // ---------------------------------------------------------------------------
734
+
735
+ /**
736
+ * @param {DuskEvmClient | null | undefined} client
737
+ * @param {Network | null} net
738
+ * @returns {(method: string, params?: unknown[]) => Promise<any>}
739
+ */
740
+ function evmRpc(client, net) {
741
+ if (client && "request" in client && typeof client.request === "function") {
742
+ const viem = client;
743
+
744
+ return (method, params = []) => viem.request({ method, params });
745
+ }
746
+
747
+ const url = (client && "url" in client ? client.url : null) ?? net?.duskEvm?.rpcUrl;
748
+
749
+ if (!url) throw new TypeError("No DuskEVM RPC to read");
750
+
751
+ const send = (client && "fetch" in client ? client.fetch : null) ?? ((input, init) => globalThis.fetch(input, init));
752
+
753
+ return async (method, params = []) => {
754
+ const response = await send(url, {
755
+ body: JSON.stringify({ id: 1, jsonrpc: "2.0", method, params }),
756
+ headers: { "content-type": "application/json" },
757
+ method: "POST",
758
+ });
759
+
760
+ if (!response.ok) throw new Error(`${method} failed with HTTP ${response.status}`);
761
+
762
+ const body = await response.json();
763
+
764
+ if (body?.error) throw Object.assign(new Error(body.error.message ?? `${method} failed`), { code: body.error.code });
765
+
766
+ return body?.result;
767
+ };
768
+ }
769
+
770
+ // ---------------------------------------------------------------------------
771
+ // The bridge's contracts on Dusk
772
+ // ---------------------------------------------------------------------------
773
+
774
+ /**
775
+ * A u64 as a contract returns it: 8 bytes, little-endian.
776
+ *
777
+ * @param {Uint8Array} bytes
778
+ * @returns {bigint}
779
+ */
780
+ function u64Le(bytes) {
781
+ if (bytes.length < 8) throw new RangeError("Expected an 8-byte u64");
782
+
783
+ let value = 0n;
784
+
785
+ for (let i = 7; i >= 0; i--) value = (value << 8n) | BigInt(bytes[i]);
786
+
787
+ return value;
788
+ }
789
+
790
+ /**
791
+ * A dispute game hub's views, read raw: a game's 20-byte address in
792
+ * (nothing for `maxClockDuration`), a u64, little-endian, or the game's
793
+ * status byte out.
794
+ *
795
+ * @type {DataDriver}
796
+ */
797
+ const HUB_CODEC = Object.freeze({
798
+ /**
799
+ * @param {string} fnName
800
+ * @param {Uint8Array} bytes
801
+ */
802
+ decodeOutputFn(fnName, bytes) {
803
+ if (fnName !== "statusForGame") return u64Le(bytes);
804
+ if (!bytes.length) throw new RangeError("Expected the game's status byte");
805
+
806
+ return bytes[0];
807
+ },
808
+ /**
809
+ * @param {string} fnName
810
+ * @param {string} json
811
+ */
812
+ encodeInputFn: (fnName, json) => (fnName === "maxClockDuration" ? new Uint8Array(0) : Uint8Array.from(JSON.parse(json))),
813
+ });
814
+
815
+ /**
816
+ * The anchor state registry's views, read raw: a game's 20-byte address
817
+ * in, yes or no out; `respectedGameTypeUpdatedAt` takes nothing and gives
818
+ * a u64.
819
+ *
820
+ * @type {DataDriver}
821
+ */
822
+ const REGISTRY_CODEC = Object.freeze({
823
+ /**
824
+ * @param {string} fnName
825
+ * @param {Uint8Array} bytes
826
+ */
827
+ decodeOutputFn(fnName, bytes) {
828
+ if (fnName === "respectedGameTypeUpdatedAt") return u64Le(bytes);
829
+ if (bytes.length !== 1 || bytes[0] > 1) throw new RangeError(`Expected ${fnName}'s yes or no`);
830
+
831
+ return bytes[0] === 1;
832
+ },
833
+ /**
834
+ * @param {string} fnName
835
+ * @param {string} json
836
+ */
837
+ encodeInputFn: (fnName, json) => (fnName === "respectedGameTypeUpdatedAt" ? new Uint8Array(0) : Uint8Array.from(JSON.parse(json))),
838
+ });
839
+
840
+ /**
841
+ * A driver, loaded once; one that failed to load is loaded again next time.
842
+ *
843
+ * @param {DriverSource | undefined} source
844
+ * @param {string} what
845
+ * @returns {() => Promise<DataDriver>}
846
+ */
847
+ function driverOf(source, what) {
848
+ if (!source) throw new TypeError(`The ${what} data driver is needed`);
849
+
850
+ /** @type {Promise<DataDriver> | null} */
851
+ let pending = null;
852
+
853
+ return () => {
854
+ if (!pending) {
855
+ const loading = Promise.resolve(typeof source === "function" ? source() : source).then((driver) => {
856
+ if (typeof driver?.encodeInputFn !== "function" || typeof driver?.decodeOutputFn !== "function") {
857
+ throw new TypeError(`The ${what} data driver encodes and decodes nothing`);
858
+ }
859
+
860
+ return driver;
861
+ });
862
+
863
+ pending = loading;
864
+ loading.catch(() => {
865
+ if (pending === loading) pending = null;
866
+ });
867
+ }
868
+
869
+ return pending;
870
+ };
871
+ }
872
+
873
+ /**
874
+ * A read kept once it succeeded, in the app's cache as text; one that
875
+ * failed is read again next time.
876
+ *
877
+ * @param {FactCache} cache
878
+ * @param {Map<string, Promise<string>>} inflight
879
+ * @param {string} key
880
+ * @param {() => Promise<string>} load
881
+ * @returns {Promise<string>}
882
+ */
883
+ function known(cache, inflight, key, load) {
884
+ const kept = cache.get(key);
885
+
886
+ if (typeof kept === "string" && kept) return Promise.resolve(kept);
887
+
888
+ let pending = inflight.get(key);
889
+
890
+ if (!pending) {
891
+ const reading = load().then((value) => {
892
+ cache.set(key, value);
893
+
894
+ return value;
895
+ });
896
+
897
+ pending = reading;
898
+ inflight.set(key, reading);
899
+ reading.then(
900
+ () => inflight.delete(key),
901
+ () => inflight.delete(key)
902
+ );
903
+ }
904
+
905
+ return pending;
906
+ }
907
+
908
+ /**
909
+ * @param {Pick<WithdrawalsOptions, "drivers" | "fetch" | "maxBatch" | "nodeUrl"> & { cache: FactCache, contracts: { portal: string, disputeGameFactory: string, anchorStateRegistry: string | null } }} options
910
+ */
911
+ function createReads({ cache, contracts, drivers, fetch, nodeUrl }) {
912
+ const base = String(nodeUrl ?? "").trim().replace(/\/+$/, "");
913
+
914
+ if (!/^https?:\/\//i.test(base)) throw new TypeError("The node's URL is an http(s) URL");
915
+
916
+ const send = fetch ?? ((input, init) => globalThis.fetch(input, init));
917
+ const portalDriver = driverOf(drivers?.optimismPortal, "Portal's");
918
+ const factoryDriver = driverOf(drivers?.disputeGameFactory, "dispute game factory's");
919
+ // Both drivers load before any read goes out, so the reads one task starts go together.
920
+ const loaded = () => Promise.all([portalDriver(), factoryDriver()]);
921
+
922
+ /**
923
+ * @param {(drivers: DataDriver[]) => DataDriver} pick
924
+ * @param {string} id
925
+ * @param {string} fnName
926
+ * @param {unknown} [args]
927
+ * @returns {Promise<any>}
928
+ */
929
+ async function read(pick, id, fnName, args) {
930
+ const driver = pick(await loaded());
931
+ const body = driver.encodeInputFn(fnName, driverJson(args));
932
+
933
+ for (let attempt = 1; ; attempt += 1) {
934
+ const response = await send(`${base}/on/contracts:${id}/${fnName}`, {
935
+ body: /** @type {BodyInit} */ (/** @type {unknown} */ (body)),
936
+ headers: { "Content-Type": "application/octet-stream" },
937
+ method: "POST",
938
+ });
939
+
940
+ if (response.ok) return driver.decodeOutputFn(fnName, new Uint8Array(await response.arrayBuffer()));
941
+
942
+ if (attempt >= ATTEMPTS || !RETRY_STATUSES.has(response.status)) {
943
+ // A refusal carries the contract's own words, as a check's does.
944
+ const refusal = await response.text().catch(() => "");
945
+
946
+ throw new Error(refusal || `The ${fnName} read failed with HTTP ${response.status}`);
947
+ }
948
+
949
+ await sleep(RETRY_MS * 2 ** (attempt - 1));
950
+ }
951
+ }
952
+
953
+ /** @param {string} fnName @param {unknown} [args] */
954
+ const portal = (fnName, args) => read(([driver]) => driver, contracts.portal, fnName, args);
955
+ /** @param {string} fnName @param {unknown} [args] */
956
+ const factory = (fnName, args) => read(([, driver]) => driver, contracts.disputeGameFactory, fnName, args);
957
+ /**
958
+ * A game hub's reads.
959
+ *
960
+ * @param {string} hubId
961
+ * @returns {(fnName: string, args?: unknown) => Promise<any>}
962
+ */
963
+ const hub = (hubId) => (fnName, args) => read(() => HUB_CODEC, hubId, fnName, args);
964
+ const registryId = contracts.anchorStateRegistry;
965
+ /** @type {((fnName: string, args?: unknown) => Promise<any>) | null} */
966
+ const registry = registryId ? (fnName, args) => read(() => REGISTRY_CODEC, registryId, fnName, args) : null;
967
+
968
+ /** @type {Map<string, Promise<string>>} */
969
+ const inflight = new Map();
970
+ /** @param {string} proxy */
971
+ const gameKey = (proxy) => `hub:${contracts.disputeGameFactory}:${proxy.toLowerCase()}`;
972
+
973
+ /**
974
+ * The hub a game is on, as the Portal reads it: the factory names it, as
975
+ * it was the game type's when the game was made. A type's hub can change
976
+ * since; its games stay where they were. None, and nothing kept, for a
977
+ * game the factory didn't make.
978
+ *
979
+ * @param {string} proxy
980
+ * @returns {Promise<string>}
981
+ */
982
+ const hubOfGame = (proxy) =>
983
+ known(cache, inflight, gameKey(proxy), () =>
984
+ factory("gameContractId", addressBytes(proxy)).then((value) => {
985
+ const hubId = contractIdHex(value);
986
+
987
+ if (/^0+$/.test(hubId)) throw new Error("The factory names no hub for the game");
988
+
989
+ return hubId;
990
+ })
991
+ );
992
+
993
+ /**
994
+ * A game's hub, if it was read before: a check can then read the game in
995
+ * the same query as the Portal.
996
+ *
997
+ * @param {string} proxy
998
+ * @returns {string | null}
999
+ */
1000
+ const knownHubOf = (proxy) => {
1001
+ const kept = cache.get(gameKey(proxy));
1002
+
1003
+ return typeof kept === "string" && kept ? kept : null;
1004
+ };
1005
+
1006
+ /**
1007
+ * The hub that holds a game type's games.
1008
+ *
1009
+ * @param {number} gameType
1010
+ * @returns {Promise<string>}
1011
+ */
1012
+ const hubOfType = (gameType) =>
1013
+ known(cache, inflight, `type:${contracts.disputeGameFactory}:${gameType}`, () =>
1014
+ factory("virtualGameHubId", gameType).then(contractIdHex)
1015
+ );
1016
+
1017
+ /**
1018
+ * A hub's clock limit, `maxClockDuration`, in seconds.
1019
+ *
1020
+ * @param {string} hubId
1021
+ * @returns {Promise<bigint>}
1022
+ */
1023
+ const hubClock = (hubId) =>
1024
+ known(cache, inflight, `clock:${hubId}`, () => hub(hubId)("maxClockDuration").then((seconds) => BigInt(seconds).toString())).then(
1025
+ BigInt
1026
+ );
1027
+
1028
+ /**
1029
+ * A game's reads on its hub, started together so they go in one query.
1030
+ *
1031
+ * @param {string} hubId
1032
+ * @param {string} proxy
1033
+ */
1034
+ const gameReads = (hubId, proxy) => {
1035
+ const address = addressBytes(proxy);
1036
+ const reads = hub(hubId);
1037
+
1038
+ return [reads("createdAtForGame", address), reads("resolvedAtForGame", address), reads("statusForGame", address), hubClock(hubId)];
1039
+ };
1040
+
1041
+ /**
1042
+ * @param {[unknown, unknown, unknown, unknown]} values
1043
+ * @returns {{ createdAt: bigint, maxClockDuration: bigint, resolvedAt: bigint, status: number }}
1044
+ */
1045
+ const asGame = ([createdAt, resolvedAt, status, maxClockDuration]) => ({
1046
+ createdAt: BigInt(/** @type {bigint} */ (createdAt)),
1047
+ maxClockDuration: BigInt(/** @type {bigint} */ (maxClockDuration)),
1048
+ resolvedAt: BigInt(/** @type {bigint} */ (resolvedAt)),
1049
+ status: Number(status),
1050
+ });
1051
+
1052
+ /**
1053
+ * A game as its hub gives it, with the hub's clock limit.
1054
+ *
1055
+ * @param {string} hubId
1056
+ * @param {string} proxy
1057
+ */
1058
+ const readGame = (hubId, proxy) => Promise.all(gameReads(hubId, proxy)).then((values) => asGame(/** @type {any} */ (values)));
1059
+
1060
+ return {
1061
+ asGame,
1062
+ base,
1063
+ factory,
1064
+ gameReads,
1065
+ hub,
1066
+ hubClock,
1067
+ hubOfGame,
1068
+ hubOfType,
1069
+ knownHubOf,
1070
+ loaded,
1071
+ portal,
1072
+ readGame,
1073
+ registry,
1074
+ send,
1075
+ };
1076
+ }
1077
+
1078
+ // ---------------------------------------------------------------------------
1079
+ // Withdrawals on Dusk
1080
+ // ---------------------------------------------------------------------------
1081
+
1082
+ /**
1083
+ * A positive whole number of games, or the default.
1084
+ *
1085
+ * @param {unknown} value
1086
+ * @returns {bigint}
1087
+ */
1088
+ function searchDepthOf(value) {
1089
+ const n = Number(value ?? GAME_SEARCH_DEPTH);
1090
+
1091
+ return Number.isSafeInteger(n) && n > 0 ? BigInt(n) : BigInt(GAME_SEARCH_DEPTH);
1092
+ }
1093
+
1094
+ /**
1095
+ * The proof's or the claim's gas limit, refused over the bound: less gas
1096
+ * could leave the withdrawal's own call short.
1097
+ *
1098
+ * @param {bigint} withdrawalGas
1099
+ * @param {bigint | number | undefined} upper
1100
+ * @returns {bigint}
1101
+ */
1102
+ function gasFor(withdrawalGas, upper) {
1103
+ const limit = withdrawalGasLimit(withdrawalGas);
1104
+
1105
+ if (limit > wholeOf(upper ?? GAS_LIMIT_UPPER, "The gas bound")) throw new WithdrawalError("gas");
1106
+
1107
+ return limit;
1108
+ }
1109
+
1110
+ /**
1111
+ * What the chain says of a game a proof went, or would go, against: its
1112
+ * status on its hub, whether the Portal blacklisted it, and whether the
1113
+ * anchor state registry calls it proper (registered, not blacklisted, not
1114
+ * retired) and respected, true for those two where the network names no
1115
+ * registry. Null where a read failed.
1116
+ *
1117
+ * @typedef {{ status: number | null, blacklisted: boolean | null, proper: boolean | null, respected: boolean | null }} Standing
1118
+ */
1119
+
1120
+ /**
1121
+ * Known to be refused: the Portal takes no proof against such a game, and
1122
+ * no claim with one. Whatever its own check says, which may take just the
1123
+ * proof and the game's registration.
1124
+ *
1125
+ * @param {Standing} game
1126
+ */
1127
+ const isRefused = ({ blacklisted, proper, respected, status }) =>
1128
+ blacklisted === true || proper === false || respected === false || status === GAME_STATUS.CHALLENGER_WINS;
1129
+
1130
+ /**
1131
+ * Known to be taken: every read answered, and none of them refuses it.
1132
+ *
1133
+ * @param {Standing} game
1134
+ */
1135
+ const isTaken = (game) => !isRefused(game) && game.blacklisted === false && game.proper === true && game.respected === true && game.status !== null;
1136
+
1137
+ /**
1138
+ * @param {WithdrawalsOptions} options
1139
+ */
1140
+ export function createWithdrawals(options) {
1141
+ const net = networkOf(options?.network);
1142
+ const found = duskEvmContracts(net);
1143
+
1144
+ if (!found?.disputeGameFactory) throw new TypeError("The network has no DuskEVM Portal and dispute game factory");
1145
+
1146
+ const contracts = { anchorStateRegistry: found.anchorStateRegistry, disputeGameFactory: found.disputeGameFactory, portal: found.portal };
1147
+
1148
+ const cache = options.cache ?? new Map();
1149
+ const now = options.now ?? Date.now;
1150
+ const searchDepth = searchDepthOf(options.gameSearchDepth);
1151
+ // Four reads a game checked: its status, the Portal's blacklist, the registry's two answers.
1152
+ const perQuery = Math.max(1, Math.floor(Number(options.maxBatch ?? MAX_BATCH) / 4) || 1);
1153
+ const rpc = evmRpc(options.duskEvm, net);
1154
+ const passer = net.duskEvm?.predeploys?.l2ToL1MessagePasser ?? MESSAGE_PASSER;
1155
+ const reads = createReads({
1156
+ cache,
1157
+ contracts,
1158
+ drivers: options.drivers,
1159
+ fetch: options.fetch,
1160
+ nodeUrl: options.nodeUrl,
1161
+ });
1162
+ const { factory, hub, portal, registry } = reads;
1163
+
1164
+ /**
1165
+ * Dusk's latest block, from the node's GraphQL.
1166
+ *
1167
+ * @returns {Promise<DuskTip>}
1168
+ */
1169
+ async function nodeTip() {
1170
+ const response = await reads.send(`${reads.base}/on/graphql/query`, {
1171
+ body: "query { block(height: -1) { header { height timestamp } } }",
1172
+ headers: { "Content-Type": "text/plain" },
1173
+ method: "POST",
1174
+ });
1175
+
1176
+ if (!response.ok) throw new Error(`The latest block's read failed with HTTP ${response.status}`);
1177
+
1178
+ const body = await response.json();
1179
+ const header = (body && typeof body === "object" && "data" in body ? body.data : body)?.block?.header;
1180
+
1181
+ if (header?.height === undefined || header?.height === null || header?.timestamp === undefined || header?.timestamp === null) {
1182
+ throw new Error("The latest block is unavailable");
1183
+ }
1184
+
1185
+ return { height: BigInt(header.height), timestamp: BigInt(header.timestamp) };
1186
+ }
1187
+
1188
+ const latestBlock = options.latestBlock ?? nodeTip;
1189
+
1190
+ /**
1191
+ * Whether the registry calls a game proper and respected, where the
1192
+ * network names one; yes for both where it doesn't.
1193
+ *
1194
+ * @param {string} proxy
1195
+ * @returns {[Promise<boolean> | boolean, Promise<boolean> | boolean]}
1196
+ */
1197
+ const registryReads = (proxy) =>
1198
+ registry
1199
+ ? [registry("isGameProper", addressBytes(proxy)).then(Boolean), registry("isGameRespected", addressBytes(proxy)).then(Boolean)]
1200
+ : [true, true];
1201
+
1202
+ /**
1203
+ * A game's standing: its status on its own hub, which the factory names
1204
+ * (a type's hub answers for a game it doesn't hold as in progress), with
1205
+ * the Portal's blacklist and the registry's two answers. Those don't wait
1206
+ * for the hub: they go in one query with it, or with the status once the
1207
+ * hub was read.
1208
+ *
1209
+ * @param {string} proxy
1210
+ * @returns {Promise<Standing>}
1211
+ */
1212
+ const standingOf = (proxy) =>
1213
+ Promise.all([
1214
+ reads.hubOfGame(proxy).then((hubId) => hub(hubId)("statusForGame", addressBytes(proxy))).then(Number, () => null),
1215
+ portal("disputeGameBlacklist", addressBytes(proxy)).then(Boolean, () => null),
1216
+ ...registryReads(proxy).map((read) => Promise.resolve(read).catch(() => null)),
1217
+ ]).then(([status, blacklisted, proper, respected]) => ({ blacklisted, proper, respected, status }));
1218
+
1219
+ /**
1220
+ * @param {string} txHash
1221
+ * @returns {Promise<ReceiptWithdrawal>}
1222
+ */
1223
+ async function receipt(txHash) {
1224
+ if (typeof txHash !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(txHash)) throw new WithdrawalError("bad-hash");
1225
+
1226
+ return parseWithdrawalReceipt(net, await rpc("eth_getTransactionReceipt", [txHash.toLowerCase()]));
1227
+ }
1228
+
1229
+ /**
1230
+ * The withdrawal's proof against the output of DuskEVM block `l2Block`:
1231
+ * the block, and the message passer's storage proof of it there.
1232
+ *
1233
+ * @param {`0x${string}`} withdrawalHash
1234
+ * @param {bigint} l2Block
1235
+ */
1236
+ async function outputProof(withdrawalHash, l2Block) {
1237
+ const key = storageKey(withdrawalHash);
1238
+ const [block, proof] = await Promise.all([
1239
+ rpc("eth_getBlockByNumber", [toHex(l2Block), false]),
1240
+ rpc("eth_getProof", [passer, [key], toHex(l2Block)]),
1241
+ ]);
1242
+
1243
+ if (!block) throw new Error(`DuskEVM block ${l2Block} isn't available`);
1244
+
1245
+ const nodes = proof?.storageProof?.[0]?.proof;
1246
+
1247
+ if (!proof?.storageHash || !Array.isArray(nodes) || !nodes.length) throw new Error("The withdrawal's storage proof isn't available");
1248
+
1249
+ const outputRootProof = {
1250
+ latestBlockhash: block.hash,
1251
+ messagePasserStorageRoot: proof.storageHash,
1252
+ stateRoot: block.stateRoot,
1253
+ version: ZERO_HASH,
1254
+ };
1255
+
1256
+ return { outputRoot: outputRootOf(outputRootProof), outputRootProof, withdrawalProof: withEmbeddedNode(keccak256(key), nodes) };
1257
+ }
1258
+
1259
+ /**
1260
+ * The first of a page of games that covers the withdrawal, whose root
1261
+ * claim is its output's, and that the Portal takes a proof against, with
1262
+ * its proof, and whether any covered it. The page's metadata goes in one
1263
+ * query.
1264
+ *
1265
+ * @param {any[]} games
1266
+ * @param {ReceiptWithdrawal} withdrawal
1267
+ * @returns {Promise<{ proof: OutputProof | null, lastError: Error | null, covered: boolean }>}
1268
+ */
1269
+ async function proofInGames(games, withdrawal) {
1270
+ /** @type {Error | null} */
1271
+ let lastError = null;
1272
+ let covered = false;
1273
+ const indexes = games.map((game) => bigOf(tupleValue(game, "index", 0)));
1274
+ const metadata = await Promise.all(indexes.map((index) => settle(factory("gameMetadataAtIndex", u256Bytes(index)))));
1275
+
1276
+ for (const [position, index] of indexes.entries()) {
1277
+ try {
1278
+ const { error, value } = metadata[position];
1279
+
1280
+ if (error) throw error;
1281
+
1282
+ const rootClaim = hexOf(fixedBytes(tupleValue(value, "rootClaim", 0), 32));
1283
+ const l2BlockNumber = gameL2Block(value);
1284
+
1285
+ if (l2BlockNumber < withdrawal.blockNumber) continue;
1286
+
1287
+ covered = true;
1288
+
1289
+ const proof = await outputProof(withdrawal.withdrawalHash, l2BlockNumber);
1290
+
1291
+ if (proof.outputRoot.toLowerCase() !== rootClaim) continue;
1292
+
1293
+ const disputeGameProxy = addressHex(tupleValue(await factory("gameAtIndex", u256Bytes(index)), "gameProxy", 2));
1294
+ const standing = await standingOf(disputeGameProxy);
1295
+
1296
+ // Not one blacklisted, retired, no longer respected, or won by the
1297
+ // challenger: the Portal takes no proof against it. Nor one whose
1298
+ // standing can't be read. An older game that covers the withdrawal
1299
+ // may do.
1300
+ if (!isTaken(standing)) {
1301
+ if (!isRefused(standing)) lastError = new Error(`Whether the Portal takes a proof against game ${index} can't be read`);
1302
+
1303
+ continue;
1304
+ }
1305
+
1306
+ return { covered, lastError, proof: { ...proof, disputeGameIndex: index, disputeGameProxy, l2BlockNumber } };
1307
+ } catch (error) {
1308
+ lastError = asError(error);
1309
+ }
1310
+ }
1311
+
1312
+ return { covered, lastError, proof: null };
1313
+ }
1314
+
1315
+ /**
1316
+ * The newest game of the Portal's type that covers the withdrawal and
1317
+ * that the Portal takes a proof against, among the newest `searchDepth`,
1318
+ * a page at a time.
1319
+ *
1320
+ * @param {{ gameType: number, start: bigint, withdrawal: ReceiptWithdrawal }} search
1321
+ */
1322
+ async function scanGames({ gameType, start, withdrawal }) {
1323
+ let from = start;
1324
+ let remaining = searchDepth;
1325
+ /** @type {Error | null} */
1326
+ let lastError = null;
1327
+ let covered = false;
1328
+
1329
+ while (remaining > 0n) {
1330
+ const games = await factory("findLatestGames", [gameType, u256Bytes(from), u256Bytes(remaining > SCAN_PAGE ? SCAN_PAGE : remaining)]);
1331
+
1332
+ if (!Array.isArray(games) || !games.length) break;
1333
+
1334
+ const found = await proofInGames(games, withdrawal);
1335
+
1336
+ if (found.proof) return found;
1337
+
1338
+ lastError = found.lastError ?? lastError;
1339
+ covered ||= found.covered;
1340
+
1341
+ const oldest = bigOf(tupleValue(games[games.length - 1], "index", 0));
1342
+
1343
+ if (oldest === 0n) break;
1344
+
1345
+ from = oldest - 1n;
1346
+ remaining -= BigInt(games.length);
1347
+ }
1348
+
1349
+ return { covered, lastError, proof: null };
1350
+ }
1351
+
1352
+ /**
1353
+ * The first of `games` that covers the withdrawal, has resolved for its
1354
+ * proposal, and that the Portal would take a proof against, with the
1355
+ * withdrawal's proof against it. Each game's standing (`standingOf`) goes
1356
+ * to the node a batch at a time, newest first, until one passes. `stop`
1357
+ * when DuskEVM no longer gives a block's proof: an older game's won't
1358
+ * come either.
1359
+ *
1360
+ * @param {ReturnType<typeof searchedGame>[]} games
1361
+ * @param {{ rules: GameRules, withdrawal: ReceiptWithdrawal }} search
1362
+ * @returns {Promise<{ proof?: OutputProof, stop?: boolean }>}
1363
+ */
1364
+ async function resolvedIn(games, { rules, withdrawal }) {
1365
+ for (let from = 0; from < games.length; from += perQuery) {
1366
+ const batch = games.slice(from, from + perQuery);
1367
+ const checked = await Promise.all(batch.map((game) => standingOf(game.proxy).then((standing) => ({ ...game, ...standing }))));
1368
+
1369
+ for (const game of checked.filter((each) => isEligibleGame(each, rules) && isTaken(each))) {
1370
+ try {
1371
+ const proof = await outputProof(withdrawal.withdrawalHash, game.l2BlockNumber);
1372
+
1373
+ if (proof.outputRoot.toLowerCase() === game.rootClaim) {
1374
+ return { proof: { ...proof, disputeGameIndex: game.index, disputeGameProxy: game.proxy, l2BlockNumber: game.l2BlockNumber } };
1375
+ }
1376
+ } catch {
1377
+ return { stop: true };
1378
+ }
1379
+ }
1380
+ }
1381
+
1382
+ return {};
1383
+ }
1384
+
1385
+ /**
1386
+ * A game covering the withdrawal that has already resolved for its
1387
+ * proposal, and that the Portal would take a proof against: a proof
1388
+ * against it can be claimed exactly its maturity delay later. Games come
1389
+ * at a steady pace, so the search starts about where games are old enough
1390
+ * to have resolved, and goes back a few pages; other game types share the
1391
+ * factory's index. Null when none covers the withdrawal yet, none of them
1392
+ * has resolved and passes, or the anchor state registry isn't known.
1393
+ *
1394
+ * @param {{ gameCount: bigint, gameType: number, tip: DuskTip, withdrawal: ReceiptWithdrawal }} search
1395
+ * @returns {Promise<OutputProof | null>}
1396
+ */
1397
+ async function resolvedProof({ gameCount, gameType, tip, withdrawal }) {
1398
+ if (!registry) return null;
1399
+
1400
+ /** @param {bigint} from */
1401
+ const page = async (from) =>
1402
+ /** @type {any[]} */ (await factory("findLatestGames", [gameType, u256Bytes(from), u256Bytes(BigInt(RESOLVED_PAGE))])).map(searchedGame);
1403
+ // In one query: the type's hub, whose clock says which games are old
1404
+ // enough to have resolved, the newest games, and when the respected game
1405
+ // type last changed. Games created then or before are retired.
1406
+ const [hubId, newest, updatedAt] = await Promise.all([
1407
+ reads.hubOfType(gameType),
1408
+ page(gameCount - 1n),
1409
+ registry("respectedGameTypeUpdatedAt").then(BigInt),
1410
+ ]);
1411
+
1412
+ if (newest.length < 2) return null;
1413
+
1414
+ // Its clock ran out, and its resolver's usual lag has passed.
1415
+ const cutoff = contractNow(contractClock(newest[0].createdAt, tip), tip) - (await reads.hubClock(hubId)) - BigInt(RESOLVER_LAG_SECONDS);
1416
+ const first = newest[0];
1417
+ const last = newest[newest.length - 1];
1418
+ const pace = (first.createdAt - last.createdAt) / (first.index - last.index || 1n) || 1n;
1419
+ const back = first.createdAt > cutoff ? (first.createdAt - cutoff + pace - 1n) / pace : 0n;
1420
+ let from = first.index > back ? first.index - back : 0n;
1421
+ const rules = { respectedGameTypeUpdatedAt: updatedAt, withdrawalBlockNumber: withdrawal.blockNumber };
1422
+
1423
+ for (let pages = 0; pages < RESOLVED_PAGES; pages += 1) {
1424
+ const games = await page(from);
1425
+ const old = games.filter((game) => game.createdAt <= cutoff);
1426
+
1427
+ // The newest of them doesn't cover the withdrawal: none does.
1428
+ if (old.length && old[0].l2BlockNumber < withdrawal.blockNumber) return null;
1429
+
1430
+ const found = await resolvedIn(
1431
+ old.filter((game) => isCandidateGame(game, rules)),
1432
+ { rules, withdrawal }
1433
+ );
1434
+
1435
+ if (found.proof || found.stop) return found.proof ?? null;
1436
+ if (!games.length || games[games.length - 1].index === 0n) break;
1437
+
1438
+ from = games[games.length - 1].index - 1n;
1439
+ }
1440
+
1441
+ return null;
1442
+ }
1443
+
1444
+ /**
1445
+ * The game to prove the withdrawal against, with its proof: the newest
1446
+ * game covering it, or with `tip`, one that has already resolved where
1447
+ * there is one.
1448
+ *
1449
+ * @param {{ snapshot: Snapshot, withdrawal: ReceiptWithdrawal, tip?: DuskTip | null }} search
1450
+ * @returns {Promise<{ proof: OutputProof | null, reason: WaitReason | null, error: Error | null, covered: boolean }>}
1451
+ */
1452
+ async function findProof({ snapshot, tip = null, withdrawal }) {
1453
+ const { gameCount, gameType } = snapshot;
1454
+
1455
+ if (gameCount === 0n) return { covered: false, error: null, proof: null, reason: "no-games" };
1456
+
1457
+ if (tip) {
1458
+ const resolved = await resolvedProof({ gameCount, gameType, tip, withdrawal }).catch(() => null);
1459
+
1460
+ if (resolved) return { covered: true, error: null, proof: resolved, reason: null };
1461
+ }
1462
+
1463
+ const found = await scanGames({ gameType, start: gameCount - 1n, withdrawal });
1464
+
1465
+ return found.proof
1466
+ ? { covered: true, error: null, proof: found.proof, reason: null }
1467
+ : { covered: found.covered, error: found.lastError, proof: null, reason: "no-output" };
1468
+ }
1469
+
1470
+ /**
1471
+ * The game a status says a proof would go against now: the newest the
1472
+ * Portal takes, among the newest `searchDepth`; where none of those
1473
+ * covering the withdrawal is one (`covered`: a proof went against one),
1474
+ * one that has resolved, as `prepareProof` finds it. A status then offers
1475
+ * a proof wherever one can be prepared.
1476
+ *
1477
+ * @param {{ snapshot: Snapshot, withdrawal: ReceiptWithdrawal, tip?: Promise<DuskTip> | DuskTip | null, covered?: boolean }} search
1478
+ */
1479
+ async function provableProof({ covered = false, snapshot, tip = null, withdrawal }) {
1480
+ const found = await findProof({ snapshot, withdrawal });
1481
+
1482
+ if (found.proof || snapshot.gameCount === 0n || !(covered || found.covered)) return found;
1483
+
1484
+ const resolved = await Promise.resolve(tip ?? latestBlock())
1485
+ .then((at) => resolvedProof({ gameCount: snapshot.gameCount, gameType: snapshot.gameType, tip: at, withdrawal }))
1486
+ .catch(() => null);
1487
+
1488
+ return resolved ? { covered: true, error: null, proof: resolved, reason: null } : found;
1489
+ }
1490
+
1491
+ /**
1492
+ * What a proven withdrawal's stage and date need beyond the Portal's
1493
+ * record: its game on its hub, whether the Portal blacklisted it, the
1494
+ * Portal's own check, and whether the registry calls it proper and
1495
+ * respected. Started together, so they go in one query.
1496
+ *
1497
+ * @param {string} hubId
1498
+ * @param {number[]} id
1499
+ * @param {string} proxy
1500
+ * @param {string} submitter
1501
+ */
1502
+ const provenReads = (hubId, id, proxy, submitter) =>
1503
+ Promise.all(
1504
+ [
1505
+ ...reads.gameReads(hubId, proxy),
1506
+ portal("disputeGameBlacklist", addressBytes(proxy)),
1507
+ portal("checkWithdrawal", [id, addressBytes(submitter)]),
1508
+ ...registryReads(proxy),
1509
+ ].map(settle)
1510
+ );
1511
+
1512
+ /**
1513
+ * The Portal's and the factory's values a status needs, in one query: the
1514
+ * record of `submitter`'s proof, the delays, the game type and count. The
1515
+ * game the last check found the proof against (`game`) goes in the same
1516
+ * query, once its hub was read.
1517
+ *
1518
+ * @param {`0x${string}`} withdrawalHash
1519
+ * @param {`0x${string}` | null} submitter
1520
+ * @param {{ game?: string | null, tip?: Promise<DuskTip> | null }} [extra]
1521
+ * @returns {Promise<Snapshot>}
1522
+ */
1523
+ async function readSnapshot(withdrawalHash, submitter, { game = null, tip = null } = {}) {
1524
+ const id = fixedBytes(withdrawalHash, 32);
1525
+ const proxy = game ? evmAddress(game, "The proof's game") : null;
1526
+ const hubId = proxy && submitter ? reads.knownHubOf(proxy) : null;
1527
+ const ahead = proxy && hubId && submitter ? { game: proxy, reads: provenReads(hubId, id, proxy, submitter) } : null;
1528
+ const [finalized, count, proven, maturity, finality, gameType, gameCount] = await Promise.all([
1529
+ portal("finalizedWithdrawals", id),
1530
+ portal("numProofSubmitters", id),
1531
+ submitter ? portal("provenWithdrawals", [id, addressBytes(submitter)]) : null,
1532
+ portal("proofMaturityDelaySeconds"),
1533
+ portal("disputeGameFinalityDelaySeconds"),
1534
+ portal("respectedGameType"),
1535
+ factory("gameCount"),
1536
+ ]);
1537
+
1538
+ return {
1539
+ ahead,
1540
+ delays: { disputeGameFinalityDelaySeconds: bigOf(finality), proofMaturityDelaySeconds: bigOf(maturity) },
1541
+ finalized: Boolean(finalized),
1542
+ gameCount: bigOf(gameCount),
1543
+ gameType: Number(gameType),
1544
+ proofSubmitterCount: bigOf(count),
1545
+ proven: proven
1546
+ ? { disputeGameProxy: addressHex(tupleValue(proven, "disputeGameProxy", 0)), timestamp: BigInt(tupleValue(proven, "timestamp", 1)) }
1547
+ : { disputeGameProxy: ZERO_ADDRESS, timestamp: 0n },
1548
+ tip,
1549
+ };
1550
+ }
1551
+
1552
+ /**
1553
+ * Whether another account holds a proof that still stands. A game known
1554
+ * to be refused never stands: blacklisted, retired, not respected, or won
1555
+ * by the challenger, whatever the Portal's check says. Otherwise the
1556
+ * Portal's own check decides first: it passes, or fails only on maturity,
1557
+ * for a proof that stands; else a game read and not refused stands, and
1558
+ * an unreadable one doesn't count. The Portal only lets the account that
1559
+ * proved claim with that proof.
1560
+ *
1561
+ * @param {`0x${string}`} withdrawalHash
1562
+ * @param {`0x${string}` | null} own
1563
+ * @param {bigint} count
1564
+ */
1565
+ async function provenByOther(withdrawalHash, own, count) {
1566
+ const n = Number(count > OTHER_PROOFS ? OTHER_PROOFS : count);
1567
+
1568
+ if (n === 0) return false;
1569
+
1570
+ const id = fixedBytes(withdrawalHash, 32);
1571
+ const submitters = (
1572
+ await Promise.all(Array.from({ length: n }, (_, index) => portal("proofSubmitters", [id, u256Bytes(BigInt(index))])))
1573
+ )
1574
+ .map(addressHex)
1575
+ .filter((address) => address !== own);
1576
+
1577
+ if (!submitters.length) return false;
1578
+
1579
+ const proofs = await Promise.all(submitters.map((address) => portal("provenWithdrawals", [id, addressBytes(address)])));
1580
+ const standing = proofs
1581
+ .map((proof, index) => ({
1582
+ address: submitters[index],
1583
+ proxy: addressHex(tupleValue(proof, "disputeGameProxy", 0)),
1584
+ timestamp: BigInt(tupleValue(proof, "timestamp", 1)),
1585
+ }))
1586
+ .filter(({ proxy, timestamp }) => timestamp !== 0n && proxy !== ZERO_ADDRESS);
1587
+ // Read together, so a multicall carries each check's own refusal.
1588
+ const checks = await Promise.all(
1589
+ standing.map(({ address, proxy }) =>
1590
+ Promise.all([
1591
+ portal("checkWithdrawal", [id, addressBytes(address)]).then(
1592
+ () => "ready",
1593
+ (error) => asError(error).message
1594
+ ),
1595
+ standingOf(proxy),
1596
+ ])
1597
+ )
1598
+ );
1599
+
1600
+ for (const [check, game] of checks) {
1601
+ if (isRefused(game)) continue;
1602
+ if (check === "ready" || /maturity delay not met/i.test(check)) return true;
1603
+ if (game.status !== null) return true;
1604
+ }
1605
+
1606
+ return false;
1607
+ }
1608
+
1609
+ /**
1610
+ * A withdrawal no proof of the account's covers yet: ready to prove once
1611
+ * a game covers it.
1612
+ *
1613
+ * @param {WithdrawalStatus} base
1614
+ * @param {Snapshot} snapshot
1615
+ * @returns {Promise<WithdrawalStatus>}
1616
+ */
1617
+ async function unproven(base, snapshot) {
1618
+ const found = await provableProof({ snapshot, tip: snapshot.tip, withdrawal: base });
1619
+
1620
+ if (!found.proof) {
1621
+ return { ...base, ...(found.error ? { error: found.error } : {}), reason: found.reason, stage: STAGE.WAITING_FOR_OUTPUT };
1622
+ }
1623
+
1624
+ return {
1625
+ ...base,
1626
+ disputeGameIndex: found.proof.disputeGameIndex,
1627
+ proofL2BlockNumber: found.proof.l2BlockNumber,
1628
+ provenByOther: await provenByOther(base.withdrawalHash, base.proofSubmitter, snapshot.proofSubmitterCount),
1629
+ stage: STAGE.READY_TO_PROVE,
1630
+ };
1631
+ }
1632
+
1633
+ /**
1634
+ * A proof the Portal won't take: proved again against another game it
1635
+ * takes, where there is one, as `prepareProof` would find it.
1636
+ *
1637
+ * @param {WithdrawalStatus} base
1638
+ * @param {Snapshot} snapshot
1639
+ * @param {string} proxy The game of the proof refused
1640
+ * @param {DuskTip} tip
1641
+ */
1642
+ async function replacement(base, snapshot, proxy, tip) {
1643
+ const found = await provableProof({ covered: true, snapshot, tip, withdrawal: base });
1644
+
1645
+ if (found.proof && found.proof.disputeGameProxy !== proxy) {
1646
+ return {
1647
+ disputeGameIndex: found.proof.disputeGameIndex,
1648
+ proofL2BlockNumber: found.proof.l2BlockNumber,
1649
+ proofRejected: false,
1650
+ reason: /** @type {WaitReason} */ ("proof-rejected"),
1651
+ stage: STAGE.READY_TO_PROVE,
1652
+ };
1653
+ }
1654
+
1655
+ return { proofRejected: true, reason: /** @type {WaitReason} */ ("proof-rejected"), stage: STAGE.PROVEN_WAITING };
1656
+ }
1657
+
1658
+ /**
1659
+ * A proven withdrawal's stage and claim date. The proof's game is read on
1660
+ * its own hub, as the Portal reads it, with whether the Portal blacklisted
1661
+ * it, whether the registry calls it proper and respected, and the Portal's
1662
+ * own check; Dusk's latest block gives the contracts' time. A proof time
1663
+ * on testnet's height clock is converted, not dropped. A game the Portal
1664
+ * refuses makes the proof one to make again, whatever its check says. A
1665
+ * game, or its standing, that can't be read leaves the date known so far
1666
+ * (no `readyAt`), and nothing to claim; a date past while the Portal still
1667
+ * refuses the claim says nothing more. A claim is ready from its date
1668
+ * only, its game resolved for the defender: a Portal whose check takes
1669
+ * just the proof and its game says yes before it, and refuses the claim
1670
+ * itself.
1671
+ *
1672
+ * @param {WithdrawalStatus} base
1673
+ * @param {Snapshot} snapshot
1674
+ * @returns {Promise<WithdrawalStatus>}
1675
+ */
1676
+ async function proven(base, snapshot) {
1677
+ const { disputeGameProxy: proxy, timestamp } = snapshot.proven;
1678
+ const submitter = /** @type {`0x${string}`} */ (base.proofSubmitter);
1679
+ const id = fixedBytes(base.withdrawalHash, 32);
1680
+ const read =
1681
+ snapshot.ahead?.game === proxy
1682
+ ? snapshot.ahead.reads
1683
+ : (() => {
1684
+ // The Portal's and the registry's don't wait for the game's hub.
1685
+ const portalReads = Promise.all(
1686
+ [portal("disputeGameBlacklist", addressBytes(proxy)), portal("checkWithdrawal", [id, addressBytes(submitter)]), ...registryReads(proxy)].map(
1687
+ settle
1688
+ )
1689
+ );
1690
+ /** @type {Promise<Array<{ value?: any, error?: Error }>>} */
1691
+ const gameRead = reads.hubOfGame(proxy).then(
1692
+ (hubId) => Promise.all(reads.gameReads(hubId, proxy).map(settle)),
1693
+ (error) => Array.from({ length: 4 }, () => ({ error: asError(error) }))
1694
+ );
1695
+
1696
+ return Promise.all([gameRead, portalReads]).then(([game, portalRead]) => [...game, ...portalRead]);
1697
+ })();
1698
+ const [tip, values] = await Promise.all([snapshot.tip ?? latestBlock(), read]);
1699
+ const clock = contractClock(timestamp, tip);
1700
+ const provenAt = BigInt(wallTime(timestamp, clock, tip) / 1_000);
1701
+ const [blacklisted, check, proper, respected] = values.slice(4);
1702
+ const gameValues = values.slice(0, 4);
1703
+ const game = gameValues.every((item) => !item.error) ? reads.asGame(/** @type {any} */ (gameValues.map((item) => item.value))) : null;
1704
+ /** @type {Standing} */
1705
+ const standing = {
1706
+ blacklisted: blacklisted.error ? null : blacklisted.value === true,
1707
+ proper: proper.error ? null : Boolean(proper.value),
1708
+ respected: respected.error ? null : Boolean(respected.value),
1709
+ status: values[2].error ? null : Number(values[2].value),
1710
+ };
1711
+ const record = { ...base, disputeGameProxy: proxy, provenAt };
1712
+
1713
+ // A proof whose game the Portal refuses is made again, whatever the
1714
+ // Portal's own check says: it may take just the proof and its game.
1715
+ if (isRefused(standing)) {
1716
+ return { ...record, claimOverdue: false, readyAt: null, readyAtExact: false, ...(await replacement(base, snapshot, proxy, tip)) };
1717
+ }
1718
+
1719
+ // Nothing is ready, nor dated, before all of that is read.
1720
+ if (!game || game.createdAt === 0n || !isTaken(standing)) return { ...record, reason: "game-unreadable", stage: STAGE.PROVEN_WAITING };
1721
+
1722
+ const claim = claimFrom({ clock, game, portal: snapshot.delays, provenAt: timestamp, tip });
1723
+ const { claimOverdue, ready, readyAt, readyAtExact, refused } = claimReadiness({ claim, portalAccepts: !check.error, tip });
1724
+
1725
+ // Ready once the Portal takes the claim and its date has come, its game
1726
+ // resolved for the defender: before, the date is only the earliest.
1727
+ if (ready && claim.exact) return { ...record, claimOverdue, readyAt, readyAtExact, stage: STAGE.READY_TO_FINALIZE };
1728
+
1729
+ return {
1730
+ ...record,
1731
+ // Not ready while its game hasn't resolved, however long it's late.
1732
+ claimOverdue: ready ? claim.overdue : claimOverdue,
1733
+ readyAt,
1734
+ readyAtExact,
1735
+ // Past its date and still refused: the date says nothing more.
1736
+ reason: refused ? "portal-refuses" : game.status === GAME_STATUS.IN_PROGRESS ? "game-in-progress" : "game-not-final",
1737
+ stage: STAGE.PROVEN_WAITING,
1738
+ };
1739
+ }
1740
+
1741
+ /**
1742
+ * @param {ReceiptWithdrawal} withdrawal
1743
+ * @param {`0x${string}` | null} submitter
1744
+ * @param {Snapshot} snapshot
1745
+ * @returns {Promise<WithdrawalStatus>}
1746
+ */
1747
+ async function statusOf(withdrawal, submitter, snapshot) {
1748
+ /** @type {WithdrawalStatus} */
1749
+ const base = {
1750
+ ...withdrawal,
1751
+ proofRejected: false,
1752
+ proofSubmitter: submitter,
1753
+ proofSubmitterCount: snapshot.proofSubmitterCount,
1754
+ reason: null,
1755
+ stage: STAGE.WAITING_FOR_OUTPUT,
1756
+ };
1757
+
1758
+ if (snapshot.finalized) return { ...base, stage: STAGE.FINALIZED };
1759
+
1760
+ const { disputeGameProxy, timestamp } = snapshot.proven;
1761
+
1762
+ if (!submitter || snapshot.proofSubmitterCount === 0n || timestamp === 0n || disputeGameProxy === ZERO_ADDRESS) {
1763
+ return unproven(base, snapshot);
1764
+ }
1765
+
1766
+ return proven(base, snapshot);
1767
+ }
1768
+
1769
+ /**
1770
+ * @param {string} txHash
1771
+ * @param {{ submitter?: string | null, proofGame?: string | null }} [options]
1772
+ * @returns {Promise<WithdrawalStatus>}
1773
+ */
1774
+ async function status(txHash, { proofGame = null, submitter = null } = {}) {
1775
+ const withdrawal = await receipt(txHash);
1776
+ const owner = submitterOf(submitter) ?? recipientSubmitter(withdrawal.facts);
1777
+ // With the proof's game known from the last check, the latest block is
1778
+ // read alongside: the proof's claim date needs it.
1779
+ const tip = proofGame ? latestBlock() : null;
1780
+
1781
+ tip?.catch(() => {});
1782
+
1783
+ return statusOf(withdrawal, owner, await readSnapshot(withdrawal.withdrawalHash, owner, { game: proofGame, tip }));
1784
+ }
1785
+
1786
+ /**
1787
+ * A call to the Portal, encoded by its driver and read back.
1788
+ *
1789
+ * @param {string} fnName
1790
+ * @param {unknown} args
1791
+ * @param {bigint} gasLimit
1792
+ * @param {{ facts: WithdrawalFacts, proof: OutputProof | null }} built
1793
+ * @returns {Promise<PortalCall>}
1794
+ */
1795
+ async function portalCall(fnName, args, gasLimit, built) {
1796
+ const [driver] = await reads.loaded();
1797
+ const fnArgs = Uint8Array.from(driver.encodeInputFn(fnName, driverJson(args)));
1798
+ const decoded = checkedCall(net, contracts.portal, fnName, fnArgs, built);
1799
+
1800
+ return {
1801
+ args,
1802
+ contractId: contracts.portal,
1803
+ decoded,
1804
+ deposit: 0n,
1805
+ fnArgs,
1806
+ fnName: /** @type {PortalCall["fnName"]} */ (fnName),
1807
+ gasLimit,
1808
+ };
1809
+ }
1810
+
1811
+ /**
1812
+ * When a proof made now against `proof`'s game can be claimed: exact when
1813
+ * the game has resolved, the earliest otherwise; null when that can't be
1814
+ * read.
1815
+ *
1816
+ * @param {Snapshot} snapshot
1817
+ * @param {OutputProof} proof
1818
+ * @param {DuskTip} tip
1819
+ * @returns {Promise<ClaimDate | null>}
1820
+ */
1821
+ async function claimIfProvedNow(snapshot, proof, tip) {
1822
+ try {
1823
+ const game = await reads.readGame(await reads.hubOfGame(proof.disputeGameProxy), proof.disputeGameProxy);
1824
+ const claim = claimFrom({ game, portal: snapshot.delays, tip });
1825
+
1826
+ return claim.at === null ? null : claim;
1827
+ } catch {
1828
+ return null;
1829
+ }
1830
+ }
1831
+
1832
+ /**
1833
+ * @param {string} txHash
1834
+ * @param {{ submitter: string, gasLimitUpper?: bigint | number }} options
1835
+ * @returns {Promise<PreparedProof>}
1836
+ */
1837
+ async function prepareProof(txHash, { gasLimitUpper, submitter } = /** @type {any} */ ({})) {
1838
+ const owner = requireSubmitter(submitter);
1839
+ const withdrawal = await receipt(txHash);
1840
+ // A withdrawal whose claim couldn't be sent isn't proved either: they take the same gas.
1841
+ const gasLimit = gasFor(withdrawal.facts.gasLimit, gasLimitUpper);
1842
+ const tipRead = latestBlock();
1843
+
1844
+ tipRead.catch(() => {});
1845
+
1846
+ const snapshot = await readSnapshot(withdrawal.withdrawalHash, owner, { tip: tipRead });
1847
+
1848
+ // A proof of a claimed withdrawal, or one this account has already,
1849
+ // costs its fee for nothing. Only a proof the Portal rejected is made
1850
+ // again, where its status found a game to make it against.
1851
+ /** @type {WithdrawalStatus | null} */
1852
+ let current = null;
1853
+
1854
+ if (snapshot.finalized || (snapshot.proven.timestamp !== 0n && snapshot.proven.disputeGameProxy !== ZERO_ADDRESS)) {
1855
+ current = await statusOf(withdrawal, owner, snapshot);
1856
+
1857
+ if (current.stage === STAGE.FINALIZED) throw new WithdrawalError("claimed", undefined, { status: current });
1858
+ if (current.stage !== STAGE.READY_TO_PROVE) throw new WithdrawalError("proven", undefined, { status: current });
1859
+ }
1860
+
1861
+ const tip = await tipRead;
1862
+ const found = await findProof({ snapshot, tip, withdrawal });
1863
+
1864
+ if (!found.proof) throw new WithdrawalError("no-proof", undefined, { cause: found.error ?? undefined, reason: found.reason });
1865
+ // Never again against the game the Portal refuses the account's proof against.
1866
+ if (current && found.proof.disputeGameProxy === snapshot.proven.disputeGameProxy) throw new WithdrawalError("proven", undefined, { status: current });
1867
+
1868
+ const [claim, call] = await Promise.all([
1869
+ claimIfProvedNow(snapshot, found.proof, tip),
1870
+ portalCall(PROVE, proveInput(withdrawal.withdrawal, found.proof), gasLimit, { facts: withdrawal.facts, proof: found.proof }),
1871
+ ]);
1872
+
1873
+ return { at: now(), call, claim, proof: found.proof, receipt: withdrawal };
1874
+ }
1875
+
1876
+ /**
1877
+ * @param {string} txHash
1878
+ * @param {{ submitter: string, gasLimitUpper?: bigint | number, proofGame?: string | null }} options
1879
+ * @returns {Promise<PortalCall>}
1880
+ */
1881
+ async function claimCall(txHash, { gasLimitUpper, proofGame = null, submitter } = /** @type {any} */ ({})) {
1882
+ const owner = requireSubmitter(submitter);
1883
+ const current = await status(txHash, { proofGame, submitter: owner });
1884
+
1885
+ if (current.stage !== STAGE.READY_TO_FINALIZE) throw new WithdrawalError("not-ready", undefined, { status: current });
1886
+
1887
+ const gasLimit = gasFor(current.facts.gasLimit, gasLimitUpper);
1888
+
1889
+ return portalCall(CLAIM, withdrawalInput(current.withdrawal), gasLimit, { facts: current.facts, proof: null });
1890
+ }
1891
+
1892
+ /**
1893
+ * The games of the proofs still waiting, each on its hub, with whether
1894
+ * the Portal blacklisted it and whether the registry calls it proper and
1895
+ * respected, in one query. A game any of that can't be read of is null.
1896
+ *
1897
+ * @param {Array<{ claimed: boolean, provenAt: bigint, proxy: `0x${string}`, ready: boolean }>} records
1898
+ */
1899
+ async function proofGames(records) {
1900
+ const proxies = [
1901
+ ...new Set(records.filter((record) => !record.claimed && record.provenAt !== 0n && record.proxy !== ZERO_ADDRESS).map((record) => record.proxy)),
1902
+ ];
1903
+ const hubs = await Promise.all(proxies.map((proxy) => reads.hubOfGame(proxy).catch(() => null)));
1904
+ const games = await Promise.all(
1905
+ proxies.map((proxy, at) => {
1906
+ const hubId = hubs[at];
1907
+
1908
+ return hubId
1909
+ ? Promise.all([reads.readGame(hubId, proxy), portal("disputeGameBlacklist", addressBytes(proxy)).then(Boolean), ...registryReads(proxy)])
1910
+ .then(([game, blacklisted, proper, respected]) => ({ ...game, blacklisted, proper, respected }))
1911
+ .catch(() => null)
1912
+ : null;
1913
+ })
1914
+ );
1915
+
1916
+ return new Map(proxies.map((proxy, at) => [proxy, games[at]]));
1917
+ }
1918
+
1919
+ /**
1920
+ * @param {ReadonlyArray<string>} hashes
1921
+ * @param {{ submitter: string }} options
1922
+ * @returns {Promise<Map<string, WithdrawalClaim>>}
1923
+ */
1924
+ async function claims(hashes, { submitter } = /** @type {any} */ ({})) {
1925
+ const account = addressBytes(requireSubmitter(submitter));
1926
+ const ids = hashes.map((hash) => fixedBytes(hash, 32));
1927
+ const tip = settle(latestBlock());
1928
+ const [maturity, finality, ...records] = await Promise.all([
1929
+ portal("proofMaturityDelaySeconds"),
1930
+ portal("disputeGameFinalityDelaySeconds"),
1931
+ ...ids.map((id) =>
1932
+ Promise.all([portal("finalizedWithdrawals", id), portal("provenWithdrawals", [id, account]), settle(portal("checkWithdrawal", [id, account]))]).then(
1933
+ ([finalized, proof, check]) => ({
1934
+ claimed: Boolean(finalized),
1935
+ provenAt: BigInt(tupleValue(proof, "timestamp", 1)),
1936
+ proxy: addressHex(tupleValue(proof, "disputeGameProxy", 0)),
1937
+ ready: !check.error,
1938
+ })
1939
+ )
1940
+ ),
1941
+ ]);
1942
+ const games = await proofGames(records);
1943
+ const latest = (await tip).value ?? null;
1944
+ const delays = { disputeGameFinalityDelaySeconds: bigOf(finality), proofMaturityDelaySeconds: bigOf(maturity) };
1945
+
1946
+ return new Map(
1947
+ hashes.map((hash, at) => {
1948
+ const record = records[at];
1949
+ const game = record.claimed ? null : (games.get(record.proxy) ?? null);
1950
+ // The Portal takes no claim of a proof whose game was blacklisted,
1951
+ // retired or is no longer respected, or that the challenger won,
1952
+ // whatever its own check says: it must be made again.
1953
+ const reprove = Boolean(game && isRefused(game));
1954
+ const claim =
1955
+ game && latest && !reprove
1956
+ ? claimFrom({ clock: contractClock(record.provenAt, latest), game, portal: delays, provenAt: record.provenAt, tip: latest })
1957
+ : { at: null, exact: false, overdue: false };
1958
+ // Ready once the Portal takes the claim and its date has come, its
1959
+ // game resolved for the defender: all of it read, never on the
1960
+ // Portal's check alone.
1961
+ const ready = Boolean(
1962
+ !record.claimed &&
1963
+ game &&
1964
+ latest &&
1965
+ isTaken(game) &&
1966
+ claim.exact &&
1967
+ claimReadiness({ claim, portalAccepts: record.ready, tip: latest }).ready
1968
+ );
1969
+
1970
+ return [hash.toLowerCase(), { ...claim, claimed: record.claimed, ready, reprove }];
1971
+ })
1972
+ );
1973
+ }
1974
+
1975
+ /** @returns {Promise<WithdrawalTiming>} */
1976
+ async function timing() {
1977
+ const [maturity, finality, gameType, count] = await Promise.all([
1978
+ portal("proofMaturityDelaySeconds"),
1979
+ portal("disputeGameFinalityDelaySeconds"),
1980
+ portal("respectedGameType"),
1981
+ factory("gameCount"),
1982
+ ]);
1983
+ const total = bigOf(count);
1984
+ /** @type {any[]} */
1985
+ const games =
1986
+ total > 1n
1987
+ ? await factory("findLatestGames", [Number(gameType), u256Bytes(total - 1n), u256Bytes(total < OUTPUT_SAMPLE ? total : OUTPUT_SAMPLE)])
1988
+ : [];
1989
+ const times = games.map((game) => Number(tupleValue(game, "timestamp", 2))).sort((a, b) => b - a);
1990
+ const gaps = times
1991
+ .slice(1)
1992
+ .map((time, index) => times[index] - time)
1993
+ .filter((gap) => gap > 0)
1994
+ .sort((a, b) => a - b);
1995
+
1996
+ return {
1997
+ finalitySeconds: Number(bigOf(finality)),
1998
+ maturitySeconds: Number(bigOf(maturity)),
1999
+ outputSeconds: gaps.length ? gaps[Math.floor(gaps.length / 2)] : null,
2000
+ };
2001
+ }
2002
+
2003
+ return Object.freeze({ claimCall, claims, network: net, prepareProof, receipt, status, timing });
2004
+ }
2005
+
2006
+ // ---------------------------------------------------------------------------
2007
+ // The start on DuskEVM
2008
+ // ---------------------------------------------------------------------------
2009
+
2010
+ /**
2011
+ * @param {NetworkRef} network
2012
+ * @param {WithdrawalCallInput} withdrawal
2013
+ * @returns {{ to: `0x${string}`, data: `0x${string}`, value: bigint }}
2014
+ */
2015
+ export function withdrawalCall(network, { amountWei, minGasLimit = WITHDRAWAL_MIN_GAS_LIMIT, sender, to } = /** @type {any} */ ({})) {
2016
+ const net = networkOf(network);
2017
+ const bridge = net.duskEvm?.predeploys?.l2StandardBridge;
2018
+
2019
+ if (!bridge) throw new TypeError("The network has no DuskEVM bridge");
2020
+
2021
+ const value = wholeOf(amountWei, "The amount");
2022
+
2023
+ // Dusk holds whole lux, and its L1 bridge pays out no more than a u64 of them.
2024
+ if (value <= 0n || value % WEI_PER_LUX !== 0n || value / WEI_PER_LUX > U64_MAX) {
2025
+ throw new RangeError("The amount is whole lux, more than 0 and at most a u64 of lux");
2026
+ }
2027
+
2028
+ const account = typeof to === "string" ? to.trim() : "";
2029
+ const raw = accountToRawKey(account);
2030
+
2031
+ if (!raw) throw new TypeError("The recipient isn't a Dusk account");
2032
+
2033
+ const from = evmAddress(sender, "The sender");
2034
+ const gas = wholeOf(minGasLimit, "The minimum gas limit");
2035
+
2036
+ if (gas < 0n || gas > U32_MAX) throw new RangeError("The minimum gas limit fits in a u32");
2037
+
2038
+ // The recipient as Dusk's bridge reads it: tag 2, version 1, an account, its raw key.
2039
+ const extraData = hexOf([2, 1, 0, ...raw]);
2040
+ const paid = duskRecipientOf(extraData);
2041
+
2042
+ if (paid?.kind !== "account" || paid.account !== account) throw new TypeError("The recipient isn't a Dusk account");
2043
+
2044
+ return {
2045
+ data: encodeFunctionData({ abi: BRIDGE_ETH_TO, args: [from, Number(gas), extraData], functionName: "bridgeETHTo" }),
2046
+ to: /** @type {`0x${string}`} */ (bridge),
2047
+ value,
2048
+ };
2049
+ }
2050
+
2051
+ // ---------------------------------------------------------------------------
2052
+ // Finding an account's withdrawals
2053
+ // ---------------------------------------------------------------------------
2054
+
2055
+ /**
2056
+ * Whom a withdrawal pays on Dusk, as the wallets keep it: an account, or a
2057
+ * contract's ID; null for neither.
2058
+ *
2059
+ * @param {DuskRecipient | null} recipient
2060
+ * @returns {string | null}
2061
+ */
2062
+ const destinationOf = (recipient) => (recipient ? (recipient.kind === "account" ? recipient.account : recipient.contractId) : null);
2063
+
2064
+ /**
2065
+ * The withdrawal a bridge log records, with whom it pays on Dusk, or null.
2066
+ *
2067
+ * @param {any} log
2068
+ * @returns {FoundWithdrawal | null}
2069
+ */
2070
+ function withdrawalFromLog(log) {
2071
+ try {
2072
+ const [amount, extraData] = decodeAbiParameters([{ type: "uint256" }, { type: "bytes" }], log.data);
2073
+
2074
+ return {
2075
+ amountWei: amount.toString(),
2076
+ blockNumber: Number(BigInt(log.blockNumber)),
2077
+ destination: destinationOf(duskRecipientOf(extraData)),
2078
+ hash: /** @type {`0x${string}`} */ (String(log.transactionHash).toLowerCase()),
2079
+ };
2080
+ } catch {
2081
+ return null;
2082
+ }
2083
+ }
2084
+
2085
+ /** @param {unknown} error */
2086
+ const tooWide = (error) => /range|limit|too many|exceed/i.test(asError(error).message);
2087
+
2088
+ /**
2089
+ * The highest DuskEVM block that can't be reorged away.
2090
+ *
2091
+ * @param {(method: string, params?: unknown[]) => Promise<any>} rpc
2092
+ */
2093
+ async function finalHead(rpc) {
2094
+ try {
2095
+ const block = await rpc("eth_getBlockByNumber", ["finalized", false]);
2096
+
2097
+ if (block?.number) return Number(BigInt(block.number));
2098
+ } catch {
2099
+ // An RPC without "finalized": the latest block, less a margin.
2100
+ }
2101
+
2102
+ return Math.max(0, Number(BigInt(await rpc("eth_blockNumber", []))) - UNFINALIZED_MARGIN);
2103
+ }
2104
+
2105
+ /**
2106
+ * @param {{ address: string, duskEvm: DuskEvmClient, network?: NetworkRef, bridge?: string, state?: WithdrawalScan | null, maxRequests?: number, now?: () => number }} options
2107
+ * @returns {Promise<{ found: FoundWithdrawal[], requests: number, state: WithdrawalScan }>}
2108
+ */
2109
+ export async function syncWithdrawals({ address, bridge, duskEvm, maxRequests = L2_RUN_REQUESTS, network, now = Date.now, state } = /** @type {any} */ ({})) {
2110
+ const net = network === undefined || network === null ? null : networkOf(network);
2111
+ const sender = evmAddress(address, "The address");
2112
+ const contract = bridge ?? net?.duskEvm?.predeploys?.l2StandardBridge;
2113
+
2114
+ if (!contract) throw new TypeError("No DuskEVM bridge to read");
2115
+
2116
+ const rpc = evmRpc(duskEvm, net);
2117
+ const target = await finalHead(rpc);
2118
+ // Coverage kept above the final head, as an older run read the latest, is
2119
+ // read again, and what was found there is dropped with it: it may have
2120
+ // been reorged away, or into another block.
2121
+ let covered = (state?.covered ?? []).map(([lo, hi]) => [lo, Math.min(hi, target)]).filter(([lo, hi]) => hi >= lo);
2122
+ let window = Number(state?.window) || L2_WINDOW;
2123
+ /** @type {Record<string, FoundWithdrawal>} */
2124
+ const entries = Object.fromEntries(Object.entries(state?.entries ?? {}).filter(([, entry]) => Number(entry?.blockNumber) <= target));
2125
+ /** @type {FoundWithdrawal[]} */
2126
+ const found = [];
2127
+ let requests = 1;
2128
+ const topics = [ETH_BRIDGE_INITIATED_TOPIC, pad(sender)];
2129
+
2130
+ while (requests < maxRequests) {
2131
+ const gap = topGap(covered, target);
2132
+
2133
+ if (!gap) break;
2134
+
2135
+ const [a, b] = gap;
2136
+ const lo = Math.max(a, b - window + 1);
2137
+ /** @type {any[]} */
2138
+ let logs;
2139
+
2140
+ requests += 1;
2141
+
2142
+ try {
2143
+ logs = await rpc("eth_getLogs", [{ address: contract, fromBlock: toHex(lo), toBlock: toHex(b), topics }]);
2144
+ } catch (error) {
2145
+ if (window > 1_000 && tooWide(error)) {
2146
+ window = Math.floor(window / 4);
2147
+ continue;
2148
+ }
2149
+
2150
+ throw error;
2151
+ }
2152
+
2153
+ for (const log of logs ?? []) {
2154
+ const entry = withdrawalFromLog(log);
2155
+
2156
+ if (!entry || entries[entry.hash]) continue;
2157
+
2158
+ entries[entry.hash] = entry;
2159
+ found.push(entry);
2160
+ }
2161
+
2162
+ covered = addCoverage(covered, lo, b);
2163
+ }
2164
+
2165
+ return { found, requests, state: { covered, entries, target, updatedAt: now(), window } };
2166
+ }
2167
+
2168
+ /**
2169
+ * The explorer's API for the network, without a trailing slash.
2170
+ *
2171
+ * @param {Network} net
2172
+ * @param {string | undefined} url
2173
+ */
2174
+ function explorerApi(net, url) {
2175
+ const api = String(url ?? net.duskEvm?.blockscoutApiUrl ?? "").trim().replace(/\/+$/, "");
2176
+
2177
+ if (!/^https?:\/\//i.test(api)) throw new TypeError("No DuskEVM explorer to read");
2178
+
2179
+ return api;
2180
+ }
2181
+
2182
+ /**
2183
+ * @param {typeof fetch} send
2184
+ * @param {string} api
2185
+ * @param {string} path
2186
+ * @param {Record<string, unknown>} [params]
2187
+ */
2188
+ async function explorerJson(send, api, path, params = {}) {
2189
+ const url = new URL(`${api}/${path}`);
2190
+
2191
+ for (const [key, value] of Object.entries(params)) {
2192
+ if (value !== null && value !== undefined) url.searchParams.set(key, String(value));
2193
+ }
2194
+
2195
+ const response = await send(url.href, { headers: { accept: "application/json" } });
2196
+
2197
+ if (!response.ok) throw new Error(`DuskEVM's explorer answered with HTTP ${response.status}`);
2198
+
2199
+ return response.json();
2200
+ }
2201
+
2202
+ /**
2203
+ * @param {unknown} value
2204
+ * @returns {`0x${string}` | null}
2205
+ */
2206
+ function listedAddress(value) {
2207
+ const hash = typeof value === "string" ? value : value && typeof value === "object" && "hash" in value ? /** @type {any} */ (value).hash : null;
2208
+
2209
+ return typeof hash === "string" && /^0x[0-9a-fA-F]{40}$/.test(hash) ? /** @type {`0x${string}`} */ (hash.toLowerCase()) : null;
2210
+ }
2211
+
2212
+ /**
2213
+ * @param {unknown} value
2214
+ * @returns {`0x${string}` | null}
2215
+ */
2216
+ const listedHash = (value) =>
2217
+ typeof value === "string" && /^0x[0-9a-fA-F]{64}$/.test(value) ? /** @type {`0x${string}`} */ (value.toLowerCase()) : null;
2218
+
2219
+ /**
2220
+ * @param {unknown} value
2221
+ * @returns {string | null}
2222
+ */
2223
+ function listedTime(value) {
2224
+ if (typeof value !== "string" && typeof value !== "number") return null;
2225
+
2226
+ const date = new Date(value);
2227
+
2228
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
2229
+ }
2230
+
2231
+ /**
2232
+ * @param {unknown} value
2233
+ * @returns {string | null}
2234
+ */
2235
+ const listedText = (value) => (typeof value === "string" || typeof value === "number" || typeof value === "bigint" ? String(value) : null);
2236
+
2237
+ /**
2238
+ * Whom a `bridgeETHTo` call pays on Dusk, from its input; null for any other call.
2239
+ *
2240
+ * @param {unknown} input
2241
+ * @returns {string | null}
2242
+ */
2243
+ function callDestination(input) {
2244
+ try {
2245
+ const { args } = decodeFunctionData({ abi: BRIDGE_ETH_TO, data: /** @type {`0x${string}`} */ (`0x${String(input).replace(/^0x/i, "")}`) });
2246
+
2247
+ return destinationOf(duskRecipientOf(args[2]));
2248
+ } catch {
2249
+ return null;
2250
+ }
2251
+ }
2252
+
2253
+ /**
2254
+ * A transaction an address's list gives, as a withdrawal.
2255
+ *
2256
+ * @param {any} transaction
2257
+ * @returns {ExplorerWithdrawal | null}
2258
+ */
2259
+ function listedTransaction(transaction) {
2260
+ const hash = listedHash(transaction?.hash);
2261
+
2262
+ if (!hash) return null;
2263
+
2264
+ return {
2265
+ amountWei: listedText(transaction?.value),
2266
+ blockNumber: listedText(transaction?.block_number),
2267
+ destination: callDestination(transaction?.raw_input ?? transaction?.input),
2268
+ explorerStatus: null,
2269
+ from: listedAddress(transaction?.from),
2270
+ hash,
2271
+ l1TransactionHash: null,
2272
+ timestamp: listedTime(transaction?.timestamp),
2273
+ };
2274
+ }
2275
+
2276
+ /**
2277
+ * A transaction's details, with what the explorer says of its withdrawal.
2278
+ *
2279
+ * @param {any} details
2280
+ * @returns {ExplorerWithdrawal | null}
2281
+ */
2282
+ function detailedTransaction(details) {
2283
+ const listed = listedTransaction(details);
2284
+
2285
+ if (!listed) return null;
2286
+
2287
+ const withdrawal = Array.isArray(details?.op_withdrawals) ? details.op_withdrawals[0] : null;
2288
+
2289
+ return {
2290
+ ...listed,
2291
+ amountWei: listedText(withdrawal?.msg_value) ?? listed.amountWei,
2292
+ explorerStatus: typeof withdrawal?.status === "string" ? withdrawal.status : null,
2293
+ l1TransactionHash: listedHash(withdrawal?.l1_transaction_hash),
2294
+ timestamp: listedTime(withdrawal?.l2_timestamp) ?? listed.timestamp,
2295
+ };
2296
+ }
2297
+
2298
+ /**
2299
+ * @param {{ network: NetworkRef, address: string, fetch?: typeof fetch, url?: string, maxPages?: number, maxItems?: number }} options
2300
+ * @returns {Promise<ExplorerWithdrawal[]>}
2301
+ */
2302
+ export async function explorerWithdrawals({
2303
+ address,
2304
+ fetch,
2305
+ maxItems = EXPLORER_ITEMS,
2306
+ maxPages = EXPLORER_PAGES,
2307
+ network,
2308
+ url,
2309
+ } = /** @type {any} */ ({})) {
2310
+ const net = networkOf(network);
2311
+ const account = evmAddress(address, "The address");
2312
+ const bridge = String(net.duskEvm?.predeploys?.l2StandardBridge ?? "").toLowerCase();
2313
+
2314
+ if (!bridge) throw new TypeError("The network has no DuskEVM bridge");
2315
+
2316
+ const api = explorerApi(net, url);
2317
+ const send = fetch ?? ((input, init) => globalThis.fetch(input, init));
2318
+ /** @type {any[]} */
2319
+ const candidates = [];
2320
+ /** @type {Record<string, unknown>} */
2321
+ let pageParams = { filter: "from" };
2322
+
2323
+ for (let page = 0; page < maxPages; page += 1) {
2324
+ const body = await explorerJson(send, api, `addresses/${account}/transactions`, pageParams);
2325
+ const transactions = Array.isArray(body?.items) ? body.items : [];
2326
+
2327
+ for (const transaction of transactions) {
2328
+ const input = typeof transaction?.raw_input === "string" ? transaction.raw_input : transaction?.input;
2329
+ // Its own call of the bridge's withdrawal that went through.
2330
+ const isWithdrawal =
2331
+ typeof input === "string" && input.length >= 10 ? input.slice(0, 10).toLowerCase() === BRIDGE_ETH_TO_SELECTOR : transaction?.method === "bridgeETHTo";
2332
+
2333
+ if (
2334
+ listedHash(transaction?.hash) &&
2335
+ listedAddress(transaction?.from) === account &&
2336
+ listedAddress(transaction?.to) === bridge &&
2337
+ isWithdrawal &&
2338
+ transaction?.status !== "error"
2339
+ ) {
2340
+ candidates.push(transaction);
2341
+ }
2342
+ }
2343
+
2344
+ if (candidates.length >= maxItems || !body?.next_page_params || !transactions.length) break;
2345
+
2346
+ pageParams = { ...body.next_page_params, filter: "from" };
2347
+ }
2348
+
2349
+ const items = await Promise.all(
2350
+ candidates.slice(0, maxItems).map(async (transaction) => {
2351
+ try {
2352
+ return detailedTransaction(await explorerJson(send, api, `transactions/${String(transaction.hash).toLowerCase()}`));
2353
+ } catch {
2354
+ return listedTransaction(transaction);
2355
+ }
2356
+ })
2357
+ );
2358
+
2359
+ return items.filter((item) => item !== null);
2360
+ }
2361
+
2362
+ /**
2363
+ * @param {{ network: NetworkRef, nonces: ReadonlyArray<bigint>, fetch?: typeof fetch, url?: string, maxPages?: number }} options
2364
+ * @returns {Promise<Map<string, ExplorerWithdrawal>>}
2365
+ */
2366
+ export async function withdrawalsByNonce({ fetch, maxPages = EXPLORER_PAGES, network, nonces, url } = /** @type {any} */ ({})) {
2367
+ const net = networkOf(network);
2368
+ const api = explorerApi(net, url);
2369
+ const send = fetch ?? ((input, init) => globalThis.fetch(input, init));
2370
+ const wanted = new Set(nonces.map(String));
2371
+ const oldest = nonces.reduce((low, nonce) => ((BigInt(nonce) & NONCE_COUNT) < low ? BigInt(nonce) & NONCE_COUNT : low), NONCE_COUNT);
2372
+ /** @type {Map<string, ExplorerWithdrawal>} */
2373
+ const found = new Map();
2374
+ /** @type {Record<string, unknown> | null} */
2375
+ let pageParams = {};
2376
+
2377
+ for (let page = 0; pageParams && page < maxPages && wanted.size; page += 1) {
2378
+ const body = await explorerJson(send, api, "optimism/withdrawals", pageParams);
2379
+ const withdrawals = Array.isArray(body?.items) ? body.items : [];
2380
+
2381
+ for (const withdrawal of withdrawals) {
2382
+ const nonce = listedText(withdrawal?.msg_nonce_raw);
2383
+ const hash = listedHash(withdrawal?.l2_transaction_hash);
2384
+
2385
+ if (nonce && wanted.delete(nonce) && hash) {
2386
+ found.set(nonce, {
2387
+ amountWei: listedText(withdrawal?.msg_value),
2388
+ blockNumber: null,
2389
+ destination: null,
2390
+ explorerStatus: typeof withdrawal?.status === "string" ? withdrawal.status : null,
2391
+ from: listedAddress(withdrawal?.from),
2392
+ hash,
2393
+ l1TransactionHash: listedHash(withdrawal?.l1_transaction_hash),
2394
+ timestamp: listedTime(withdrawal?.l2_timestamp),
2395
+ });
2396
+ }
2397
+ }
2398
+
2399
+ const last = withdrawals[withdrawals.length - 1];
2400
+
2401
+ // Newest first: past the oldest asked for, none is left to find.
2402
+ pageParams = body?.next_page_params && last && BigInt(listedText(last.msg_nonce) ?? 0) > oldest ? body.next_page_params : null;
2403
+ }
2404
+
2405
+ return found;
2406
+ }
2407
+
2408
+ /**
2409
+ * @param {string | null | undefined} status
2410
+ * @returns {WithdrawalStage | null}
2411
+ */
2412
+ export function explorerStage(status) {
2413
+ switch (String(status ?? "").trim().toLowerCase()) {
2414
+ case "waiting for state root":
2415
+ return STAGE.WAITING_FOR_OUTPUT;
2416
+ case "ready to prove":
2417
+ return STAGE.READY_TO_PROVE;
2418
+ case "waiting a game to resolve":
2419
+ case "in challenge period":
2420
+ case "proven":
2421
+ return STAGE.PROVEN_WAITING;
2422
+ case "ready for relay":
2423
+ return STAGE.READY_TO_FINALIZE;
2424
+ case "relayed":
2425
+ return STAGE.FINALIZED;
2426
+ default:
2427
+ return null;
2428
+ }
2429
+ }