@dusk-network/app-core 0.4.1 → 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.
@@ -0,0 +1,639 @@
1
+ /**
2
+ * What `/bridge` and `/bridge-explorer` share: values read from the chains
3
+ * and the app, requests with the app's fetch, the network a read takes, and
4
+ * the matching of credits to transfers. Not an entry point.
5
+ *
6
+ * @module
7
+ */
8
+
9
+ import { networkFor } from "@dusk-network/networks";
10
+ import { isAccount } from "@dusk-network/networks/account";
11
+
12
+ /** @typedef {import("./bridge.js").Arrival} Arrival */
13
+ /** @typedef {import("./bridge.js").BridgeNetwork} BridgeNetwork */
14
+ /** @typedef {import("./bridge.js").Deposit} Deposit */
15
+ /** @typedef {import("./bridge.js").FetchLike} FetchLike */
16
+ /** @typedef {import("./bridge.js").Scan} Scan */
17
+ /** @typedef {import("@dusk-network/networks").Network} Network */
18
+
19
+ /**
20
+ * A transfer as the matching sees it: when it was sent, the block on the
21
+ * other chain its credit can't be before, and whether it is one of the
22
+ * app's own (`given`) or one read from the chain.
23
+ *
24
+ * @typedef {{ hash: string, at: number, notBefore: bigint | null, given: boolean, scan: Scan | null }} Queued
25
+ */
26
+
27
+ /**
28
+ * A credit on the other chain: its transaction, its block and time, its
29
+ * amount in wei, whether it went through, and the address it came from as
30
+ * its log names it (lower case; null where it isn't named). Found by an RPC
31
+ * search, the passes over its blocks again done before it was (`pass`).
32
+ *
33
+ * @typedef {{ hash: string, block: bigint | null, at: number, wei: bigint, success: boolean, from: string | null, pass?: number }} Credit
34
+ */
35
+
36
+ /**
37
+ * What a group's matching gave each transfer.
38
+ *
39
+ * @typedef {"arrived" | "waiting" | "unsure" | "late"} Verdict
40
+ */
41
+
42
+ export const CREDIT_WINDOW_MS = 120_000;
43
+ export const WAIT_MS = 7 * 24 * 60 * 60_000;
44
+ export const EXPLORER_PAGES = 5;
45
+
46
+ export const WEI_PER_LUX = 1_000_000_000n;
47
+ const MAX_U64 = (1n << 64n) - 1n;
48
+ export const TIMEOUT_MS = 8_000;
49
+ const DUSK_HASH = /^[0-9a-f]{64}$/;
50
+ export const EVM_HASH = /^0x[0-9a-f]{64}$/;
51
+ export const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/;
52
+ // As a network file's schema has them: http(s), without a trailing slash,
53
+ // and a contract ID's 64 lower-case hex digits.
54
+ const URL_TEXT = /^https?:\/\/[^\s]*[^/\s]$/;
55
+ const CONTRACT_ID = /^[0-9a-f]{64}$/;
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Values
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Lux from a bigint, a safe integer or its decimal text: null for anything
63
+ * else, and past a u64.
64
+ *
65
+ * @param {unknown} value
66
+ * @returns {bigint | null}
67
+ */
68
+ export function luxOf(value) {
69
+ /** @type {bigint} */
70
+ let n;
71
+
72
+ if (typeof value === "bigint") n = value;
73
+ else if (typeof value === "number" && Number.isSafeInteger(value)) n = BigInt(value);
74
+ else if (typeof value === "string" && /^\d+$/.test(value.trim())) n = BigInt(value.trim());
75
+ else return null;
76
+
77
+ return n >= 0n && n <= MAX_U64 ? n : null;
78
+ }
79
+
80
+ /**
81
+ * A block number or a time as JSON-RPC writes it (hex), or as decimal: null
82
+ * for anything else.
83
+ *
84
+ * @param {unknown} value
85
+ * @returns {bigint | null}
86
+ */
87
+ export function quantityOf(value) {
88
+ if (typeof value === "bigint") return value >= 0n ? value : null;
89
+ if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null;
90
+
91
+ const text = String(value ?? "").trim();
92
+
93
+ return /^0x[0-9a-fA-F]+$/.test(text) || /^\d+$/.test(text) ? BigInt(text) : null;
94
+ }
95
+
96
+ /**
97
+ * A Dusk transaction's hash, lower case, without 0x; "" for anything else.
98
+ *
99
+ * @param {unknown} value
100
+ */
101
+ export const duskHashOf = (value) => {
102
+ const hash = String(value ?? "")
103
+ .trim()
104
+ .toLowerCase()
105
+ .replace(/^0x/, "");
106
+
107
+ return DUSK_HASH.test(hash) ? hash : "";
108
+ };
109
+
110
+ /**
111
+ * An EVM transaction's hash, lower case; "" for anything else.
112
+ *
113
+ * @param {unknown} value
114
+ */
115
+ export const evmHashOf = (value) => {
116
+ const hash = String(value ?? "")
117
+ .trim()
118
+ .toLowerCase();
119
+
120
+ return EVM_HASH.test(hash) ? hash : "";
121
+ };
122
+
123
+ /**
124
+ * An EVM address, lower case; "" for anything else.
125
+ *
126
+ * @param {unknown} value
127
+ */
128
+ export const addressOf = (value) => (typeof value === "string" && EVM_ADDRESS.test(value) ? value.toLowerCase() : "");
129
+
130
+ /**
131
+ * @param {unknown} error
132
+ * @returns {string}
133
+ */
134
+ export const messageOf = (error) => String(/** @type {any} */ (error)?.message ?? error ?? "");
135
+
136
+ /**
137
+ * A time in milliseconds, or null.
138
+ *
139
+ * @param {unknown} value
140
+ */
141
+ export const msOf = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null);
142
+
143
+ /**
144
+ * A height, or null.
145
+ *
146
+ * @param {unknown} value
147
+ */
148
+ export const heightOf = (value) => (typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null);
149
+
150
+ /**
151
+ * @template T
152
+ * @param {Iterable<T>} items
153
+ * @param {(item: T) => string} keyOf
154
+ * @returns {Map<string, T[]>}
155
+ */
156
+ export function groupBy(items, keyOf) {
157
+ /** @type {Map<string, T[]>} */
158
+ const out = new Map();
159
+
160
+ for (const item of items) {
161
+ const key = keyOf(item);
162
+
163
+ out.set(key, [...(out.get(key) ?? []), item]);
164
+ }
165
+
166
+ return out;
167
+ }
168
+
169
+ /**
170
+ * Oldest first, then by hash, so the order is the same everywhere.
171
+ *
172
+ * @param {{ at: number, hash: string, block?: bigint | null }} a
173
+ * @param {{ at: number, hash: string, block?: bigint | null }} b
174
+ */
175
+ export const byTime = (a, b) =>
176
+ 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);
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // The network
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /** What a network an app built from its settings doesn't say, it has none of. */
183
+ const NO_DISPLAY = Object.freeze({ blocklist: Object.freeze([]), labels: Object.freeze({}) });
184
+ const NO_ROUTES = Object.freeze({
185
+ chains: Object.freeze({}),
186
+ duskEvm: Object.freeze([]),
187
+ migrations: Object.freeze([]),
188
+ retired: Object.freeze([]),
189
+ services: Object.freeze([]),
190
+ });
191
+
192
+ /**
193
+ * @param {boolean} ok
194
+ * @param {string} message
195
+ */
196
+ function check(ok, message) {
197
+ if (!ok) throw new TypeError(message);
198
+ }
199
+
200
+ /**
201
+ * The network a read takes: a network file, by its chain ID or as it is, or
202
+ * one an app built from its own settings, for a chain there's no file for.
203
+ * An object's fields that the reads take are checked as a network file's
204
+ * are: its chain ID, and DuskEVM's bridge address, RPC and explorer API
205
+ * (http(s), without a trailing slash) and its contracts' IDs on Dusk; what
206
+ * it doesn't say, as routes to other chains, it has none of. A TypeError
207
+ * for anything else.
208
+ *
209
+ * @param {BridgeNetwork} network
210
+ * @returns {Network}
211
+ */
212
+ export function networkOf(network) {
213
+ if (!network || typeof network !== "object") {
214
+ const net = networkFor(/** @type {number | bigint | string} */ (network));
215
+
216
+ if (!net) throw new TypeError("Not one of the networks");
217
+
218
+ return net;
219
+ }
220
+
221
+ const given = /** @type {any} */ (network);
222
+ const evm = given.duskEvm;
223
+
224
+ check(Number.isSafeInteger(given.chainId) && given.chainId >= 0, "A network's chainId is a whole number");
225
+ check(given.routes === undefined || (given.routes !== null && typeof given.routes === "object"), "A network's routes are as a network file has them");
226
+
227
+ if (evm !== undefined && evm !== null) {
228
+ check(typeof evm === "object", "A network's duskEvm is an object, or null");
229
+ check(typeof evm.predeploys?.l2StandardBridge === "string" && EVM_ADDRESS.test(evm.predeploys.l2StandardBridge), "A network's DuskEVM bridge (duskEvm.predeploys.l2StandardBridge) is 0x and 40 hex digits");
230
+
231
+ for (const key of ["rpcUrl", "blockscoutApiUrl"]) {
232
+ check(evm[key] === undefined || (typeof evm[key] === "string" && URL_TEXT.test(evm[key])), `A network's duskEvm.${key} is an http(s) URL, without a trailing slash`);
233
+ }
234
+
235
+ check(
236
+ evm.contracts === undefined || (evm.contracts !== null && typeof evm.contracts === "object" && Object.values(evm.contracts).every((each) => typeof each?.contractId === "string" && CONTRACT_ID.test(each.contractId))),
237
+ "A network's DuskEVM contracts (duskEvm.contracts) each have a contractId: 64 lower-case hex digits"
238
+ );
239
+ }
240
+
241
+ if (given.display && given.routes && evm !== undefined) return given;
242
+
243
+ return { ...given, display: given.display ?? NO_DISPLAY, duskEvm: evm ?? null, routes: given.routes ?? NO_ROUTES };
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Requests
248
+ // ---------------------------------------------------------------------------
249
+
250
+ /**
251
+ * @param {string} url
252
+ * @returns {string}
253
+ */
254
+ function hostOf(url) {
255
+ try {
256
+ return new URL(url).host;
257
+ } catch {
258
+ return url;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * The app's fetch, each request within `timeoutMs`, counted.
264
+ *
265
+ * @param {FetchLike | undefined} fetchFn
266
+ * @param {number} timeoutMs
267
+ */
268
+ export function reader(fetchFn, timeoutMs) {
269
+ const counter = { requests: 0 };
270
+
271
+ /**
272
+ * @param {string} url
273
+ * @param {{ method: "GET" | "POST", headers: Record<string, string>, body?: string }} init
274
+ * @returns {Promise<{ ok: boolean, status: number, body: any, host: string }>}
275
+ */
276
+ async function exchange(url, init) {
277
+ const controller = new AbortController();
278
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
279
+ const host = hostOf(url);
280
+
281
+ counter.requests += 1;
282
+
283
+ try {
284
+ const send = fetchFn ?? globalThis.fetch;
285
+ const response = await send(url, { ...init, signal: controller.signal });
286
+ const text = await response.text();
287
+ /** @type {any} */
288
+ let body;
289
+
290
+ try {
291
+ body = JSON.parse(text);
292
+ } catch {
293
+ throw new Error(response.ok ? `${host} didn't answer with JSON` : `${host} answers with HTTP ${response.status}`);
294
+ }
295
+
296
+ return { body, host, ok: response.ok, status: response.status };
297
+ } catch (error) {
298
+ if (controller.signal.aborted) throw new Error(`${host} didn't answer within ${Math.round(timeoutMs / 100) / 10} s`);
299
+
300
+ throw error;
301
+ } finally {
302
+ clearTimeout(timer);
303
+ }
304
+ }
305
+
306
+ return {
307
+ counter,
308
+ // What a call reads once for all its searches, by what and where: a
309
+ // chain's head and pace.
310
+ /** @type {Map<string, Promise<any>>} */
311
+ once: new Map(),
312
+
313
+ /**
314
+ * @param {string} url
315
+ * @returns {Promise<any>}
316
+ */
317
+ async getJson(url) {
318
+ const answer = await exchange(url, { headers: { accept: "application/json" }, method: "GET" });
319
+
320
+ if (!answer.ok) throw new Error(`${answer.host} answers with HTTP ${answer.status}`);
321
+
322
+ return answer.body;
323
+ },
324
+
325
+ /**
326
+ * One JSON-RPC call; its error's message, with its code, when it refuses.
327
+ *
328
+ * @param {string} url
329
+ * @param {string} method
330
+ * @param {unknown[]} params
331
+ * @returns {Promise<any>}
332
+ */
333
+ async rpc(url, method, params) {
334
+ const answer = await exchange(url, {
335
+ body: JSON.stringify({ id: 1, jsonrpc: "2.0", method, params }),
336
+ headers: { "content-type": "application/json" },
337
+ method: "POST",
338
+ });
339
+ const error = answer.body?.error;
340
+
341
+ if (error) throw Object.assign(new Error(String(error.message ?? `${method} was refused`)), { code: error.code });
342
+ if (!answer.ok) throw new Error(`${answer.host} answers ${method} with HTTP ${answer.status}`);
343
+
344
+ return answer.body?.result;
345
+ },
346
+ };
347
+ }
348
+
349
+ /** @typedef {ReturnType<typeof reader>} Reader */
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Matching: each credit pays one transfer, in the order they were sent
353
+ // ---------------------------------------------------------------------------
354
+
355
+ /**
356
+ * Transfers whose windows touch, each window from `windowMs` before its
357
+ * time to `windowMs` after: a credit in one could be another's.
358
+ *
359
+ * @param {ReadonlyArray<Queued>} queue oldest first
360
+ * @param {number} windowMs
361
+ * @returns {Queued[][]}
362
+ */
363
+ function clustersOf(queue, windowMs) {
364
+ /** @type {Queued[][]} */
365
+ const out = [];
366
+
367
+ for (const item of queue) {
368
+ const last = out[out.length - 1];
369
+
370
+ if (last && item.at - windowMs <= last[last.length - 1].at + windowMs) last.push(item);
371
+ else out.push([item]);
372
+ }
373
+
374
+ return out;
375
+ }
376
+
377
+ /**
378
+ * Whether every window of the queue is still open: nothing in it can be
379
+ * told apart yet.
380
+ *
381
+ * @param {ReadonlyArray<Queued>} queue
382
+ * @param {number} windowMs
383
+ * @param {number} now
384
+ */
385
+ export const allOpen = (queue, windowMs, now) => clustersOf(queue, windowMs).every((cluster) => now < cluster[cluster.length - 1].at + windowMs);
386
+
387
+ /**
388
+ * How a group's credits are matched: see `settle`.
389
+ *
390
+ * @typedef {{ guarded: boolean, now: number, slackMs: number, used: ReadonlySet<string>, windowMs: number, rivals?: ReadonlySet<string> }} Rules
391
+ */
392
+
393
+ /**
394
+ * Whether a credit could pay a transfer, whether or not another one took it:
395
+ * it went through, isn't one the app gave another of its transfers when
396
+ * this one is the app's, comes from none of `rivals`, and is in the
397
+ * transfer's time and at or after the block it can't be before.
398
+ *
399
+ * @param {Queued} item
400
+ * @param {Credit} credit
401
+ * @param {Rules} rules
402
+ */
403
+ export function pays(item, credit, { guarded, rivals = new Set(), slackMs, used, windowMs }) {
404
+ const lo = item.at - (guarded ? windowMs : slackMs);
405
+ const hi = guarded ? item.at + windowMs : Infinity;
406
+
407
+ return (
408
+ credit.success &&
409
+ (!item.given || !used.has(credit.hash)) &&
410
+ (credit.from === null || !rivals.has(credit.from)) &&
411
+ credit.at >= lo &&
412
+ credit.at <= hi &&
413
+ (item.notBefore === null || credit.block === null || credit.block >= item.notBefore)
414
+ );
415
+ }
416
+
417
+ /**
418
+ * Each transfer of a queue of equal ones, oldest first, takes the first
419
+ * credit that went through after it that no earlier one took: from
420
+ * `slackMs` before it on, and at or after the block it can't be before.
421
+ * None comes from an address of `rivals`: the senders of other transfers
422
+ * the app gave, whose credits these could be. A credit the app gave another
423
+ * of its transfers (`used`) is none of the app's own waiting ones; a
424
+ * transfer read from the chain may have it, as the one it paid comes back
425
+ * among them.
426
+ *
427
+ * Where the queue may not hold every transfer that could have made the
428
+ * credits (`guarded`), a credit is a transfer's only within `windowMs`
429
+ * either side of it, and once the windows that touch its own are over,
430
+ * only when every payment of its amount in them (those that failed too) is
431
+ * one of those transfers' credits, or one the app gave another. Otherwise
432
+ * it could be another transfer's: unsure. A transfer whose windows closed
433
+ * without a credit is late: one coming later can't be told apart.
434
+ *
435
+ * @param {ReadonlyArray<Queued>} queue oldest first
436
+ * @param {ReadonlyArray<Credit>} credits
437
+ * @param {Rules} rules
438
+ * @returns {{ assigned: Map<string, Credit>, verdicts: Map<string, Verdict> }}
439
+ */
440
+ export function settle(queue, credits, rules) {
441
+ const { guarded, now, used, windowMs } = rules;
442
+ const sorted = [...credits].sort(byTime);
443
+ /** @type {Map<string, Credit>} */
444
+ const assigned = new Map();
445
+ const taken = new Set();
446
+ /** @type {Map<string, Verdict>} */
447
+ const verdicts = new Map();
448
+
449
+ for (const item of queue) {
450
+ const credit = sorted.find((c) => !taken.has(c.hash) && pays(item, c, rules));
451
+
452
+ if (credit) {
453
+ assigned.set(item.hash, credit);
454
+ taken.add(credit.hash);
455
+ }
456
+ }
457
+
458
+ if (!guarded) {
459
+ for (const item of queue) verdicts.set(item.hash, assigned.has(item.hash) ? "arrived" : "waiting");
460
+
461
+ return { assigned, verdicts };
462
+ }
463
+
464
+ for (const cluster of clustersOf(queue, windowMs)) {
465
+ const lo = cluster[0].at - windowMs;
466
+ const hi = cluster[cluster.length - 1].at + windowMs;
467
+ const own = new Set(cluster.flatMap((item) => (assigned.has(item.hash) ? [/** @type {Credit} */ (assigned.get(item.hash)).hash] : [])));
468
+ const stray = sorted.some((c) => c.at >= lo && c.at <= hi && !used.has(c.hash) && !own.has(c.hash));
469
+
470
+ for (const item of cluster) {
471
+ verdicts.set(item.hash, now < hi ? "waiting" : stray ? "unsure" : assigned.has(item.hash) ? "arrived" : "late");
472
+ }
473
+ }
474
+
475
+ return { assigned, verdicts };
476
+ }
477
+
478
+ /**
479
+ * A queue of equal transfers where the credits can't be told apart, with
480
+ * the ones the app gave but no longer looks for (waited for too long) whose
481
+ * windows touch theirs: those were sent first, and take their credits
482
+ * first.
483
+ *
484
+ * @param {ReadonlyArray<Queued>} given
485
+ * @param {ReadonlyArray<Queued>} past
486
+ * @param {number} windowMs
487
+ * @returns {Queued[]}
488
+ */
489
+ export function withPast(given, past, windowMs) {
490
+ const polled = new Set(given.map((item) => item.hash));
491
+
492
+ return clustersOf([...given, ...past].sort(byTime), windowMs)
493
+ .filter((cluster) => cluster.some((item) => polled.has(item.hash)))
494
+ .flat();
495
+ }
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // Inputs
499
+ // ---------------------------------------------------------------------------
500
+
501
+ /**
502
+ * A deposit as given, checked.
503
+ *
504
+ * @typedef {{ amount: bigint, at: number | null, hash: string, height: number | null, notBefore: bigint | null, scan: Scan | null, sender: string | null, to: string }} DepositItem
505
+ */
506
+
507
+ /**
508
+ * @param {Deposit} deposit
509
+ * @param {(scan: unknown) => Scan | null} scanOf how its scan is read
510
+ * @returns {DepositItem}
511
+ */
512
+ function depositIn(deposit, scanOf) {
513
+ const hash = duskHashOf(deposit?.hash);
514
+ const amount = luxOf(deposit?.amount);
515
+ const to = addressOf(deposit?.to);
516
+ const sender = deposit?.sender ?? null;
517
+
518
+ if (!hash) throw new TypeError("A deposit's hash is its Dusk transaction's: 64 hex digits");
519
+ if (amount === null || amount === 0n) throw new TypeError("A deposit's amount is lux: more than 0, at most a u64");
520
+ if (!to) throw new TypeError("A deposit pays a DuskEVM address: 0x and 40 hex digits");
521
+ if (sender !== null && !isAccount(sender)) throw new TypeError("A deposit's sender is a Dusk account");
522
+
523
+ return {
524
+ amount,
525
+ at: msOf(deposit.at),
526
+ hash,
527
+ height: heightOf(deposit.height),
528
+ notBefore: quantityOf(deposit.notBefore),
529
+ scan: scanOf(deposit.scan),
530
+ sender: /** @type {string | null} */ (sender),
531
+ to,
532
+ };
533
+ }
534
+
535
+ /**
536
+ * @param {{ hash: string, at: number | null, notBefore: bigint | null, scan: Scan | null }} item
537
+ * @returns {Queued}
538
+ */
539
+ export const queued = (item) => ({ at: /** @type {number} */ (item.at), given: true, hash: item.hash, notBefore: item.notBefore, scan: item.scan });
540
+
541
+ /**
542
+ * Sorts out the transfers that aren't looked for: without their block's
543
+ * time yet, waiting; sent longer ago than they're waited for, unknown.
544
+ *
545
+ * @template {{ hash: string, at: number | null }} T
546
+ * @param {ReadonlyArray<T>} list
547
+ * @param {Map<string, Arrival>} results
548
+ * @param {{ now: number, waitMs: number }} options
549
+ * @returns {T[]}
550
+ */
551
+ export function triage(list, results, { now, waitMs }) {
552
+ /** @type {T[]} */
553
+ const live = [];
554
+
555
+ for (const item of list) {
556
+ if (item.at === null) results.set(item.hash, { state: "waiting" });
557
+ else if (now - item.at > waitMs) results.set(item.hash, { reason: "expired", state: "unknown" });
558
+ else live.push(item);
559
+ }
560
+
561
+ return live;
562
+ }
563
+
564
+ /**
565
+ * Each item once, by hash: the first.
566
+ *
567
+ * @template {{ hash: string }} T
568
+ * @param {ReadonlyArray<T>} list
569
+ * @returns {T[]}
570
+ */
571
+ export function uniqueByHash(list) {
572
+ const seen = new Set();
573
+
574
+ return list.filter((item) => !seen.has(item.hash) && Boolean(seen.add(item.hash)));
575
+ }
576
+
577
+ /**
578
+ * The results in the order the transfers came.
579
+ *
580
+ * @param {ReadonlyArray<{ hash: string }>} list
581
+ * @param {Map<string, Arrival>} results
582
+ * @returns {Map<string, Arrival>}
583
+ */
584
+ export const inOrder = (list, results) => new Map(list.map((item) => [item.hash, /** @type {Arrival} */ (results.get(item.hash))]));
585
+
586
+ /**
587
+ * @param {Iterable<string> | undefined} taken
588
+ * @param {(value: unknown) => string} hashOf
589
+ */
590
+ export const usedOf = (taken, hashOf) => new Set([...(taken ?? [])].map(hashOf).filter(Boolean));
591
+
592
+ /**
593
+ * What a call that follows deposits starts from, on either path: the
594
+ * network, its DuskEVM (null without: every deposit is `no-route`), the
595
+ * deposits given, checked, each once (`list`), those looked for (`live`),
596
+ * those waited for too long, which still take their credits first (`past`),
597
+ * the results so far, and the credits the app's other deposits arrived
598
+ * with. A TypeError for a network or a deposit that isn't one.
599
+ *
600
+ * @param {{ network: BridgeNetwork, deposits: Iterable<Deposit> | undefined, taken: Iterable<string> | undefined, now: number, waitMs: number }} options
601
+ * @param {(scan: unknown) => Scan | null} [scanOf] how a deposit's scan is read: on the explorer, not at all
602
+ */
603
+ export function depositsIn({ deposits, network, now, taken, waitMs }, scanOf = () => null) {
604
+ const net = networkOf(network);
605
+ const list = uniqueByHash(Array.from(deposits ?? [], (deposit) => depositIn(deposit, scanOf)));
606
+ /** @type {Map<string, Arrival>} */
607
+ const results = new Map();
608
+ const evm = net.duskEvm;
609
+
610
+ if (!evm) for (const item of list) results.set(item.hash, { reason: "no-route", state: "unknown" });
611
+
612
+ return {
613
+ evm,
614
+ list,
615
+ live: evm ? triage(list, results, { now, waitMs }) : [],
616
+ net,
617
+ // Waited for too long: not looked for, though still before the newer ones.
618
+ past: list.filter((item) => item.at !== null && now - item.at > waitMs),
619
+ results,
620
+ used: usedOf(taken, evmHashOf),
621
+ };
622
+ }
623
+
624
+ /**
625
+ * Where a read goes: the URL the app gave, else the network's; a TypeError
626
+ * where there's neither.
627
+ *
628
+ * @param {unknown} given
629
+ * @param {unknown} network
630
+ * @param {string} what
631
+ * @returns {string}
632
+ */
633
+ export function urlOf(given, network, what) {
634
+ const url = given ?? network;
635
+
636
+ if (url === undefined || url === null || url === "") throw new TypeError(`No ${what} to read`);
637
+
638
+ return String(url);
639
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Where a deposit to DuskEVM arrived, followed on DuskEVM's explorer alone,
3
+ * for an app that reads only the explorer: what `/bridge`'s
4
+ * `depositArrivals` does with `via: "explorer"`, without its RPC path and
5
+ * `/history`. `/bridge` exports it too.
6
+ *
7
+ * @example
8
+ * ```js
9
+ * import { depositArrivalsFromExplorer } from "@dusk/app-core/bridge-explorer";
10
+ *
11
+ * const { arrivals } = await depositArrivalsFromExplorer({ network: chainId, deposits: [{ hash, amount, to, at }], fetch });
12
+ * arrivals.get(hash); // { state: "arrived", hash: "0x029e…", at: 1793080333000, amount: 500000000n, block: 3951755 }
13
+ * ```
14
+ *
15
+ * @module
16
+ */
17
+
18
+ import type { Arrival, DepositArrivalOptions } from "./bridge.js";
19
+
20
+ /** What `depositArrivalsFromExplorer` takes: `depositArrivals`' options for the explorer. */
21
+ export type ExplorerDepositOptions = Omit<DepositArrivalOptions, "via" | "rpcUrl" | "archive" | "blockTimes" | "cache" | "maxWindows">;
22
+
23
+ /**
24
+ * Where each deposit arrived on DuskEVM, by its Dusk transaction's hash
25
+ * (lower case), in the order given, and the requests it took, as
26
+ * `depositArrivals` gives them with `via: "explorer"`: the recipient's
27
+ * incoming calls are read once for all its deposits, newest first, back
28
+ * past the oldest one waited for, at most `maxPages` pages; none is asked
29
+ * while every deposit's window is still open. A network without DuskEVM
30
+ * asks nothing. Throws a TypeError for a deposit that isn't one, for a
31
+ * network that isn't one, and for one without DuskEVM's explorer API where
32
+ * none is given.
33
+ */
34
+ export declare function depositArrivalsFromExplorer(options: ExplorerDepositOptions): Promise<{ arrivals: Map<string, Arrival>; requests: number }>;