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