@dusk-network/app-core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bridge.js ADDED
@@ -0,0 +1,1927 @@
1
+ /* @ts-self-types="./bridge.d.ts" */
2
+ /**
3
+ * The other side of the bridge's routes, as facts: whether a deposit to
4
+ * DuskEVM arrived there, whether a migration into Dusk was paid out, and
5
+ * whether a transfer out to another chain arrived there. Each credit pays
6
+ * one transfer, in the order they were sent. The app passes its fetch, the
7
+ * URLs and the archive it reads, and its own caches; nothing here keeps
8
+ * state outside the calls.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ import { networkFor, routeForAccount, routes } from "@dusk-network/networks";
14
+ import { isAccount } from "@dusk-network/networks/account";
15
+ import { duskEvmContracts, evmAddressOf } from "@dusk-network/networks/duskevm";
16
+ import { DETAILS_BATCH, TRANSFER_CONTRACT, fetchTxDetails, historyRange } from "./history.js";
17
+
18
+ /** @typedef {import("./bridge.js").Arrival} Arrival */
19
+ /** @typedef {import("./bridge.js").BlockTimes} BlockTimes */
20
+ /** @typedef {import("./bridge.js").Deposit} Deposit */
21
+ /** @typedef {import("./bridge.js").DepositArrivalOptions} DepositArrivalOptions */
22
+ /** @typedef {import("./bridge.js").FetchLike} FetchLike */
23
+ /** @typedef {import("./bridge.js").HistoryLike} HistoryLike */
24
+ /** @typedef {import("./bridge.js").Migration} Migration */
25
+ /** @typedef {import("./bridge.js").MigrationPayout} MigrationPayout */
26
+ /** @typedef {import("./bridge.js").MigrationPayoutOptions} MigrationPayoutOptions */
27
+ /** @typedef {import("./bridge.js").Scan} Scan */
28
+ /** @typedef {import("./bridge.js").ServiceArrivalOptions} ServiceArrivalOptions */
29
+ /** @typedef {import("./bridge.js").ServiceTransfer} ServiceTransfer */
30
+ /** @typedef {import("./bridge.js").ServiceTransferFacts} ServiceTransferFacts */
31
+ /** @typedef {import("./bridge.js").UnknownReason} UnknownReason */
32
+ /** @typedef {import("./history.js").Archive} Archive */
33
+ /** @typedef {import("./history.js").HistoryEntry} HistoryEntry */
34
+ /** @typedef {import("@dusk-network/networks").Network} Network */
35
+ /** @typedef {import("@dusk-network/networks").NetworkRef} NetworkRef */
36
+ /** @typedef {import("@dusk-network/networks").Route} Route */
37
+
38
+ /**
39
+ * A transfer as the matching sees it: when it was sent, the block on the
40
+ * other chain its credit can't be before, and whether it is one of the
41
+ * app's own (`given`) or one read from the chain.
42
+ *
43
+ * @typedef {{ hash: string, at: number, notBefore: bigint | null, given: boolean, scan: Scan | null }} Queued
44
+ */
45
+
46
+ /**
47
+ * A credit on the other chain: its transaction, its block and time, its
48
+ * amount in wei, whether it went through, and the address it came from as
49
+ * its log names it (lower case; null where it isn't named).
50
+ *
51
+ * @typedef {{ hash: string, block: bigint | null, at: number, wei: bigint, success: boolean, from: string | null }} Credit
52
+ */
53
+
54
+ /**
55
+ * What a group's matching gave each transfer.
56
+ *
57
+ * @typedef {"arrived" | "waiting" | "unsure" | "late"} Verdict
58
+ */
59
+
60
+ export const CLOCK_SLACK_MS = 120_000;
61
+ export const CREDIT_WINDOW_MS = 120_000;
62
+ export const WAIT_MS = 7 * 24 * 60 * 60_000;
63
+ export const EXPLORER_PAGES = 5;
64
+ export const LOG_WINDOW = 5_000;
65
+ export const LOG_WINDOWS = 8;
66
+ export const DEPOSIT_HORIZON_MS = 2 * 60 * 60_000;
67
+ export const SERVICE_HORIZON_MS = 12 * 60 * 60_000;
68
+ export const HISTORY_PAGES = 4;
69
+ export const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
70
+ export const ETH_BRIDGE_FINALIZED_TOPIC = "0x31b2166ff604fc5672ea5df08a78081d2bc6d746cadce880747f3643d819e83d";
71
+
72
+ const WEI_PER_LUX = 1_000_000_000n;
73
+ const MAX_U64 = (1n << 64n) - 1n;
74
+ const TIMEOUT_MS = 8_000;
75
+ // About one Dusk block every 10 seconds: how far back a sender's history is read.
76
+ const DUSK_BLOCK_MS = 10_000;
77
+ // The blocks the chain's pace is read over, how much further back than the
78
+ // pace says a search starts, and how often it steps back again from a block
79
+ // that is still later than the transfer.
80
+ const PACE_SAMPLE = 100_000n;
81
+ const PACE_MARGIN = 1.2;
82
+ const START_MARGIN = 50n;
83
+ const START_CHECKS = 4;
84
+ const MIN_WINDOW = 50n;
85
+ // What an RPC says when it won't search that many blocks at once.
86
+ const RANGE_REFUSED = /limit|range|too many|exceed/i;
87
+ const DUSK_HASH = /^[0-9a-f]{64}$/;
88
+ const EVM_HASH = /^0x[0-9a-f]{64}$/;
89
+ const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/;
90
+ const WORDS = /^0x(?:[0-9a-fA-F]{64})+$/;
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Values
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * Lux from a bigint, a safe integer or its decimal text: null for anything
98
+ * else, and past a u64.
99
+ *
100
+ * @param {unknown} value
101
+ * @returns {bigint | null}
102
+ */
103
+ function luxOf(value) {
104
+ /** @type {bigint} */
105
+ let n;
106
+
107
+ if (typeof value === "bigint") n = value;
108
+ else if (typeof value === "number" && Number.isSafeInteger(value)) n = BigInt(value);
109
+ else if (typeof value === "string" && /^\d+$/.test(value.trim())) n = BigInt(value.trim());
110
+ else return null;
111
+
112
+ return n >= 0n && n <= MAX_U64 ? n : null;
113
+ }
114
+
115
+ /**
116
+ * A block number or a time as JSON-RPC writes it (hex), or as decimal: null
117
+ * for anything else.
118
+ *
119
+ * @param {unknown} value
120
+ * @returns {bigint | null}
121
+ */
122
+ function quantityOf(value) {
123
+ if (typeof value === "bigint") return value >= 0n ? value : null;
124
+ if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null;
125
+
126
+ const text = String(value ?? "").trim();
127
+
128
+ return /^0x[0-9a-fA-F]+$/.test(text) || /^\d+$/.test(text) ? BigInt(text) : null;
129
+ }
130
+
131
+ /**
132
+ * A Dusk transaction's hash, lower case, without 0x; "" for anything else.
133
+ *
134
+ * @param {unknown} value
135
+ */
136
+ const duskHashOf = (value) => {
137
+ const hash = String(value ?? "")
138
+ .trim()
139
+ .toLowerCase()
140
+ .replace(/^0x/, "");
141
+
142
+ return DUSK_HASH.test(hash) ? hash : "";
143
+ };
144
+
145
+ /**
146
+ * An EVM transaction's hash, lower case; "" for anything else.
147
+ *
148
+ * @param {unknown} value
149
+ */
150
+ const evmHashOf = (value) => {
151
+ const hash = String(value ?? "")
152
+ .trim()
153
+ .toLowerCase();
154
+
155
+ return EVM_HASH.test(hash) ? hash : "";
156
+ };
157
+
158
+ /**
159
+ * An EVM address, lower case; "" for anything else.
160
+ *
161
+ * @param {unknown} value
162
+ */
163
+ const addressOf = (value) => (typeof value === "string" && EVM_ADDRESS.test(value) ? value.toLowerCase() : "");
164
+
165
+ /**
166
+ * An address as an indexed log topic: 32 bytes, lower case.
167
+ *
168
+ * @param {string} address
169
+ */
170
+ const topicOf = (address) => `0x${address.slice(2).toLowerCase().padStart(64, "0")}`;
171
+
172
+ /** @param {bigint} n */
173
+ const hex = (n) => `0x${n.toString(16)}`;
174
+
175
+ /**
176
+ * @param {unknown} error
177
+ * @returns {string}
178
+ */
179
+ const messageOf = (error) => String(/** @type {any} */ (error)?.message ?? error ?? "");
180
+
181
+ /**
182
+ * A Blockscout time ("2026-10-27T05:52:13.000000Z") in milliseconds.
183
+ *
184
+ * @param {unknown} text
185
+ */
186
+ const timeOf = (text) => Date.parse(String(text ?? "").replace(/(\.\d{3})\d+/, "$1"));
187
+
188
+ /**
189
+ * A time in milliseconds, or null.
190
+ *
191
+ * @param {unknown} value
192
+ */
193
+ const msOf = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null);
194
+
195
+ /**
196
+ * A height, or null.
197
+ *
198
+ * @param {unknown} value
199
+ */
200
+ const heightOf = (value) => (typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null);
201
+
202
+ /**
203
+ * Where a search stopped, what it searched for, and the credits it found
204
+ * before, as a result gave them; null for anything else.
205
+ *
206
+ * @param {unknown} value
207
+ * @returns {Scan | null}
208
+ */
209
+ function scanOf(value) {
210
+ const scan = /** @type {any} */ (value);
211
+ const from = quantityOf(scan?.from);
212
+
213
+ if (from === null || typeof scan?.basis !== "number" || !Number.isFinite(scan.basis) || typeof scan.search !== "string") return null;
214
+
215
+ const seen = (Array.isArray(scan.seen) ? scan.seen : []).flatMap((/** @type {any} */ credit) => {
216
+ const hash = evmHashOf(credit?.hash);
217
+ const block = quantityOf(credit?.block);
218
+ const at = msOf(credit?.at);
219
+ const sender = credit?.sender === null ? null : addressOf(credit?.sender);
220
+
221
+ return hash && block !== null && at !== null && sender !== "" ? [{ at, block: block.toString(), hash, sender }] : [];
222
+ });
223
+
224
+ return { basis: scan.basis, from: from.toString(), search: scan.search, seen };
225
+ }
226
+
227
+ /**
228
+ * The network a reference names; a TypeError for none there's a file for.
229
+ *
230
+ * @param {NetworkRef} network
231
+ * @returns {Network}
232
+ */
233
+ function networkOf(network) {
234
+ const net = network && typeof network === "object" ? network : networkFor(/** @type {number | bigint | string} */ (network));
235
+
236
+ if (!net || typeof net.chainId !== "number" || !net.routes) throw new TypeError("Not one of the networks");
237
+
238
+ return net;
239
+ }
240
+
241
+ /**
242
+ * @template T
243
+ * @param {Iterable<T>} items
244
+ * @param {(item: T) => string} keyOf
245
+ * @returns {Map<string, T[]>}
246
+ */
247
+ function groupBy(items, keyOf) {
248
+ /** @type {Map<string, T[]>} */
249
+ const out = new Map();
250
+
251
+ for (const item of items) {
252
+ const key = keyOf(item);
253
+
254
+ out.set(key, [...(out.get(key) ?? []), item]);
255
+ }
256
+
257
+ return out;
258
+ }
259
+
260
+ /**
261
+ * Oldest first, then by hash, so the order is the same everywhere.
262
+ *
263
+ * @param {{ at: number, hash: string, block?: bigint | null }} a
264
+ * @param {{ at: number, hash: string, block?: bigint | null }} b
265
+ */
266
+ const byTime = (a, b) =>
267
+ a.at - b.at || (a.block !== undefined && b.block !== undefined && a.block !== null && b.block !== null && a.block !== b.block ? (a.block < b.block ? -1 : 1) : 0) || (a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0);
268
+
269
+ /**
270
+ * @param {ReadonlyArray<bigint>} list
271
+ * @returns {bigint}
272
+ */
273
+ const minOf = (list) => list.reduce((a, b) => (b < a ? b : a));
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Requests
277
+ // ---------------------------------------------------------------------------
278
+
279
+ /**
280
+ * @param {string} url
281
+ * @returns {string}
282
+ */
283
+ function hostOf(url) {
284
+ try {
285
+ return new URL(url).host;
286
+ } catch {
287
+ return url;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * The app's fetch, each request within `timeoutMs`, counted.
293
+ *
294
+ * @param {FetchLike | undefined} fetchFn
295
+ * @param {number} timeoutMs
296
+ */
297
+ function reader(fetchFn, timeoutMs) {
298
+ const counter = { requests: 0 };
299
+
300
+ /**
301
+ * @param {string} url
302
+ * @param {{ method: "GET" | "POST", headers: Record<string, string>, body?: string }} init
303
+ * @returns {Promise<{ ok: boolean, status: number, body: any, host: string }>}
304
+ */
305
+ async function exchange(url, init) {
306
+ const controller = new AbortController();
307
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
308
+ const host = hostOf(url);
309
+
310
+ counter.requests += 1;
311
+
312
+ try {
313
+ const send = fetchFn ?? globalThis.fetch;
314
+ const response = await send(url, { ...init, signal: controller.signal });
315
+ const text = await response.text();
316
+ /** @type {any} */
317
+ let body;
318
+
319
+ try {
320
+ body = JSON.parse(text);
321
+ } catch {
322
+ throw new Error(response.ok ? `${host} didn't answer with JSON` : `${host} answers with HTTP ${response.status}`);
323
+ }
324
+
325
+ return { body, host, ok: response.ok, status: response.status };
326
+ } catch (error) {
327
+ if (controller.signal.aborted) throw new Error(`${host} didn't answer within ${Math.round(timeoutMs / 100) / 10} s`);
328
+
329
+ throw error;
330
+ } finally {
331
+ clearTimeout(timer);
332
+ }
333
+ }
334
+
335
+ return {
336
+ counter,
337
+
338
+ /**
339
+ * @param {string} url
340
+ * @returns {Promise<any>}
341
+ */
342
+ async getJson(url) {
343
+ const answer = await exchange(url, { headers: { accept: "application/json" }, method: "GET" });
344
+
345
+ if (!answer.ok) throw new Error(`${answer.host} answers with HTTP ${answer.status}`);
346
+
347
+ return answer.body;
348
+ },
349
+
350
+ /**
351
+ * One JSON-RPC call; its error's message, with its code, when it refuses.
352
+ *
353
+ * @param {string} url
354
+ * @param {string} method
355
+ * @param {unknown[]} params
356
+ * @returns {Promise<any>}
357
+ */
358
+ async rpc(url, method, params) {
359
+ const answer = await exchange(url, {
360
+ body: JSON.stringify({ id: 1, jsonrpc: "2.0", method, params }),
361
+ headers: { "content-type": "application/json" },
362
+ method: "POST",
363
+ });
364
+ const error = answer.body?.error;
365
+
366
+ if (error) throw Object.assign(new Error(String(error.message ?? `${method} was refused`)), { code: error.code });
367
+ if (!answer.ok) throw new Error(`${answer.host} answers ${method} with HTTP ${answer.status}`);
368
+
369
+ return answer.body?.result;
370
+ },
371
+ };
372
+ }
373
+
374
+ /** @typedef {ReturnType<typeof reader>} Reader */
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Matching: each credit pays one transfer, in the order they were sent
378
+ // ---------------------------------------------------------------------------
379
+
380
+ /**
381
+ * Transfers whose windows touch, each window from `windowMs` before its
382
+ * time to `windowMs` after: a credit in one could be another's.
383
+ *
384
+ * @param {ReadonlyArray<Queued>} queue oldest first
385
+ * @param {number} windowMs
386
+ * @returns {Queued[][]}
387
+ */
388
+ function clustersOf(queue, windowMs) {
389
+ /** @type {Queued[][]} */
390
+ const out = [];
391
+
392
+ for (const item of queue) {
393
+ const last = out[out.length - 1];
394
+
395
+ if (last && item.at - windowMs <= last[last.length - 1].at + windowMs) last.push(item);
396
+ else out.push([item]);
397
+ }
398
+
399
+ return out;
400
+ }
401
+
402
+ /**
403
+ * Whether every window of the queue is still open: nothing in it can be
404
+ * told apart yet.
405
+ *
406
+ * @param {ReadonlyArray<Queued>} queue
407
+ * @param {number} windowMs
408
+ * @param {number} now
409
+ */
410
+ const allOpen = (queue, windowMs, now) => clustersOf(queue, windowMs).every((cluster) => now < cluster[cluster.length - 1].at + windowMs);
411
+
412
+ /**
413
+ * Each transfer of a queue of equal ones, oldest first, takes the first
414
+ * credit that went through after it that no earlier one took: from
415
+ * `slackMs` before it on, and at or after the block it can't be before.
416
+ * None comes from an address of `rivals`: the senders of other transfers
417
+ * the app gave, whose credits these could be. A credit the app gave another
418
+ * of its transfers (`used`) is none of the app's own waiting ones; a
419
+ * transfer read from the chain may have it, as the one it paid comes back
420
+ * among them.
421
+ *
422
+ * Where the queue may not hold every transfer that could have made the
423
+ * credits (`guarded`), a credit is a transfer's only within `windowMs`
424
+ * either side of it, and once the windows that touch its own are over,
425
+ * only when every payment of its amount in them (those that failed too) is
426
+ * one of those transfers' credits, or one the app gave another. Otherwise
427
+ * it could be another transfer's: unsure. A transfer whose windows closed
428
+ * without a credit is late: one coming later can't be told apart.
429
+ *
430
+ * @param {ReadonlyArray<Queued>} queue oldest first
431
+ * @param {ReadonlyArray<Credit>} credits
432
+ * @param {{ guarded: boolean, now: number, slackMs: number, used: ReadonlySet<string>, windowMs: number, rivals?: ReadonlySet<string> }} rules
433
+ * @returns {{ assigned: Map<string, Credit>, verdicts: Map<string, Verdict> }}
434
+ */
435
+ function settle(queue, credits, { guarded, now, rivals = new Set(), slackMs, used, windowMs }) {
436
+ const sorted = [...credits].sort(byTime);
437
+ /** @type {Map<string, Credit>} */
438
+ const assigned = new Map();
439
+ const taken = new Set();
440
+ /** @type {Map<string, Verdict>} */
441
+ const verdicts = new Map();
442
+
443
+ for (const item of queue) {
444
+ const lo = item.at - (guarded ? windowMs : slackMs);
445
+ const hi = guarded ? item.at + windowMs : Infinity;
446
+ const credit = sorted.find(
447
+ (c) =>
448
+ c.success &&
449
+ !taken.has(c.hash) &&
450
+ (!item.given || !used.has(c.hash)) &&
451
+ (c.from === null || !rivals.has(c.from)) &&
452
+ c.at >= lo &&
453
+ c.at <= hi &&
454
+ (item.notBefore === null || c.block === null || c.block >= item.notBefore)
455
+ );
456
+
457
+ if (credit) {
458
+ assigned.set(item.hash, credit);
459
+ taken.add(credit.hash);
460
+ }
461
+ }
462
+
463
+ if (!guarded) {
464
+ for (const item of queue) verdicts.set(item.hash, assigned.has(item.hash) ? "arrived" : "waiting");
465
+
466
+ return { assigned, verdicts };
467
+ }
468
+
469
+ for (const cluster of clustersOf(queue, windowMs)) {
470
+ const lo = cluster[0].at - windowMs;
471
+ const hi = cluster[cluster.length - 1].at + windowMs;
472
+ const own = new Set(cluster.flatMap((item) => (assigned.has(item.hash) ? [/** @type {Credit} */ (assigned.get(item.hash)).hash] : [])));
473
+ const stray = sorted.some((c) => c.at >= lo && c.at <= hi && !used.has(c.hash) && !own.has(c.hash));
474
+
475
+ for (const item of cluster) {
476
+ verdicts.set(item.hash, now < hi ? "waiting" : stray ? "unsure" : assigned.has(item.hash) ? "arrived" : "late");
477
+ }
478
+ }
479
+
480
+ return { assigned, verdicts };
481
+ }
482
+
483
+ /**
484
+ * The queue of equal transfers: the app's, and those read from the chain
485
+ * that were sent shortly before one of them, each within `horizonMs` of the
486
+ * next. Older ones were paid before. The app's own win over a read of the
487
+ * same transfer.
488
+ *
489
+ * @param {ReadonlyArray<Queued>} given
490
+ * @param {ReadonlyArray<Queued>} read
491
+ * @param {number} horizonMs
492
+ * @returns {Queued[]}
493
+ */
494
+ function queueOf(given, read, horizonMs) {
495
+ const all = [...new Map([...read, ...given].map((item) => [item.hash, item])).values()].sort(byTime);
496
+ const kept = all.map((item) => item.given);
497
+
498
+ for (let i = all.length - 1; i > 0; i -= 1) {
499
+ if (kept[i] && all[i].at - all[i - 1].at <= horizonMs) kept[i - 1] = true;
500
+ }
501
+
502
+ return all.filter((_, i) => kept[i]);
503
+ }
504
+
505
+ /**
506
+ * A queue of equal transfers where the credits can't be told apart, with
507
+ * the ones the app gave but no longer looks for (waited for too long) whose
508
+ * windows touch theirs: those were sent first, and take their credits
509
+ * first.
510
+ *
511
+ * @param {ReadonlyArray<Queued>} given
512
+ * @param {ReadonlyArray<Queued>} past
513
+ * @param {number} windowMs
514
+ * @returns {Queued[]}
515
+ */
516
+ function withPast(given, past, windowMs) {
517
+ const polled = new Set(given.map((item) => item.hash));
518
+
519
+ return clustersOf([...given, ...past].sort(byTime), windowMs)
520
+ .filter((cluster) => cluster.some((item) => polled.has(item.hash)))
521
+ .flat();
522
+ }
523
+
524
+ /**
525
+ * The first transfer of the run of equal ones before `item`, each within
526
+ * `horizonMs` of the next.
527
+ *
528
+ * @param {ReadonlyArray<Queued>} queue oldest first
529
+ * @param {Queued} item
530
+ * @param {number} horizonMs
531
+ */
532
+ function runStart(queue, item, horizonMs) {
533
+ let i = queue.indexOf(item);
534
+
535
+ while (i > 0 && queue[i].at - queue[i - 1].at <= horizonMs) i -= 1;
536
+
537
+ return queue[Math.max(i, 0)];
538
+ }
539
+
540
+ // ---------------------------------------------------------------------------
541
+ // EVM logs
542
+ // ---------------------------------------------------------------------------
543
+
544
+ /**
545
+ * `Transfer(address indexed, address indexed, uint256)`'s data: the value,
546
+ * one word.
547
+ *
548
+ * @param {string} data
549
+ * @returns {bigint | null}
550
+ */
551
+ function transferValue(data) {
552
+ return /^0x[0-9a-fA-F]{64}$/.test(data) ? BigInt(data) : null;
553
+ }
554
+
555
+ /**
556
+ * `ETHBridgeFinalized(address indexed from, address indexed to, uint256
557
+ * amount, bytes extraData)`'s data: the amount, then where the extra data
558
+ * starts (0x40), its length, and its words.
559
+ *
560
+ * @param {string} data
561
+ * @returns {bigint | null}
562
+ */
563
+ function bridgeFinalizedAmount(data) {
564
+ if (!WORDS.test(data)) return null;
565
+
566
+ const words = (data.length - 2) / 64;
567
+ /** @param {number} i */
568
+ const word = (i) => BigInt(`0x${data.slice(2 + i * 64, 2 + (i + 1) * 64)}`);
569
+
570
+ if (words < 3 || word(1) !== 0x40n || BigInt(words) !== 3n + (word(2) + 31n) / 32n) return null;
571
+
572
+ return word(0);
573
+ }
574
+
575
+ /**
576
+ * A log as one of the credits searched for: not removed, from the contract
577
+ * asked, with the topics asked, in the blocks asked, of the amount asked.
578
+ *
579
+ * @param {any} log
580
+ * @param {{ address: string, topics: ReadonlyArray<string | null>, wei: bigint, amountOf: (data: string) => bigint | null, from: bigint, to: bigint }} ask
581
+ * @returns {{ hash: string, block: bigint, at: number | null, from: string } | null}
582
+ */
583
+ function creditLog(log, { address, amountOf, from, to, topics, wei }) {
584
+ if (!log || typeof log !== "object" || log.removed === true) return null;
585
+ if (String(log.address ?? "").toLowerCase() !== address) return null;
586
+
587
+ const got = Array.isArray(log.topics) ? log.topics.map((/** @type {unknown} */ topic) => String(topic).toLowerCase()) : [];
588
+
589
+ if (got.length !== topics.length || topics.some((topic, i) => topic !== null && got[i] !== topic)) return null;
590
+
591
+ const hash = evmHashOf(log.transactionHash);
592
+ const block = quantityOf(log.blockNumber);
593
+
594
+ if (!hash || block === null || block < from || block > to || amountOf(String(log.data ?? "")) !== wei) return null;
595
+
596
+ const seconds = quantityOf(log.blockTimestamp);
597
+
598
+ return { at: seconds === null ? null : Number(seconds) * 1000, block, from: `0x${got[1].slice(-40)}`, hash };
599
+ }
600
+
601
+ /**
602
+ * A block's time, in milliseconds: from the app's cache, else the chain.
603
+ *
604
+ * @param {Reader} read
605
+ * @param {string} url
606
+ * @param {bigint} block
607
+ * @param {BlockTimes | undefined} cache
608
+ * @returns {Promise<number>}
609
+ */
610
+ async function blockTime(read, url, block, cache) {
611
+ const kept = cache?.get(Number(block));
612
+
613
+ if (typeof kept === "number" && Number.isFinite(kept)) return kept;
614
+
615
+ const seconds = quantityOf((await read.rpc(url, "eth_getBlockByNumber", [hex(block), false]))?.timestamp);
616
+
617
+ if (seconds === null) throw new Error(`The chain gave no time for block ${block}`);
618
+
619
+ const at = Number(seconds) * 1000;
620
+
621
+ cache?.set(Number(block), at);
622
+
623
+ return at;
624
+ }
625
+
626
+ /**
627
+ * @param {Reader} read
628
+ * @param {string} url
629
+ * @returns {Promise<bigint>}
630
+ */
631
+ async function headOf(read, url) {
632
+ const head = quantityOf(await read.rpc(url, "eth_blockNumber", []));
633
+
634
+ if (head === null) throw new Error("The chain gave no head");
635
+
636
+ return head;
637
+ }
638
+
639
+ /**
640
+ * The chain's head, and a block from before `atMs`: estimated from the
641
+ * chain's pace over its last 100,000 blocks, a fifth further back, then
642
+ * stepped back while its own time is still later, twice as far each time,
643
+ * as the chain's pace may have changed.
644
+ *
645
+ * @param {Reader} read
646
+ * @param {string} url
647
+ * @param {number} atMs
648
+ * @param {BlockTimes | undefined} cache
649
+ * @returns {Promise<{ head: bigint, start: bigint }>}
650
+ */
651
+ async function startBefore(read, url, atMs, cache) {
652
+ const latest = await read.rpc(url, "eth_getBlockByNumber", ["latest", false]);
653
+ const head = quantityOf(latest?.number);
654
+ const headSeconds = quantityOf(latest?.timestamp);
655
+
656
+ if (head === null || headSeconds === null) throw new Error("The chain gave no head");
657
+
658
+ cache?.set(Number(head), Number(headSeconds) * 1000);
659
+
660
+ const back = head < PACE_SAMPLE ? head : PACE_SAMPLE;
661
+ // Blocks a second.
662
+ let pace = 1;
663
+
664
+ if (back > 0n) {
665
+ const sample = await blockTime(read, url, head - back, cache);
666
+ const span = Number(headSeconds) - sample / 1000;
667
+
668
+ if (span > 0) pace = Number(back) / span;
669
+ }
670
+
671
+ /**
672
+ * @param {bigint} block
673
+ * @param {number} seconds
674
+ * @param {number} [times]
675
+ */
676
+ const stepBack = (block, seconds, times = 1) => {
677
+ const step = BigInt(Math.ceil(Math.max(0, seconds) * pace * PACE_MARGIN * times)) + START_MARGIN;
678
+
679
+ return block > step ? block - step : 0n;
680
+ };
681
+ let start = stepBack(head, Number(headSeconds) - atMs / 1000);
682
+
683
+ for (let check = 0; check < START_CHECKS && start > 0n; check += 1) {
684
+ const at = await blockTime(read, url, start, cache);
685
+
686
+ if (at <= atMs) break;
687
+
688
+ start = stepBack(start, (at - atMs) / 1000, 2 ** (check + 1));
689
+ }
690
+
691
+ return { head, start };
692
+ }
693
+
694
+ /**
695
+ * The credits asked for from block `start`, a window of blocks at a time, at
696
+ * most `maxWindows`, until the head, `enough`, or a window ending after
697
+ * `until` (`covered`): each checked against what was asked, with its block's
698
+ * time. A window the RPC won't search at once is asked again a quarter as
699
+ * wide.
700
+ *
701
+ * @param {Reader} read
702
+ * @param {{ url: string, address: string, topics: ReadonlyArray<string | null>, wei: bigint, amountOf: (data: string) => bigint | null, start: bigint, head: bigint, maxWindows: number, blockTimes: BlockTimes | undefined, enough: (credits: Credit[]) => boolean, until: number | null, known: Credit[] }} search
703
+ * @returns {Promise<{ complete: boolean, covered: boolean, credits: Credit[], next: bigint }>}
704
+ */
705
+ async function searchLogs(read, { address, amountOf, blockTimes, enough, head, known, maxWindows, start, topics, until, url, wei }) {
706
+ // What an earlier search found before `start`.
707
+ /** @type {Credit[]} */
708
+ const credits = [...known];
709
+ let from = start;
710
+ let size = BigInt(LOG_WINDOW);
711
+ let covered = false;
712
+
713
+ for (let round = 0; round < maxWindows && from <= head; round += 1) {
714
+ const to = from + size - 1n < head ? from + size - 1n : head;
715
+ /** @type {any} */
716
+ let logs;
717
+
718
+ try {
719
+ logs = await read.rpc(url, "eth_getLogs", [{ address, fromBlock: hex(from), toBlock: hex(to), topics }]);
720
+ } catch (error) {
721
+ if (size > MIN_WINDOW && RANGE_REFUSED.test(messageOf(error))) {
722
+ size = size / 4n > MIN_WINDOW ? size / 4n : MIN_WINDOW;
723
+ continue;
724
+ }
725
+
726
+ throw error;
727
+ }
728
+
729
+ for (const log of Array.isArray(logs) ? logs : []) {
730
+ const found = creditLog(log, { address, amountOf, from, to, topics, wei });
731
+
732
+ if (!found || credits.some((credit) => credit.hash === found.hash)) continue;
733
+ if (found.at !== null) blockTimes?.set(Number(found.block), found.at);
734
+
735
+ credits.push({ at: found.at ?? (await blockTime(read, url, found.block, blockTimes)), block: found.block, from: found.from, hash: found.hash, success: true, wei });
736
+ }
737
+
738
+ from = to + 1n;
739
+
740
+ if (enough(credits)) break;
741
+ if (until !== null && from <= head && (await blockTime(read, url, to, blockTimes)) > until) {
742
+ covered = true;
743
+ break;
744
+ }
745
+ }
746
+
747
+ return { complete: from > head, covered, credits, next: from };
748
+ }
749
+
750
+ /**
751
+ * One group of equal transfers followed to their credits in a chain's logs:
752
+ * from where the last search for the same logs stopped when it began no
753
+ * later than the group's first transfer, else from the block the first was
754
+ * sent at (as the app saw the chain's head then), else from about when it
755
+ * was sent.
756
+ *
757
+ * @param {Reader} read
758
+ * @param {{ given: Queued[], queue: Queued[], guarded: boolean, unsure: ReadonlySet<string>, rivals?: ReadonlySet<string>, filter: { address: string, topics: ReadonlyArray<string | null>, wei: bigint, amountOf: (data: string) => bigint | null } }} group
759
+ * @param {{ url: string, maxWindows: number, blockTimes: BlockTimes | undefined, now: number, slackMs: number, windowMs: number, used: Set<string> }} options
760
+ * @returns {Promise<Map<string, Arrival>>}
761
+ */
762
+ async function followLogs(read, { filter, given, guarded, queue, rivals, unsure }, { blockTimes, maxWindows, now, slackMs, url, used, windowMs }) {
763
+ /** @type {Map<string, Arrival>} */
764
+ const out = new Map();
765
+ const live = given.filter((item) => !unsure.has(item.hash));
766
+
767
+ for (const item of given) if (unsure.has(item.hash)) out.set(item.hash, { reason: "unsure", state: "unknown" });
768
+
769
+ if (!live.length) return out;
770
+
771
+ if (guarded && allOpen(queue, windowMs, now)) {
772
+ for (const item of live) out.set(item.hash, { state: "waiting" });
773
+
774
+ return out;
775
+ }
776
+
777
+ const rules = { guarded, now, rivals, slackMs, used, windowMs };
778
+ const first = queue[0];
779
+ const reach = first.at - (guarded ? windowMs : slackMs);
780
+ // The logs searched for: a search for others, as for another sender or
781
+ // amount, isn't carried on.
782
+ const search = [filter.address, ...filter.topics.map((topic) => topic ?? "*"), filter.wei.toString()].join(":");
783
+ const resumed = live.flatMap((item) => (item.scan && item.scan.search === search && item.scan.basis <= first.at ? [item.scan] : []));
784
+ /** @type {Map<string, Credit>} */
785
+ const carried = new Map();
786
+
787
+ // The credits found before where an earlier search stopped: every one
788
+ // counts again, as the transfer it paid may be among the queue's.
789
+ for (const scan of resumed) {
790
+ for (const credit of scan.seen ?? []) {
791
+ if (credit.at >= reach) carried.set(credit.hash, { at: credit.at, block: BigInt(credit.block), from: credit.sender, hash: credit.hash, success: true, wei: filter.wei });
792
+ }
793
+ }
794
+
795
+ /** @type {bigint} */
796
+ let head;
797
+ /** @type {bigint} */
798
+ let start;
799
+
800
+ if (resumed.length) {
801
+ start = minOf(resumed.map((scan) => BigInt(scan.from)));
802
+ head = await headOf(read, url);
803
+ } else if (first.notBefore !== null) {
804
+ start = first.notBefore;
805
+ head = await headOf(read, url);
806
+ } else {
807
+ ({ head, start } = await startBefore(read, url, reach, blockTimes));
808
+ }
809
+
810
+ // Where the credits can't be told apart, only those in the transfers'
811
+ // windows count: once they're all over, read no further than the last.
812
+ const last = queue[queue.length - 1].at + windowMs;
813
+ const { complete, covered, credits, next } = await searchLogs(read, {
814
+ ...filter,
815
+ blockTimes,
816
+ // Once every transfer of the queue has its credit: read no further.
817
+ enough: (found) => !guarded && settle(queue, found, rules).assigned.size === queue.length,
818
+ head,
819
+ known: [...carried.values()],
820
+ maxWindows,
821
+ start,
822
+ until: guarded && now >= last ? last : null,
823
+ url,
824
+ });
825
+ const seen = complete || covered;
826
+ const { assigned, verdicts } = settle(queue, credits, rules);
827
+ /** @type {Set<string>} */
828
+ const arrived = new Set();
829
+
830
+ for (const item of live) {
831
+ const credit = assigned.get(item.hash);
832
+
833
+ if (credit && verdicts.get(item.hash) === "arrived" && (seen || !guarded)) arrived.add(credit.hash);
834
+ }
835
+
836
+ // The next search carries on from where this one stopped, with the
837
+ // credits found so far: a transfer that arrived may come back from the
838
+ // chain among those before the next one, and takes its own again.
839
+ /** @type {Scan} */
840
+ const scan = {
841
+ basis: first.at,
842
+ from: next.toString(),
843
+ search,
844
+ seen: credits.flatMap((credit) => (credit.at >= reach && credit.block !== null ? [{ at: credit.at, block: credit.block.toString(), hash: credit.hash, sender: credit.from }] : [])),
845
+ };
846
+
847
+ for (const item of live) {
848
+ const credit = assigned.get(item.hash);
849
+ const verdict = verdicts.get(item.hash);
850
+
851
+ if (credit && arrived.has(credit.hash)) {
852
+ used.add(credit.hash);
853
+ out.set(item.hash, {
854
+ amount: credit.wei / WEI_PER_LUX,
855
+ at: credit.at,
856
+ block: credit.block === null ? null : Number(credit.block),
857
+ hash: credit.hash,
858
+ state: "arrived",
859
+ });
860
+ } else if (guarded ? !seen : verdict === "waiting" && !complete) {
861
+ out.set(item.hash, { reason: "searching", scan, state: "unknown" });
862
+ } else if (verdict === "waiting") {
863
+ out.set(item.hash, { scan, state: "waiting" });
864
+ } else {
865
+ out.set(item.hash, { reason: verdict === "unsure" ? "unsure" : "late", scan, state: "unknown" });
866
+ }
867
+ }
868
+
869
+ return out;
870
+ }
871
+
872
+ // ---------------------------------------------------------------------------
873
+ // The archive
874
+ // ---------------------------------------------------------------------------
875
+
876
+ /**
877
+ * The transfers whose memo is exactly each of `memos`, as the archive lists
878
+ * them: one request.
879
+ *
880
+ * @param {Archive} archive
881
+ * @param {Iterable<string>} memos
882
+ * @param {{ requests: number }} counter
883
+ * @returns {Promise<Map<string, any[]>>}
884
+ */
885
+ async function byMemo(archive, memos, counter) {
886
+ const list = [...new Set(memos)];
887
+
888
+ if (!list.length) return new Map();
889
+
890
+ counter.requests += 1;
891
+
892
+ const data = await archive.query(`query { ${list.map((memo, i) => `m${i}: transactionsByMemo(memo: ${JSON.stringify(memo)}) { json }`).join(" ")} }`);
893
+
894
+ return new Map(list.map((memo, i) => [memo, Array.isArray(data?.[`m${i}`]?.json) ? data[`m${i}`].json : []]));
895
+ }
896
+
897
+ // A JSON string, or a whole JSON number: outside strings, only numbers have digits.
898
+ const TOKEN = /("(?:[^"\\]|\\.)*")|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g;
899
+
900
+ /**
901
+ * JSON.parse, with the integers past 2^53 kept exact, as their text: the
902
+ * node writes a transaction's amounts as bare numbers.
903
+ *
904
+ * @param {string} text
905
+ * @returns {any}
906
+ */
907
+ function exactJson(text) {
908
+ return JSON.parse(text.replace(TOKEN, (match, string) => string ?? (/^-?\d+$/.test(match) && !Number.isSafeInteger(Number(match)) ? `"${match}"` : match)));
909
+ }
910
+
911
+ const utf8 = new TextDecoder();
912
+
913
+ /**
914
+ * A memo's bytes, given as hex, as text, read as the route's bridge reads
915
+ * them: as UTF-8, bytes that aren't UTF-8 becoming the replacement
916
+ * character; "" for none.
917
+ *
918
+ * @param {unknown} hex
919
+ */
920
+ function memoOf(hex) {
921
+ const text = String(hex ?? "").replace(/^0x/i, "");
922
+
923
+ return /^(?:[0-9a-fA-F]{2})+$/.test(text) ? utf8.decode(Uint8Array.from(text.match(/../g) ?? [], (byte) => parseInt(byte, 16))) : "";
924
+ }
925
+
926
+ /**
927
+ * A transaction as `tx(hash) { blockHeight blockTimestamp err tx { memo
928
+ * json } }` gives it: its block's height and time, whether it failed, its
929
+ * kind, and as a public transfer, whom it paid how much and its memo as the
930
+ * route's bridge reads it. Null for none; `kind` null when its JSON can't be
931
+ * read.
932
+ *
933
+ * @param {any} raw
934
+ * @returns {{ at: number | null, failed: boolean, height: number | null, kind: string | null, memo: string, receiver: string | null, value: bigint | null } | null}
935
+ */
936
+ function transferFacts(raw) {
937
+ if (!raw || typeof raw !== "object") return null;
938
+
939
+ /** @type {any} */
940
+ let json = null;
941
+
942
+ try {
943
+ json = exactJson(String(raw.tx?.json ?? ""));
944
+ } catch {
945
+ json = null;
946
+ }
947
+
948
+ const seconds = Number(raw.blockTimestamp);
949
+
950
+ return {
951
+ at: Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null,
952
+ failed: raw.err !== null && raw.err !== undefined,
953
+ height: /^\d+$/.test(String(raw.blockHeight)) ? heightOf(Number(raw.blockHeight)) : null,
954
+ kind: typeof json?.type === "string" ? json.type : null,
955
+ memo: memoOf(raw.tx?.memo),
956
+ receiver: typeof json?.receiver === "string" ? json.receiver : null,
957
+ value: luxOf(json?.value),
958
+ };
959
+ }
960
+
961
+ /** @typedef {NonNullable<ReturnType<typeof transferFacts>>} TransferFacts */
962
+
963
+ /**
964
+ * Transactions as `transferFacts` reads them, by hash, a batch per request:
965
+ * those the archive has.
966
+ *
967
+ * @param {Archive} archive
968
+ * @param {ReadonlyArray<string>} hashes
969
+ * @param {{ requests: number }} counter
970
+ * @returns {Promise<Map<string, TransferFacts>>}
971
+ */
972
+ async function transfersOf(archive, hashes, counter) {
973
+ /** @type {Map<string, TransferFacts>} */
974
+ const out = new Map();
975
+ const list = [...new Set(hashes)];
976
+
977
+ for (let i = 0; i < list.length; i += DETAILS_BATCH) {
978
+ const batch = list.slice(i, i + DETAILS_BATCH);
979
+
980
+ counter.requests += 1;
981
+
982
+ const data = await archive.query(`query { ${batch.map((hash, j) => `x${j}: tx(hash: "${hash}") { blockHeight blockTimestamp err tx { memo json } }`).join(" ")} }`);
983
+
984
+ batch.forEach((hash, j) => {
985
+ const facts = transferFacts(data?.[`x${j}`]);
986
+
987
+ if (facts) out.set(hash, facts);
988
+ });
989
+ }
990
+
991
+ return out;
992
+ }
993
+
994
+ /**
995
+ * Details of transactions, a batch per request.
996
+ *
997
+ * @param {Archive} archive
998
+ * @param {ReadonlyArray<string>} hashes
999
+ * @param {{ network?: Network, args?: boolean }} options
1000
+ * @param {{ requests: number }} counter
1001
+ */
1002
+ async function detailsOf(archive, hashes, { args = false, network }, counter) {
1003
+ /** @type {Map<string, any>} */
1004
+ const out = new Map();
1005
+ const list = [...new Set(hashes)];
1006
+
1007
+ for (let i = 0; i < list.length; i += DETAILS_BATCH) {
1008
+ const batch = list.slice(i, i + DETAILS_BATCH);
1009
+
1010
+ counter.requests += 1;
1011
+
1012
+ for (const [hash, found] of await fetchTxDetails(archive, batch, { args: args ? new Set(batch) : undefined, network })) out.set(hash, found);
1013
+ }
1014
+
1015
+ return out;
1016
+ }
1017
+
1018
+ /**
1019
+ * A sender's deposits to DuskEVM between two heights, from its history and
1020
+ * their calls: amount, DuskEVM address and time, but those `skip` names,
1021
+ * which are looked for. Only those of at least `least` lux are read: what
1022
+ * an account paid the bridge is its deposit and any gas refund it paid
1023
+ * another account. Those whose call or time the archive doesn't give are
1024
+ * `unresolved`: they could be any deposit of up to what they paid.
1025
+ *
1026
+ * @param {Archive} archive
1027
+ * @param {Network} net
1028
+ * @param {{ sender: string, from: number, to: number, least: bigint, skip: ReadonlySet<string> }} range
1029
+ * @param {{ requests: number }} counter
1030
+ */
1031
+ async function senderDeposits(archive, net, { from, least, sender, skip, to }, counter) {
1032
+ const bridge = duskEvmContracts(net)?.bridge;
1033
+ const read = await historyRange(archive, { account: sender, direction: "out", from, pages: HISTORY_PAGES, to });
1034
+
1035
+ counter.requests += read.requests;
1036
+
1037
+ // A history cut short leaves its deposits' order unclear: nothing more is read.
1038
+ if (!read.complete) return { complete: false, deposits: [], unresolved: [] };
1039
+
1040
+ const candidates = read.entries.filter((entry) => entry.counterparty === bridge && !skip.has(entry.hash) && entry.value >= least);
1041
+ const details = await detailsOf(archive, candidates.map((entry) => entry.hash), { args: true, network: net }, counter);
1042
+ /** @type {{ amount: bigint, at: number, hash: string, height: number, to: string }[]} */
1043
+ const deposits = [];
1044
+ /** @type {{ hash: string, height: number, value: bigint }[]} */
1045
+ const unresolved = [];
1046
+
1047
+ for (const entry of candidates) {
1048
+ const found = details.get(entry.hash);
1049
+ const bridged = found?.bridge;
1050
+
1051
+ // Failed, or a call that isn't a deposit: no DUSK left for DuskEVM.
1052
+ if (found?.success === false || bridged === null || (bridged && bridged.kind !== "deposit")) continue;
1053
+
1054
+ const at = msOf(found?.timestamp);
1055
+ const amount = luxOf(bridged?.amount);
1056
+ const recipient = addressOf(bridged?.to);
1057
+
1058
+ if (found && at !== null && amount !== null && recipient) deposits.push({ amount, at, hash: entry.hash, height: entry.blockHeight, to: recipient });
1059
+ else unresolved.push({ hash: entry.hash, height: entry.blockHeight, value: entry.value });
1060
+ }
1061
+
1062
+ return { complete: true, deposits, unresolved };
1063
+ }
1064
+
1065
+ // ---------------------------------------------------------------------------
1066
+ // Inputs
1067
+ // ---------------------------------------------------------------------------
1068
+
1069
+ /**
1070
+ * A deposit as given, checked.
1071
+ *
1072
+ * @typedef {{ amount: bigint, at: number | null, hash: string, height: number | null, notBefore: bigint | null, scan: Scan | null, sender: string | null, to: string }} DepositItem
1073
+ */
1074
+
1075
+ /**
1076
+ * @param {Deposit} deposit
1077
+ * @returns {DepositItem}
1078
+ */
1079
+ function depositIn(deposit) {
1080
+ const hash = duskHashOf(deposit?.hash);
1081
+ const amount = luxOf(deposit?.amount);
1082
+ const to = addressOf(deposit?.to);
1083
+ const sender = deposit?.sender ?? null;
1084
+
1085
+ if (!hash) throw new TypeError("A deposit's hash is its Dusk transaction's: 64 hex digits");
1086
+ if (amount === null || amount === 0n) throw new TypeError("A deposit's amount is lux: more than 0, at most a u64");
1087
+ if (!to) throw new TypeError("A deposit pays a DuskEVM address: 0x and 40 hex digits");
1088
+ if (sender !== null && !isAccount(sender)) throw new TypeError("A deposit's sender is a Dusk account");
1089
+
1090
+ return {
1091
+ amount,
1092
+ at: msOf(deposit.at),
1093
+ hash,
1094
+ height: heightOf(deposit.height),
1095
+ notBefore: quantityOf(deposit.notBefore),
1096
+ scan: scanOf(deposit.scan),
1097
+ sender: /** @type {string | null} */ (sender),
1098
+ to,
1099
+ };
1100
+ }
1101
+
1102
+ /**
1103
+ * A transfer out as given, checked: its route, the fee the app gives where
1104
+ * it gives one, and where an earlier search stopped.
1105
+ *
1106
+ * @typedef {{ fee: bigint | null, hash: string, notBefore: bigint | null, route: (Route & { kind: "service" }) | undefined, scan: Scan | null }} TransferItem
1107
+ */
1108
+
1109
+ /**
1110
+ * A transfer out as the chain has it, which pays out: its block's time and
1111
+ * height, the bridge's fee, what it pays out, and the address it pays.
1112
+ *
1113
+ * @typedef {TransferItem & { at: number, fee: bigint, height: number, net: bigint, route: Route & { kind: "service" }, to: string }} SentItem
1114
+ */
1115
+
1116
+ /**
1117
+ * @param {ServiceTransfer} transfer
1118
+ * @param {ReadonlyArray<Route>} services the network's service routes
1119
+ * @returns {TransferItem}
1120
+ */
1121
+ function transferIn(transfer, services) {
1122
+ const hash = duskHashOf(transfer?.hash);
1123
+ const route = /** @type {(Route & { kind: "service" }) | undefined} */ (services.find((each) => each.id === transfer?.route));
1124
+ const given = transfer?.fee !== undefined && transfer.fee !== null;
1125
+ const fee = given ? luxOf(transfer.fee) : null;
1126
+
1127
+ if (!hash) throw new TypeError("A transfer's hash is its Dusk transaction's: 64 hex digits");
1128
+ if (given && fee === null) throw new TypeError("A transfer's fee is lux: at most a u64");
1129
+
1130
+ return { fee, hash, notBefore: quantityOf(transfer.notBefore), route, scan: scanOf(transfer.scan) };
1131
+ }
1132
+
1133
+ /**
1134
+ * How long apart equal transfers out on a route are still paid in their
1135
+ * order: 12 hours, or four times the route's usual time.
1136
+ *
1137
+ * @param {Route} route
1138
+ */
1139
+ const horizonOf = (route) => Math.max(SERVICE_HORIZON_MS, 4 * (route.typicalSeconds ?? 0) * 1000);
1140
+
1141
+ /**
1142
+ * @param {{ hash: string, at: number | null, notBefore: bigint | null, scan: Scan | null }} item
1143
+ * @returns {Queued}
1144
+ */
1145
+ const queued = (item) => ({ at: /** @type {number} */ (item.at), given: true, hash: item.hash, notBefore: item.notBefore, scan: item.scan });
1146
+
1147
+ /**
1148
+ * Sorts out the transfers that aren't looked for: without their block's
1149
+ * time yet, waiting; sent longer ago than they're waited for, unknown.
1150
+ *
1151
+ * @template {{ hash: string, at: number | null }} T
1152
+ * @param {ReadonlyArray<T>} list
1153
+ * @param {Map<string, Arrival>} results
1154
+ * @param {{ now: number, waitMs: number }} options
1155
+ * @returns {T[]}
1156
+ */
1157
+ function triage(list, results, { now, waitMs }) {
1158
+ /** @type {T[]} */
1159
+ const live = [];
1160
+
1161
+ for (const item of list) {
1162
+ if (item.at === null) results.set(item.hash, { state: "waiting" });
1163
+ else if (now - item.at > waitMs) results.set(item.hash, { reason: "expired", state: "unknown" });
1164
+ else live.push(item);
1165
+ }
1166
+
1167
+ return live;
1168
+ }
1169
+
1170
+ /**
1171
+ * Each item once, by hash: the first.
1172
+ *
1173
+ * @template {{ hash: string }} T
1174
+ * @param {ReadonlyArray<T>} list
1175
+ * @returns {T[]}
1176
+ */
1177
+ function uniqueByHash(list) {
1178
+ const seen = new Set();
1179
+
1180
+ return list.filter((item) => !seen.has(item.hash) && Boolean(seen.add(item.hash)));
1181
+ }
1182
+
1183
+ /**
1184
+ * The results in the order the transfers came.
1185
+ *
1186
+ * @param {ReadonlyArray<{ hash: string }>} list
1187
+ * @param {Map<string, Arrival>} results
1188
+ * @returns {Map<string, Arrival>}
1189
+ */
1190
+ const inOrder = (list, results) => new Map(list.map((item) => [item.hash, /** @type {Arrival} */ (results.get(item.hash))]));
1191
+
1192
+ /**
1193
+ * @param {Iterable<string> | undefined} taken
1194
+ * @param {(value: unknown) => string} hashOf
1195
+ */
1196
+ const usedOf = (taken, hashOf) => new Set([...(taken ?? [])].map(hashOf).filter(Boolean));
1197
+
1198
+ // ---------------------------------------------------------------------------
1199
+ // Deposits to DuskEVM
1200
+ // ---------------------------------------------------------------------------
1201
+
1202
+ /**
1203
+ * @param {DepositArrivalOptions} options
1204
+ * @returns {Promise<{ arrivals: Map<string, Arrival>, requests: number }>}
1205
+ */
1206
+ export async function depositArrivals({
1207
+ network,
1208
+ deposits,
1209
+ via = "explorer",
1210
+ fetch: fetchFn,
1211
+ explorerUrl,
1212
+ rpcUrl,
1213
+ archive,
1214
+ taken,
1215
+ blockTimes,
1216
+ now = Date.now(),
1217
+ waitMs = WAIT_MS,
1218
+ windowMs = CREDIT_WINDOW_MS,
1219
+ maxPages = EXPLORER_PAGES,
1220
+ maxWindows = LOG_WINDOWS,
1221
+ timeoutMs = TIMEOUT_MS,
1222
+ }) {
1223
+ if (via !== "explorer" && via !== "rpc") throw new RangeError('A deposit is followed on the "explorer" or the "rpc"');
1224
+
1225
+ const net = networkOf(network);
1226
+ const list = uniqueByHash(Array.from(deposits ?? [], depositIn));
1227
+ const read = reader(fetchFn, timeoutMs);
1228
+ /** @type {Map<string, Arrival>} */
1229
+ const results = new Map();
1230
+ const evm = net.duskEvm;
1231
+
1232
+ if (!evm) {
1233
+ for (const item of list) results.set(item.hash, { reason: "no-route", state: "unknown" });
1234
+
1235
+ return { arrivals: inOrder(list, results), requests: 0 };
1236
+ }
1237
+
1238
+ const used = usedOf(taken, evmHashOf);
1239
+ const live = triage(list, results, { now, waitMs });
1240
+ // Waited for too long: not looked for, though still before the newer ones.
1241
+ const past = list.filter((item) => item.at !== null && now - item.at > waitMs);
1242
+ const bridge = evm.predeploys.l2StandardBridge.toLowerCase();
1243
+ const rules = { now, past, results, used, windowMs };
1244
+
1245
+ if (via === "explorer") {
1246
+ await onExplorer(read, live, { ...rules, api: String(explorerUrl ?? evm.blockscoutApiUrl), bridge, maxPages });
1247
+ } else {
1248
+ await onRpc(read, live, { ...rules, archive, blockTimes, bridge, list, maxWindows, net, url: String(rpcUrl ?? evm.rpcUrl) });
1249
+ }
1250
+
1251
+ return { arrivals: inOrder(list, results), requests: read.counter.requests };
1252
+ }
1253
+
1254
+ /**
1255
+ * The L2 bridge's payments to an address, as DuskEVM's explorer lists its
1256
+ * incoming calls: newest first, a page at a time (the explorer's
1257
+ * `next_page_params`), back past `since`, at most `maxPages`. `complete`
1258
+ * once they reach it, or there are no more.
1259
+ *
1260
+ * @param {Reader} read
1261
+ * @param {{ api: string, bridge: string, maxPages: number, since: number, to: string }} ask
1262
+ * @returns {Promise<{ complete: boolean, payments: Credit[] }>}
1263
+ */
1264
+ async function explorerPayments(read, { api, bridge, maxPages, since, to }) {
1265
+ const base = `${api.replace(/\/+$/, "")}/addresses/${to}/internal-transactions`;
1266
+ /** @type {any[]} */
1267
+ const items = [];
1268
+ /** @type {Record<string, unknown> | null} */
1269
+ let next = null;
1270
+ let complete = false;
1271
+
1272
+ for (let page = 0; page < Math.max(1, Math.floor(maxPages)) && !complete; page += 1) {
1273
+ const query = new URLSearchParams({ filter: "to" });
1274
+
1275
+ for (const [key, value] of Object.entries(next ?? {})) query.set(key, String(value));
1276
+
1277
+ const body = await read.getJson(`${base}?${query}`);
1278
+ const calls = Array.isArray(body?.items) ? body.items : [];
1279
+ const oldest = Math.min(...calls.map((/** @type {any} */ call) => timeOf(call?.timestamp)).filter(Number.isFinite));
1280
+
1281
+ items.push(...calls);
1282
+ next = body?.next_page_params && typeof body.next_page_params === "object" ? body.next_page_params : null;
1283
+ complete = !next || !calls.length || oldest < since;
1284
+ }
1285
+
1286
+ /** @type {Map<string, Credit>} */
1287
+ const payments = new Map();
1288
+
1289
+ for (const call of items) {
1290
+ const hash = evmHashOf(call?.transaction_hash);
1291
+ const at = timeOf(call?.timestamp);
1292
+ const value = String(call?.value ?? "");
1293
+ const payee = call?.to?.hash;
1294
+
1295
+ if (!hash || payments.has(hash) || !Number.isFinite(at) || !/^\d+$/.test(value)) continue;
1296
+ if (String(call?.from?.hash ?? "").toLowerCase() !== bridge) continue;
1297
+ if (payee !== undefined && payee !== null && String(payee).toLowerCase() !== to) continue;
1298
+
1299
+ payments.set(hash, { at, block: quantityOf(call?.block_number), from: null, hash, success: call?.success !== false, wei: BigInt(value) });
1300
+ }
1301
+
1302
+ return { complete, payments: [...payments.values()] };
1303
+ }
1304
+
1305
+ /**
1306
+ * Deposits followed on DuskEVM's explorer: each recipient's incoming calls
1307
+ * read once for all its deposits, and matched as where the credits can't be
1308
+ * told apart, as they don't say who deposited. Equal deposits past their
1309
+ * wait just before them take their payments first, their results kept.
1310
+ *
1311
+ * @param {Reader} read
1312
+ * @param {DepositItem[]} live
1313
+ * @param {{ api: string, bridge: string, maxPages: number, now: number, past: DepositItem[], results: Map<string, Arrival>, used: Set<string>, windowMs: number }} options
1314
+ */
1315
+ async function onExplorer(read, live, { api, bridge, maxPages, now, past, results, used, windowMs }) {
1316
+ for (const [to, items] of groupBy(live, (item) => item.to)) {
1317
+ const polled = new Set(items.map((item) => item.hash));
1318
+ const groups = [...groupBy(items, (item) => item.amount.toString()).values()].map((group) => ({
1319
+ queue: withPast(
1320
+ group.map(queued),
1321
+ past.filter((item) => item.to === to && item.amount === group[0].amount).map(queued),
1322
+ windowMs
1323
+ ),
1324
+ wei: group[0].amount * WEI_PER_LUX,
1325
+ }));
1326
+
1327
+ // Nothing can be told apart before a window is over: nothing is asked.
1328
+ if (groups.every(({ queue }) => allOpen(queue, windowMs, now))) {
1329
+ for (const item of items) results.set(item.hash, { state: "waiting" });
1330
+ continue;
1331
+ }
1332
+
1333
+ /** @type {{ complete: boolean, payments: Credit[] }} */
1334
+ let page;
1335
+
1336
+ try {
1337
+ page = await explorerPayments(read, { api, bridge, maxPages, since: Math.min(...groups.flatMap(({ queue }) => queue.map((item) => item.at))) - windowMs, to });
1338
+ } catch (error) {
1339
+ for (const item of items) results.set(item.hash, { error: messageOf(error), reason: "error", state: "unknown" });
1340
+ continue;
1341
+ }
1342
+
1343
+ // Not back to the oldest: a payment of an older one may be a newer one's.
1344
+ if (!page.complete) {
1345
+ for (const item of items) results.set(item.hash, { reason: "beyond", state: "unknown" });
1346
+ continue;
1347
+ }
1348
+
1349
+ for (const { queue, wei } of groups) {
1350
+ const { assigned, verdicts } = settle(
1351
+ queue,
1352
+ page.payments.filter((payment) => payment.wei === wei),
1353
+ { guarded: true, now, slackMs: windowMs, used, windowMs }
1354
+ );
1355
+
1356
+ for (const item of queue.filter((each) => polled.has(each.hash))) {
1357
+ const credit = assigned.get(item.hash);
1358
+ const verdict = verdicts.get(item.hash);
1359
+
1360
+ if (credit && verdict === "arrived") {
1361
+ used.add(credit.hash);
1362
+ results.set(item.hash, { amount: credit.wei / WEI_PER_LUX, at: credit.at, block: credit.block === null ? null : Number(credit.block), hash: credit.hash, state: "arrived" });
1363
+ } else {
1364
+ results.set(item.hash, verdict === "waiting" ? { state: "waiting" } : { reason: verdict === "unsure" ? "unsure" : "late", state: "unknown" });
1365
+ }
1366
+ }
1367
+ }
1368
+ }
1369
+ }
1370
+
1371
+ /**
1372
+ * Deposits followed on DuskEVM's RPC, by the bridge's events from each
1373
+ * sender's DuskEVM address: a group of equal deposits from one sender at a
1374
+ * time, with the sender's equal deposits shortly before, from its history,
1375
+ * when there's an archive; without it, with the equal ones given that are
1376
+ * past their wait just before them.
1377
+ *
1378
+ * @param {Reader} read
1379
+ * @param {DepositItem[]} live
1380
+ * @param {{ archive: Archive | undefined, blockTimes: BlockTimes | undefined, bridge: string, list: DepositItem[], maxWindows: number, net: Network, now: number, past: DepositItem[], results: Map<string, Arrival>, url: string, used: Set<string>, windowMs: number }} options
1381
+ */
1382
+ async function onRpc(read, live, { archive, blockTimes, bridge, list, maxWindows, net, now, past, results, url, used, windowMs }) {
1383
+ /** @type {Map<string, Awaited<ReturnType<typeof senderDeposits>> & { bottom: number, top: number, topAt: number }>} */
1384
+ const histories = new Map();
1385
+
1386
+ // The senders' deposits shortly before theirs, one history read a sender:
1387
+ // the app's deposits no longer looked for among them.
1388
+ if (archive) {
1389
+ const reach = Math.ceil((3 * DEPOSIT_HORIZON_MS) / DUSK_BLOCK_MS);
1390
+
1391
+ for (const [sender, items] of groupBy(
1392
+ live.filter((item) => item.sender !== null && item.height !== null),
1393
+ (item) => /** @type {string} */ (item.sender)
1394
+ )) {
1395
+ const heights = items.map((item) => /** @type {number} */ (item.height));
1396
+ const top = Math.max(...heights);
1397
+ const bottom = Math.max(0, Math.min(...heights) - reach);
1398
+ const least = items.reduce((a, item) => (item.amount < a ? item.amount : a), MAX_U64);
1399
+
1400
+ try {
1401
+ const found = await senderDeposits(archive, net, { from: bottom, least, sender, skip: new Set(items.map((item) => item.hash)), to: top }, read.counter);
1402
+ const topAt = /** @type {number} */ (items.find((item) => item.height === top)?.at);
1403
+
1404
+ histories.set(sender, { ...found, bottom, top, topAt });
1405
+ } catch (error) {
1406
+ for (const item of items) results.set(item.hash, { error: messageOf(error), reason: "error", state: "unknown" });
1407
+ }
1408
+ }
1409
+ }
1410
+
1411
+ // Equal deposits (the same DuskEVM address and amount) from a known sender
1412
+ // first, a sender at a time, its events telling its credits apart; then
1413
+ // those whose sender isn't known, which take none from the senders of the
1414
+ // app's other equal deposits, looked for or not: those could be theirs.
1415
+ /** @param {DepositItem} item */
1416
+ const keyOf = (item) => `${item.to}:${item.amount}`;
1417
+ const aliases = new Map(
1418
+ [...groupBy(list.filter((item) => item.sender !== null), keyOf)].map(([key, items]) => [key, new Set(items.map((item) => evmAddressOf(/** @type {string} */ (item.sender)).toLowerCase()))])
1419
+ );
1420
+ /** @type {{ items: DepositItem[], rivals: Set<string> }[]} */
1421
+ const known = [];
1422
+ /** @type {{ items: DepositItem[], rivals: Set<string> }[]} */
1423
+ const unknown = [];
1424
+
1425
+ for (const [key, items] of groupBy(
1426
+ live.filter((item) => !results.has(item.hash)),
1427
+ keyOf
1428
+ )) {
1429
+ const rest = items.filter((item) => item.sender === null);
1430
+
1431
+ for (const group of groupBy(
1432
+ items.filter((item) => item.sender !== null),
1433
+ (item) => /** @type {string} */ (item.sender)
1434
+ ).values()) {
1435
+ known.push({ items: group, rivals: new Set() });
1436
+ }
1437
+
1438
+ if (rest.length) unknown.push({ items: rest, rivals: aliases.get(key) ?? new Set() });
1439
+ }
1440
+
1441
+ // The block each deposit given can't be credited before, the app's own.
1442
+ const bounds = new Map(list.map((item) => [item.hash, item.notBefore]));
1443
+
1444
+ for (const { items, rivals } of [...known, ...unknown]) {
1445
+ const { amount, sender, to } = items[0];
1446
+ const history = sender === null ? undefined : histories.get(sender);
1447
+ const vouched = Boolean(history) && items.every((item) => item.height !== null);
1448
+ const given = items.map(queued).sort(byTime);
1449
+ const alike = vouched
1450
+ ? /** @type {NonNullable<typeof history>} */ (history).deposits
1451
+ .filter((deposit) => deposit.to === to && deposit.amount === amount)
1452
+ .map((deposit) => ({ at: deposit.at, given: false, hash: deposit.hash, notBefore: bounds.get(deposit.hash) ?? null, scan: null }))
1453
+ : [];
1454
+ const queue = vouched
1455
+ ? queueOf(given, alike, DEPOSIT_HORIZON_MS)
1456
+ : withPast(
1457
+ given,
1458
+ past.filter((item) => item.to === to && item.amount === amount && item.sender === sender).map(queued),
1459
+ windowMs
1460
+ );
1461
+ /** @type {Set<string>} */
1462
+ const unsure = new Set();
1463
+
1464
+ // An equal deposit just under the history read could come first, and so
1465
+ // could one before it whose call can't be read: the credits' order
1466
+ // can't be told.
1467
+ if (history && vouched) {
1468
+ const bottomAt = history.bottom === 0 ? -Infinity : history.topAt - (history.top - history.bottom) * DUSK_BLOCK_MS;
1469
+
1470
+ for (const item of given) {
1471
+ const height = /** @type {number} */ (items.find((each) => each.hash === item.hash)?.height);
1472
+
1473
+ if (
1474
+ !history.complete ||
1475
+ runStart(queue, item, DEPOSIT_HORIZON_MS).at - bottomAt < DEPOSIT_HORIZON_MS ||
1476
+ history.unresolved.some((peer) => peer.value >= amount && peer.height <= height)
1477
+ ) {
1478
+ unsure.add(item.hash);
1479
+ }
1480
+ }
1481
+ }
1482
+
1483
+ try {
1484
+ const found = await followLogs(
1485
+ read,
1486
+ {
1487
+ filter: {
1488
+ address: bridge,
1489
+ amountOf: bridgeFinalizedAmount,
1490
+ topics: [ETH_BRIDGE_FINALIZED_TOPIC, sender === null ? null : topicOf(evmAddressOf(sender)), topicOf(to)],
1491
+ wei: amount * WEI_PER_LUX,
1492
+ },
1493
+ given,
1494
+ guarded: !vouched,
1495
+ queue,
1496
+ rivals,
1497
+ unsure,
1498
+ },
1499
+ { blockTimes, maxWindows, now, slackMs: CLOCK_SLACK_MS, url, used, windowMs }
1500
+ );
1501
+
1502
+ for (const [hash, arrival] of found) results.set(hash, arrival);
1503
+ } catch (error) {
1504
+ for (const item of items) results.set(item.hash, { error: messageOf(error), reason: "error", state: "unknown" });
1505
+ }
1506
+ }
1507
+ }
1508
+
1509
+ // ---------------------------------------------------------------------------
1510
+ // Transfers out to another chain
1511
+ // ---------------------------------------------------------------------------
1512
+
1513
+ /**
1514
+ * @param {unknown} error
1515
+ * @returns {Arrival}
1516
+ */
1517
+ const failed = (error) => ({ error: messageOf(error), reason: "error", state: "unknown" });
1518
+
1519
+ /**
1520
+ * @param {ServiceArrivalOptions} options
1521
+ * @returns {Promise<{ arrivals: Map<string, Arrival>, requests: number }>}
1522
+ */
1523
+ export async function serviceArrivals({
1524
+ network,
1525
+ transfers,
1526
+ archive,
1527
+ fetch: fetchFn,
1528
+ rpcUrl,
1529
+ taken,
1530
+ blockTimes,
1531
+ now = Date.now(),
1532
+ waitMs = WAIT_MS,
1533
+ maxWindows = LOG_WINDOWS,
1534
+ timeoutMs = TIMEOUT_MS,
1535
+ }) {
1536
+ if (!archive || typeof archive.query !== "function") {
1537
+ throw new TypeError("An archive is required: it gives each transfer, and those that named the same address before it");
1538
+ }
1539
+
1540
+ const net = networkOf(network);
1541
+ const services = routes(net).filter((route) => route.kind === "service");
1542
+ const list = uniqueByHash(Array.from(transfers ?? [], (transfer) => transferIn(transfer, services)));
1543
+ const read = reader(fetchFn, timeoutMs);
1544
+ /** @type {Map<string, Arrival>} */
1545
+ const results = new Map();
1546
+ const used = usedOf(taken, evmHashOf);
1547
+ const done = () => ({ arrivals: inOrder(list, results), requests: read.counter.requests });
1548
+ const routed = list.filter((item) => {
1549
+ const route = item.route;
1550
+
1551
+ if (!route || !route.payoutAddress || !route.chain.token) results.set(item.hash, { reason: "no-route", state: "unknown" });
1552
+
1553
+ return !results.has(item.hash);
1554
+ });
1555
+ /** @type {Map<string, TransferFacts>} */
1556
+ let sent;
1557
+
1558
+ // Each transfer as the chain has it: whom it paid, what the route's
1559
+ // account received, and the address its memo gives, as the route's
1560
+ // bridge reads them, not as the app does.
1561
+ try {
1562
+ sent = await transfersOf(archive, routed.map((item) => item.hash), read.counter);
1563
+ } catch (error) {
1564
+ for (const item of routed) results.set(item.hash, failed(error));
1565
+
1566
+ return done();
1567
+ }
1568
+
1569
+ /** @type {SentItem[]} */
1570
+ const known = [];
1571
+
1572
+ for (const item of routed) {
1573
+ const route = /** @type {Route & { kind: "service" }} */ (item.route);
1574
+ const facts = sent.get(item.hash);
1575
+ const fee = item.fee ?? route.feeLux;
1576
+
1577
+ // Not in a final block yet, as far as the archive knows.
1578
+ if (!facts) {
1579
+ results.set(item.hash, { state: "waiting" });
1580
+ continue;
1581
+ }
1582
+
1583
+ if (facts.kind === null || facts.at === null || facts.height === null || (facts.kind === "moonlight" && (facts.value === null || facts.receiver === null))) {
1584
+ results.set(item.hash, { reason: "unsure", state: "unknown" });
1585
+ continue;
1586
+ }
1587
+
1588
+ const value = /** @type {bigint} */ (facts.value);
1589
+ /** @type {"failed" | "elsewhere" | "under-fee" | "no-address" | null} */
1590
+ const problem = facts.failed
1591
+ ? "failed"
1592
+ : facts.kind !== "moonlight" || facts.receiver !== route.account
1593
+ ? "elsewhere"
1594
+ : value <= fee
1595
+ ? "under-fee"
1596
+ : EVM_ADDRESS.test(facts.memo)
1597
+ ? null
1598
+ : "no-address";
1599
+
1600
+ if (problem) results.set(item.hash, { problem, reason: "no-payout", state: "unknown" });
1601
+ else known.push({ ...item, at: facts.at, fee, height: facts.height, net: value - fee, route, to: facts.memo.toLowerCase() });
1602
+ }
1603
+
1604
+ const live = triage(known, results, { now, waitMs });
1605
+ /** @type {Map<string, { complete: boolean, entries: HistoryEntry[], bottom: number, top: number, topAt: number, horizonMs: number }>} */
1606
+ const histories = new Map();
1607
+
1608
+ // Each route's account's history, back from its transfers: every transfer
1609
+ // that named the same address, in any case, and could be paid before.
1610
+ for (const [id, items] of groupBy(live, (item) => item.route.id)) {
1611
+ const { route } = items[0];
1612
+ const horizonMs = horizonOf(route);
1613
+ const heights = items.map((item) => item.height);
1614
+ const top = Math.max(...heights);
1615
+ const bottom = Math.max(0, Math.min(...heights) - Math.ceil((3 * horizonMs) / DUSK_BLOCK_MS));
1616
+
1617
+ try {
1618
+ const range = await historyRange(archive, { account: route.account, direction: "in", from: bottom, pages: HISTORY_PAGES, to: top });
1619
+
1620
+ read.counter.requests += range.requests;
1621
+ histories.set(id, { bottom, complete: range.complete, entries: range.entries, horizonMs, top, topAt: /** @type {SentItem} */ (items.find((item) => item.height === top)).at });
1622
+ } catch (error) {
1623
+ for (const item of items) results.set(item.hash, failed(error));
1624
+ }
1625
+ }
1626
+
1627
+ const groups = [...groupBy(live.filter((item) => histories.has(item.route.id)), (item) => `${item.route.id}:${item.to}:${item.net}`).values()];
1628
+ const polled = new Set(groups.flat().map((item) => item.hash));
1629
+ // The fee of each transfer the app gave, those no longer looked for too,
1630
+ // and the block its payout can't be before.
1631
+ const feeOf = new Map(routed.map((item) => [item.hash, item.fee ?? /** @type {Route & { kind: "service" }} */ (item.route).feeLux]));
1632
+ const bounds = new Map(routed.map((item) => [item.hash, item.notBefore]));
1633
+ // Each group's others: what the account received, at least as much as the
1634
+ // group's transfers, naming the same address, and the app's transfers no
1635
+ // longer looked for. What each paid whom, its memo and its time come from
1636
+ // its own transaction.
1637
+ const others = groups.map((items) => {
1638
+ const { route, to } = items[0];
1639
+ const least = items.reduce((a, item) => (item.net + item.fee < a ? item.net + item.fee : a), MAX_U64);
1640
+
1641
+ return (/** @type {NonNullable<ReturnType<typeof histories.get>>} */ (histories.get(route.id)).entries).filter(
1642
+ (entry) => !polled.has(entry.hash) && entry.kind === "transfer" && entry.direction === "in" && entry.memo.toLowerCase() === to && (entry.value >= least || feeOf.has(entry.hash))
1643
+ );
1644
+ });
1645
+ /** @type {Map<string, TransferFacts>} */
1646
+ let peers;
1647
+
1648
+ try {
1649
+ peers = new Map([...sent, ...(await transfersOf(archive, others.flat().flatMap((entry) => (sent.has(entry.hash) ? [] : [entry.hash])), read.counter))]);
1650
+ } catch (error) {
1651
+ for (const items of groups) for (const item of items) results.set(item.hash, failed(error));
1652
+
1653
+ return done();
1654
+ }
1655
+
1656
+ for (const [i, items] of groups.entries()) {
1657
+ const { net: payout, route, to } = items[0];
1658
+ const history = /** @type {NonNullable<ReturnType<typeof histories.get>>} */ (histories.get(route.id));
1659
+ const fees = new Set(items.map((item) => item.fee));
1660
+ /** @type {Queued[]} */
1661
+ const alike = [];
1662
+ // The heights of transfers to the same address that can't be read.
1663
+ /** @type {number[]} */
1664
+ const unread = [];
1665
+
1666
+ for (const entry of others[i]) {
1667
+ const facts = peers.get(entry.hash);
1668
+
1669
+ if (!facts || facts.kind === null || facts.at === null || (facts.kind === "moonlight" && (facts.value === null || facts.receiver === null))) {
1670
+ unread.push(entry.blockHeight);
1671
+ } else if (!facts.failed && facts.kind === "moonlight" && facts.receiver === route.account && facts.memo.toLowerCase() === to) {
1672
+ const value = /** @type {bigint} */ (facts.value);
1673
+ // One the app gave pays out what its own fee leaves; one it didn't,
1674
+ // what the group's do.
1675
+ const own = feeOf.get(entry.hash);
1676
+
1677
+ if (own === undefined ? [...fees].some((fee) => value - fee === payout) : value - own === payout) {
1678
+ alike.push({ at: facts.at, given: false, hash: entry.hash, notBefore: bounds.get(entry.hash) ?? null, scan: null });
1679
+ }
1680
+ }
1681
+ }
1682
+
1683
+ const given = items.map(queued).sort(byTime);
1684
+ const queue = queueOf(given, alike, history.horizonMs);
1685
+ const bottomAt = history.bottom === 0 ? -Infinity : history.topAt - (history.top - history.bottom) * DUSK_BLOCK_MS;
1686
+ /** @type {Set<string>} */
1687
+ const unsure = new Set();
1688
+
1689
+ // A history cut short, an equal transfer just under it, or one before
1690
+ // that can't be read: the payouts' order can't be told.
1691
+ for (const item of given) {
1692
+ const height = /** @type {SentItem} */ (items.find((each) => each.hash === item.hash)).height;
1693
+
1694
+ if (!history.complete || runStart(queue, item, history.horizonMs).at - bottomAt < history.horizonMs || unread.some((each) => each <= height)) unsure.add(item.hash);
1695
+ }
1696
+
1697
+ try {
1698
+ const found = await followLogs(
1699
+ read,
1700
+ {
1701
+ filter: {
1702
+ address: /** @type {string} */ (route.chain.token).toLowerCase(),
1703
+ amountOf: transferValue,
1704
+ topics: [TRANSFER_TOPIC, topicOf(/** @type {string} */ (route.payoutAddress)), topicOf(to)],
1705
+ wei: payout * WEI_PER_LUX,
1706
+ },
1707
+ given,
1708
+ guarded: false,
1709
+ queue,
1710
+ unsure,
1711
+ },
1712
+ { blockTimes, maxWindows, now, slackMs: CLOCK_SLACK_MS, url: String(rpcUrl ?? route.chain.rpcUrl), used, windowMs: CREDIT_WINDOW_MS }
1713
+ );
1714
+
1715
+ for (const [hash, arrival] of found) results.set(hash, arrival);
1716
+ } catch (error) {
1717
+ for (const item of items) results.set(item.hash, failed(error));
1718
+ }
1719
+ }
1720
+
1721
+ return done();
1722
+ }
1723
+
1724
+ // ---------------------------------------------------------------------------
1725
+ // Migrations into Dusk
1726
+ // ---------------------------------------------------------------------------
1727
+
1728
+ /**
1729
+ * The payouts of migrations a transfer the archive lists holds for
1730
+ * `account`, read from its own events: from a migration route's payout
1731
+ * account, with a transaction hash as the memo, each of its own amount, a
1732
+ * gas refund aside.
1733
+ *
1734
+ * @param {any} raw
1735
+ * @param {string} account
1736
+ * @param {Network} net
1737
+ * @returns {MigrationPayout[]}
1738
+ */
1739
+ function payoutsIn(raw, account, net) {
1740
+ const payout = duskHashOf(raw?.origin);
1741
+ const block = heightOf(Number(raw?.block_height));
1742
+
1743
+ if (!payout || block === null) return [];
1744
+
1745
+ return (Array.isArray(raw?.events) ? raw.events : []).flatMap((/** @type {any} */ event) => {
1746
+ const data = event?.data;
1747
+ const route = data && event.reverted !== true && event.topic === "moonlight" && String(event.target ?? "").toLowerCase() === TRANSFER_CONTRACT && data.receiver === account && typeof data.sender === "string" ? routeForAccount(net, data.sender, "in") : null;
1748
+ const memo = memoOf(data?.memo).trim().toLowerCase();
1749
+ const amount = luxOf(data?.value);
1750
+
1751
+ return route?.kind === "migration" && route.payoutAccount && EVM_HASH.test(memo) && amount !== null
1752
+ ? [{ amount, at: null, block, hash: /** @type {`0x${string}`} */ (memo), payout, route: route.id }]
1753
+ : [];
1754
+ });
1755
+ }
1756
+
1757
+ /**
1758
+ * @param {MigrationPayoutOptions} options
1759
+ * @returns {Promise<{ payouts: Map<string, Arrival>, unlisted: MigrationPayout[], requests: number }>}
1760
+ */
1761
+ export async function migrationPayouts({ network, migrations, entries, archive, account, taken, now = Date.now(), waitMs = WAIT_MS }) {
1762
+ const net = networkOf(network);
1763
+ const kinds = routes(net).filter((route) => route.kind === "migration");
1764
+ const list = uniqueByHash(
1765
+ Array.from(migrations ?? [], (migration) => {
1766
+ const hash = evmHashOf(migration?.hash);
1767
+ const amount = luxOf(migration?.amount);
1768
+
1769
+ if (!hash) throw new TypeError("A migration's hash is its transaction's on its chain: 0x and 64 hex digits");
1770
+ if (amount === null) throw new TypeError("A migration's amount is lux: at most a u64");
1771
+
1772
+ return { amount, at: msOf(migration.at), hash, route: kinds.find((route) => route.id === migration?.route) };
1773
+ })
1774
+ );
1775
+ /** @type {Map<string, Arrival>} */
1776
+ const results = new Map();
1777
+ const counter = { requests: 0 };
1778
+ const used = usedOf(taken, duskHashOf);
1779
+ const payable = list.filter((item) => {
1780
+ if (!item.route || item.route.kind !== "migration" || !item.route.payoutAccount) results.set(item.hash, { reason: "no-route", state: "unknown" });
1781
+
1782
+ return !results.has(item.hash);
1783
+ });
1784
+ /** @type {MigrationPayout[]} */
1785
+ let candidates;
1786
+
1787
+ if (entries) {
1788
+ candidates = Array.from(entries, (entry) => migrationPayoutOf(entry, net)).filter((payout) => payout !== null);
1789
+ } else {
1790
+ if (!archive || typeof archive.query !== "function") throw new TypeError("Either the account's entries or an archive is required");
1791
+ if (!isAccount(account)) throw new TypeError("The archive is read for a Dusk account: the one the migrations pay");
1792
+
1793
+ // Not looked for once waited for too long.
1794
+ const asked = payable.filter((item) => item.at === null || now - item.at <= waitMs);
1795
+
1796
+ try {
1797
+ const listed = await byMemo(archive, asked.map((item) => item.hash), counter);
1798
+ const found = [...listed.values()].flat().flatMap((raw) => payoutsIn(raw, /** @type {string} */ (account), net));
1799
+ // Their times, and whether they went through.
1800
+ const details = await detailsOf(archive, found.map((payout) => payout.payout), {}, counter);
1801
+
1802
+ candidates = found.flatMap((payout) => {
1803
+ const known = details.get(payout.payout);
1804
+
1805
+ return known?.success === false ? [] : [{ ...payout, at: msOf(known?.timestamp) }];
1806
+ });
1807
+ } catch (error) {
1808
+ for (const item of asked) results.set(item.hash, { error: messageOf(error), reason: "error", state: "unknown" });
1809
+ candidates = [];
1810
+ }
1811
+ }
1812
+
1813
+ // Each payout pays one migration: the migration its memo names, of the
1814
+ // amount it paid, on the route whose payout account paid it.
1815
+ const byBlock = [...candidates].sort((a, b) => a.block - b.block || (a.payout < b.payout ? -1 : 1));
1816
+
1817
+ for (const item of [...payable].sort((a, b) => (a.at ?? Infinity) - (b.at ?? Infinity))) {
1818
+ if (results.has(item.hash)) continue;
1819
+
1820
+ const named = byBlock.filter((each) => !used.has(each.payout) && each.hash === item.hash && each.route === item.route?.id);
1821
+ const payout = named.find((each) => each.amount === item.amount);
1822
+
1823
+ if (payout) {
1824
+ used.add(payout.payout);
1825
+ results.set(item.hash, { amount: payout.amount, at: payout.at, block: payout.block, hash: payout.payout, state: "arrived" });
1826
+ } else if (named.length) {
1827
+ // Its payout, of another amount: an entry's value holds a gas refund
1828
+ // paid to the account, which it doesn't tell apart, or another amount
1829
+ // was paid. Not waiting: unsure.
1830
+ results.set(item.hash, { reason: "unsure", state: "unknown" });
1831
+ } else {
1832
+ results.set(item.hash, item.at !== null && now - item.at > waitMs ? { reason: "expired", state: "unknown" } : { state: "waiting" });
1833
+ }
1834
+ }
1835
+
1836
+ // Payouts of migrations the app didn't list: sent from another device, or before.
1837
+ const named = new Set(list.map((item) => item.hash));
1838
+ const unlisted = entries ? byBlock.filter((payout) => !named.has(payout.hash) && !used.has(payout.payout)) : [];
1839
+
1840
+ return { payouts: inOrder(list, results), requests: counter.requests, unlisted };
1841
+ }
1842
+
1843
+ // ---------------------------------------------------------------------------
1844
+ // History entries as the bridge's transfers
1845
+ // ---------------------------------------------------------------------------
1846
+
1847
+ /**
1848
+ * @param {HistoryLike} entry
1849
+ * @returns {{ hash: string, value: bigint | null, at: number | null, height: number | null }}
1850
+ */
1851
+ const factsOf = (entry) => ({ at: msOf(entry?.timestamp), hash: duskHashOf(entry?.hash), height: heightOf(entry?.blockHeight), value: luxOf(entry?.value) });
1852
+
1853
+ /**
1854
+ * @param {HistoryLike} entry
1855
+ * @param {string} [account]
1856
+ * @returns {Deposit | null}
1857
+ */
1858
+ export function depositOf(entry, account) {
1859
+ const bridge = /** @type {any} */ (entry)?.bridge;
1860
+ const { at, hash, height } = factsOf(entry);
1861
+ const amount = luxOf(bridge?.amount);
1862
+ const to = typeof bridge?.to === "string" && EVM_ADDRESS.test(bridge.to) ? bridge.to : "";
1863
+
1864
+ if (bridge?.kind !== "deposit" || !entry.payer || entry.success === false || !hash || amount === null || amount === 0n || !to) return null;
1865
+
1866
+ return { amount, at, hash, height, sender: account && isAccount(account) ? account : null, to };
1867
+ }
1868
+
1869
+ /**
1870
+ * @param {HistoryLike} entry
1871
+ * @param {NetworkRef} network
1872
+ * @returns {ServiceTransferFacts | null}
1873
+ */
1874
+ export function serviceTransferOf(entry, network) {
1875
+ const net = networkOf(network);
1876
+ const route = entry?.kind === "transfer" && entry.direction === "out" && typeof entry.counterparty === "string" ? routeForAccount(net, entry.counterparty, "out") : null;
1877
+ const { at, hash, height, value } = factsOf(entry);
1878
+
1879
+ if (!route || route.kind !== "service" || !hash || value === null) return null;
1880
+
1881
+ // What left the account holds any gas refund it paid another account,
1882
+ // which the entry doesn't tell apart: what the route's account received
1883
+ // is known only where no gas was left to refund.
1884
+ const limit = luxOf(entry.gasLimit);
1885
+ const spent = luxOf(entry.gasSpent);
1886
+ const price = luxOf(entry.gasPrice);
1887
+ const amount = limit !== null && spent !== null && price !== null && (limit - spent) * price <= 0n ? value : null;
1888
+ // The address the memo gives, exactly as the bridge reads it.
1889
+ const memo = typeof entry.memo === "string" ? entry.memo : "";
1890
+ const to = EVM_ADDRESS.test(memo) ? memo : null;
1891
+ const problem = entry.success === false ? "failed" : value <= route.feeLux ? "under-fee" : to === null ? "no-address" : null;
1892
+
1893
+ return { amount, at, fee: route.feeLux, hash, height, problem, route: route.id, sent: value, to };
1894
+ }
1895
+
1896
+ /**
1897
+ * @param {HistoryLike} entry
1898
+ * @param {NetworkRef} network
1899
+ * @returns {MigrationPayout | null}
1900
+ */
1901
+ export function migrationPayoutOf(entry, network) {
1902
+ const net = networkOf(network);
1903
+ const route = entry?.kind === "transfer" && entry.direction === "in" && typeof entry.counterparty === "string" ? routeForAccount(net, entry.counterparty, "in") : null;
1904
+ const { at, hash, height, value } = factsOf(entry);
1905
+ const memo = String(entry?.memo ?? "")
1906
+ .trim()
1907
+ .toLowerCase();
1908
+
1909
+ if (!route || route.kind !== "migration" || !route.payoutAccount || entry.success === false || !hash || height === null || value === null || !EVM_HASH.test(memo)) {
1910
+ return null;
1911
+ }
1912
+
1913
+ return { amount: value, at, block: height, hash: /** @type {`0x${string}`} */ (memo), payout: hash, route: route.id };
1914
+ }
1915
+
1916
+ /**
1917
+ * @param {ReadonlyMap<string, Arrival>} arrivals
1918
+ * @returns {Map<string, { hash: string, at: number | null }>}
1919
+ */
1920
+ export function arrivalsOf(arrivals) {
1921
+ /** @type {Map<string, { hash: string, at: number | null }>} */
1922
+ const out = new Map();
1923
+
1924
+ for (const [hash, arrival] of arrivals) if (arrival.state === "arrived") out.set(hash, { at: arrival.at, hash: arrival.hash });
1925
+
1926
+ return out;
1927
+ }