@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,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 }>;
|