@dusk-network/app-core 0.3.0 → 0.3.2

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/src/bridge.js CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { networkFor, routeForAccount, routes } from "@dusk-network/networks";
14
14
  import { isAccount } from "@dusk-network/networks/account";
15
- import { duskEvmContracts, evmAddressOf } from "@dusk-network/networks/duskevm";
15
+ import { BRIDGE_FUNCTIONS, duskEvmContracts, evmAddressOf } from "@dusk-network/networks/duskevm";
16
16
  import { DETAILS_BATCH, TRANSFER_CONTRACT, fetchTxDetails, historyRange } from "./history.js";
17
17
 
18
18
  /** @typedef {import("./bridge.js").Arrival} Arrival */
@@ -21,6 +21,7 @@ import { DETAILS_BATCH, TRANSFER_CONTRACT, fetchTxDetails, historyRange } from "
21
21
  /** @typedef {import("./bridge.js").DepositArrivalOptions} DepositArrivalOptions */
22
22
  /** @typedef {import("./bridge.js").FetchLike} FetchLike */
23
23
  /** @typedef {import("./bridge.js").HistoryLike} HistoryLike */
24
+ /** @typedef {import("./bridge.js").HistoryScan} HistoryScan */
24
25
  /** @typedef {import("./bridge.js").Migration} Migration */
25
26
  /** @typedef {import("./bridge.js").MigrationPayout} MigrationPayout */
26
27
  /** @typedef {import("./bridge.js").MigrationPayoutOptions} MigrationPayoutOptions */
@@ -46,9 +47,10 @@ import { DETAILS_BATCH, TRANSFER_CONTRACT, fetchTxDetails, historyRange } from "
46
47
  /**
47
48
  * A credit on the other chain: its transaction, its block and time, its
48
49
  * 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
+ * its log names it (lower case; null where it isn't named). Found by an RPC
51
+ * search, the passes over its blocks again done before it was (`pass`).
50
52
  *
51
- * @typedef {{ hash: string, block: bigint | null, at: number, wei: bigint, success: boolean, from: string | null }} Credit
53
+ * @typedef {{ hash: string, block: bigint | null, at: number, wei: bigint, success: boolean, from: string | null, pass?: number }} Credit
52
54
  */
53
55
 
54
56
  /**
@@ -82,6 +84,17 @@ const PACE_MARGIN = 1.2;
82
84
  const START_MARGIN = 50n;
83
85
  const START_CHECKS = 4;
84
86
  const MIN_WINDOW = 50n;
87
+ // How far back from the head a search's last window reaches: those blocks
88
+ // are read again on each call.
89
+ const OVERLAP = 1_000n;
90
+ // How long after a search first reaches the head it reads its blocks again
91
+ // from its first one; each pass after waits as long again as it has been
92
+ // since then, so the waits about double each time.
93
+ const RECHECK_MS = 60_000;
94
+ // How many passes over the blocks before a credit, since it was found, a
95
+ // transfer waits for before it takes the credit where a later equal one,
96
+ // still without its own, could have had it instead.
97
+ const CONFIRM_PASSES = 8;
85
98
  // What an RPC says when it won't search that many blocks at once.
86
99
  const RANGE_REFUSED = /limit|range|too many|exceed/i;
87
100
  const DUSK_HASH = /^[0-9a-f]{64}$/;
@@ -199,9 +212,42 @@ const msOf = (value) => (typeof value === "number" && Number.isFinite(value) &&
199
212
  */
200
213
  const heightOf = (value) => (typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null);
201
214
 
215
+ /**
216
+ * How far a transfer's history is read, as a result gave it; null for
217
+ * anything else.
218
+ *
219
+ * @param {unknown} value
220
+ * @returns {HistoryScan | null}
221
+ */
222
+ function historyScanOf(value) {
223
+ const read = /** @type {any} */ (value);
224
+
225
+ if (!read || typeof read !== "object" || typeof read.of !== "string" || !Array.isArray(read.found)) return null;
226
+
227
+ const lo = read.lo === null ? null : heightOf(read.lo);
228
+ const next = read.next === null ? null : { from: heightOf(read.next?.from), page: heightOf(read.next?.page), to: heightOf(read.next?.to) };
229
+
230
+ if ((read.lo !== null && lo === null) || (lo === null && next === null)) return null;
231
+ if (next && (next.from === null || next.to === null || next.page === null || next.page < 1 || next.from > next.to)) return null;
232
+
233
+ const found = read.found.flatMap((/** @type {any} */ each) => {
234
+ const hash = duskHashOf(each?.hash);
235
+ const height = heightOf(each?.height);
236
+ const value = each?.value;
237
+
238
+ return hash && height !== null && typeof value === "string" && /^\d{1,20}$/.test(value) ? [{ hash, height, value }] : [];
239
+ });
240
+
241
+ // One left out could be equal: it's read afresh.
242
+ if (found.length !== read.found.length) return null;
243
+
244
+ return { found, lo, next: /** @type {HistoryScan["next"]} */ (next), of: read.of };
245
+ }
246
+
202
247
  /**
203
248
  * Where a search stopped, what it searched for, and the credits it found
204
- * before, as a result gave them; null for anything else.
249
+ * before, and how far the transfer's history is read, as a result gave
250
+ * them; null for anything else.
205
251
  *
206
252
  * @param {unknown} value
207
253
  * @returns {Scan | null}
@@ -209,19 +255,34 @@ const heightOf = (value) => (typeof value === "number" && Number.isSafeInteger(v
209
255
  function scanOf(value) {
210
256
  const scan = /** @type {any} */ (value);
211
257
  const from = quantityOf(scan?.from);
258
+ const history = historyScanOf(scan?.history);
259
+ /** @type {Scan} */
260
+ const out = history ? { history } : {};
261
+
262
+ if (from !== null && typeof scan?.basis === "number" && Number.isFinite(scan.basis) && typeof scan.search === "string") {
263
+ const seen = (Array.isArray(scan.seen) ? scan.seen : []).flatMap((/** @type {any} */ credit) => {
264
+ const hash = evmHashOf(credit?.hash);
265
+ const block = quantityOf(credit?.block);
266
+ const at = msOf(credit?.at);
267
+ const sender = credit?.sender === null ? null : addressOf(credit?.sender);
268
+ // The passes done before it was found: none where it isn't said.
269
+ const pass = Number.isSafeInteger(credit?.pass) && credit.pass >= 0 ? credit.pass : 0;
270
+
271
+ return hash && block !== null && at !== null && sender !== "" ? [{ at, block: block.toString(), hash, pass, sender }] : [];
272
+ });
273
+ // Where it began, and its passes over the blocks again: without them,
274
+ // as a search kept by an earlier version.
275
+ const start = quantityOf(scan.start);
276
+ const recheck = start === null ? null : recheckOf(scan.recheck);
277
+
278
+ Object.assign(out, { basis: scan.basis, from: from.toString(), search: scan.search, seen });
279
+ if (start !== null) out.start = start.toString();
280
+ if (recheck) out.recheck = recheckIn(recheck);
281
+ } else if (!history) {
282
+ return null;
283
+ }
212
284
 
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 };
285
+ return out;
225
286
  }
226
287
 
227
288
  /**
@@ -409,6 +470,36 @@ function clustersOf(queue, windowMs) {
409
470
  */
410
471
  const allOpen = (queue, windowMs, now) => clustersOf(queue, windowMs).every((cluster) => now < cluster[cluster.length - 1].at + windowMs);
411
472
 
473
+ /**
474
+ * How a group's credits are matched: see `settle`.
475
+ *
476
+ * @typedef {{ guarded: boolean, now: number, slackMs: number, used: ReadonlySet<string>, windowMs: number, rivals?: ReadonlySet<string> }} Rules
477
+ */
478
+
479
+ /**
480
+ * Whether a credit could pay a transfer, whether or not another one took it:
481
+ * it went through, isn't one the app gave another of its transfers when
482
+ * this one is the app's, comes from none of `rivals`, and is in the
483
+ * transfer's time and at or after the block it can't be before.
484
+ *
485
+ * @param {Queued} item
486
+ * @param {Credit} credit
487
+ * @param {Rules} rules
488
+ */
489
+ function pays(item, credit, { guarded, rivals = new Set(), slackMs, used, windowMs }) {
490
+ const lo = item.at - (guarded ? windowMs : slackMs);
491
+ const hi = guarded ? item.at + windowMs : Infinity;
492
+
493
+ return (
494
+ credit.success &&
495
+ (!item.given || !used.has(credit.hash)) &&
496
+ (credit.from === null || !rivals.has(credit.from)) &&
497
+ credit.at >= lo &&
498
+ credit.at <= hi &&
499
+ (item.notBefore === null || credit.block === null || credit.block >= item.notBefore)
500
+ );
501
+ }
502
+
412
503
  /**
413
504
  * Each transfer of a queue of equal ones, oldest first, takes the first
414
505
  * credit that went through after it that no earlier one took: from
@@ -429,10 +520,11 @@ const allOpen = (queue, windowMs, now) => clustersOf(queue, windowMs).every((clu
429
520
  *
430
521
  * @param {ReadonlyArray<Queued>} queue oldest first
431
522
  * @param {ReadonlyArray<Credit>} credits
432
- * @param {{ guarded: boolean, now: number, slackMs: number, used: ReadonlySet<string>, windowMs: number, rivals?: ReadonlySet<string> }} rules
523
+ * @param {Rules} rules
433
524
  * @returns {{ assigned: Map<string, Credit>, verdicts: Map<string, Verdict> }}
434
525
  */
435
- function settle(queue, credits, { guarded, now, rivals = new Set(), slackMs, used, windowMs }) {
526
+ function settle(queue, credits, rules) {
527
+ const { guarded, now, used, windowMs } = rules;
436
528
  const sorted = [...credits].sort(byTime);
437
529
  /** @type {Map<string, Credit>} */
438
530
  const assigned = new Map();
@@ -441,18 +533,7 @@ function settle(queue, credits, { guarded, now, rivals = new Set(), slackMs, use
441
533
  const verdicts = new Map();
442
534
 
443
535
  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
- );
536
+ const credit = sorted.find((c) => !taken.has(c.hash) && pays(item, c, rules));
456
537
 
457
538
  if (credit) {
458
539
  assigned.set(item.hash, credit);
@@ -693,32 +774,48 @@ async function startBefore(read, url, atMs, cache) {
693
774
 
694
775
  /**
695
776
  * 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
777
+ * most `maxWindows`, until `head`, `enough`, or a window ending after
697
778
  * `until` (`covered`): each checked against what was asked, with its block's
698
779
  * time. A window the RPC won't search at once is asked again a quarter as
699
- * wide.
780
+ * wide (`size`, the width it ends with; `refused`, the narrowest it
781
+ * refused, and `took`, the widest it answered). The window ending at `head`
782
+ * reaches back up to `overlap` blocks from it, no wider than `size`, though
783
+ * not under `floor`: those blocks are read again (from `low`). `asked`: the
784
+ * windows asked.
700
785
  *
701
786
  * @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 }>}
787
+ * @param {{ url: string, address: string, topics: ReadonlyArray<string | null>, wei: bigint, amountOf: (data: string) => bigint | null, start: bigint, floor: bigint, head: bigint, size?: bigint, overlap?: bigint, maxWindows: number, blockTimes: BlockTimes | undefined, enough: (credits: Credit[]) => boolean, until: number | null, known: Credit[] }} search
788
+ * @returns {Promise<{ asked: number, complete: boolean, covered: boolean, credits: Credit[], low: bigint | null, next: bigint, refused: bigint | null, size: bigint, took: bigint | null }>}
704
789
  */
705
- async function searchLogs(read, { address, amountOf, blockTimes, enough, head, known, maxWindows, start, topics, until, url, wei }) {
790
+ async function searchLogs(read, { address, amountOf, blockTimes, enough, floor, head, known, maxWindows, overlap = OVERLAP, size: width = BigInt(LOG_WINDOW), start, topics, until, url, wei }) {
706
791
  // What an earlier search found before `start`.
707
792
  /** @type {Credit[]} */
708
793
  const credits = [...known];
709
794
  let from = start;
710
- let size = BigInt(LOG_WINDOW);
795
+ let size = width;
711
796
  let covered = false;
712
-
713
- for (let round = 0; round < maxWindows && from <= head; round += 1) {
797
+ let asked = 0;
798
+ /** @type {bigint | null} */
799
+ let low = null;
800
+ /** @type {bigint | null} */
801
+ let refused = null;
802
+ /** @type {bigint | null} */
803
+ let took = null;
804
+
805
+ while (asked < maxWindows && from <= head) {
714
806
  const to = from + size - 1n < head ? from + size - 1n : head;
807
+ const back = head + 1n - (size < overlap ? size : overlap);
808
+ const lo = to === head && back < from ? (back > floor ? back : floor) : from;
715
809
  /** @type {any} */
716
810
  let logs;
717
811
 
812
+ asked += 1;
813
+
718
814
  try {
719
- logs = await read.rpc(url, "eth_getLogs", [{ address, fromBlock: hex(from), toBlock: hex(to), topics }]);
815
+ logs = await read.rpc(url, "eth_getLogs", [{ address, fromBlock: hex(lo), toBlock: hex(to), topics }]);
720
816
  } catch (error) {
721
817
  if (size > MIN_WINDOW && RANGE_REFUSED.test(messageOf(error))) {
818
+ if (refused === null || to - lo + 1n < refused) refused = to - lo + 1n;
722
819
  size = size / 4n > MIN_WINDOW ? size / 4n : MIN_WINDOW;
723
820
  continue;
724
821
  }
@@ -726,8 +823,11 @@ async function searchLogs(read, { address, amountOf, blockTimes, enough, head, k
726
823
  throw error;
727
824
  }
728
825
 
826
+ if (to === head) low = lo;
827
+ if (took === null || to - lo + 1n > took) took = to - lo + 1n;
828
+
729
829
  for (const log of Array.isArray(logs) ? logs : []) {
730
- const found = creditLog(log, { address, amountOf, from, to, topics, wei });
830
+ const found = creditLog(log, { address, amountOf, from: lo, to, topics, wei });
731
831
 
732
832
  if (!found || credits.some((credit) => credit.hash === found.hash)) continue;
733
833
  if (found.at !== null) blockTimes?.set(Number(found.block), found.at);
@@ -744,7 +844,39 @@ async function searchLogs(read, { address, amountOf, blockTimes, enough, head, k
744
844
  }
745
845
  }
746
846
 
747
- return { complete: from > head, covered, credits, next: from };
847
+ return { asked, complete: from > head, covered, credits, low, next: from, refused, size, took };
848
+ }
849
+
850
+ /**
851
+ * A search's passes over its blocks again, as a result gave them: where the
852
+ * one under way is (null between two), when the next begins, how many are
853
+ * done, when the search first reached the head, and the widest window it
854
+ * reads them in where the RPC refused a wider one; null for anything else.
855
+ *
856
+ * @param {unknown} value
857
+ * @returns {{ at: number, from: bigint | null, passes: number, since: number, size: number | null } | null}
858
+ */
859
+ function recheckOf(value) {
860
+ const kept = /** @type {any} */ (value);
861
+ const from = kept?.from === null ? null : quantityOf(kept?.from);
862
+ /** @param {unknown} ms */
863
+ const time = (ms) => typeof ms === "number" && Number.isFinite(ms);
864
+ const size = Number.isSafeInteger(kept?.size) && kept.size >= Number(MIN_WINDOW) && kept.size <= LOG_WINDOW ? kept.size : null;
865
+
866
+ if (!kept || !time(kept.at) || !time(kept.since) || !Number.isSafeInteger(kept.passes) || kept.passes < 0) return null;
867
+ if (kept.from !== null && from === null) return null;
868
+
869
+ return { at: kept.at, from, passes: kept.passes, since: kept.since, size };
870
+ }
871
+
872
+ /**
873
+ * A search's passes over its blocks again, as its scan keeps them.
874
+ *
875
+ * @param {NonNullable<ReturnType<typeof recheckOf>>} recheck
876
+ * @returns {NonNullable<Scan["recheck"]>}
877
+ */
878
+ function recheckIn({ at, from, passes, since, size }) {
879
+ return { at, from: from === null ? null : from.toString(), passes, since, ...(size === null ? {} : { size }) };
748
880
  }
749
881
 
750
882
  /**
@@ -754,6 +886,16 @@ async function searchLogs(read, { address, amountOf, blockTimes, enough, head, k
754
886
  * sent at (as the app saw the chain's head then), else from about when it
755
887
  * was sent.
756
888
  *
889
+ * Each block is read more than once: on each call, those up to `OVERLAP`
890
+ * under the head, and once the search has reached the head with a transfer
891
+ * not arrived, all of them again from where the search began, a pass at a
892
+ * time: the first a minute later, each after as long again as it has been
893
+ * since then, and one after the other, as far as its credit, while an
894
+ * arrival is held back. A pass under way takes what the search ahead leaves
895
+ * of a call's windows, one at least, in windows no wider than the RPC took
896
+ * before. A pass counts once it read the blocks again, as does a call whose
897
+ * window at the head reached back to where the search began.
898
+ *
757
899
  * @param {Reader} read
758
900
  * @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
901
  * @param {{ url: string, maxWindows: number, blockTimes: BlockTimes | undefined, now: number, slackMs: number, windowMs: number, used: Set<string> }} options
@@ -780,7 +922,12 @@ async function followLogs(read, { filter, given, guarded, queue, rivals, unsure
780
922
  // The logs searched for: a search for others, as for another sender or
781
923
  // amount, isn't carried on.
782
924
  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] : []));
925
+ /** @type {{ basis: number, from: string, seen?: Scan["seen"], start?: string, recheck?: Scan["recheck"] }[]} */
926
+ const resumed = live.flatMap(({ scan }) =>
927
+ scan?.from !== undefined && scan.search === search && scan.basis !== undefined && scan.basis <= first.at
928
+ ? [{ basis: scan.basis, from: scan.from, recheck: scan.recheck, seen: scan.seen, start: scan.start }]
929
+ : []
930
+ );
784
931
  /** @type {Map<string, Credit>} */
785
932
  const carried = new Map();
786
933
 
@@ -788,7 +935,7 @@ async function followLogs(read, { filter, given, guarded, queue, rivals, unsure
788
935
  // counts again, as the transfer it paid may be among the queue's.
789
936
  for (const scan of resumed) {
790
937
  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 });
938
+ if (credit.at >= reach) carried.set(credit.hash, { at: credit.at, block: BigInt(credit.block), from: credit.sender, hash: credit.hash, pass: credit.pass ?? 0, success: true, wei: filter.wei });
792
939
  }
793
940
  }
794
941
 
@@ -796,43 +943,193 @@ async function followLogs(read, { filter, given, guarded, queue, rivals, unsure
796
943
  let head;
797
944
  /** @type {bigint} */
798
945
  let start;
946
+ // The block the search began at, and its passes over the blocks before.
947
+ /** @type {bigint} */
948
+ let floor;
949
+ /** @type {ReturnType<typeof recheckOf>} */
950
+ let recheck = null;
799
951
 
800
- if (resumed.length) {
801
- start = minOf(resumed.map((scan) => BigInt(scan.from)));
802
- head = await headOf(read, url);
952
+ if (resumed.length && resumed.every((scan) => scan.basis === first.at)) {
953
+ const kept = resumed.reduce((a, b) => (BigInt(b.from) < BigInt(a.from) ? b : a));
954
+
955
+ start = BigInt(kept.from);
956
+ recheck = recheckOf(kept.recheck);
957
+
958
+ if (resumed.every((scan) => scan.start !== undefined)) {
959
+ floor = minOf(resumed.map((scan) => BigInt(/** @type {string} */ (scan.start))));
960
+ head = await headOf(read, url);
961
+ } else {
962
+ // Kept without where it began: from where a search from the first
963
+ // begins.
964
+ const fresh = first.notBefore !== null ? { head: await headOf(read, url), start: first.notBefore } : await startBefore(read, url, reach, blockTimes);
965
+
966
+ floor = fresh.start;
967
+ head = fresh.head;
968
+ }
969
+ } else if (resumed.length) {
970
+ // Begun from a transfer before the first now, as one that since
971
+ // arrived: none of the queue's credits is before where a search from
972
+ // the first starts, which it skips to.
973
+ const from = minOf(resumed.map((scan) => BigInt(scan.from)));
974
+ const fresh = first.notBefore !== null ? { head: await headOf(read, url), start: first.notBefore } : await startBefore(read, url, reach, blockTimes);
975
+
976
+ head = fresh.head;
977
+ start = from > fresh.start ? from : fresh.start;
978
+ floor = fresh.start;
803
979
  } else if (first.notBefore !== null) {
804
980
  start = first.notBefore;
981
+ floor = start;
805
982
  head = await headOf(read, url);
806
983
  } else {
807
984
  ({ head, start } = await startBefore(read, url, reach, blockTimes));
985
+ floor = start;
808
986
  }
809
987
 
988
+ if (floor > start) floor = start;
989
+
990
+ // No later than a credit found before: the blocks are read again from it.
991
+ for (const credit of carried.values()) if (credit.block !== null && credit.block < floor) floor = credit.block;
992
+
993
+ // The widest window the blocks are read again in, where the RPC refused a
994
+ // wider one.
995
+ const width = recheck?.size ? BigInt(recheck.size) : BigInt(LOG_WINDOW);
996
+
997
+ /**
998
+ * The given transfers' credits with `found`: those that arrived, by their
999
+ * credits, and those held back (`held`), with the last of their credits'
1000
+ * blocks (`top`). One a later transfer of the queue, still without a
1001
+ * credit, could have had the credit of instead is held back until the
1002
+ * blocks before it are read again `CONFIRM_PASSES` times since it was
1003
+ * found: its own may yet be found there.
1004
+ *
1005
+ * @param {Credit[]} found
1006
+ * @param {boolean} seen
1007
+ */
1008
+ const settled = (found, seen) => {
1009
+ const { assigned, verdicts } = settle(queue, found, rules);
1010
+ const passes = recheck?.passes ?? 0;
1011
+ const mine = new Set(live.map((item) => item.hash));
1012
+ /** @type {Set<string>} */
1013
+ const arrived = new Set();
1014
+ /** @type {Set<string>} */
1015
+ const held = new Set();
1016
+ /** @type {bigint | null} */
1017
+ let top = null;
1018
+
1019
+ queue.forEach((item, i) => {
1020
+ const credit = assigned.get(item.hash);
1021
+
1022
+ if (!credit || !mine.has(item.hash) || verdicts.get(item.hash) !== "arrived" || !(seen || !guarded)) return;
1023
+
1024
+ // A later one without a credit could have had this one instead.
1025
+ const doubt = !guarded && passes - (credit.pass ?? 0) < CONFIRM_PASSES && queue.slice(i + 1).some((later) => !assigned.has(later.hash) && pays(later, credit, rules));
1026
+
1027
+ if (!doubt) {
1028
+ arrived.add(credit.hash);
1029
+ } else {
1030
+ held.add(item.hash);
1031
+ if (credit.block !== null && (top === null || credit.block > top)) top = credit.block;
1032
+ }
1033
+ });
1034
+
1035
+ return { arrived, assigned, held, top, verdicts };
1036
+ };
1037
+ /**
1038
+ * The credits found in this call, with the passes done before the next
1039
+ * one to begin.
1040
+ *
1041
+ * @param {Credit[]} found
1042
+ * @returns {Credit[]}
1043
+ */
1044
+ const mark = (found) => {
1045
+ const pass = recheck === null ? 0 : recheck.passes + (recheck.from !== null ? 1 : 0);
1046
+
1047
+ return found.map((credit) => (credit.pass === undefined ? { ...credit, pass } : credit));
1048
+ };
810
1049
  // Where the credits can't be told apart, only those in the transfers'
811
1050
  // windows count: once they're all over, read no further than the last.
812
1051
  const last = queue[queue.length - 1].at + windowMs;
813
- const { complete, covered, credits, next } = await searchLogs(read, {
1052
+ const until = guarded && now >= last ? last : null;
1053
+ const due = recheck !== null && (recheck.from !== null || now >= recheck.at);
1054
+ const ahead = await searchLogs(read, {
814
1055
  ...filter,
815
1056
  blockTimes,
816
1057
  // Once every transfer of the queue has its credit: read no further.
817
1058
  enough: (found) => !guarded && settle(queue, found, rules).assigned.size === queue.length,
1059
+ floor,
818
1060
  head,
819
1061
  known: [...carried.values()],
820
1062
  maxWindows,
1063
+ overlap: width < OVERLAP ? width : OVERLAP,
821
1064
  start,
822
- until: guarded && now >= last ? last : null,
1065
+ until,
823
1066
  url,
824
1067
  });
1068
+ const { complete, covered, next } = ahead;
825
1069
  const seen = complete || covered;
826
- const { assigned, verdicts } = settle(queue, credits, rules);
827
- /** @type {Set<string>} */
828
- const arrived = new Set();
1070
+ // Its window at the head reached back to where the search began: every
1071
+ // block read before this call is read again, as by a pass.
1072
+ const again = recheck !== null && ahead.low !== null && ahead.low <= floor && start > floor;
1073
+
1074
+ if (again && recheck !== null) recheck = { ...recheck, passes: recheck.passes + 1 };
1075
+
1076
+ let credits = mark(ahead.credits);
1077
+ const before = settled(credits, seen);
1078
+ let { refused, took } = ahead;
1079
+
1080
+ if ((seen || before.held.size) && recheck === null) recheck = { at: before.held.size ? now : now + RECHECK_MS, from: null, passes: 0, since: now, size: null };
1081
+
1082
+ // A transfer not arrived yet: the blocks read before this call, again, as
1083
+ // far as the search ahead reads; while an arrival is held back, as far as
1084
+ // its credit. With what the search ahead leaves of a call's windows, one
1085
+ // at least.
1086
+ if (recheck !== null && (due || before.held.size) && before.arrived.size < live.length) {
1087
+ const from = recheck.from !== null && recheck.from > floor ? recheck.from : floor;
1088
+ const below = ahead.low !== null && ahead.low < start ? ahead.low : start;
1089
+ const pass = await searchLogs(read, {
1090
+ ...filter,
1091
+ blockTimes,
1092
+ // Read through: a credit further on, still before those found ahead,
1093
+ // could be a transfer's first.
1094
+ enough: () => false,
1095
+ floor: from,
1096
+ head: before.top !== null && before.top < below ? before.top : below - 1n,
1097
+ known: credits,
1098
+ maxWindows: maxWindows < 1 ? 0 : Math.max(1, maxWindows - ahead.asked),
1099
+ size: ahead.size < width ? ahead.size : width,
1100
+ start: from,
1101
+ until,
1102
+ url,
1103
+ });
1104
+ const done = pass.complete || pass.covered;
1105
+ // A pass that read no block is none: only one that read the blocks again
1106
+ // counts, and not twice in a call.
1107
+ const counted = done && !again && pass.next > floor;
1108
+
1109
+ recheck = done ? { ...recheck, at: now + Math.max(RECHECK_MS, now - recheck.since), from: null, passes: recheck.passes + (counted ? 1 : 0) } : { ...recheck, from: pass.next };
1110
+ credits = mark(pass.credits);
1111
+ if (pass.refused !== null && (refused === null || pass.refused < refused)) refused = pass.refused;
1112
+ if (pass.took !== null && (took === null || pass.took > took)) took = pass.took;
1113
+ }
829
1114
 
830
- for (const item of live) {
831
- const credit = assigned.get(item.hash);
1115
+ // A window refused: from the next call on, the blocks are read again in
1116
+ // ones as wide as the widest it took narrower than that, else a quarter
1117
+ // as wide.
1118
+ if (recheck !== null && refused !== null) {
1119
+ const fits = took !== null && took < refused ? took : refused / 4n;
1120
+ const narrower = fits > MIN_WINDOW ? fits : MIN_WINDOW;
832
1121
 
833
- if (credit && verdicts.get(item.hash) === "arrived" && (seen || !guarded)) arrived.add(credit.hash);
1122
+ if (narrower < width) recheck = { ...recheck, size: Number(narrower) };
834
1123
  }
835
1124
 
1125
+ const { arrived, assigned, held, verdicts } = settled(credits, seen);
1126
+
1127
+ // Passes go on one after the other while an arrival is held back; once one
1128
+ // arrives with others still waiting, theirs begin again a minute later,
1129
+ // the waits growing from there.
1130
+ if (recheck !== null && held.size) recheck = { ...recheck, at: Math.min(recheck.at, now) };
1131
+ else if (recheck !== null && arrived.size && arrived.size < live.length) recheck = { ...recheck, at: Math.min(recheck.at, now + RECHECK_MS), since: now };
1132
+
836
1133
  // The next search carries on from where this one stopped, with the
837
1134
  // credits found so far: a transfer that arrived may come back from the
838
1135
  // chain among those before the next one, and takes its own again.
@@ -840,15 +1137,19 @@ async function followLogs(read, { filter, given, guarded, queue, rivals, unsure
840
1137
  const scan = {
841
1138
  basis: first.at,
842
1139
  from: next.toString(),
1140
+ ...(recheck ? { recheck: recheckIn(recheck) } : {}),
843
1141
  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 }] : [])),
1142
+ seen: credits.flatMap((credit) => (credit.at >= reach && credit.block !== null ? [{ at: credit.at, block: credit.block.toString(), hash: credit.hash, pass: credit.pass ?? 0, sender: credit.from }] : [])),
1143
+ start: floor.toString(),
845
1144
  };
846
1145
 
847
1146
  for (const item of live) {
848
1147
  const credit = assigned.get(item.hash);
849
1148
  const verdict = verdicts.get(item.hash);
850
1149
 
851
- if (credit && arrived.has(credit.hash)) {
1150
+ if (held.has(item.hash)) {
1151
+ out.set(item.hash, { scan, state: "waiting" });
1152
+ } else if (credit && arrived.has(credit.hash)) {
852
1153
  used.add(credit.hash);
853
1154
  out.set(item.hash, {
854
1155
  amount: credit.wei / WEI_PER_LUX,
@@ -1016,50 +1317,224 @@ async function detailsOf(archive, hashes, { args = false, network }, counter) {
1016
1317
  }
1017
1318
 
1018
1319
  /**
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.
1320
+ * A transfer whose history is read: its hash, its Dusk block's time and
1321
+ * height, the block on the other chain its credit can't be before, and its
1322
+ * scan.
1323
+ *
1324
+ * @typedef {{ hash: string, at: number, height: number, notBefore: bigint | null, scan: Scan | null }} Reading
1325
+ */
1326
+
1327
+ /**
1328
+ * The history a group of equal transfers needs, each transfer's read on
1329
+ * from where its scan stopped: its account's history from `reach` blocks
1330
+ * under its block up to it, and `reach` further down at a time while the
1331
+ * run of equal transfers it is in reaches within `horizonMs` of the bottom.
1332
+ * The transfer itself must be listed there: until it is, the archive
1333
+ * hasn't read its block. Each transfer reads `pages` pages at most, so a
1334
+ * later one's history never keeps an earlier one waiting.
1335
+ *
1336
+ * Those whose history is all read are `covered`, and `given` as the queue
1337
+ * holds them, with `queue`: theirs and the equal transfers found
1338
+ * (`peersOf`), and `unread`: by transfer, the heights it read of those that
1339
+ * could be equal but can't be read. Each one's reading, done or not, is in
1340
+ * `states`.
1341
+ *
1342
+ * @param {Archive} archive
1343
+ * @param {ReadonlyArray<Reading>} items
1344
+ * @param {{ account: string, direction: "in" | "out", horizonMs: number, reach: number, pages: number, keep: (entry: HistoryEntry) => boolean, of: (item: Reading) => string, peersOf: (found: HistoryScan["found"]) => Promise<{ peers: Queued[], unread: ReadonlyArray<{ hash: string, height: number }> }> }} read
1345
+ * @param {{ requests: number }} counter
1346
+ */
1347
+ async function readHistories(archive, items, { account, direction, horizonMs, keep, of, pages, peersOf, reach }, counter) {
1348
+ const walks = items.map((item) => {
1349
+ const kept = item.scan?.history;
1350
+ /** @type {HistoryScan} */
1351
+ const state =
1352
+ kept && kept.of === of(item)
1353
+ ? structuredClone(/** @type {HistoryScan} */ (kept))
1354
+ : { found: [], lo: null, next: { from: Math.max(0, item.height - reach), page: 1, to: item.height }, of: of(item) };
1355
+
1356
+ return { item, left: pages, state };
1357
+ });
1358
+ /** @type {Reading[]} */
1359
+ let covered = [];
1360
+ /** @type {Queued[]} */
1361
+ let given = [];
1362
+ /** @type {Queued[]} */
1363
+ let queue = [];
1364
+ /** @type {Map<string, number[]>} */
1365
+ const unread = new Map();
1366
+
1367
+ for (;;) {
1368
+ // Each one's next range, as far as its pages go.
1369
+ for (const walk of walks) {
1370
+ const { state } = walk;
1371
+
1372
+ while (state.next && walk.left > 0) {
1373
+ const next = /** @type {NonNullable<HistoryScan["next"]>} */ (state.next);
1374
+ const range = await historyRange(archive, { account, direction, from: next.from, page: next.page, pages: walk.left, to: next.to });
1375
+ const found = /** @type {HistoryScan["found"][number][]} */ (state.found);
1376
+
1377
+ counter.requests += range.requests;
1378
+ walk.left -= range.requests;
1379
+
1380
+ for (const entry of range.entries) {
1381
+ if (keep(entry) && !found.some((each) => each.hash === entry.hash)) found.push({ hash: entry.hash, height: entry.blockHeight, value: String(entry.value) });
1382
+ }
1383
+
1384
+ if (!range.complete) {
1385
+ state.next = { ...next, page: next.page + range.requests };
1386
+ } else if (state.lo === null && !found.some((each) => each.hash === walk.item.hash)) {
1387
+ // Its own block isn't read yet: its last page is read again.
1388
+ state.next = { ...next, page: next.page + range.requests - 1 };
1389
+ walk.left = 0;
1390
+ } else {
1391
+ state.lo = next.from;
1392
+ state.next = null;
1393
+ }
1394
+ }
1395
+ }
1396
+
1397
+ const done = walks.filter((walk) => !walk.state.next);
1398
+
1399
+ if (!done.length) {
1400
+ covered = [];
1401
+ given = [];
1402
+ break;
1403
+ }
1404
+
1405
+ given = done.map((walk) => queued(walk.item)).sort(byTime);
1406
+
1407
+ const mine = new Set(given.map((item) => item.hash));
1408
+ // The others found: those given are in the queue as they are.
1409
+ const { peers, unread: unknown } = await peersOf(uniqueByHash(done.flatMap((walk) => walk.state.found)).filter((entry) => !mine.has(entry.hash)));
1410
+ let deeper = false;
1411
+
1412
+ queue = queueOf(given, peers, horizonMs);
1413
+
1414
+ // A run reaching within a horizon of the bottom may go on under it: the
1415
+ // history is read further down.
1416
+ for (const walk of done) {
1417
+ const lo = /** @type {number} */ (walk.state.lo);
1418
+ const bottomAt = lo === 0 ? -Infinity : walk.item.at - (walk.item.height - lo) * DUSK_BLOCK_MS;
1419
+ const self = /** @type {Queued} */ (given.find((each) => each.hash === walk.item.hash));
1420
+
1421
+ if (runStart(queue, self, horizonMs).at - bottomAt < horizonMs) {
1422
+ walk.state.next = { from: Math.max(0, lo - reach), page: 1, to: lo - 1 };
1423
+ deeper = true;
1424
+ } else {
1425
+ unread.set(
1426
+ walk.item.hash,
1427
+ unknown.flatMap((each) => (each.height >= lo && each.height <= walk.item.height ? [each.height] : []))
1428
+ );
1429
+ }
1430
+ }
1431
+
1432
+ if (!deeper) {
1433
+ covered = done.map((walk) => walk.item);
1434
+ break;
1435
+ }
1436
+ }
1437
+
1438
+ return { covered, given, queue, states: new Map(walks.map((walk) => [walk.item.hash, walk.state])), unread };
1439
+ }
1440
+
1441
+ /**
1442
+ * Of the deposits a sender's history lists, those equal to the group's
1443
+ * (`to`, `amount`), from their calls: each with the block the app gave it
1444
+ * its credit can't be before; and those whose call or time the archive
1445
+ * doesn't give, which could be any deposit of up to what they paid, but
1446
+ * for one the app gave: its own facts tell. `calls` keeps each call read.
1025
1447
  *
1026
1448
  * @param {Archive} archive
1027
1449
  * @param {Network} net
1028
- * @param {{ sender: string, from: number, to: number, least: bigint, skip: ReadonlySet<string> }} range
1450
+ * @param {ReadonlyArray<{ hash: string, height: number }>} found
1451
+ * @param {{ amount: bigint, to: string, bounds: ReadonlyMap<string, bigint | null>, calls: Map<string, any>, supplied: ReadonlyMap<string, DepositItem> }} group
1029
1452
  * @param {{ requests: number }} counter
1030
1453
  */
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 });
1454
+ async function depositPeers(archive, net, found, { amount, bounds, calls, supplied, to }, counter) {
1455
+ const missing = found.flatMap((entry) => (calls.has(entry.hash) ? [] : [entry.hash]));
1456
+ const read = await detailsOf(archive, missing, { args: true, network: net }, counter);
1034
1457
 
1035
- counter.requests += read.requests;
1458
+ for (const hash of missing) calls.set(hash, read.get(hash) ?? null);
1036
1459
 
1037
- // A history cut short leaves its deposits' order unclear: nothing more is read.
1038
- if (!read.complete) return { complete: false, deposits: [], unresolved: [] };
1460
+ /** @type {Queued[]} */
1461
+ const peers = [];
1462
+ /** @type {{ hash: string, height: number }[]} */
1463
+ const unread = [];
1039
1464
 
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 = [];
1465
+ for (const entry of found) {
1466
+ const call = calls.get(entry.hash);
1467
+ const bridged = call?.bridge;
1046
1468
 
1047
- for (const entry of candidates) {
1048
- const found = details.get(entry.hash);
1049
- const bridged = found?.bridge;
1469
+ // Failed, or a call that isn't a deposit: no DUSK left for DuskEVM. A
1470
+ // deposit whose arguments can't be read could be any.
1471
+ if (call?.success === false || (bridged && bridged.kind !== "deposit") || (bridged === null && call?.fnName !== BRIDGE_FUNCTIONS.deposit)) continue;
1050
1472
 
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;
1473
+ const own = supplied.get(entry.hash);
1474
+ let at = msOf(call?.timestamp);
1475
+ let paid = luxOf(bridged?.amount);
1476
+ let recipient = addressOf(bridged?.to);
1053
1477
 
1054
- const at = msOf(found?.timestamp);
1055
- const amount = luxOf(bridged?.amount);
1056
- const recipient = addressOf(bridged?.to);
1478
+ // One the app gave whose call can't be read: its own facts.
1479
+ if (own && (!call || at === null || paid === null || !recipient)) ({ amount: paid, at, to: recipient } = own);
1057
1480
 
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 });
1481
+ if (at === null || paid === null || !recipient) unread.push(entry);
1482
+ else if (recipient === to && paid === amount) peers.push({ at, given: false, hash: entry.hash, notBefore: bounds.get(entry.hash) ?? null, scan: null });
1060
1483
  }
1061
1484
 
1062
- return { complete: true, deposits, unresolved };
1485
+ return { peers, unread };
1486
+ }
1487
+
1488
+ /**
1489
+ * Where a scan's search stopped, without its history.
1490
+ *
1491
+ * @param {Scan | null | undefined} scan
1492
+ * @returns {Scan}
1493
+ */
1494
+ function searchOf(scan) {
1495
+ if (scan?.from === undefined) return {};
1496
+
1497
+ /** @type {Scan} */
1498
+ const out = { basis: scan.basis, from: scan.from, search: scan.search, seen: scan.seen };
1499
+
1500
+ if (scan.start !== undefined) out.start = scan.start;
1501
+ if (scan.recheck !== undefined) out.recheck = scan.recheck;
1502
+
1503
+ return out;
1504
+ }
1505
+
1506
+ /**
1507
+ * The transfers whose history isn't all read yet wait, with where their
1508
+ * reading stopped beside where their last search did.
1509
+ *
1510
+ * @param {ReadonlyArray<{ hash: string, scan: Scan | null }>} items
1511
+ * @param {{ covered: ReadonlyArray<{ hash: string }>, states: ReadonlyMap<string, HistoryScan> }} history
1512
+ * @param {Map<string, Arrival>} results
1513
+ */
1514
+ function waitForHistory(items, { covered, states }, results) {
1515
+ const read = new Set(covered.map((item) => item.hash));
1516
+
1517
+ for (const item of items) {
1518
+ if (!read.has(item.hash)) results.set(item.hash, { scan: { ...searchOf(item.scan), history: /** @type {HistoryScan} */ (states.get(item.hash)) }, state: "waiting" });
1519
+ }
1520
+ }
1521
+
1522
+ /**
1523
+ * A result, with how far its transfer's history is read, which every one
1524
+ * but an arrival keeps in its scan for the next call.
1525
+ *
1526
+ * @param {string} hash
1527
+ * @param {Arrival} arrival
1528
+ * @param {ReadonlyArray<Queued>} given
1529
+ * @param {ReadonlyMap<string, HistoryScan>} states
1530
+ * @returns {Arrival}
1531
+ */
1532
+ function withHistory(hash, arrival, given, states) {
1533
+ const history = states.get(hash);
1534
+
1535
+ if (!history || arrival.state === "arrived") return arrival;
1536
+
1537
+ return { ...arrival, scan: { ...(arrival.scan ?? searchOf(given.find((each) => each.hash === hash)?.scan)), history } };
1063
1538
  }
1064
1539
 
1065
1540
  // ---------------------------------------------------------------------------
@@ -1380,34 +1855,6 @@ async function onExplorer(read, live, { api, bridge, maxPages, now, past, result
1380
1855
  * @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
1856
  */
1382
1857
  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
1858
  // Equal deposits (the same DuskEVM address and amount) from a known sender
1412
1859
  // first, a sender at a time, its events telling its credits apart; then
1413
1860
  // those whose sender isn't known, which take none from the senders of the
@@ -1438,46 +1885,69 @@ async function onRpc(read, live, { archive, blockTimes, bridge, list, maxWindows
1438
1885
  if (rest.length) unknown.push({ items: rest, rivals: aliases.get(key) ?? new Set() });
1439
1886
  }
1440
1887
 
1441
- // The block each deposit given can't be credited before, the app's own.
1888
+ // The block each deposit given can't be credited before, the app's own,
1889
+ // the deposits given, and the deposits' calls, read once.
1442
1890
  const bounds = new Map(list.map((item) => [item.hash, item.notBefore]));
1891
+ const supplied = new Map(list.map((item) => [item.hash, item]));
1892
+ /** @type {Map<string, any>} */
1893
+ const calls = new Map();
1894
+ const contract = duskEvmContracts(net)?.bridge;
1443
1895
 
1444
1896
  for (const { items, rivals } of [...known, ...unknown]) {
1445
1897
  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
- );
1898
+ const vouched = Boolean(archive) && sender !== null && items.every((item) => item.height !== null);
1899
+ let given = items.map(queued).sort(byTime);
1900
+ /** @type {Queued[]} */
1901
+ let queue;
1461
1902
  /** @type {Set<string>} */
1462
1903
  const unsure = new Set();
1904
+ /** @type {Map<string, HistoryScan>} */
1905
+ let states = new Map();
1463
1906
 
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
- }
1907
+ if (vouched) {
1908
+ // Each deposit's own hours of its sender's history, with its sender's
1909
+ // equal deposits there, the app's no longer looked for among them.
1910
+ /** @type {Awaited<ReturnType<typeof readHistories>>} */
1911
+ let history;
1912
+
1913
+ try {
1914
+ history = await readHistories(
1915
+ /** @type {Archive} */ (archive),
1916
+ /** @type {Reading[]} */ ([...items].sort((a, b) => /** @type {number} */ (a.height) - /** @type {number} */ (b.height))),
1917
+ {
1918
+ account: /** @type {string} */ (sender),
1919
+ direction: "out",
1920
+ horizonMs: DEPOSIT_HORIZON_MS,
1921
+ keep: (entry) => entry.counterparty === contract && entry.value >= amount,
1922
+ of: (item) => `${sender}:${item.height}:${amount}`,
1923
+ pages: HISTORY_PAGES,
1924
+ peersOf: (found) => depositPeers(/** @type {Archive} */ (archive), net, found, { amount, bounds, calls, supplied, to }, read.counter),
1925
+ reach: Math.ceil((3 * DEPOSIT_HORIZON_MS) / DUSK_BLOCK_MS),
1926
+ },
1927
+ read.counter
1928
+ );
1929
+ } catch (error) {
1930
+ for (const item of items) results.set(item.hash, { error: messageOf(error), reason: "error", state: "unknown" });
1931
+ continue;
1480
1932
  }
1933
+
1934
+ states = history.states;
1935
+ waitForHistory(items, history, results);
1936
+
1937
+ if (!history.covered.length) continue;
1938
+
1939
+ given = history.given;
1940
+ queue = history.queue;
1941
+
1942
+ // One before it whose call can't be read could be equal: the credits'
1943
+ // order can't be told.
1944
+ for (const item of given) if (/** @type {number[]} */ (history.unread.get(item.hash)).length) unsure.add(item.hash);
1945
+ } else {
1946
+ queue = withPast(
1947
+ given,
1948
+ past.filter((item) => item.to === to && item.amount === amount && item.sender === sender).map(queued),
1949
+ windowMs
1950
+ );
1481
1951
  }
1482
1952
 
1483
1953
  try {
@@ -1499,9 +1969,9 @@ async function onRpc(read, live, { archive, blockTimes, bridge, list, maxWindows
1499
1969
  { blockTimes, maxWindows, now, slackMs: CLOCK_SLACK_MS, url, used, windowMs }
1500
1970
  );
1501
1971
 
1502
- for (const [hash, arrival] of found) results.set(hash, arrival);
1972
+ for (const [hash, arrival] of found) results.set(hash, withHistory(hash, arrival, given, states));
1503
1973
  } catch (error) {
1504
- for (const item of items) results.set(item.hash, { error: messageOf(error), reason: "error", state: "unknown" });
1974
+ for (const item of given) results.set(item.hash, withHistory(item.hash, { error: messageOf(error), reason: "error", state: "unknown" }, given, states));
1505
1975
  }
1506
1976
  }
1507
1977
  }
@@ -1602,97 +2072,62 @@ export async function serviceArrivals({
1602
2072
  }
1603
2073
 
1604
2074
  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
2075
  // The fee of each transfer the app gave, those no longer looked for too,
1630
2076
  // and the block its payout can't be before.
1631
2077
  const feeOf = new Map(routed.map((item) => [item.hash, item.fee ?? /** @type {Route & { kind: "service" }} */ (item.route).feeLux]));
1632
2078
  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
- }
2079
+ // What each transfer to a route's account paid whom, its memo and its
2080
+ // time, from its own transaction, read once.
2081
+ /** @type {Map<string, TransferFacts | null>} */
2082
+ const facts = new Map(sent);
1655
2083
 
1656
- for (const [i, items] of groups.entries()) {
2084
+ for (const items of groupBy(live, (item) => `${item.route.id}:${item.to}:${item.net}`).values()) {
1657
2085
  const { net: payout, route, to } = items[0];
1658
- const history = /** @type {NonNullable<ReturnType<typeof histories.get>>} */ (histories.get(route.id));
2086
+ const horizonMs = horizonOf(route);
1659
2087
  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
- }
2088
+ const least = payout + [...fees].reduce((a, fee) => (fee < a ? fee : a));
2089
+ // Each transfer's own hours of the route's account's history: every
2090
+ // transfer that named the same address, in any case, and could be paid
2091
+ // before, the app's no longer looked for among them. Of those, the ones
2092
+ // that could pay out as much under the group's fees, or under their own.
2093
+ /** @type {Awaited<ReturnType<typeof readHistories>>} */
2094
+ let history;
2095
+
2096
+ try {
2097
+ history = await readHistories(
2098
+ archive,
2099
+ [...items].sort((a, b) => a.height - b.height),
2100
+ {
2101
+ account: route.account,
2102
+ direction: "in",
2103
+ horizonMs,
2104
+ keep: (entry) => entry.kind === "transfer" && entry.direction === "in" && entry.memo.toLowerCase() === to && entry.value >= payout,
2105
+ of: (item) => `${route.account}:${item.height}:${to}:${payout}`,
2106
+ pages: HISTORY_PAGES,
2107
+ peersOf: (found) =>
2108
+ servicePeers(
2109
+ archive,
2110
+ found.filter((entry) => BigInt(entry.value) >= least || feeOf.has(entry.hash)),
2111
+ { bounds, facts, feeOf, fees, payout, route, to },
2112
+ read.counter
2113
+ ),
2114
+ reach: Math.ceil((3 * horizonMs) / DUSK_BLOCK_MS),
2115
+ },
2116
+ read.counter
2117
+ );
2118
+ } catch (error) {
2119
+ for (const item of items) results.set(item.hash, failed(error));
2120
+ continue;
1681
2121
  }
1682
2122
 
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();
2123
+ waitForHistory(items, history, results);
1688
2124
 
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;
2125
+ if (!history.covered.length) continue;
1693
2126
 
1694
- if (!history.complete || runStart(queue, item, history.horizonMs).at - bottomAt < history.horizonMs || unread.some((each) => each <= height)) unsure.add(item.hash);
1695
- }
2127
+ const { given } = history;
2128
+ // One before it that can't be read could be equal: the payouts' order
2129
+ // can't be told.
2130
+ const unsure = new Set(given.flatMap((item) => (/** @type {number[]} */ (history.unread.get(item.hash)).length ? [item.hash] : [])));
1696
2131
 
1697
2132
  try {
1698
2133
  const found = await followLogs(
@@ -1706,21 +2141,63 @@ export async function serviceArrivals({
1706
2141
  },
1707
2142
  given,
1708
2143
  guarded: false,
1709
- queue,
2144
+ queue: history.queue,
1710
2145
  unsure,
1711
2146
  },
1712
2147
  { blockTimes, maxWindows, now, slackMs: CLOCK_SLACK_MS, url: String(rpcUrl ?? route.chain.rpcUrl), used, windowMs: CREDIT_WINDOW_MS }
1713
2148
  );
1714
2149
 
1715
- for (const [hash, arrival] of found) results.set(hash, arrival);
2150
+ for (const [hash, arrival] of found) results.set(hash, withHistory(hash, arrival, given, history.states));
1716
2151
  } catch (error) {
1717
- for (const item of items) results.set(item.hash, failed(error));
2152
+ for (const item of given) results.set(item.hash, withHistory(item.hash, failed(error), given, history.states));
1718
2153
  }
1719
2154
  }
1720
2155
 
1721
2156
  return done();
1722
2157
  }
1723
2158
 
2159
+ /**
2160
+ * Of the transfers a route's account received naming a group's address,
2161
+ * those equal to the group's, from their own transactions: one the app
2162
+ * gave pays out what its own fee leaves, one it didn't what the group's
2163
+ * fees do; each with the block the app gave it its payout can't be before.
2164
+ * And those whose transaction can't be read. `facts` keeps each
2165
+ * transaction read.
2166
+ *
2167
+ * @param {Archive} archive
2168
+ * @param {ReadonlyArray<{ hash: string, height: number }>} found
2169
+ * @param {{ bounds: ReadonlyMap<string, bigint | null>, facts: Map<string, TransferFacts | null>, feeOf: ReadonlyMap<string, bigint>, fees: ReadonlySet<bigint>, payout: bigint, route: Route & { kind: "service" }, to: string }} group
2170
+ * @param {{ requests: number }} counter
2171
+ */
2172
+ async function servicePeers(archive, found, { bounds, facts, feeOf, fees, payout, route, to }, counter) {
2173
+ const missing = found.flatMap((entry) => (facts.has(entry.hash) ? [] : [entry.hash]));
2174
+ const read = await transfersOf(archive, missing, counter);
2175
+
2176
+ for (const hash of missing) facts.set(hash, read.get(hash) ?? null);
2177
+
2178
+ /** @type {Queued[]} */
2179
+ const peers = [];
2180
+ /** @type {{ hash: string, height: number }[]} */
2181
+ const unread = [];
2182
+
2183
+ for (const entry of found) {
2184
+ const fact = facts.get(entry.hash);
2185
+
2186
+ if (!fact || fact.kind === null || fact.at === null || (fact.kind === "moonlight" && (fact.value === null || fact.receiver === null))) {
2187
+ unread.push(entry);
2188
+ } else if (!fact.failed && fact.kind === "moonlight" && fact.receiver === route.account && fact.memo.toLowerCase() === to) {
2189
+ const value = /** @type {bigint} */ (fact.value);
2190
+ const own = feeOf.get(entry.hash);
2191
+
2192
+ if (own === undefined ? [...fees].some((fee) => value - fee === payout) : value - own === payout) {
2193
+ peers.push({ at: fact.at, given: false, hash: entry.hash, notBefore: bounds.get(entry.hash) ?? null, scan: null });
2194
+ }
2195
+ }
2196
+ }
2197
+
2198
+ return { peers, unread };
2199
+ }
2200
+
1724
2201
  // ---------------------------------------------------------------------------
1725
2202
  // Migrations into Dusk
1726
2203
  // ---------------------------------------------------------------------------