@dusk-network/app-core 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this package are listed here. It follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [0.3.1]
6
+
7
+ ### Added
8
+
9
+ - `/history`: `historyRange` reads on from a `page`
10
+
11
+ ### Fixed
12
+
13
+ - `/bridge`: each transfer reads only its own hours of its account's history, read on from its `scan` while a call can't read it all: it waits then, rather than staying unsure
14
+ - `/bridge`: a transfer the archive doesn't list in its account's history yet waits, rather than taking an earlier one's credit
15
+ - `/bridge`: a deposit whose call's arguments the archive doesn't give counts as one that could be equal, rather than none
16
+
5
17
  ## [0.3.0]
6
18
 
7
19
  ### Added
package/README.md CHANGED
@@ -98,7 +98,7 @@ const history = createHistory({
98
98
 
99
99
  ### Other reads
100
100
 
101
- - `historyRange(archive, { account, from, to, direction, pages })`: an account's entries between two heights, a page at a time.
101
+ - `historyRange(archive, { account, from, to, direction, pages, page })`: an account's entries between two heights, a page at a time, from `page` on.
102
102
  - `fetchTxDetails(archive, hashes, options)` and `fetchTxNonce(archive, hash)`: a transaction's time, outcome, fee and call, and its nonce.
103
103
  - `archive.query(text)`: one GraphQL request, its integers past 2^53 kept as text. A request answered 429, 502, 503 or 504 is sent again, twice at most.
104
104
 
@@ -201,9 +201,10 @@ arrivalsOf(arrivals); // Map { hash => { hash: "0x029e…", at: 1793080333000 }
201
201
  - Each credit pays one transfer. Equal transfers (the same route, recipient and payout) are paid in the order they were sent: the oldest takes the first credit after it, from 2 minutes before its block, as the chains' clocks differ. The app passes the transfers it still waits for, and the credits its others arrived with (`taken`): none of those pays a waiting one, though an earlier transfer read from the chain takes its own back, as one given but not looked for does.
202
202
  - A transfer's time is its Dusk block's, never the device's clock. One without it yet isn't looked for. One sent more than a week ago (`waitMs`) is unknown, and isn't asked about.
203
203
  - A deposit is followed on DuskEVM's explorer (`via: "explorer"`, the default): the recipient's incoming calls, newest first, read once for all its deposits, back past the oldest, 5 pages at most. Those calls don't say who deposited, so a payment is a deposit's only when it can't be another's: within 2 minutes either side of its block, once those minutes are over, with no other payment of its amount there (a failed one counts) that isn't another given deposit's. Nothing is asked before then.
204
- - On DuskEVM's RPC (`via: "rpc"`), a deposit is found by DuskEVM's bridge event from its sender's DuskEVM address. With `/history`'s archive, each sender's equal deposits in the hours before its own are read from its history and paid first; a history too long to read, or a deposit there whose call can't be read, leaves the order unclear. Without it, the RPC's credits are taken as the explorer's are. A deposit given without its sender takes no credit from the sender of another equal one given.
204
+ - On DuskEVM's RPC (`via: "rpc"`), a deposit is found by DuskEVM's bridge event from its sender's DuskEVM address. With `/history`'s archive, each sender's equal deposits in the hours before its own are read from its history and paid first; a deposit there whose call can't be read leaves the order unclear. Without it, the RPC's credits are taken as the explorer's are. A deposit given without its sender takes no credit from the sender of another equal one given.
205
205
  - A migration is paid by a transfer to the account from its route's payout account, with the migration's transaction hash as the memo, of exactly its amount: from the account's entries, less any `refund` paid to the account with it, or from the archive's memo index, which gives each payout's own amount. One from that account of another amount leaves it unsure, not waiting: the value of an entry an earlier version kept holds any gas refund paid to the account. With `entries`, the payouts there of migrations the app didn't list come too (`unlisted`), each with its `payer`; where one account pays out more than one route, a payout of a migration not listed doesn't say which: its `route` is null.
206
- - A transfer out is read from the chain: what the route's account received, the address its memo gives as the route's bridge reads it (0x and 40 hex digits, any case, nothing around them), and its block. It arrives with the DUSK token's `Transfer` from its route's payout address to that address, of what the account received less the fee. The account's history lists every transfer that named the same address, in any case: equal ones in the hours before (a given one's by its own fee) are paid first, and one that can't be read, or a history cut short, leaves the order unclear. One that pays nothing out is `no-payout`, with its `problem`. `serviceTransferOf` gives what left the account (`sent`), and what the account received (`amount`) only where no gas was left to refund: an entry's value holds any refund paid to another account.
206
+ - A transfer out is read from the chain: what the route's account received, the address its memo gives as the route's bridge reads it (0x and 40 hex digits, any case, nothing around them), and its block. It arrives with the DUSK token's `Transfer` from its route's payout address to that address, of what the account received less the fee. The account's history lists every transfer that named the same address, in any case: equal ones in the hours before (a given one's by its own fee) are paid first, and one that can't be read leaves the order unclear. One that pays nothing out is `no-payout`, with its `problem`. `serviceTransferOf` gives what left the account (`sent`), and what the account received (`amount`) only where no gas was left to refund: an entry's value holds any refund paid to another account.
207
+ - With the archive, each transfer reads only its own hours of its account's history (its sender's for a deposit, the route's account's for a transfer out), further back while equal ones before it run on, and only once the archive lists the transfer there. A call reads 4 pages of it (`HISTORY_PAGES`) for each: a transfer whose history is longer waits, and the next call reads on from its `scan`.
207
208
  - A route without its payout account or address in the network's file pays out nothing that can be told apart: `no-route`.
208
209
  - An RPC search starts at a block from before the transfer, estimated from the chain's pace and checked by its time, and reads 8 windows of 5,000 blocks at most, narrower where the RPC asks for it. A result that isn't arrived says where the next search starts, with what it searched for and the credits found so far (`scan`): the app keeps it with the arrivals it was made with, and passes both back. A search for another sender or amount starts afresh.
209
210
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dusk-network/app-core",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Shared logic for the Dusk apps: public and shielded history, activity rows, bridge arrivals and DuskEVM withdrawals.",
5
5
  "license": "MPL-2.0",
6
6
  "type": "module",
package/src/bridge.d.ts CHANGED
@@ -85,7 +85,7 @@ export declare const DEPOSIT_HORIZON_MS: 7200000;
85
85
  /** Equal transfers out sent within this of each other are paid in their order, or 4 times the route's usual time: 12 hours, in milliseconds. */
86
86
  export declare const SERVICE_HORIZON_MS: 43200000;
87
87
 
88
- /** Pages of a sender's history read for its earlier equal deposits. */
88
+ /** Pages of its account's history one call reads for a transfer's earlier equal ones; a longer history is read on in the next call, from its `scan`. */
89
89
  export declare const HISTORY_PAGES: 4;
90
90
 
91
91
  /** `Transfer(address,address,uint256)`: the DUSK token's transfer that pays a transfer out. */
@@ -94,15 +94,37 @@ export declare const TRANSFER_TOPIC: "0xddf252ad1be2c89b69c2b068fc378daa952ba7f1
94
94
  /** `ETHBridgeFinalized(address,address,uint256,bytes)`: DuskEVM's bridge paying a deposit, from the depositor's DuskEVM address. */
95
95
  export declare const ETH_BRIDGE_FINALIZED_TOPIC: "0x31b2166ff604fc5672ea5df08a78081d2bc6d746cadce880747f3643d819e83d";
96
96
 
97
+ /**
98
+ * How far a transfer's own history is read: what it was read for (`of`),
99
+ * all of it from `lo` up to the transfer's block once `lo` is set, the
100
+ * range read next (`next`: its first block, its last and the page it
101
+ * carries on from), and the transfers found in it that could be equal to
102
+ * it, each with what it paid, in lux (`value`).
103
+ */
104
+ export type HistoryScan = {
105
+ of: string;
106
+ lo: number | null;
107
+ next: { from: number; to: number; page: number } | null;
108
+ found: ReadonlyArray<{ hash: string; height: number; value: string }>;
109
+ };
110
+
97
111
  /**
98
112
  * Where a search stopped: the block the next one starts at, the time of the
99
113
  * transfer it began from, the logs it searched for (`search`), and the
100
114
  * credits it found before (`seen`), which the next one counts again. A
101
115
  * search for other logs, as for another sender or amount, starts afresh.
102
- * Kept by the app with the credits it was made with, and passed back as it
103
- * is.
116
+ * With the archive, how far the transfer's own history is read
117
+ * (`history`): a history longer than one call reads is read on from there,
118
+ * before any search. Kept by the app with the credits it was made with,
119
+ * and passed back as it is.
104
120
  */
105
- export type Scan = { from: string; basis: number; search: string; seen?: ReadonlyArray<{ hash: string; block: string; at: number; sender: string | null }> };
121
+ export type Scan = {
122
+ from?: string;
123
+ basis?: number;
124
+ search?: string;
125
+ seen?: ReadonlyArray<{ hash: string; block: string; at: number; sender: string | null }>;
126
+ history?: HistoryScan;
127
+ };
106
128
 
107
129
  /**
108
130
  * Why a transfer's credit is unknown:
@@ -190,7 +212,9 @@ export type DepositArrivalOptions = {
190
212
  /**
191
213
  * `/history`'s archive, for "rpc": each sender's equal deposits in the
192
214
  * hours before its own are read from its history, those given but not
193
- * looked for too, and paid first.
215
+ * looked for too, and paid first. Each deposit reads its own hours, once
216
+ * the archive lists it there, `HISTORY_PAGES` pages a call: a deposit
217
+ * whose history is read on in the next call waits, with its `scan`.
194
218
  * Without it, or for a deposit without its sender and height, its credit
195
219
  * is a deposit's only as where they can't be told apart.
196
220
  */
@@ -279,9 +303,12 @@ export type ServiceArrivalOptions = {
279
303
  * route's account received in the hours before that named the same address,
280
304
  * in any case, and pay out the same are paid first: its history lists them,
281
305
  * and their own transactions say what each received (one given but not
282
- * looked for pays out what its own fee leaves). One that can't be
283
- * read, or a history cut short, leaves the order unclear: unsure. Throws a
284
- * TypeError without an archive, and for a network there's no file for.
306
+ * looked for pays out what its own fee leaves). One that can't be read
307
+ * leaves the order unclear: unsure. Each transfer reads its own hours of
308
+ * that history, once the archive lists it there, `HISTORY_PAGES` pages a
309
+ * call: one whose history is read on in the next call waits, with its
310
+ * `scan`. Throws a TypeError without an archive, and for a network there's
311
+ * no file for.
285
312
  */
286
313
  export declare function serviceArrivals(options: ServiceArrivalOptions): Promise<{ arrivals: Map<string, Arrival>; requests: number }>;
287
314
 
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 */
@@ -199,9 +200,42 @@ const msOf = (value) => (typeof value === "number" && Number.isFinite(value) &&
199
200
  */
200
201
  const heightOf = (value) => (typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null);
201
202
 
203
+ /**
204
+ * How far a transfer's history is read, as a result gave it; null for
205
+ * anything else.
206
+ *
207
+ * @param {unknown} value
208
+ * @returns {HistoryScan | null}
209
+ */
210
+ function historyScanOf(value) {
211
+ const read = /** @type {any} */ (value);
212
+
213
+ if (!read || typeof read !== "object" || typeof read.of !== "string" || !Array.isArray(read.found)) return null;
214
+
215
+ const lo = read.lo === null ? null : heightOf(read.lo);
216
+ const next = read.next === null ? null : { from: heightOf(read.next?.from), page: heightOf(read.next?.page), to: heightOf(read.next?.to) };
217
+
218
+ if ((read.lo !== null && lo === null) || (lo === null && next === null)) return null;
219
+ if (next && (next.from === null || next.to === null || next.page === null || next.page < 1 || next.from > next.to)) return null;
220
+
221
+ const found = read.found.flatMap((/** @type {any} */ each) => {
222
+ const hash = duskHashOf(each?.hash);
223
+ const height = heightOf(each?.height);
224
+ const value = each?.value;
225
+
226
+ return hash && height !== null && typeof value === "string" && /^\d{1,20}$/.test(value) ? [{ hash, height, value }] : [];
227
+ });
228
+
229
+ // One left out could be equal: it's read afresh.
230
+ if (found.length !== read.found.length) return null;
231
+
232
+ return { found, lo, next: /** @type {HistoryScan["next"]} */ (next), of: read.of };
233
+ }
234
+
202
235
  /**
203
236
  * Where a search stopped, what it searched for, and the credits it found
204
- * before, as a result gave them; null for anything else.
237
+ * before, and how far the transfer's history is read, as a result gave
238
+ * them; null for anything else.
205
239
  *
206
240
  * @param {unknown} value
207
241
  * @returns {Scan | null}
@@ -209,19 +243,26 @@ const heightOf = (value) => (typeof value === "number" && Number.isSafeInteger(v
209
243
  function scanOf(value) {
210
244
  const scan = /** @type {any} */ (value);
211
245
  const from = quantityOf(scan?.from);
246
+ const history = historyScanOf(scan?.history);
247
+ /** @type {Scan} */
248
+ const out = history ? { history } : {};
212
249
 
213
- if (from === null || typeof scan?.basis !== "number" || !Number.isFinite(scan.basis) || typeof scan.search !== "string") return null;
250
+ if (from !== null && typeof scan?.basis === "number" && Number.isFinite(scan.basis) && typeof scan.search === "string") {
251
+ const seen = (Array.isArray(scan.seen) ? scan.seen : []).flatMap((/** @type {any} */ credit) => {
252
+ const hash = evmHashOf(credit?.hash);
253
+ const block = quantityOf(credit?.block);
254
+ const at = msOf(credit?.at);
255
+ const sender = credit?.sender === null ? null : addressOf(credit?.sender);
214
256
 
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);
257
+ return hash && block !== null && at !== null && sender !== "" ? [{ at, block: block.toString(), hash, sender }] : [];
258
+ });
220
259
 
221
- return hash && block !== null && at !== null && sender !== "" ? [{ at, block: block.toString(), hash, sender }] : [];
222
- });
260
+ Object.assign(out, { basis: scan.basis, from: from.toString(), search: scan.search, seen });
261
+ } else if (!history) {
262
+ return null;
263
+ }
223
264
 
224
- return { basis: scan.basis, from: from.toString(), search: scan.search, seen };
265
+ return out;
225
266
  }
226
267
 
227
268
  /**
@@ -780,7 +821,10 @@ async function followLogs(read, { filter, given, guarded, queue, rivals, unsure
780
821
  // The logs searched for: a search for others, as for another sender or
781
822
  // amount, isn't carried on.
782
823
  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] : []));
824
+ /** @type {{ basis: number, from: string, seen?: Scan["seen"] }[]} */
825
+ 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 }] : []
827
+ );
784
828
  /** @type {Map<string, Credit>} */
785
829
  const carried = new Map();
786
830
 
@@ -797,9 +841,18 @@ async function followLogs(read, { filter, given, guarded, queue, rivals, unsure
797
841
  /** @type {bigint} */
798
842
  let start;
799
843
 
800
- if (resumed.length) {
844
+ if (resumed.length && resumed.every((scan) => scan.basis === first.at)) {
801
845
  start = minOf(resumed.map((scan) => BigInt(scan.from)));
802
846
  head = await headOf(read, url);
847
+ } else if (resumed.length) {
848
+ // Begun from a transfer before the first now, as one that since
849
+ // arrived: none of the queue's credits is before where a search from
850
+ // the first starts, which it skips to.
851
+ const from = minOf(resumed.map((scan) => BigInt(scan.from)));
852
+ const fresh = first.notBefore !== null ? { head: await headOf(read, url), start: first.notBefore } : await startBefore(read, url, reach, blockTimes);
853
+
854
+ head = fresh.head;
855
+ start = from > fresh.start ? from : fresh.start;
803
856
  } else if (first.notBefore !== null) {
804
857
  start = first.notBefore;
805
858
  head = await headOf(read, url);
@@ -1016,50 +1069,214 @@ async function detailsOf(archive, hashes, { args = false, network }, counter) {
1016
1069
  }
1017
1070
 
1018
1071
  /**
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.
1072
+ * A transfer whose history is read: its hash, its Dusk block's time and
1073
+ * height, the block on the other chain its credit can't be before, and its
1074
+ * scan.
1075
+ *
1076
+ * @typedef {{ hash: string, at: number, height: number, notBefore: bigint | null, scan: Scan | null }} Reading
1077
+ */
1078
+
1079
+ /**
1080
+ * The history a group of equal transfers needs, each transfer's read on
1081
+ * from where its scan stopped: its account's history from `reach` blocks
1082
+ * under its block up to it, and `reach` further down at a time while the
1083
+ * run of equal transfers it is in reaches within `horizonMs` of the bottom.
1084
+ * The transfer itself must be listed there: until it is, the archive
1085
+ * hasn't read its block. Each transfer reads `pages` pages at most, so a
1086
+ * later one's history never keeps an earlier one waiting.
1087
+ *
1088
+ * Those whose history is all read are `covered`, and `given` as the queue
1089
+ * holds them, with `queue`: theirs and the equal transfers found
1090
+ * (`peersOf`), and `unread`: by transfer, the heights it read of those that
1091
+ * could be equal but can't be read. Each one's reading, done or not, is in
1092
+ * `states`.
1093
+ *
1094
+ * @param {Archive} archive
1095
+ * @param {ReadonlyArray<Reading>} items
1096
+ * @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
1097
+ * @param {{ requests: number }} counter
1098
+ */
1099
+ async function readHistories(archive, items, { account, direction, horizonMs, keep, of, pages, peersOf, reach }, counter) {
1100
+ const walks = items.map((item) => {
1101
+ const kept = item.scan?.history;
1102
+ /** @type {HistoryScan} */
1103
+ const state =
1104
+ kept && kept.of === of(item)
1105
+ ? structuredClone(/** @type {HistoryScan} */ (kept))
1106
+ : { found: [], lo: null, next: { from: Math.max(0, item.height - reach), page: 1, to: item.height }, of: of(item) };
1107
+
1108
+ return { item, left: pages, state };
1109
+ });
1110
+ /** @type {Reading[]} */
1111
+ let covered = [];
1112
+ /** @type {Queued[]} */
1113
+ let given = [];
1114
+ /** @type {Queued[]} */
1115
+ let queue = [];
1116
+ /** @type {Map<string, number[]>} */
1117
+ const unread = new Map();
1118
+
1119
+ for (;;) {
1120
+ // Each one's next range, as far as its pages go.
1121
+ for (const walk of walks) {
1122
+ const { state } = walk;
1123
+
1124
+ while (state.next && walk.left > 0) {
1125
+ const next = /** @type {NonNullable<HistoryScan["next"]>} */ (state.next);
1126
+ const range = await historyRange(archive, { account, direction, from: next.from, page: next.page, pages: walk.left, to: next.to });
1127
+ const found = /** @type {HistoryScan["found"][number][]} */ (state.found);
1128
+
1129
+ counter.requests += range.requests;
1130
+ walk.left -= range.requests;
1131
+
1132
+ for (const entry of range.entries) {
1133
+ if (keep(entry) && !found.some((each) => each.hash === entry.hash)) found.push({ hash: entry.hash, height: entry.blockHeight, value: String(entry.value) });
1134
+ }
1135
+
1136
+ if (!range.complete) {
1137
+ state.next = { ...next, page: next.page + range.requests };
1138
+ } else if (state.lo === null && !found.some((each) => each.hash === walk.item.hash)) {
1139
+ // Its own block isn't read yet: its last page is read again.
1140
+ state.next = { ...next, page: next.page + range.requests - 1 };
1141
+ walk.left = 0;
1142
+ } else {
1143
+ state.lo = next.from;
1144
+ state.next = null;
1145
+ }
1146
+ }
1147
+ }
1148
+
1149
+ const done = walks.filter((walk) => !walk.state.next);
1150
+
1151
+ if (!done.length) {
1152
+ covered = [];
1153
+ given = [];
1154
+ break;
1155
+ }
1156
+
1157
+ given = done.map((walk) => queued(walk.item)).sort(byTime);
1158
+
1159
+ const mine = new Set(given.map((item) => item.hash));
1160
+ // The others found: those given are in the queue as they are.
1161
+ const { peers, unread: unknown } = await peersOf(uniqueByHash(done.flatMap((walk) => walk.state.found)).filter((entry) => !mine.has(entry.hash)));
1162
+ let deeper = false;
1163
+
1164
+ queue = queueOf(given, peers, horizonMs);
1165
+
1166
+ // A run reaching within a horizon of the bottom may go on under it: the
1167
+ // history is read further down.
1168
+ for (const walk of done) {
1169
+ const lo = /** @type {number} */ (walk.state.lo);
1170
+ const bottomAt = lo === 0 ? -Infinity : walk.item.at - (walk.item.height - lo) * DUSK_BLOCK_MS;
1171
+ const self = /** @type {Queued} */ (given.find((each) => each.hash === walk.item.hash));
1172
+
1173
+ if (runStart(queue, self, horizonMs).at - bottomAt < horizonMs) {
1174
+ walk.state.next = { from: Math.max(0, lo - reach), page: 1, to: lo - 1 };
1175
+ deeper = true;
1176
+ } else {
1177
+ unread.set(
1178
+ walk.item.hash,
1179
+ unknown.flatMap((each) => (each.height >= lo && each.height <= walk.item.height ? [each.height] : []))
1180
+ );
1181
+ }
1182
+ }
1183
+
1184
+ if (!deeper) {
1185
+ covered = done.map((walk) => walk.item);
1186
+ break;
1187
+ }
1188
+ }
1189
+
1190
+ return { covered, given, queue, states: new Map(walks.map((walk) => [walk.item.hash, walk.state])), unread };
1191
+ }
1192
+
1193
+ /**
1194
+ * Of the deposits a sender's history lists, those equal to the group's
1195
+ * (`to`, `amount`), from their calls: each with the block the app gave it
1196
+ * its credit can't be before; and those whose call or time the archive
1197
+ * doesn't give, which could be any deposit of up to what they paid, but
1198
+ * for one the app gave: its own facts tell. `calls` keeps each call read.
1025
1199
  *
1026
1200
  * @param {Archive} archive
1027
1201
  * @param {Network} net
1028
- * @param {{ sender: string, from: number, to: number, least: bigint, skip: ReadonlySet<string> }} range
1202
+ * @param {ReadonlyArray<{ hash: string, height: number }>} found
1203
+ * @param {{ amount: bigint, to: string, bounds: ReadonlyMap<string, bigint | null>, calls: Map<string, any>, supplied: ReadonlyMap<string, DepositItem> }} group
1029
1204
  * @param {{ requests: number }} counter
1030
1205
  */
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 });
1206
+ async function depositPeers(archive, net, found, { amount, bounds, calls, supplied, to }, counter) {
1207
+ const missing = found.flatMap((entry) => (calls.has(entry.hash) ? [] : [entry.hash]));
1208
+ const read = await detailsOf(archive, missing, { args: true, network: net }, counter);
1034
1209
 
1035
- counter.requests += read.requests;
1210
+ for (const hash of missing) calls.set(hash, read.get(hash) ?? null);
1036
1211
 
1037
- // A history cut short leaves its deposits' order unclear: nothing more is read.
1038
- if (!read.complete) return { complete: false, deposits: [], unresolved: [] };
1212
+ /** @type {Queued[]} */
1213
+ const peers = [];
1214
+ /** @type {{ hash: string, height: number }[]} */
1215
+ const unread = [];
1039
1216
 
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 = [];
1217
+ for (const entry of found) {
1218
+ const call = calls.get(entry.hash);
1219
+ const bridged = call?.bridge;
1046
1220
 
1047
- for (const entry of candidates) {
1048
- const found = details.get(entry.hash);
1049
- const bridged = found?.bridge;
1221
+ // Failed, or a call that isn't a deposit: no DUSK left for DuskEVM. A
1222
+ // deposit whose arguments can't be read could be any.
1223
+ if (call?.success === false || (bridged && bridged.kind !== "deposit") || (bridged === null && call?.fnName !== BRIDGE_FUNCTIONS.deposit)) continue;
1050
1224
 
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;
1225
+ const own = supplied.get(entry.hash);
1226
+ let at = msOf(call?.timestamp);
1227
+ let paid = luxOf(bridged?.amount);
1228
+ let recipient = addressOf(bridged?.to);
1053
1229
 
1054
- const at = msOf(found?.timestamp);
1055
- const amount = luxOf(bridged?.amount);
1056
- const recipient = addressOf(bridged?.to);
1230
+ // One the app gave whose call can't be read: its own facts.
1231
+ if (own && (!call || at === null || paid === null || !recipient)) ({ amount: paid, at, to: recipient } = own);
1057
1232
 
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 });
1233
+ if (at === null || paid === null || !recipient) unread.push(entry);
1234
+ else if (recipient === to && paid === amount) peers.push({ at, given: false, hash: entry.hash, notBefore: bounds.get(entry.hash) ?? null, scan: null });
1060
1235
  }
1061
1236
 
1062
- return { complete: true, deposits, unresolved };
1237
+ return { peers, unread };
1238
+ }
1239
+
1240
+ /**
1241
+ * Where a scan's search stopped, without its history.
1242
+ *
1243
+ * @param {Scan | null | undefined} scan
1244
+ * @returns {Scan}
1245
+ */
1246
+ const searchOf = (scan) => (scan?.from !== undefined ? { basis: scan.basis, from: scan.from, search: scan.search, seen: scan.seen } : {});
1247
+
1248
+ /**
1249
+ * The transfers whose history isn't all read yet wait, with where their
1250
+ * reading stopped beside where their last search did.
1251
+ *
1252
+ * @param {ReadonlyArray<{ hash: string, scan: Scan | null }>} items
1253
+ * @param {{ covered: ReadonlyArray<{ hash: string }>, states: ReadonlyMap<string, HistoryScan> }} history
1254
+ * @param {Map<string, Arrival>} results
1255
+ */
1256
+ function waitForHistory(items, { covered, states }, results) {
1257
+ const read = new Set(covered.map((item) => item.hash));
1258
+
1259
+ for (const item of items) {
1260
+ if (!read.has(item.hash)) results.set(item.hash, { scan: { ...searchOf(item.scan), history: /** @type {HistoryScan} */ (states.get(item.hash)) }, state: "waiting" });
1261
+ }
1262
+ }
1263
+
1264
+ /**
1265
+ * A result, with how far its transfer's history is read, which every one
1266
+ * but an arrival keeps in its scan for the next call.
1267
+ *
1268
+ * @param {string} hash
1269
+ * @param {Arrival} arrival
1270
+ * @param {ReadonlyArray<Queued>} given
1271
+ * @param {ReadonlyMap<string, HistoryScan>} states
1272
+ * @returns {Arrival}
1273
+ */
1274
+ function withHistory(hash, arrival, given, states) {
1275
+ const history = states.get(hash);
1276
+
1277
+ if (!history || arrival.state === "arrived") return arrival;
1278
+
1279
+ return { ...arrival, scan: { ...(arrival.scan ?? searchOf(given.find((each) => each.hash === hash)?.scan)), history } };
1063
1280
  }
1064
1281
 
1065
1282
  // ---------------------------------------------------------------------------
@@ -1380,34 +1597,6 @@ async function onExplorer(read, live, { api, bridge, maxPages, now, past, result
1380
1597
  * @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
1598
  */
1382
1599
  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
1600
  // Equal deposits (the same DuskEVM address and amount) from a known sender
1412
1601
  // first, a sender at a time, its events telling its credits apart; then
1413
1602
  // those whose sender isn't known, which take none from the senders of the
@@ -1438,46 +1627,69 @@ async function onRpc(read, live, { archive, blockTimes, bridge, list, maxWindows
1438
1627
  if (rest.length) unknown.push({ items: rest, rivals: aliases.get(key) ?? new Set() });
1439
1628
  }
1440
1629
 
1441
- // The block each deposit given can't be credited before, the app's own.
1630
+ // The block each deposit given can't be credited before, the app's own,
1631
+ // the deposits given, and the deposits' calls, read once.
1442
1632
  const bounds = new Map(list.map((item) => [item.hash, item.notBefore]));
1633
+ const supplied = new Map(list.map((item) => [item.hash, item]));
1634
+ /** @type {Map<string, any>} */
1635
+ const calls = new Map();
1636
+ const contract = duskEvmContracts(net)?.bridge;
1443
1637
 
1444
1638
  for (const { items, rivals } of [...known, ...unknown]) {
1445
1639
  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
- );
1640
+ const vouched = Boolean(archive) && sender !== null && items.every((item) => item.height !== null);
1641
+ let given = items.map(queued).sort(byTime);
1642
+ /** @type {Queued[]} */
1643
+ let queue;
1461
1644
  /** @type {Set<string>} */
1462
1645
  const unsure = new Set();
1646
+ /** @type {Map<string, HistoryScan>} */
1647
+ let states = new Map();
1463
1648
 
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
- }
1649
+ if (vouched) {
1650
+ // Each deposit's own hours of its sender's history, with its sender's
1651
+ // equal deposits there, the app's no longer looked for among them.
1652
+ /** @type {Awaited<ReturnType<typeof readHistories>>} */
1653
+ let history;
1654
+
1655
+ try {
1656
+ history = await readHistories(
1657
+ /** @type {Archive} */ (archive),
1658
+ /** @type {Reading[]} */ ([...items].sort((a, b) => /** @type {number} */ (a.height) - /** @type {number} */ (b.height))),
1659
+ {
1660
+ account: /** @type {string} */ (sender),
1661
+ direction: "out",
1662
+ horizonMs: DEPOSIT_HORIZON_MS,
1663
+ keep: (entry) => entry.counterparty === contract && entry.value >= amount,
1664
+ of: (item) => `${sender}:${item.height}:${amount}`,
1665
+ pages: HISTORY_PAGES,
1666
+ peersOf: (found) => depositPeers(/** @type {Archive} */ (archive), net, found, { amount, bounds, calls, supplied, to }, read.counter),
1667
+ reach: Math.ceil((3 * DEPOSIT_HORIZON_MS) / DUSK_BLOCK_MS),
1668
+ },
1669
+ read.counter
1670
+ );
1671
+ } catch (error) {
1672
+ for (const item of items) results.set(item.hash, { error: messageOf(error), reason: "error", state: "unknown" });
1673
+ continue;
1480
1674
  }
1675
+
1676
+ states = history.states;
1677
+ waitForHistory(items, history, results);
1678
+
1679
+ if (!history.covered.length) continue;
1680
+
1681
+ given = history.given;
1682
+ queue = history.queue;
1683
+
1684
+ // One before it whose call can't be read could be equal: the credits'
1685
+ // order can't be told.
1686
+ for (const item of given) if (/** @type {number[]} */ (history.unread.get(item.hash)).length) unsure.add(item.hash);
1687
+ } else {
1688
+ queue = withPast(
1689
+ given,
1690
+ past.filter((item) => item.to === to && item.amount === amount && item.sender === sender).map(queued),
1691
+ windowMs
1692
+ );
1481
1693
  }
1482
1694
 
1483
1695
  try {
@@ -1499,9 +1711,9 @@ async function onRpc(read, live, { archive, blockTimes, bridge, list, maxWindows
1499
1711
  { blockTimes, maxWindows, now, slackMs: CLOCK_SLACK_MS, url, used, windowMs }
1500
1712
  );
1501
1713
 
1502
- for (const [hash, arrival] of found) results.set(hash, arrival);
1714
+ for (const [hash, arrival] of found) results.set(hash, withHistory(hash, arrival, given, states));
1503
1715
  } catch (error) {
1504
- for (const item of items) results.set(item.hash, { error: messageOf(error), reason: "error", state: "unknown" });
1716
+ for (const item of given) results.set(item.hash, withHistory(item.hash, { error: messageOf(error), reason: "error", state: "unknown" }, given, states));
1505
1717
  }
1506
1718
  }
1507
1719
  }
@@ -1602,97 +1814,62 @@ export async function serviceArrivals({
1602
1814
  }
1603
1815
 
1604
1816
  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
1817
  // The fee of each transfer the app gave, those no longer looked for too,
1630
1818
  // and the block its payout can't be before.
1631
1819
  const feeOf = new Map(routed.map((item) => [item.hash, item.fee ?? /** @type {Route & { kind: "service" }} */ (item.route).feeLux]));
1632
1820
  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;
1821
+ // What each transfer to a route's account paid whom, its memo and its
1822
+ // time, from its own transaction, read once.
1823
+ /** @type {Map<string, TransferFacts | null>} */
1824
+ const facts = new Map(sent);
1647
1825
 
1648
- try {
1649
- peers = new Map([...sent, ...(await transfersOf(archive, others.flat().flatMap((entry) => (sent.has(entry.hash) ? [] : [entry.hash])), read.counter))]);
1650
- } catch (error) {
1651
- for (const items of groups) for (const item of items) results.set(item.hash, failed(error));
1652
-
1653
- return done();
1654
- }
1655
-
1656
- for (const [i, items] of groups.entries()) {
1826
+ for (const items of groupBy(live, (item) => `${item.route.id}:${item.to}:${item.net}`).values()) {
1657
1827
  const { net: payout, route, to } = items[0];
1658
- const history = /** @type {NonNullable<ReturnType<typeof histories.get>>} */ (histories.get(route.id));
1828
+ const horizonMs = horizonOf(route);
1659
1829
  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
- }
1830
+ const least = payout + [...fees].reduce((a, fee) => (fee < a ? fee : a));
1831
+ // Each transfer's own hours of the route's account's history: every
1832
+ // transfer that named the same address, in any case, and could be paid
1833
+ // before, the app's no longer looked for among them. Of those, the ones
1834
+ // that could pay out as much under the group's fees, or under their own.
1835
+ /** @type {Awaited<ReturnType<typeof readHistories>>} */
1836
+ let history;
1837
+
1838
+ try {
1839
+ history = await readHistories(
1840
+ archive,
1841
+ [...items].sort((a, b) => a.height - b.height),
1842
+ {
1843
+ account: route.account,
1844
+ direction: "in",
1845
+ horizonMs,
1846
+ keep: (entry) => entry.kind === "transfer" && entry.direction === "in" && entry.memo.toLowerCase() === to && entry.value >= payout,
1847
+ of: (item) => `${route.account}:${item.height}:${to}:${payout}`,
1848
+ pages: HISTORY_PAGES,
1849
+ peersOf: (found) =>
1850
+ servicePeers(
1851
+ archive,
1852
+ found.filter((entry) => BigInt(entry.value) >= least || feeOf.has(entry.hash)),
1853
+ { bounds, facts, feeOf, fees, payout, route, to },
1854
+ read.counter
1855
+ ),
1856
+ reach: Math.ceil((3 * horizonMs) / DUSK_BLOCK_MS),
1857
+ },
1858
+ read.counter
1859
+ );
1860
+ } catch (error) {
1861
+ for (const item of items) results.set(item.hash, failed(error));
1862
+ continue;
1681
1863
  }
1682
1864
 
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();
1865
+ waitForHistory(items, history, results);
1688
1866
 
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;
1867
+ if (!history.covered.length) continue;
1693
1868
 
1694
- if (!history.complete || runStart(queue, item, history.horizonMs).at - bottomAt < history.horizonMs || unread.some((each) => each <= height)) unsure.add(item.hash);
1695
- }
1869
+ const { given } = history;
1870
+ // One before it that can't be read could be equal: the payouts' order
1871
+ // can't be told.
1872
+ const unsure = new Set(given.flatMap((item) => (/** @type {number[]} */ (history.unread.get(item.hash)).length ? [item.hash] : [])));
1696
1873
 
1697
1874
  try {
1698
1875
  const found = await followLogs(
@@ -1706,21 +1883,63 @@ export async function serviceArrivals({
1706
1883
  },
1707
1884
  given,
1708
1885
  guarded: false,
1709
- queue,
1886
+ queue: history.queue,
1710
1887
  unsure,
1711
1888
  },
1712
1889
  { blockTimes, maxWindows, now, slackMs: CLOCK_SLACK_MS, url: String(rpcUrl ?? route.chain.rpcUrl), used, windowMs: CREDIT_WINDOW_MS }
1713
1890
  );
1714
1891
 
1715
- for (const [hash, arrival] of found) results.set(hash, arrival);
1892
+ for (const [hash, arrival] of found) results.set(hash, withHistory(hash, arrival, given, history.states));
1716
1893
  } catch (error) {
1717
- for (const item of items) results.set(item.hash, failed(error));
1894
+ for (const item of given) results.set(item.hash, withHistory(item.hash, failed(error), given, history.states));
1718
1895
  }
1719
1896
  }
1720
1897
 
1721
1898
  return done();
1722
1899
  }
1723
1900
 
1901
+ /**
1902
+ * Of the transfers a route's account received naming a group's address,
1903
+ * those equal to the group's, from their own transactions: one the app
1904
+ * gave pays out what its own fee leaves, one it didn't what the group's
1905
+ * fees do; each with the block the app gave it its payout can't be before.
1906
+ * And those whose transaction can't be read. `facts` keeps each
1907
+ * transaction read.
1908
+ *
1909
+ * @param {Archive} archive
1910
+ * @param {ReadonlyArray<{ hash: string, height: number }>} found
1911
+ * @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
1912
+ * @param {{ requests: number }} counter
1913
+ */
1914
+ async function servicePeers(archive, found, { bounds, facts, feeOf, fees, payout, route, to }, counter) {
1915
+ const missing = found.flatMap((entry) => (facts.has(entry.hash) ? [] : [entry.hash]));
1916
+ const read = await transfersOf(archive, missing, counter);
1917
+
1918
+ for (const hash of missing) facts.set(hash, read.get(hash) ?? null);
1919
+
1920
+ /** @type {Queued[]} */
1921
+ const peers = [];
1922
+ /** @type {{ hash: string, height: number }[]} */
1923
+ const unread = [];
1924
+
1925
+ for (const entry of found) {
1926
+ const fact = facts.get(entry.hash);
1927
+
1928
+ if (!fact || fact.kind === null || fact.at === null || (fact.kind === "moonlight" && (fact.value === null || fact.receiver === null))) {
1929
+ unread.push(entry);
1930
+ } else if (!fact.failed && fact.kind === "moonlight" && fact.receiver === route.account && fact.memo.toLowerCase() === to) {
1931
+ const value = /** @type {bigint} */ (fact.value);
1932
+ const own = feeOf.get(entry.hash);
1933
+
1934
+ if (own === undefined ? [...fees].some((fee) => value - fee === payout) : value - own === payout) {
1935
+ peers.push({ at: fact.at, given: false, hash: entry.hash, notBefore: bounds.get(entry.hash) ?? null, scan: null });
1936
+ }
1937
+ }
1938
+ }
1939
+
1940
+ return { peers, unread };
1941
+ }
1942
+
1724
1943
  // ---------------------------------------------------------------------------
1725
1944
  // Migrations into Dusk
1726
1945
  // ---------------------------------------------------------------------------
package/src/history.d.ts CHANGED
@@ -373,6 +373,8 @@ export type RangeOptions = {
373
373
  direction?: "in" | "out" | "both";
374
374
  /** The most pages to read, 1 by default. */
375
375
  pages?: number;
376
+ /** The first page to read, 1 by default: a read of the same range carries on where an earlier one stopped. */
377
+ page?: number;
376
378
  };
377
379
 
378
380
  /**
package/src/history.js CHANGED
@@ -816,7 +816,7 @@ function nextWindow(window, count) {
816
816
  * @param {RangeOptions} options
817
817
  * @returns {Promise<{ entries: HistoryEntry[], complete: boolean, requests: number }>}
818
818
  */
819
- export async function historyRange(archive, { account, from = 0, to, direction = "both", pages = 1 }) {
819
+ export async function historyRange(archive, { account, from = 0, to, direction = "both", pages = 1, page: first = 1 }) {
820
820
  /** @type {Record<string, ReadonlyArray<"in" | "out">>} */
821
821
  const ASKED = { both: ["out", "in"], in: ["in"], out: ["out"] };
822
822
  const aliases = Object.hasOwn(ASKED, direction) ? ASKED[direction] : null;
@@ -827,8 +827,9 @@ export async function historyRange(archive, { account, from = 0, to, direction =
827
827
  const entries = [];
828
828
  let complete = false;
829
829
  let requests = 0;
830
+ const start = Math.max(1, Math.floor(Number(first)) || 1);
830
831
 
831
- for (let page = 1; page <= Math.max(1, Math.floor(pages)) && !complete; page += 1) {
832
+ for (let page = start; page < start + Math.max(1, Math.floor(pages)) && !complete; page += 1) {
832
833
  requests += 1;
833
834
 
834
835
  const data = await archive.query(rangeQuery(account, { aliases, from, page, to }));