@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.
- package/CHANGELOG.md +24 -0
- package/README.md +10 -6
- package/package.json +5 -1
- package/src/bridge-common.js +639 -0
- package/src/bridge-explorer.d.ts +34 -0
- package/src/bridge-explorer.js +186 -0
- package/src/bridge.d.ts +78 -20
- package/src/bridge.js +274 -708
- package/src/duskevm-withdrawals.d.ts +33 -4
- package/src/duskevm-withdrawals.js +218 -12
|
@@ -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,
|
|
38
|
-
* outside the calls, so it runs in an
|
|
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,
|
|
213
|
-
network:
|
|
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,
|
|
235
|
-
*
|
|
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,
|
|
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:
|
|
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
|
|
332
|
-
*
|
|
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:
|
|
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:
|
|
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
|
|
455
|
-
* route is null where that account pays out more than one route.
|
|
456
|
-
* any other entry,
|
|
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:
|
|
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
|