@dusk-network/app-core 0.3.1 → 0.4.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/src/bridge.js CHANGED
@@ -47,9 +47,10 @@ import { DETAILS_BATCH, TRANSFER_CONTRACT, fetchTxDetails, historyRange } from "
47
47
  /**
48
48
  * A credit on the other chain: its transaction, its block and time, its
49
49
  * amount in wei, whether it went through, and the address it came from as
50
- * 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`).
51
52
  *
52
- * @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
53
54
  */
54
55
 
55
56
  /**
@@ -75,14 +76,27 @@ const MAX_U64 = (1n << 64n) - 1n;
75
76
  const TIMEOUT_MS = 8_000;
76
77
  // About one Dusk block every 10 seconds: how far back a sender's history is read.
77
78
  const DUSK_BLOCK_MS = 10_000;
78
- // The blocks the chain's pace is read over, how much further back than the
79
- // pace says a search starts, and how often it steps back again from a block
80
- // that is still later than the transfer.
79
+ // The blocks the chain's pace is read over, from a block rounded down to a
80
+ // multiple of `PACE_STEP` so that the app's cache keeps its time, and how
81
+ // long before the transfer a search starts as the pace says: the block is
82
+ // then checked by its time, and stepped back while it's still later.
81
83
  const PACE_SAMPLE = 100_000n;
82
- const PACE_MARGIN = 1.2;
83
- const START_MARGIN = 50n;
84
- const START_CHECKS = 4;
84
+ const PACE_STEP = 10_000n;
85
+ const START_MS = 10 * 60_000;
85
86
  const MIN_WINDOW = 50n;
87
+ // The widest a search's windows grow, while the RPC answers them whole.
88
+ const MAX_WINDOW = 100_000n;
89
+ // How far back from the head a search's last window reaches: those blocks
90
+ // are read again on each call.
91
+ const OVERLAP = 1_000n;
92
+ // How long after a search first reaches the head it reads its blocks again
93
+ // from its first one; each pass after waits as long again as it has been
94
+ // since then, so the waits about double each time.
95
+ const RECHECK_MS = 60_000;
96
+ // How many passes over the blocks before a credit, since it was found, a
97
+ // transfer waits for before it takes the credit where a later equal one,
98
+ // still without its own, could have had it instead.
99
+ const CONFIRM_PASSES = 8;
86
100
  // What an RPC says when it won't search that many blocks at once.
87
101
  const RANGE_REFUSED = /limit|range|too many|exceed/i;
88
102
  const DUSK_HASH = /^[0-9a-f]{64}$/;
@@ -233,9 +247,9 @@ function historyScanOf(value) {
233
247
  }
234
248
 
235
249
  /**
236
- * Where a search stopped, what it searched for, and the credits it found
237
- * before, and how far the transfer's history is read, as a result gave
238
- * them; null for anything else.
250
+ * Where a search stopped, what it searched for, the credits it found
251
+ * before, the width of its windows, and how far the transfer's history is
252
+ * read, as a result gave them; null for anything else.
239
253
  *
240
254
  * @param {unknown} value
241
255
  * @returns {Scan | null}
@@ -253,11 +267,25 @@ function scanOf(value) {
253
267
  const block = quantityOf(credit?.block);
254
268
  const at = msOf(credit?.at);
255
269
  const sender = credit?.sender === null ? null : addressOf(credit?.sender);
270
+ // The passes done before it was found: none where it isn't said.
271
+ const pass = Number.isSafeInteger(credit?.pass) && credit.pass >= 0 ? credit.pass : 0;
256
272
 
257
- return hash && block !== null && at !== null && sender !== "" ? [{ at, block: block.toString(), hash, sender }] : [];
273
+ return hash && block !== null && at !== null && sender !== "" ? [{ at, block: block.toString(), hash, pass, sender }] : [];
258
274
  });
275
+ // Where it began, and its passes over the blocks again: without them,
276
+ // as a search kept by an earlier version.
277
+ const start = quantityOf(scan.start);
278
+ const recheck = start === null ? null : recheckOf(scan.recheck);
279
+ // The width of its windows, as an earlier version kept it for its passes
280
+ // where it isn't said; and the narrowest the RPC refused.
281
+ const size = Number.isSafeInteger(scan.size) && scan.size >= Number(MIN_WINDOW) && scan.size <= Number(MAX_WINDOW) ? scan.size : (recheck?.size ?? null);
282
+ const refused = Number.isSafeInteger(scan.refused) && scan.refused > 0 ? scan.refused : null;
259
283
 
260
284
  Object.assign(out, { basis: scan.basis, from: from.toString(), search: scan.search, seen });
285
+ if (start !== null) out.start = start.toString();
286
+ if (recheck) out.recheck = recheckIn(recheck);
287
+ if (size !== null) out.size = size;
288
+ if (refused !== null) out.refused = refused;
261
289
  } else if (!history) {
262
290
  return null;
263
291
  }
@@ -375,6 +403,10 @@ function reader(fetchFn, timeoutMs) {
375
403
 
376
404
  return {
377
405
  counter,
406
+ // What a call reads once for all its searches, by what and where: a
407
+ // chain's head and pace.
408
+ /** @type {Map<string, Promise<any>>} */
409
+ once: new Map(),
378
410
 
379
411
  /**
380
412
  * @param {string} url
@@ -450,6 +482,36 @@ function clustersOf(queue, windowMs) {
450
482
  */
451
483
  const allOpen = (queue, windowMs, now) => clustersOf(queue, windowMs).every((cluster) => now < cluster[cluster.length - 1].at + windowMs);
452
484
 
485
+ /**
486
+ * How a group's credits are matched: see `settle`.
487
+ *
488
+ * @typedef {{ guarded: boolean, now: number, slackMs: number, used: ReadonlySet<string>, windowMs: number, rivals?: ReadonlySet<string> }} Rules
489
+ */
490
+
491
+ /**
492
+ * Whether a credit could pay a transfer, whether or not another one took it:
493
+ * it went through, isn't one the app gave another of its transfers when
494
+ * this one is the app's, comes from none of `rivals`, and is in the
495
+ * transfer's time and at or after the block it can't be before.
496
+ *
497
+ * @param {Queued} item
498
+ * @param {Credit} credit
499
+ * @param {Rules} rules
500
+ */
501
+ function pays(item, credit, { guarded, rivals = new Set(), slackMs, used, windowMs }) {
502
+ const lo = item.at - (guarded ? windowMs : slackMs);
503
+ const hi = guarded ? item.at + windowMs : Infinity;
504
+
505
+ return (
506
+ credit.success &&
507
+ (!item.given || !used.has(credit.hash)) &&
508
+ (credit.from === null || !rivals.has(credit.from)) &&
509
+ credit.at >= lo &&
510
+ credit.at <= hi &&
511
+ (item.notBefore === null || credit.block === null || credit.block >= item.notBefore)
512
+ );
513
+ }
514
+
453
515
  /**
454
516
  * Each transfer of a queue of equal ones, oldest first, takes the first
455
517
  * credit that went through after it that no earlier one took: from
@@ -470,10 +532,11 @@ const allOpen = (queue, windowMs, now) => clustersOf(queue, windowMs).every((clu
470
532
  *
471
533
  * @param {ReadonlyArray<Queued>} queue oldest first
472
534
  * @param {ReadonlyArray<Credit>} credits
473
- * @param {{ guarded: boolean, now: number, slackMs: number, used: ReadonlySet<string>, windowMs: number, rivals?: ReadonlySet<string> }} rules
535
+ * @param {Rules} rules
474
536
  * @returns {{ assigned: Map<string, Credit>, verdicts: Map<string, Verdict> }}
475
537
  */
476
- function settle(queue, credits, { guarded, now, rivals = new Set(), slackMs, used, windowMs }) {
538
+ function settle(queue, credits, rules) {
539
+ const { guarded, now, used, windowMs } = rules;
477
540
  const sorted = [...credits].sort(byTime);
478
541
  /** @type {Map<string, Credit>} */
479
542
  const assigned = new Map();
@@ -482,18 +545,7 @@ function settle(queue, credits, { guarded, now, rivals = new Set(), slackMs, use
482
545
  const verdicts = new Map();
483
546
 
484
547
  for (const item of queue) {
485
- const lo = item.at - (guarded ? windowMs : slackMs);
486
- const hi = guarded ? item.at + windowMs : Infinity;
487
- const credit = sorted.find(
488
- (c) =>
489
- c.success &&
490
- !taken.has(c.hash) &&
491
- (!item.given || !used.has(c.hash)) &&
492
- (c.from === null || !rivals.has(c.from)) &&
493
- c.at >= lo &&
494
- c.at <= hi &&
495
- (item.notBefore === null || c.block === null || c.block >= item.notBefore)
496
- );
548
+ const credit = sorted.find((c) => !taken.has(c.hash) && pays(item, c, rules));
497
549
 
498
550
  if (credit) {
499
551
  assigned.set(item.hash, credit);
@@ -653,80 +705,125 @@ async function blockTime(read, url, block, cache) {
653
705
 
654
706
  if (typeof kept === "number" && Number.isFinite(kept)) return kept;
655
707
 
656
- const seconds = quantityOf((await read.rpc(url, "eth_getBlockByNumber", [hex(block), false]))?.timestamp);
708
+ return once(read, `time ${url} ${block}`, async () => {
709
+ const seconds = quantityOf((await read.rpc(url, "eth_getBlockByNumber", [hex(block), false]))?.timestamp);
657
710
 
658
- if (seconds === null) throw new Error(`The chain gave no time for block ${block}`);
711
+ if (seconds === null) throw new Error(`The chain gave no time for block ${block}`);
659
712
 
660
- const at = Number(seconds) * 1000;
713
+ const at = Number(seconds) * 1000;
661
714
 
662
- cache?.set(Number(block), at);
715
+ cache?.set(Number(block), at);
663
716
 
664
- return at;
717
+ return at;
718
+ });
665
719
  }
666
720
 
667
721
  /**
722
+ * What a call reads once for all its searches on a chain.
723
+ *
724
+ * @template T
725
+ * @param {Reader} read
726
+ * @param {string} key
727
+ * @param {() => Promise<T>} get
728
+ * @returns {Promise<T>}
729
+ */
730
+ function once(read, key, get) {
731
+ const kept = read.once.get(key);
732
+
733
+ if (kept) return kept;
734
+
735
+ const got = get();
736
+
737
+ read.once.set(key, got);
738
+ // One that failed is read again by the next search.
739
+ got.catch(() => read.once.delete(key));
740
+
741
+ return got;
742
+ }
743
+
744
+ /**
745
+ * The chain's head, read once a call: the head its pace was read with, where
746
+ * the call read that.
747
+ *
668
748
  * @param {Reader} read
669
749
  * @param {string} url
670
750
  * @returns {Promise<bigint>}
671
751
  */
672
752
  async function headOf(read, url) {
673
- const head = quantityOf(await read.rpc(url, "eth_blockNumber", []));
753
+ const paced = read.once.get(`pace ${url}`);
754
+
755
+ if (paced) return (await paced).head;
674
756
 
675
- if (head === null) throw new Error("The chain gave no head");
757
+ return once(read, `head ${url}`, async () => {
758
+ const head = quantityOf(await read.rpc(url, "eth_blockNumber", []));
676
759
 
677
- return head;
760
+ if (head === null) throw new Error("The chain gave no head");
761
+
762
+ return head;
763
+ });
678
764
  }
679
765
 
680
766
  /**
681
- * The chain's head, and a block from before `atMs`: estimated from the
682
- * chain's pace over its last 100,000 blocks, a fifth further back, then
683
- * stepped back while its own time is still later, twice as far each time,
684
- * as the chain's pace may have changed.
767
+ * The chain's head, its time, and its pace (blocks a second) over its last
768
+ * 100,000 blocks or so, read once a call.
685
769
  *
686
770
  * @param {Reader} read
687
771
  * @param {string} url
688
- * @param {number} atMs
689
772
  * @param {BlockTimes | undefined} cache
690
- * @returns {Promise<{ head: bigint, start: bigint }>}
773
+ * @returns {Promise<{ head: bigint, seconds: number, pace: number }>}
691
774
  */
692
- async function startBefore(read, url, atMs, cache) {
693
- const latest = await read.rpc(url, "eth_getBlockByNumber", ["latest", false]);
694
- const head = quantityOf(latest?.number);
695
- const headSeconds = quantityOf(latest?.timestamp);
775
+ function paceOf(read, url, cache) {
776
+ return once(read, `pace ${url}`, async () => {
777
+ const latest = await read.rpc(url, "eth_getBlockByNumber", ["latest", false]);
778
+ const head = quantityOf(latest?.number);
779
+ const headSeconds = quantityOf(latest?.timestamp);
696
780
 
697
- if (head === null || headSeconds === null) throw new Error("The chain gave no head");
781
+ if (head === null || headSeconds === null) throw new Error("The chain gave no head");
698
782
 
699
- cache?.set(Number(head), Number(headSeconds) * 1000);
783
+ cache?.set(Number(head), Number(headSeconds) * 1000);
784
+ read.once.set(`time ${url} ${head}`, Promise.resolve(Number(headSeconds) * 1000));
700
785
 
701
- const back = head < PACE_SAMPLE ? head : PACE_SAMPLE;
702
- // Blocks a second.
703
- let pace = 1;
786
+ const back = head < PACE_SAMPLE ? 0n : ((head - PACE_SAMPLE) / PACE_STEP) * PACE_STEP;
787
+ let pace = 1;
704
788
 
705
- if (back > 0n) {
706
- const sample = await blockTime(read, url, head - back, cache);
707
- const span = Number(headSeconds) - sample / 1000;
789
+ if (back < head) {
790
+ const span = Number(headSeconds) - (await blockTime(read, url, back, cache)) / 1000;
708
791
 
709
- if (span > 0) pace = Number(back) / span;
710
- }
792
+ if (span > 0) pace = Number(head - back) / span;
793
+ }
711
794
 
712
- /**
713
- * @param {bigint} block
714
- * @param {number} seconds
715
- * @param {number} [times]
716
- */
717
- const stepBack = (block, seconds, times = 1) => {
718
- const step = BigInt(Math.ceil(Math.max(0, seconds) * pace * PACE_MARGIN * times)) + START_MARGIN;
795
+ return { head, pace, seconds: Number(headSeconds) };
796
+ });
797
+ }
798
+
799
+ /**
800
+ * The chain's head, and a block from before `atMs`: the block 10 minutes
801
+ * before it, as the chain's pace says, checked by its time, and stepped back
802
+ * while that's still later, twice as far each time, as the chain's pace may
803
+ * have changed.
804
+ *
805
+ * @param {Reader} read
806
+ * @param {string} url
807
+ * @param {number} atMs
808
+ * @param {BlockTimes | undefined} cache
809
+ * @returns {Promise<{ head: bigint, start: bigint }>}
810
+ */
811
+ async function startBefore(read, url, atMs, cache) {
812
+ const { head, pace, seconds } = await paceOf(read, url, cache);
813
+ /** @param {bigint} block @param {number} ms @param {number} times */
814
+ const back = (block, ms, times) => {
815
+ const step = BigInt(Math.ceil((Math.max(0, ms) / 1000) * pace * times));
719
816
 
720
817
  return block > step ? block - step : 0n;
721
818
  };
722
- let start = stepBack(head, Number(headSeconds) - atMs / 1000);
819
+ let start = back(head, seconds * 1000 - atMs + START_MS, 1);
723
820
 
724
- for (let check = 0; check < START_CHECKS && start > 0n; check += 1) {
821
+ for (let times = 2; start > 0n; times *= 2) {
725
822
  const at = await blockTime(read, url, start, cache);
726
823
 
727
824
  if (at <= atMs) break;
728
825
 
729
- start = stepBack(start, (at - atMs) / 1000, 2 ** (check + 1));
826
+ start = back(start, at - atMs + START_MS, times);
730
827
  }
731
828
 
732
829
  return { head, start };
@@ -734,41 +831,75 @@ async function startBefore(read, url, atMs, cache) {
734
831
 
735
832
  /**
736
833
  * The credits asked for from block `start`, a window of blocks at a time, at
737
- * most `maxWindows`, until the head, `enough`, or a window ending after
738
- * `until` (`covered`): each checked against what was asked, with its block's
739
- * time. A window the RPC won't search at once is asked again a quarter as
740
- * wide.
834
+ * most `maxWindows`, until `head`, `enough`, or a window ending after
835
+ * `until` (`covered`, as the block before `start` may already be, where a
836
+ * search before stopped there): each checked against what was asked, with
837
+ * its block's time. Each window is `size` blocks wide, twice as wide after
838
+ * one the RPC answered whole, up to `MAX_WINDOW`, and narrower than any it
839
+ * refused (`refused`, the narrowest). One it won't search at once, or one
840
+ * wider than `LOG_WINDOW` that isn't answered, is asked again a quarter as
841
+ * wide, or as wide as the widest it answered where that's wider: `size` and
842
+ * `refused` are as the search leaves them. The window ending at `head`
843
+ * reaches back up to `overlap` blocks from it, no wider than `size`, though
844
+ * not under `floor`: those blocks are read again (from `low`). `asked`: the
845
+ * windows asked.
741
846
  *
742
847
  * @param {Reader} read
743
- * @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
744
- * @returns {Promise<{ complete: boolean, covered: boolean, credits: Credit[], next: bigint }>}
848
+ * @param {{ url: string, address: string, topics: ReadonlyArray<string | null>, wei: bigint, amountOf: (data: string) => bigint | null, start: bigint, floor: bigint, head: bigint, size: bigint, refused: bigint | null, overlap?: bigint, maxWindows: number, blockTimes: BlockTimes | undefined, enough: (credits: Credit[]) => boolean, until: number | null, known: Credit[] }} search
849
+ * @returns {Promise<{ asked: number, complete: boolean, covered: boolean, credits: Credit[], low: bigint | null, next: bigint, refused: bigint | null, size: bigint }>}
745
850
  */
746
- async function searchLogs(read, { address, amountOf, blockTimes, enough, head, known, maxWindows, start, topics, until, url, wei }) {
851
+ async function searchLogs(read, { address, amountOf, blockTimes, enough, floor, head, known, maxWindows, overlap = OVERLAP, refused: kept, size: width, start, topics, until, url, wei }) {
747
852
  // What an earlier search found before `start`.
748
853
  /** @type {Credit[]} */
749
854
  const credits = [...known];
750
855
  let from = start;
751
- let size = BigInt(LOG_WINDOW);
856
+ let size = width;
857
+ let refused = kept;
752
858
  let covered = false;
859
+ let asked = 0;
860
+ /** @type {bigint | null} */
861
+ let low = null;
862
+ /** @type {bigint | null} */
863
+ let took = null;
864
+
865
+ if (until !== null && start > floor && start <= head) covered = (await blockTime(read, url, start - 1n, blockTimes)) > until;
753
866
 
754
- for (let round = 0; round < maxWindows && from <= head; round += 1) {
867
+ while (!covered && asked < maxWindows && from <= head) {
755
868
  const to = from + size - 1n < head ? from + size - 1n : head;
869
+ const back = head + 1n - (size < overlap ? size : overlap);
870
+ const lo = to === head && back < from ? (back > floor ? back : floor) : from;
871
+ const blocks = to - lo + 1n;
756
872
  /** @type {any} */
757
873
  let logs;
758
874
 
875
+ asked += 1;
876
+
759
877
  try {
760
- logs = await read.rpc(url, "eth_getLogs", [{ address, fromBlock: hex(from), toBlock: hex(to), topics }]);
878
+ logs = await read.rpc(url, "eth_getLogs", [{ address, fromBlock: hex(lo), toBlock: hex(to), topics }]);
761
879
  } catch (error) {
762
- if (size > MIN_WINDOW && RANGE_REFUSED.test(messageOf(error))) {
763
- size = size / 4n > MIN_WINDOW ? size / 4n : MIN_WINDOW;
880
+ if (blocks > MIN_WINDOW && (blocks > BigInt(LOG_WINDOW) || RANGE_REFUSED.test(messageOf(error)))) {
881
+ const fits = took !== null && took < blocks && took > blocks / 4n ? took : blocks / 4n;
882
+
883
+ if (refused === null || blocks < refused) refused = blocks;
884
+ size = fits > MIN_WINDOW ? fits : MIN_WINDOW;
764
885
  continue;
765
886
  }
766
887
 
767
888
  throw error;
768
889
  }
769
890
 
891
+ if (to === head) low = lo;
892
+ if (took === null || blocks > took) took = blocks;
893
+
894
+ // Answered whole: the next one twice as wide, while narrower than any refused.
895
+ if (blocks === size) {
896
+ const wider = size * 2n < MAX_WINDOW ? size * 2n : MAX_WINDOW;
897
+
898
+ if (refused === null || wider < refused) size = wider;
899
+ }
900
+
770
901
  for (const log of Array.isArray(logs) ? logs : []) {
771
- const found = creditLog(log, { address, amountOf, from, to, topics, wei });
902
+ const found = creditLog(log, { address, amountOf, from: lo, to, topics, wei });
772
903
 
773
904
  if (!found || credits.some((credit) => credit.hash === found.hash)) continue;
774
905
  if (found.at !== null) blockTimes?.set(Number(found.block), found.at);
@@ -785,7 +916,40 @@ async function searchLogs(read, { address, amountOf, blockTimes, enough, head, k
785
916
  }
786
917
  }
787
918
 
788
- return { complete: from > head, covered, credits, next: from };
919
+ return { asked, complete: from > head, covered, credits, low, next: from, refused, size };
920
+ }
921
+
922
+ /**
923
+ * A search's passes over its blocks again, as a result gave them: where the
924
+ * one under way is (null between two), when the next begins, how many are
925
+ * done, when the search first reached the head, and the width of the
926
+ * windows it read them in, as an earlier version kept it; null for anything
927
+ * else.
928
+ *
929
+ * @param {unknown} value
930
+ * @returns {{ at: number, from: bigint | null, passes: number, since: number, size: number | null } | null}
931
+ */
932
+ function recheckOf(value) {
933
+ const kept = /** @type {any} */ (value);
934
+ const from = kept?.from === null ? null : quantityOf(kept?.from);
935
+ /** @param {unknown} ms */
936
+ const time = (ms) => typeof ms === "number" && Number.isFinite(ms);
937
+ const size = Number.isSafeInteger(kept?.size) && kept.size >= Number(MIN_WINDOW) && kept.size <= LOG_WINDOW ? kept.size : null;
938
+
939
+ if (!kept || !time(kept.at) || !time(kept.since) || !Number.isSafeInteger(kept.passes) || kept.passes < 0) return null;
940
+ if (kept.from !== null && from === null) return null;
941
+
942
+ return { at: kept.at, from, passes: kept.passes, since: kept.since, size };
943
+ }
944
+
945
+ /**
946
+ * A search's passes over its blocks again, as its scan keeps them.
947
+ *
948
+ * @param {NonNullable<ReturnType<typeof recheckOf>>} recheck
949
+ * @returns {NonNullable<Scan["recheck"]>}
950
+ */
951
+ function recheckIn({ at, from, passes, since }) {
952
+ return { at, from: from === null ? null : from.toString(), passes, since };
789
953
  }
790
954
 
791
955
  /**
@@ -795,6 +959,17 @@ async function searchLogs(read, { address, amountOf, blockTimes, enough, head, k
795
959
  * sent at (as the app saw the chain's head then), else from about when it
796
960
  * was sent.
797
961
  *
962
+ * Each block is read more than once: on each call, those up to `OVERLAP`
963
+ * under the head, and once the search has reached the head with a transfer
964
+ * not arrived, all of them again from where the search began, a pass at a
965
+ * time: the first a minute later, each after as long again as it has been
966
+ * since then, and one after the other, as far as its credit, while an
967
+ * arrival is held back. A pass under way takes what the search ahead leaves
968
+ * of a call's windows, one at least. A pass counts once it read the blocks
969
+ * again, as does a call whose window at the head reached back to where the
970
+ * search began. Both go on in windows as wide as the searches before left
971
+ * them, narrower than the narrowest the RPC refused.
972
+ *
798
973
  * @param {Reader} read
799
974
  * @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
800
975
  * @param {{ url: string, maxWindows: number, blockTimes: BlockTimes | undefined, now: number, slackMs: number, windowMs: number, used: Set<string> }} options
@@ -821,9 +996,11 @@ async function followLogs(read, { filter, given, guarded, queue, rivals, unsure
821
996
  // The logs searched for: a search for others, as for another sender or
822
997
  // amount, isn't carried on.
823
998
  const search = [filter.address, ...filter.topics.map((topic) => topic ?? "*"), filter.wei.toString()].join(":");
824
- /** @type {{ basis: number, from: string, seen?: Scan["seen"] }[]} */
999
+ /** @type {{ basis: number, from: string, seen?: Scan["seen"], start?: string, recheck?: Scan["recheck"], size?: number, refused?: number }[]} */
825
1000
  const resumed = live.flatMap(({ scan }) =>
826
- scan?.from !== undefined && scan.search === search && scan.basis !== undefined && scan.basis <= first.at ? [{ basis: scan.basis, from: scan.from, seen: scan.seen }] : []
1001
+ scan?.from !== undefined && scan.search === search && scan.basis !== undefined && scan.basis <= first.at
1002
+ ? [{ basis: scan.basis, from: scan.from, recheck: scan.recheck, refused: scan.refused, seen: scan.seen, size: scan.size, start: scan.start }]
1003
+ : []
827
1004
  );
828
1005
  /** @type {Map<string, Credit>} */
829
1006
  const carried = new Map();
@@ -832,7 +1009,7 @@ async function followLogs(read, { filter, given, guarded, queue, rivals, unsure
832
1009
  // counts again, as the transfer it paid may be among the queue's.
833
1010
  for (const scan of resumed) {
834
1011
  for (const credit of scan.seen ?? []) {
835
- 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 });
1012
+ 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 });
836
1013
  }
837
1014
  }
838
1015
 
@@ -840,10 +1017,29 @@ async function followLogs(read, { filter, given, guarded, queue, rivals, unsure
840
1017
  let head;
841
1018
  /** @type {bigint} */
842
1019
  let start;
1020
+ // The block the search began at, and its passes over the blocks before.
1021
+ /** @type {bigint} */
1022
+ let floor;
1023
+ /** @type {ReturnType<typeof recheckOf>} */
1024
+ let recheck = null;
843
1025
 
844
1026
  if (resumed.length && resumed.every((scan) => scan.basis === first.at)) {
845
- start = minOf(resumed.map((scan) => BigInt(scan.from)));
846
- head = await headOf(read, url);
1027
+ const kept = resumed.reduce((a, b) => (BigInt(b.from) < BigInt(a.from) ? b : a));
1028
+
1029
+ start = BigInt(kept.from);
1030
+ recheck = recheckOf(kept.recheck);
1031
+
1032
+ if (resumed.every((scan) => scan.start !== undefined)) {
1033
+ floor = minOf(resumed.map((scan) => BigInt(/** @type {string} */ (scan.start))));
1034
+ head = await headOf(read, url);
1035
+ } else {
1036
+ // Kept without where it began: from where a search from the first
1037
+ // begins.
1038
+ const fresh = first.notBefore !== null ? { head: await headOf(read, url), start: first.notBefore } : await startBefore(read, url, reach, blockTimes);
1039
+
1040
+ floor = fresh.start;
1041
+ head = fresh.head;
1042
+ }
847
1043
  } else if (resumed.length) {
848
1044
  // Begun from a transfer before the first now, as one that since
849
1045
  // arrived: none of the queue's credits is before where a search from
@@ -853,38 +1049,153 @@ async function followLogs(read, { filter, given, guarded, queue, rivals, unsure
853
1049
 
854
1050
  head = fresh.head;
855
1051
  start = from > fresh.start ? from : fresh.start;
1052
+ floor = fresh.start;
856
1053
  } else if (first.notBefore !== null) {
857
1054
  start = first.notBefore;
1055
+ floor = start;
858
1056
  head = await headOf(read, url);
859
1057
  } else {
860
1058
  ({ head, start } = await startBefore(read, url, reach, blockTimes));
1059
+ floor = start;
861
1060
  }
862
1061
 
1062
+ if (floor > start) floor = start;
1063
+
1064
+ // No later than a credit found before: the blocks are read again from it.
1065
+ for (const credit of carried.values()) if (credit.block !== null && credit.block < floor) floor = credit.block;
1066
+
1067
+ // The width of the windows, and the narrowest the RPC refused, as the
1068
+ // searches before left them: the narrowest of each.
1069
+ const sizes = resumed.flatMap((scan) => (scan.size === undefined ? [] : [BigInt(scan.size)]));
1070
+ const refusals = resumed.flatMap((scan) => (scan.refused === undefined ? [] : [BigInt(scan.refused)]));
1071
+ const width = sizes.length ? minOf(sizes) : BigInt(LOG_WINDOW);
1072
+
1073
+ /**
1074
+ * The given transfers' credits with `found`: those that arrived, by their
1075
+ * credits, and those held back (`held`), with the last of their credits'
1076
+ * blocks (`top`). One a later transfer of the queue, still without a
1077
+ * credit, could have had the credit of instead is held back until the
1078
+ * blocks before it are read again `CONFIRM_PASSES` times since it was
1079
+ * found: its own may yet be found there.
1080
+ *
1081
+ * @param {Credit[]} found
1082
+ * @param {boolean} seen
1083
+ */
1084
+ const settled = (found, seen) => {
1085
+ const { assigned, verdicts } = settle(queue, found, rules);
1086
+ const passes = recheck?.passes ?? 0;
1087
+ const mine = new Set(live.map((item) => item.hash));
1088
+ /** @type {Set<string>} */
1089
+ const arrived = new Set();
1090
+ /** @type {Set<string>} */
1091
+ const held = new Set();
1092
+ /** @type {bigint | null} */
1093
+ let top = null;
1094
+
1095
+ queue.forEach((item, i) => {
1096
+ const credit = assigned.get(item.hash);
1097
+
1098
+ if (!credit || !mine.has(item.hash) || verdicts.get(item.hash) !== "arrived" || !(seen || !guarded)) return;
1099
+
1100
+ // A later one without a credit could have had this one instead.
1101
+ const doubt = !guarded && passes - (credit.pass ?? 0) < CONFIRM_PASSES && queue.slice(i + 1).some((later) => !assigned.has(later.hash) && pays(later, credit, rules));
1102
+
1103
+ if (!doubt) {
1104
+ arrived.add(credit.hash);
1105
+ } else {
1106
+ held.add(item.hash);
1107
+ if (credit.block !== null && (top === null || credit.block > top)) top = credit.block;
1108
+ }
1109
+ });
1110
+
1111
+ return { arrived, assigned, held, top, verdicts };
1112
+ };
1113
+ /**
1114
+ * The credits found in this call, with the passes done before the next
1115
+ * one to begin.
1116
+ *
1117
+ * @param {Credit[]} found
1118
+ * @returns {Credit[]}
1119
+ */
1120
+ const mark = (found) => {
1121
+ const pass = recheck === null ? 0 : recheck.passes + (recheck.from !== null ? 1 : 0);
1122
+
1123
+ return found.map((credit) => (credit.pass === undefined ? { ...credit, pass } : credit));
1124
+ };
863
1125
  // Where the credits can't be told apart, only those in the transfers'
864
1126
  // windows count: once they're all over, read no further than the last.
865
1127
  const last = queue[queue.length - 1].at + windowMs;
866
- const { complete, covered, credits, next } = await searchLogs(read, {
1128
+ const until = guarded && now >= last ? last : null;
1129
+ const due = recheck !== null && (recheck.from !== null || now >= recheck.at);
1130
+ const ahead = await searchLogs(read, {
867
1131
  ...filter,
868
1132
  blockTimes,
869
1133
  // Once every transfer of the queue has its credit: read no further.
870
1134
  enough: (found) => !guarded && settle(queue, found, rules).assigned.size === queue.length,
1135
+ floor,
871
1136
  head,
872
1137
  known: [...carried.values()],
873
1138
  maxWindows,
1139
+ refused: refusals.length ? minOf(refusals) : null,
1140
+ size: width,
874
1141
  start,
875
- until: guarded && now >= last ? last : null,
1142
+ until,
876
1143
  url,
877
1144
  });
1145
+ const { complete, covered, next } = ahead;
878
1146
  const seen = complete || covered;
879
- const { assigned, verdicts } = settle(queue, credits, rules);
880
- /** @type {Set<string>} */
881
- const arrived = new Set();
1147
+ // Its window at the head reached back to where the search began: every
1148
+ // block read before this call is read again, as by a pass.
1149
+ const again = recheck !== null && ahead.low !== null && ahead.low <= floor && start > floor;
1150
+
1151
+ if (again && recheck !== null) recheck = { ...recheck, passes: recheck.passes + 1 };
1152
+
1153
+ let credits = mark(ahead.credits);
1154
+ const before = settled(credits, seen);
1155
+ let { refused, size } = ahead;
1156
+
1157
+ if ((seen || before.held.size) && recheck === null) recheck = { at: before.held.size ? now : now + RECHECK_MS, from: null, passes: 0, since: now, size: null };
1158
+
1159
+ // A transfer not arrived yet: the blocks read before this call, again, as
1160
+ // far as the search ahead reads; while an arrival is held back, as far as
1161
+ // its credit. With what the search ahead leaves of a call's windows, one
1162
+ // at least.
1163
+ if (recheck !== null && (due || before.held.size) && before.arrived.size < live.length) {
1164
+ const from = recheck.from !== null && recheck.from > floor ? recheck.from : floor;
1165
+ const below = ahead.low !== null && ahead.low < start ? ahead.low : start;
1166
+ const pass = await searchLogs(read, {
1167
+ ...filter,
1168
+ blockTimes,
1169
+ // Read through: a credit further on, still before those found ahead,
1170
+ // could be a transfer's first.
1171
+ enough: () => false,
1172
+ floor: from,
1173
+ head: before.top !== null && before.top < below ? before.top : below - 1n,
1174
+ known: credits,
1175
+ maxWindows: maxWindows < 1 ? 0 : Math.max(1, maxWindows - ahead.asked),
1176
+ refused,
1177
+ size,
1178
+ start: from,
1179
+ until,
1180
+ url,
1181
+ });
1182
+ const done = pass.complete || pass.covered;
1183
+ // A pass that read no block is none: only one that read the blocks again
1184
+ // counts, and not twice in a call.
1185
+ const counted = done && !again && pass.next > floor;
1186
+
1187
+ recheck = done ? { ...recheck, at: now + Math.max(RECHECK_MS, now - recheck.since), from: null, passes: recheck.passes + (counted ? 1 : 0) } : { ...recheck, from: pass.next };
1188
+ credits = mark(pass.credits);
1189
+ ({ refused, size } = pass);
1190
+ }
882
1191
 
883
- for (const item of live) {
884
- const credit = assigned.get(item.hash);
1192
+ const { arrived, assigned, held, verdicts } = settled(credits, seen);
885
1193
 
886
- if (credit && verdicts.get(item.hash) === "arrived" && (seen || !guarded)) arrived.add(credit.hash);
887
- }
1194
+ // Passes go on one after the other while an arrival is held back; once one
1195
+ // arrives with others still waiting, theirs begin again a minute later,
1196
+ // the waits growing from there.
1197
+ if (recheck !== null && held.size) recheck = { ...recheck, at: Math.min(recheck.at, now) };
1198
+ else if (recheck !== null && arrived.size && arrived.size < live.length) recheck = { ...recheck, at: Math.min(recheck.at, now + RECHECK_MS), since: now };
888
1199
 
889
1200
  // The next search carries on from where this one stopped, with the
890
1201
  // credits found so far: a transfer that arrived may come back from the
@@ -893,15 +1204,21 @@ async function followLogs(read, { filter, given, guarded, queue, rivals, unsure
893
1204
  const scan = {
894
1205
  basis: first.at,
895
1206
  from: next.toString(),
1207
+ ...(recheck ? { recheck: recheckIn(recheck) } : {}),
1208
+ ...(refused === null ? {} : { refused: Number(refused) }),
896
1209
  search,
897
- seen: credits.flatMap((credit) => (credit.at >= reach && credit.block !== null ? [{ at: credit.at, block: credit.block.toString(), hash: credit.hash, sender: credit.from }] : [])),
1210
+ 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 }] : [])),
1211
+ size: Number(size),
1212
+ start: floor.toString(),
898
1213
  };
899
1214
 
900
1215
  for (const item of live) {
901
1216
  const credit = assigned.get(item.hash);
902
1217
  const verdict = verdicts.get(item.hash);
903
1218
 
904
- if (credit && arrived.has(credit.hash)) {
1219
+ if (held.has(item.hash)) {
1220
+ out.set(item.hash, { scan, state: "waiting" });
1221
+ } else if (credit && arrived.has(credit.hash)) {
905
1222
  used.add(credit.hash);
906
1223
  out.set(item.hash, {
907
1224
  amount: credit.wei / WEI_PER_LUX,
@@ -1238,12 +1555,25 @@ async function depositPeers(archive, net, found, { amount, bounds, calls, suppli
1238
1555
  }
1239
1556
 
1240
1557
  /**
1241
- * Where a scan's search stopped, without its history.
1558
+ * Where a scan's search stopped, with the width of its windows, without its
1559
+ * history.
1242
1560
  *
1243
1561
  * @param {Scan | null | undefined} scan
1244
1562
  * @returns {Scan}
1245
1563
  */
1246
- const searchOf = (scan) => (scan?.from !== undefined ? { basis: scan.basis, from: scan.from, search: scan.search, seen: scan.seen } : {});
1564
+ function searchOf(scan) {
1565
+ if (scan?.from === undefined) return {};
1566
+
1567
+ /** @type {Scan} */
1568
+ const out = { basis: scan.basis, from: scan.from, search: scan.search, seen: scan.seen };
1569
+
1570
+ if (scan.start !== undefined) out.start = scan.start;
1571
+ if (scan.recheck !== undefined) out.recheck = scan.recheck;
1572
+ if (scan.size !== undefined) out.size = scan.size;
1573
+ if (scan.refused !== undefined) out.refused = scan.refused;
1574
+
1575
+ return out;
1576
+ }
1247
1577
 
1248
1578
  /**
1249
1579
  * The transfers whose history isn't all read yet wait, with where their
@@ -1814,6 +2144,12 @@ export async function serviceArrivals({
1814
2144
  }
1815
2145
 
1816
2146
  const live = triage(known, results, { now, waitMs });
2147
+ // The block times the app keeps for a route's chain: by its chain ID, or
2148
+ // the one cache given, where the network's service routes are all on one
2149
+ // chain.
2150
+ const oneChain = new Set(services.map((route) => route.chain?.chainId)).size === 1;
2151
+ /** @param {Route} route @returns {BlockTimes | undefined} */
2152
+ const timesOf = (route) => (typeof blockTimes === "function" ? (blockTimes(route.chain.chainId) ?? undefined) : oneChain ? blockTimes : undefined);
1817
2153
  // The fee of each transfer the app gave, those no longer looked for too,
1818
2154
  // and the block its payout can't be before.
1819
2155
  const feeOf = new Map(routed.map((item) => [item.hash, item.fee ?? /** @type {Route & { kind: "service" }} */ (item.route).feeLux]));
@@ -1886,7 +2222,7 @@ export async function serviceArrivals({
1886
2222
  queue: history.queue,
1887
2223
  unsure,
1888
2224
  },
1889
- { blockTimes, maxWindows, now, slackMs: CLOCK_SLACK_MS, url: String(rpcUrl ?? route.chain.rpcUrl), used, windowMs: CREDIT_WINDOW_MS }
2225
+ { blockTimes: timesOf(route), maxWindows, now, slackMs: CLOCK_SLACK_MS, url: String(rpcUrl ?? route.chain.rpcUrl), used, windowMs: CREDIT_WINDOW_MS }
1890
2226
  );
1891
2227
 
1892
2228
  for (const [hash, arrival] of found) results.set(hash, withHistory(hash, arrival, given, history.states));