@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.
- package/CHANGELOG.md +22 -0
- package/README.md +53 -6
- package/package.json +19 -2
- package/src/bridge.d.ts +55 -14
- package/src/bridge.js +439 -199
- package/src/duskevm-withdrawals.d.ts +535 -0
- package/src/duskevm-withdrawals.js +2429 -0
- package/src/history.d.ts +11 -2
- package/src/history.js +14 -6
|
@@ -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
|
+
}
|