@dusk-network/app-core 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -90,7 +90,11 @@ export declare const L2_WINDOW: 100000;
90
90
  /** Log searches one run of `syncWithdrawals` makes, the head's included, by default. */
91
91
  export declare const L2_RUN_REQUESTS: 8;
92
92
 
93
- /** DuskEVM blocks under the head counted as final where the RPC has no "finalized" block: about an hour. */
93
+ /**
94
+ * DuskEVM blocks under the head counted as final where the RPC has no
95
+ * "finalized" block: about an hour. The blocks a run reads within that
96
+ * margin under it are read again once they're under a later run's.
97
+ */
94
98
  export declare const UNFINALIZED_MARGIN: 1800;
95
99
 
96
100
  /** Pages of an address's transactions `explorerWithdrawals` reads at most, by default. */
@@ -359,7 +363,11 @@ export type WithdrawalsOptions = {
359
363
  maxBatch?: number;
360
364
  /** How many of the newest games a proof is looked for among: 64 by default, and for anything but a positive whole number. */
361
365
  gameSearchDepth?: number | bigint;
362
- /** Where the deployment's fixed facts are kept: each game's hub, each game type's hub, each hub's clock limit. */
366
+ /**
367
+ * Where the deployment's fixed facts are kept: each game's hub and each
368
+ * game type's hub, once the factory names one, each hub's clock limit, and
369
+ * each withdrawal's receipt once its DuskEVM block is final.
370
+ */
363
371
  cache?: FactCache;
364
372
  /** The clock, in milliseconds. */
365
373
  now?: () => number;
@@ -368,7 +376,14 @@ export type WithdrawalsOptions = {
368
376
  /** A network's withdrawals, read on its Dusk node and DuskEVM. */
369
377
  export type Withdrawals = {
370
378
  readonly network: Network;
371
- /** A DuskEVM transaction's withdrawal, from its receipt; refuses as `parseWithdrawalReceipt` does. */
379
+ /**
380
+ * A DuskEVM transaction's withdrawal, from its receipt; refuses as
381
+ * `parseWithdrawalReceipt` does. The receipt is read after DuskEVM's
382
+ * finalized block, which the receipts read at the same time share, and
383
+ * kept in `cache` once its block is at or under that one: a final
384
+ * receipt is read once. One that isn't final yet, or isn't a
385
+ * withdrawal's, is read again each time.
386
+ */
372
387
  receipt(txHash: string): Promise<ReceiptWithdrawal>;
373
388
  /**
374
389
  * Where a withdrawal is, for `submitter` (a Dusk account, or its
@@ -452,6 +467,13 @@ export type FoundWithdrawal = {
452
467
  export type WithdrawalScan = {
453
468
  /** The final blocks covered, as ranges, highest first. */
454
469
  covered: number[][];
470
+ /**
471
+ * Where the RPC has no finalized block: the blocks covered within
472
+ * `UNFINALIZED_MARGIN` under the head a run read them under, as ranges,
473
+ * highest first, the newest 16, read again once they're under a later
474
+ * run's margin. Older ones are left to the passes over all the blocks.
475
+ */
476
+ open?: number[][];
455
477
  /** What was found, by hash. */
456
478
  entries: Record<string, FoundWithdrawal>;
457
479
  /** The final head the last run read under. */
@@ -478,7 +500,14 @@ export type WithdrawalScan = {
478
500
  * take though in half its requests at most (while older blocks are still to
479
501
  * be read, only once the final head has moved), and carries on below; under
480
502
  * a final head lower than the last run's, what was covered and found above
481
- * it is dropped, and read again. Once all the final blocks are
503
+ * it is dropped, and read again. Where the RPC has no finalized block, the
504
+ * final head is the latest less `UNFINALIZED_MARGIN`, and the blocks a run
505
+ * reads within that margin under it are read again once they're under a
506
+ * later run's (`open`): among the newest blocks a run reads again, or, once
507
+ * all the final blocks are covered, before the passes over them, with the
508
+ * requests a run has left. A deeper reorg there is found, and older blocks
509
+ * are read as they would be without it.
510
+ * Once all the final blocks are
482
511
  * covered, they're read again from the newest down, a pass at a time with
483
512
  * the requests a run has left: the first an hour later, each after as long
484
513
  * again as it has been since. A withdrawal is found once: one read again
@@ -133,6 +133,10 @@ const NONCE_COUNT = (1n << 240n) - 1n;
133
133
  // since.
134
134
  const L2_OVERLAP = 50_000;
135
135
  const L2_RECHECK_MS = 60 * 60_000;
136
+ // The ranges of blocks read only under a head the RPC didn't call final that
137
+ // a sync keeps to read again, the newest: older ones are left to its passes
138
+ // over all the blocks.
139
+ const L2_OPEN_RANGES = 16;
136
140
  // Answers a read is sent again for, a quarter second, then half a second later.
137
141
  const RETRY_STATUSES = new Set([429, 502, 503, 504]);
138
142
  const ATTEMPTS = 3;
@@ -773,6 +777,20 @@ function evmRpc(client, net) {
773
777
  };
774
778
  }
775
779
 
780
+ /**
781
+ * A block number as DuskEVM's RPC gives it, a hex quantity, or as a whole
782
+ * number; null for anything else.
783
+ *
784
+ * @param {unknown} value
785
+ * @returns {bigint | null}
786
+ */
787
+ function blockNumberOf(value) {
788
+ if (typeof value === "bigint") return value >= 0n ? value : null;
789
+ if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null;
790
+
791
+ return typeof value === "string" && /^0x[0-9a-fA-F]+$/.test(value) ? BigInt(value) : null;
792
+ }
793
+
776
794
  // ---------------------------------------------------------------------------
777
795
  // The bridge's contracts on Dusk
778
796
  // ---------------------------------------------------------------------------
@@ -1010,14 +1028,21 @@ function createReads({ cache, contracts, drivers, fetch, nodeUrl }) {
1010
1028
  };
1011
1029
 
1012
1030
  /**
1013
- * The hub that holds a game type's games.
1031
+ * The hub that holds a game type's games. None, and nothing kept, for a
1032
+ * type the factory names no hub for yet.
1014
1033
  *
1015
1034
  * @param {number} gameType
1016
1035
  * @returns {Promise<string>}
1017
1036
  */
1018
1037
  const hubOfType = (gameType) =>
1019
1038
  known(cache, inflight, `type:${contracts.disputeGameFactory}:${gameType}`, () =>
1020
- factory("virtualGameHubId", gameType).then(contractIdHex)
1039
+ factory("virtualGameHubId", gameType).then((value) => {
1040
+ const hubId = contractIdHex(value);
1041
+
1042
+ if (/^0+$/.test(hubId)) throw new Error("The factory names no hub for the game type");
1043
+
1044
+ return hubId;
1045
+ })
1021
1046
  );
1022
1047
 
1023
1048
  /**
@@ -1223,13 +1248,137 @@ export function createWithdrawals(options) {
1223
1248
  ]).then(([status, blacklisted, proper, respected]) => ({ blacklisted, proper, respected, status }));
1224
1249
 
1225
1250
  /**
1251
+ * Where a final receipt is kept: by the network, its Portal and the
1252
+ * transaction's hash, so another network's or deployment's stays apart.
1253
+ *
1254
+ * @param {string} hash - 0x and 64 lower-case hex digits
1255
+ */
1256
+ const receiptKey = (hash) => `receipt:${net.chainId}:${contracts.portal}:${hash}`;
1257
+
1258
+ /** @type {Promise<bigint | null> | null} */
1259
+ let finalizing = null;
1260
+
1261
+ /**
1262
+ * DuskEVM's finalized block's height, or null where it can't be read;
1263
+ * the receipts read at the same time share one read.
1264
+ *
1265
+ * @returns {Promise<bigint | null>}
1266
+ */
1267
+ function finalizedHeight() {
1268
+ if (!finalizing) {
1269
+ const reading = Promise.resolve()
1270
+ .then(() => rpc("eth_getBlockByNumber", ["finalized", false]))
1271
+ .then(
1272
+ (block) => blockNumberOf(block?.number),
1273
+ () => null
1274
+ );
1275
+
1276
+ finalizing = reading;
1277
+ reading.then(() => {
1278
+ if (finalizing === reading) finalizing = null;
1279
+ });
1280
+ }
1281
+
1282
+ return finalizing;
1283
+ }
1284
+
1285
+ /** @type {Map<string, Promise<any>>} */
1286
+ const receiptReads = new Map();
1287
+
1288
+ /**
1289
+ * The withdrawal a kept receipt records, as when it was kept; null when
1290
+ * none is kept, or what is kept isn't this transaction's withdrawal.
1291
+ *
1292
+ * @param {string} hash
1293
+ * @returns {ReceiptWithdrawal | null}
1294
+ */
1295
+ function keptReceipt(hash) {
1296
+ try {
1297
+ const text = cache.get(receiptKey(hash));
1298
+
1299
+ if (typeof text !== "string" || !text) return null;
1300
+
1301
+ const withdrawal = parseWithdrawalReceipt(net, JSON.parse(text));
1302
+
1303
+ return withdrawal.transactionHash === hash ? withdrawal : null;
1304
+ } catch {
1305
+ return null;
1306
+ }
1307
+ }
1308
+
1309
+ /**
1310
+ * Keeps a receipt that can't change any more: a withdrawal's, of this
1311
+ * transaction, in a block at or under `final`, the finalized height read
1312
+ * before the receipt was. A failed or missing receipt, or one that isn't
1313
+ * a withdrawal's, isn't kept. Kept as the RPC gave it, once its text
1314
+ * reads back as the same withdrawal.
1315
+ *
1316
+ * @param {string} hash
1317
+ * @param {any} raw
1318
+ * @param {bigint} final
1319
+ */
1320
+ function keepIfFinal(hash, raw, final) {
1321
+ try {
1322
+ const withdrawal = parseWithdrawalReceipt(net, raw);
1323
+ const block = blockNumberOf(raw?.blockNumber);
1324
+
1325
+ if (block === null || block !== withdrawal.blockNumber || block > final || withdrawal.transactionHash !== hash) return;
1326
+
1327
+ const text = JSON.stringify(raw);
1328
+ const again = parseWithdrawalReceipt(net, JSON.parse(text));
1329
+
1330
+ if (again.withdrawalHash !== withdrawal.withdrawalHash || again.blockNumber !== block || again.transactionHash !== hash) return;
1331
+
1332
+ cache.set(receiptKey(hash), text);
1333
+ } catch {
1334
+ // Not kept: it's read again next time.
1335
+ }
1336
+ }
1337
+
1338
+ /**
1339
+ * A transaction's receipt as DuskEVM gives it, read after DuskEVM's
1340
+ * finalized block: a receipt read once its block is final is the final
1341
+ * chain's, and is kept. The callers asking for one receipt at the same
1342
+ * time share one read.
1343
+ *
1344
+ * @param {string} hash
1345
+ * @returns {Promise<any>}
1346
+ */
1347
+ function readReceipt(hash) {
1348
+ let reading = receiptReads.get(hash);
1349
+
1350
+ if (!reading) {
1351
+ reading = (async () => {
1352
+ const final = await finalizedHeight();
1353
+ const raw = await rpc("eth_getTransactionReceipt", [hash]);
1354
+
1355
+ if (final !== null) keepIfFinal(hash, raw, final);
1356
+
1357
+ return raw;
1358
+ })();
1359
+ receiptReads.set(hash, reading);
1360
+ reading.then(
1361
+ () => receiptReads.delete(hash),
1362
+ () => receiptReads.delete(hash)
1363
+ );
1364
+ }
1365
+
1366
+ return reading;
1367
+ }
1368
+
1369
+ /**
1370
+ * A transaction's withdrawal, from its receipt: the one kept once its
1371
+ * block was final, else the one DuskEVM gives now.
1372
+ *
1226
1373
  * @param {string} txHash
1227
1374
  * @returns {Promise<ReceiptWithdrawal>}
1228
1375
  */
1229
1376
  async function receipt(txHash) {
1230
1377
  if (typeof txHash !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(txHash)) throw new WithdrawalError("bad-hash");
1231
1378
 
1232
- return parseWithdrawalReceipt(net, await rpc("eth_getTransactionReceipt", [txHash.toLowerCase()]));
1379
+ const hash = txHash.toLowerCase();
1380
+
1381
+ return keptReceipt(hash) ?? parseWithdrawalReceipt(net, await readReceipt(hash));
1233
1382
  }
1234
1383
 
1235
1384
  /**
@@ -2092,22 +2241,26 @@ function withdrawalFromLog(log) {
2092
2241
  const tooWide = (error) => /range|limit|too many|exceed/i.test(asError(error).message);
2093
2242
 
2094
2243
  /**
2095
- * The highest DuskEVM block that can't be reorged away, and the requests it
2096
- * took.
2244
+ * The highest DuskEVM block that can't be reorged away, the requests it
2245
+ * took, and the margin under it within which the blocks a run reads are
2246
+ * read again on a later one: none under the RPC's finalized block. Where
2247
+ * the RPC has none, the head is the latest less a margin, and the blocks
2248
+ * read within the margin under it are read again, as a deeper reorg could
2249
+ * still change them.
2097
2250
  *
2098
2251
  * @param {(method: string, params?: unknown[]) => Promise<any>} rpc
2099
- * @returns {Promise<{ head: number, requests: number }>}
2252
+ * @returns {Promise<{ head: number, margin: number, requests: number }>}
2100
2253
  */
2101
2254
  async function finalHead(rpc) {
2102
2255
  try {
2103
2256
  const block = await rpc("eth_getBlockByNumber", ["finalized", false]);
2104
2257
 
2105
- if (block?.number) return { head: Number(BigInt(block.number)), requests: 1 };
2258
+ if (block?.number) return { head: Number(BigInt(block.number)), margin: 0, requests: 1 };
2106
2259
  } catch {
2107
2260
  // An RPC without "finalized": the latest block, less a margin.
2108
2261
  }
2109
2262
 
2110
- return { head: Math.max(0, Number(BigInt(await rpc("eth_blockNumber", []))) - UNFINALIZED_MARGIN), requests: 2 };
2263
+ return { head: Math.max(0, Number(BigInt(await rpc("eth_blockNumber", []))) - UNFINALIZED_MARGIN), margin: UNFINALIZED_MARGIN, requests: 2 };
2111
2264
  }
2112
2265
 
2113
2266
  /**
@@ -2129,6 +2282,39 @@ function recheckOf(value, target) {
2129
2282
  return { at: kept.at, from: kept.from === null ? null : Math.min(kept.from, target), since: kept.since };
2130
2283
  }
2131
2284
 
2285
+ /**
2286
+ * The blocks a state kept as read only under a head the RPC didn't call
2287
+ * final, as far as they're still covered, as ranges, highest first; none
2288
+ * for anything else.
2289
+ *
2290
+ * @param {unknown} value
2291
+ * @param {number[][]} covered
2292
+ * @returns {number[][]}
2293
+ */
2294
+ function openOf(value, covered) {
2295
+ const kept = Array.isArray(value) ? value.filter((range) => Array.isArray(range) && Number.isSafeInteger(range[0]) && Number.isSafeInteger(range[1]) && range[0] <= range[1]) : [];
2296
+ /** @type {number[][]} */
2297
+ let out = [];
2298
+
2299
+ for (const [a, b] of kept) {
2300
+ for (const [c, d] of covered) {
2301
+ if (Math.max(a, c) <= Math.min(b, d)) out = addCoverage(out, Math.max(a, c), Math.min(b, d));
2302
+ }
2303
+ }
2304
+
2305
+ return out;
2306
+ }
2307
+
2308
+ /**
2309
+ * Ranges of blocks, highest first, without blocks `lo` to `hi`.
2310
+ *
2311
+ * @param {number[][]} ranges
2312
+ * @param {number} lo
2313
+ * @param {number} hi
2314
+ * @returns {number[][]}
2315
+ */
2316
+ const without = (ranges, lo, hi) => ranges.flatMap(([a, b]) => [...(b > hi ? [[Math.max(a, hi + 1), b]] : []), ...(a < lo ? [[a, Math.min(b, lo - 1)]] : [])]);
2317
+
2132
2318
  /**
2133
2319
  * @param {{ address: string, duskEvm: DuskEvmClient, network?: NetworkRef, bridge?: string, state?: WithdrawalScan | null, maxRequests?: number, now?: () => number }} options
2134
2320
  * @returns {Promise<{ found: FoundWithdrawal[], requests: number, state: WithdrawalScan }>}
@@ -2141,7 +2327,7 @@ export async function syncWithdrawals({ address, bridge, duskEvm, maxRequests =
2141
2327
  if (!contract) throw new TypeError("No DuskEVM bridge to read");
2142
2328
 
2143
2329
  const rpc = evmRpc(duskEvm, net);
2144
- const { head: target, requests: asked } = await finalHead(rpc);
2330
+ const { head: target, margin, requests: asked } = await finalHead(rpc);
2145
2331
  const time = now();
2146
2332
  // Coverage kept above the final head, as an older run read the latest, is
2147
2333
  // read again, and what was found there is dropped with it: it may have
@@ -2155,6 +2341,11 @@ export async function syncWithdrawals({ address, bridge, duskEvm, maxRequests =
2155
2341
  let requests = asked;
2156
2342
  let recheck = recheckOf(state?.recheck, target);
2157
2343
  const topics = [ETH_BRIDGE_INITIATED_TOPIC, pad(sender)];
2344
+ // Blocks read only under a head the RPC didn't call final, within its
2345
+ // margin, are read again once they're under a run's margin too: a deeper
2346
+ // reorg could still change them. Those kept above the final head go with
2347
+ // its coverage.
2348
+ let open = openOf(state?.open, covered);
2158
2349
 
2159
2350
  /**
2160
2351
  * The address's withdrawals in blocks `lo` to `hi`, those blocks covered;
@@ -2193,6 +2384,10 @@ export async function syncWithdrawals({ address, bridge, duskEvm, maxRequests =
2193
2384
  }
2194
2385
 
2195
2386
  covered = addCoverage(covered, lo, hi);
2387
+ // Read under this run's margin, they're as final as its head; within it,
2388
+ // under a head the RPC didn't call final, they're open.
2389
+ open = without(open, lo, hi);
2390
+ if (margin && hi > target - margin) open = addCoverage(open, Math.max(lo, target - margin + 1), hi);
2196
2391
 
2197
2392
  return true;
2198
2393
  };
@@ -2225,8 +2420,19 @@ export async function syncWithdrawals({ address, bridge, duskEvm, maxRequests =
2225
2420
  continue;
2226
2421
  }
2227
2422
 
2228
- // All the final blocks covered: they're read again, a pass at a time,
2229
- // from the newest down, with what a run has left.
2423
+ // All the final blocks covered: those read only under a head the RPC
2424
+ // didn't call final, and under this run's margin now, are read again
2425
+ // first, the newest first. Then all of them, a pass at a time, from the
2426
+ // newest down, with what a run has left.
2427
+ const settling = open.find(([a]) => a <= target - margin);
2428
+
2429
+ if (settling) {
2430
+ const b = Math.min(settling[1], target - margin);
2431
+
2432
+ await read(Math.max(settling[0], b - window + 1), b);
2433
+ continue;
2434
+ }
2435
+
2230
2436
  if (recheck === null) recheck = { at: time + L2_RECHECK_MS, from: null, since: time };
2231
2437
  if (recheck.from === null && time < recheck.at) break;
2232
2438
 
@@ -2240,7 +2446,7 @@ export async function syncWithdrawals({ address, bridge, duskEvm, maxRequests =
2240
2446
 
2241
2447
  if (recheck === null && !topGap(covered, target)) recheck = { at: time + L2_RECHECK_MS, from: null, since: time };
2242
2448
 
2243
- return { found, requests, state: { covered, entries, ...(recheck ? { recheck } : {}), target, updatedAt: now(), window } };
2449
+ return { found, requests, state: { covered, entries, ...(open.length ? { open: open.slice(0, L2_OPEN_RANGES) } : {}), ...(recheck ? { recheck } : {}), target, updatedAt: now(), window } };
2244
2450
  }
2245
2451
 
2246
2452
  /**