@dusk-network/app-core 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ /* @ts-self-types="./bridge-explorer.d.ts" */
2
+ /**
3
+ * Where a deposit to DuskEVM arrived, followed on DuskEVM's explorer alone:
4
+ * what `/bridge`'s `depositArrivals` does with `via: "explorer"`, for an
5
+ * app that reads only the explorer, without the RPC path and `/history`.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import {
11
+ CREDIT_WINDOW_MS,
12
+ EXPLORER_PAGES,
13
+ TIMEOUT_MS,
14
+ WAIT_MS,
15
+ WEI_PER_LUX,
16
+ allOpen,
17
+ depositsIn,
18
+ evmHashOf,
19
+ groupBy,
20
+ inOrder,
21
+ messageOf,
22
+ quantityOf,
23
+ queued,
24
+ reader,
25
+ settle,
26
+ urlOf,
27
+ withPast,
28
+ } from "./bridge-common.js";
29
+
30
+ /** @typedef {import("./bridge.js").Arrival} Arrival */
31
+ /** @typedef {import("./bridge-common.js").Credit} Credit */
32
+ /** @typedef {import("./bridge-common.js").DepositItem} DepositItem */
33
+ /** @typedef {import("./bridge-common.js").Reader} Reader */
34
+ /** @typedef {import("./bridge-explorer.js").ExplorerDepositOptions} ExplorerDepositOptions */
35
+
36
+ /**
37
+ * A Blockscout time ("2026-10-27T05:52:13.000000Z") in milliseconds.
38
+ *
39
+ * @param {unknown} text
40
+ */
41
+ const timeOf = (text) => Date.parse(String(text ?? "").replace(/(\.\d{3})\d+/, "$1"));
42
+
43
+ /**
44
+ * @param {ExplorerDepositOptions} options
45
+ * @returns {Promise<{ arrivals: Map<string, Arrival>, requests: number }>}
46
+ */
47
+ export async function depositArrivalsFromExplorer({
48
+ network,
49
+ deposits,
50
+ fetch: fetchFn,
51
+ explorerUrl,
52
+ taken,
53
+ now = Date.now(),
54
+ waitMs = WAIT_MS,
55
+ windowMs = CREDIT_WINDOW_MS,
56
+ maxPages = EXPLORER_PAGES,
57
+ timeoutMs = TIMEOUT_MS,
58
+ }) {
59
+ const { evm, list, live, past, results, used } = depositsIn({ deposits, network, now, taken, waitMs });
60
+ const read = reader(fetchFn, timeoutMs);
61
+
62
+ if (evm) {
63
+ const api = urlOf(explorerUrl, evm.blockscoutApiUrl, "DuskEVM explorer");
64
+
65
+ await onExplorer(read, live, { api, bridge: evm.predeploys.l2StandardBridge.toLowerCase(), maxPages, now, past, results, used, windowMs });
66
+ }
67
+
68
+ return { arrivals: inOrder(list, results), requests: read.counter.requests };
69
+ }
70
+
71
+ /**
72
+ * The L2 bridge's payments to an address, as DuskEVM's explorer lists its
73
+ * incoming calls: newest first, a page at a time (the explorer's
74
+ * `next_page_params`), back past `since`, at most `maxPages`. `complete`
75
+ * once they reach it, or there are no more.
76
+ *
77
+ * @param {Reader} read
78
+ * @param {{ api: string, bridge: string, maxPages: number, since: number, to: string }} ask
79
+ * @returns {Promise<{ complete: boolean, payments: Credit[] }>}
80
+ */
81
+ async function explorerPayments(read, { api, bridge, maxPages, since, to }) {
82
+ const base = `${api.replace(/\/+$/, "")}/addresses/${to}/internal-transactions`;
83
+ /** @type {any[]} */
84
+ const items = [];
85
+ /** @type {Record<string, unknown> | null} */
86
+ let next = null;
87
+ let complete = false;
88
+
89
+ for (let page = 0; page < Math.max(1, Math.floor(maxPages)) && !complete; page += 1) {
90
+ const query = new URLSearchParams({ filter: "to" });
91
+
92
+ for (const [key, value] of Object.entries(next ?? {})) query.set(key, String(value));
93
+
94
+ const body = await read.getJson(`${base}?${query}`);
95
+ const calls = Array.isArray(body?.items) ? body.items : [];
96
+ const oldest = Math.min(...calls.map((/** @type {any} */ call) => timeOf(call?.timestamp)).filter(Number.isFinite));
97
+
98
+ items.push(...calls);
99
+ next = body?.next_page_params && typeof body.next_page_params === "object" ? body.next_page_params : null;
100
+ complete = !next || !calls.length || oldest < since;
101
+ }
102
+
103
+ /** @type {Map<string, Credit>} */
104
+ const payments = new Map();
105
+
106
+ for (const call of items) {
107
+ const hash = evmHashOf(call?.transaction_hash);
108
+ const at = timeOf(call?.timestamp);
109
+ const value = String(call?.value ?? "");
110
+ const payee = call?.to?.hash;
111
+
112
+ if (!hash || payments.has(hash) || !Number.isFinite(at) || !/^\d+$/.test(value)) continue;
113
+ if (String(call?.from?.hash ?? "").toLowerCase() !== bridge) continue;
114
+ if (payee !== undefined && payee !== null && String(payee).toLowerCase() !== to) continue;
115
+
116
+ payments.set(hash, { at, block: quantityOf(call?.block_number), from: null, hash, success: call?.success !== false, wei: BigInt(value) });
117
+ }
118
+
119
+ return { complete, payments: [...payments.values()] };
120
+ }
121
+
122
+ /**
123
+ * Deposits followed on DuskEVM's explorer: each recipient's incoming calls
124
+ * read once for all its deposits, and matched as where the credits can't be
125
+ * told apart, as they don't say who deposited. Equal deposits past their
126
+ * wait just before them take their payments first, their results kept.
127
+ *
128
+ * @param {Reader} read
129
+ * @param {DepositItem[]} live
130
+ * @param {{ api: string, bridge: string, maxPages: number, now: number, past: DepositItem[], results: Map<string, Arrival>, used: Set<string>, windowMs: number }} options
131
+ */
132
+ async function onExplorer(read, live, { api, bridge, maxPages, now, past, results, used, windowMs }) {
133
+ for (const [to, items] of groupBy(live, (item) => item.to)) {
134
+ const polled = new Set(items.map((item) => item.hash));
135
+ const groups = [...groupBy(items, (item) => item.amount.toString()).values()].map((group) => ({
136
+ queue: withPast(
137
+ group.map(queued),
138
+ past.filter((item) => item.to === to && item.amount === group[0].amount).map(queued),
139
+ windowMs
140
+ ),
141
+ wei: group[0].amount * WEI_PER_LUX,
142
+ }));
143
+
144
+ // Nothing can be told apart before a window is over: nothing is asked.
145
+ if (groups.every(({ queue }) => allOpen(queue, windowMs, now))) {
146
+ for (const item of items) results.set(item.hash, { state: "waiting" });
147
+ continue;
148
+ }
149
+
150
+ /** @type {{ complete: boolean, payments: Credit[] }} */
151
+ let page;
152
+
153
+ try {
154
+ page = await explorerPayments(read, { api, bridge, maxPages, since: Math.min(...groups.flatMap(({ queue }) => queue.map((item) => item.at))) - windowMs, to });
155
+ } catch (error) {
156
+ for (const item of items) results.set(item.hash, { error: messageOf(error), reason: "error", state: "unknown" });
157
+ continue;
158
+ }
159
+
160
+ // Not back to the oldest: a payment of an older one may be a newer one's.
161
+ if (!page.complete) {
162
+ for (const item of items) results.set(item.hash, { reason: "beyond", state: "unknown" });
163
+ continue;
164
+ }
165
+
166
+ for (const { queue, wei } of groups) {
167
+ const { assigned, verdicts } = settle(
168
+ queue,
169
+ page.payments.filter((payment) => payment.wei === wei),
170
+ { guarded: true, now, slackMs: windowMs, used, windowMs }
171
+ );
172
+
173
+ for (const item of queue.filter((each) => polled.has(each.hash))) {
174
+ const credit = assigned.get(item.hash);
175
+ const verdict = verdicts.get(item.hash);
176
+
177
+ if (credit && verdict === "arrived") {
178
+ used.add(credit.hash);
179
+ 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" });
180
+ } else {
181
+ results.set(item.hash, verdict === "waiting" ? { state: "waiting" } : { reason: verdict === "unsure" ? "unsure" : "late", state: "unknown" });
182
+ }
183
+ }
184
+ }
185
+ }
186
+ }
package/src/bridge.d.ts CHANGED
@@ -34,9 +34,15 @@
34
34
  * - The app owns all I/O and storage: it passes `fetch`, the URLs (the
35
35
  * network's file's by default), `/history`'s archive for the Dusk side,
36
36
  * and its own caches: the credits it gave other transfers, where the last
37
- * search stopped, and the chain's block times. Nothing here keeps state
38
- * outside the calls, so it runs in an extension's service worker as well
39
- * as in a page.
37
+ * search stopped, the chain's block times, and what can't change any
38
+ * more. Nothing here keeps state outside the calls, so it runs in an
39
+ * extension's service worker as well as in a page.
40
+ * - A network is a network file, or its chain ID; on a chain there's no
41
+ * file for, one the app builds from its own settings (`NetworkSettings`),
42
+ * checked as a file's fields are.
43
+ * - An app that follows deposits on DuskEVM's explorer alone imports
44
+ * `depositArrivalsFromExplorer` from `/bridge-explorer`, without the RPC
45
+ * path and `/history`.
40
46
  *
41
47
  * @example
42
48
  * ```js
@@ -55,9 +61,44 @@
55
61
  * @module
56
62
  */
57
63
 
58
- import type { Account, EvmAddress, NetworkRef } from "@dusk-network/networks";
64
+ import type { Account, EvmAddress, Network, NetworkRef } from "@dusk-network/networks";
59
65
  import type { Archive, Direction, EntryKind } from "./history.js";
60
66
 
67
+ export { depositArrivalsFromExplorer, type ExplorerDepositOptions } from "./bridge-explorer.js";
68
+
69
+ /**
70
+ * A network an app builds from its own settings, for a chain there's no
71
+ * network file for: its chain ID as the node gives it, and DuskEVM's bridge,
72
+ * RPC (for "rpc") and explorer API (for "explorer"), as a network file has
73
+ * them. Its fields are checked as a file's are: a TypeError for one that
74
+ * isn't (an address that isn't 0x and 40 hex digits, a URL that isn't
75
+ * http(s) or ends with a slash). It has no routes to other chains unless it
76
+ * gives them as a file does: transfers out and migrations on it are
77
+ * `no-route`.
78
+ */
79
+ export type NetworkSettings = {
80
+ chainId: number;
81
+ /** DuskEVM, or null where it has none: then no deposit is followed. */
82
+ duskEvm?: {
83
+ rpcUrl?: string;
84
+ blockscoutApiUrl?: string;
85
+ predeploys: { l2StandardBridge: string };
86
+ /**
87
+ * DuskEVM's contracts on Dusk, by their names in a network file: with
88
+ * the bridge's and the Portal's (`l1_standard_bridge`,
89
+ * `optimism_portal`), a deposit's sender's history is read on "rpc".
90
+ */
91
+ contracts?: Record<string, { contractId: string; evmAddress?: string }>;
92
+ } | null;
93
+ routes?: Network["routes"];
94
+ };
95
+
96
+ /** The network a read takes: a network file, its chain ID, or one built from an app's settings. */
97
+ export type BridgeNetwork = NetworkRef | NetworkSettings;
98
+
99
+ /** The app's cache of what can't change any more, as text: a Map does. */
100
+ export type FactCache = { get(key: string): string | null | undefined; set(key: string, value: string): unknown };
101
+
61
102
  /** How long before its transfer's block a credit still counts: Dusk's clock and the other chain's can differ. 2 minutes, in milliseconds. */
62
103
  export declare const CLOCK_SLACK_MS: 120000;
63
104
 
@@ -209,8 +250,8 @@ export type Deposit = {
209
250
 
210
251
  /** What `depositArrivals` takes. */
211
252
  export type DepositArrivalOptions = {
212
- /** The Dusk network, or its chain ID: its DuskEVM, and its URLs. */
213
- network: NetworkRef;
253
+ /** The Dusk network, its chain ID, or one built from the app's settings: its DuskEVM, and its URLs. */
254
+ network: BridgeNetwork;
214
255
  /** The deposits waited for: each once, by hash. */
215
256
  deposits: Iterable<Deposit>;
216
257
  /**
@@ -231,8 +272,11 @@ export type DepositArrivalOptions = {
231
272
  * looked for too, and paid first. Each deposit reads its own hours, once
232
273
  * the archive lists it there, `HISTORY_PAGES` pages a call: a deposit
233
274
  * whose history is read on in the next call waits, with its `scan`.
234
- * Without it, or for a deposit without its sender and height, its credit
235
- * is a deposit's only as where they can't be told apart.
275
+ * Without it, while it can't be read, on a network that doesn't name
276
+ * DuskEVM's bridge and Portal on Dusk, or for a deposit without its sender
277
+ * and height, its credit is a deposit's only as where they can't be told
278
+ * apart; a deposit is an `error` only when DuskEVM's RPC can't be read
279
+ * either.
236
280
  */
237
281
  archive?: Archive;
238
282
  /**
@@ -243,6 +287,15 @@ export type DepositArrivalOptions = {
243
287
  taken?: Iterable<string>;
244
288
  /** DuskEVM's block times the app keeps, for "rpc". */
245
289
  blockTimes?: BlockTimes;
290
+ /**
291
+ * Where the calls of the deposits a sender's history lists are kept, for
292
+ * "rpc" with the archive: each is kept once its block is final and its
293
+ * answer says all a match reads of it (that it failed, that it's another
294
+ * call than a deposit, or a deposit's amount and DuskEVM address), and
295
+ * isn't asked for again. One whose arguments come cut short is asked for
296
+ * again.
297
+ */
298
+ cache?: FactCache;
246
299
  /** The clock, in milliseconds. */
247
300
  now?: number;
248
301
  /** How long a deposit is waited for; `WAIT_MS` by default. */
@@ -269,7 +322,8 @@ export type DepositArrivalOptions = {
269
322
  * from about when the first was sent, `maxWindows` windows at most (one more
270
323
  * where the search ahead takes them all while its blocks are read again).
271
324
  * A network without DuskEVM asks nothing. Throws a TypeError for a deposit
272
- * that isn't one, and for a network there's no file for.
325
+ * that isn't one, for a network that isn't one, and for one without the URL
326
+ * the reads go to where none is given.
273
327
  */
274
328
  export declare function depositArrivals(options: DepositArrivalOptions): Promise<{ arrivals: Map<string, Arrival>; requests: number }>;
275
329
 
@@ -293,7 +347,7 @@ export type ServiceTransfer = {
293
347
 
294
348
  /** What `serviceArrivals` takes. */
295
349
  export type ServiceArrivalOptions = {
296
- network: NetworkRef;
350
+ network: BridgeNetwork;
297
351
  transfers: Iterable<ServiceTransfer>;
298
352
  /** `/history`'s archive: each transfer, and the route's account's history, which lists the equal ones paid before. */
299
353
  archive: Archive;
@@ -328,8 +382,8 @@ export type ServiceArrivalOptions = {
328
382
  * leaves the order unclear: unsure. Each transfer reads its own hours of
329
383
  * that history, once the archive lists it there, `HISTORY_PAGES` pages a
330
384
  * call: one whose history is read on in the next call waits, with its
331
- * `scan`. Throws a TypeError without an archive, and for a network there's
332
- * no file for.
385
+ * `scan`. Throws a TypeError without an archive, and for a network that
386
+ * isn't one.
333
387
  */
334
388
  export declare function serviceArrivals(options: ServiceArrivalOptions): Promise<{ arrivals: Map<string, Arrival>; requests: number }>;
335
389
 
@@ -369,7 +423,7 @@ export type MigrationPayout = {
369
423
 
370
424
  /** What `migrationPayouts` takes: the account's entries, or the archive and the account. */
371
425
  export type MigrationPayoutOptions = {
372
- network: NetworkRef;
426
+ network: BridgeNetwork;
373
427
  migrations: Iterable<Migration>;
374
428
  /** The account's public history, as `/history` reads or keeps it. */
375
429
  entries?: Iterable<HistoryLike>;
@@ -409,6 +463,8 @@ export type HistoryLike = {
409
463
  /** Of `value`, a gas refund paid to another account than the sender. */
410
464
  refund?: bigint | string | null;
411
465
  memo: string;
466
+ /** True when `memo` is bytes that aren't text, given as 0x hex: no address or hash is read from it. */
467
+ memoBinary?: boolean;
412
468
  payer: boolean;
413
469
  timestamp?: number | null;
414
470
  success?: boolean | null;
@@ -433,7 +489,7 @@ export type ServiceTransferFacts = {
433
489
  /** What the route's account received: known only where no gas was left to refund; null otherwise. */
434
490
  amount: bigint | null;
435
491
  fee: bigint;
436
- /** The address its memo gives, exactly as the bridge reads it; null when it isn't one. */
492
+ /** The address its memo gives, exactly as the bridge reads it; null when it isn't one, or the memo isn't text. */
437
493
  to: string | null;
438
494
  at: number | null;
439
495
  height: number | null;
@@ -444,18 +500,20 @@ export type ServiceTransferFacts = {
444
500
  /**
445
501
  * A transfer the account sent to a service route's account, as its entry
446
502
  * says it; null for any other entry. The memo is read as the route's bridge
447
- * reads it: 0x and 40 hex digits, any case, nothing around them.
503
+ * reads it: 0x and 40 hex digits, any case, nothing around them, as text. A
504
+ * memo of bytes that aren't text (`memoBinary`) gives no address.
448
505
  */
449
- export declare function serviceTransferOf(entry: HistoryLike, network: NetworkRef): ServiceTransferFacts | null;
506
+ export declare function serviceTransferOf(entry: HistoryLike, network: BridgeNetwork): ServiceTransferFacts | null;
450
507
 
451
508
  /**
452
509
  * A payout of a migration, from its entry: a transfer the account received
453
510
  * from a migration route's payout account, with a transaction hash as the
454
- * memo, of its value less any gas `refund` paid to the account with it. Its
455
- * route is null where that account pays out more than one route. Null for
456
- * any other entry, and on a route without its payout account.
511
+ * memo as text, of its value less any gas `refund` paid to the account with
512
+ * it. Its route is null where that account pays out more than one route.
513
+ * Null for any other entry, a memo of bytes that aren't text included, and
514
+ * on a route without its payout account.
457
515
  */
458
- export declare function migrationPayoutOf(entry: HistoryLike, network: NetworkRef): MigrationPayout | null;
516
+ export declare function migrationPayoutOf(entry: HistoryLike, network: BridgeNetwork): MigrationPayout | null;
459
517
 
460
518
  /**
461
519
  * The deposits that arrived, as `/activity`'s `arrivals` takes them: by the