@dusk-network/app-core 0.2.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/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;
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
- }
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);
1655
1825
 
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,25 +1883,78 @@ 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
  // ---------------------------------------------------------------------------
1727
1946
 
1947
+ /**
1948
+ * The migration routes an account pays out. More than one where a
1949
+ * deployment has one account pay out both chains' migrations: then a
1950
+ * payout of it doesn't say which route it is on.
1951
+ *
1952
+ * @param {Network} net
1953
+ * @param {string} account
1954
+ * @returns {Route[]}
1955
+ */
1956
+ const paidOutBy = (net, account) => routes(net).filter((route) => route.kind === "migration" && Boolean(route.payoutAccount) && route.payoutAccount === account.trim());
1957
+
1728
1958
  /**
1729
1959
  * The payouts of migrations a transfer the archive lists holds for
1730
1960
  * `account`, read from its own events: from a migration route's payout
@@ -1744,12 +1974,12 @@ function payoutsIn(raw, account, net) {
1744
1974
 
1745
1975
  return (Array.isArray(raw?.events) ? raw.events : []).flatMap((/** @type {any} */ event) => {
1746
1976
  const data = event?.data;
1747
- const route = data && event.reverted !== true && event.topic === "moonlight" && String(event.target ?? "").toLowerCase() === TRANSFER_CONTRACT && data.receiver === account && typeof data.sender === "string" ? routeForAccount(net, data.sender, "in") : null;
1977
+ const paying = data && event.reverted !== true && event.topic === "moonlight" && String(event.target ?? "").toLowerCase() === TRANSFER_CONTRACT && data.receiver === account && typeof data.sender === "string" ? paidOutBy(net, data.sender) : [];
1748
1978
  const memo = memoOf(data?.memo).trim().toLowerCase();
1749
1979
  const amount = luxOf(data?.value);
1750
1980
 
1751
- return route?.kind === "migration" && route.payoutAccount && EVM_HASH.test(memo) && amount !== null
1752
- ? [{ amount, at: null, block, hash: /** @type {`0x${string}`} */ (memo), payout, route: route.id }]
1981
+ return paying.length && EVM_HASH.test(memo) && amount !== null
1982
+ ? [{ amount, at: null, block, hash: /** @type {`0x${string}`} */ (memo), payer: data.sender.trim(), payout, route: paying.length === 1 ? paying[0].id : null }]
1753
1983
  : [];
1754
1984
  });
1755
1985
  }
@@ -1811,22 +2041,22 @@ export async function migrationPayouts({ network, migrations, entries, archive,
1811
2041
  }
1812
2042
 
1813
2043
  // Each payout pays one migration: the migration its memo names, of the
1814
- // amount it paid, on the route whose payout account paid it.
2044
+ // amount it paid, from its route's payout account.
1815
2045
  const byBlock = [...candidates].sort((a, b) => a.block - b.block || (a.payout < b.payout ? -1 : 1));
1816
2046
 
1817
2047
  for (const item of [...payable].sort((a, b) => (a.at ?? Infinity) - (b.at ?? Infinity))) {
1818
2048
  if (results.has(item.hash)) continue;
1819
2049
 
1820
- const named = byBlock.filter((each) => !used.has(each.payout) && each.hash === item.hash && each.route === item.route?.id);
2050
+ const named = byBlock.filter((each) => !used.has(each.payout) && each.hash === item.hash && each.payer === item.route?.payoutAccount);
1821
2051
  const payout = named.find((each) => each.amount === item.amount);
1822
2052
 
1823
2053
  if (payout) {
1824
2054
  used.add(payout.payout);
1825
2055
  results.set(item.hash, { amount: payout.amount, at: payout.at, block: payout.block, hash: payout.payout, state: "arrived" });
1826
2056
  } else if (named.length) {
1827
- // Its payout, of another amount: an entry's value holds a gas refund
1828
- // paid to the account, which it doesn't tell apart, or another amount
1829
- // was paid. Not waiting: unsure.
2057
+ // Its payout, of another amount: another amount was paid, or the value
2058
+ // of an entry an earlier version kept holds a gas refund paid to the
2059
+ // account. Not waiting: unsure.
1830
2060
  results.set(item.hash, { reason: "unsure", state: "unknown" });
1831
2061
  } else {
1832
2062
  results.set(item.hash, item.at !== null && now - item.at > waitMs ? { reason: "expired", state: "unknown" } : { state: "waiting" });
@@ -1900,17 +2130,27 @@ export function serviceTransferOf(entry, network) {
1900
2130
  */
1901
2131
  export function migrationPayoutOf(entry, network) {
1902
2132
  const net = networkOf(network);
1903
- const route = entry?.kind === "transfer" && entry.direction === "in" && typeof entry.counterparty === "string" ? routeForAccount(net, entry.counterparty, "in") : null;
2133
+ const paying = entry?.kind === "transfer" && entry.direction === "in" && typeof entry.counterparty === "string" ? paidOutBy(net, entry.counterparty) : [];
1904
2134
  const { at, hash, height, value } = factsOf(entry);
2135
+ // What the transfer paid: a gas refund paid to the account with it aside.
2136
+ const refund = entry?.refund === undefined || entry.refund === null ? 0n : luxOf(entry.refund);
1905
2137
  const memo = String(entry?.memo ?? "")
1906
2138
  .trim()
1907
2139
  .toLowerCase();
1908
2140
 
1909
- if (!route || route.kind !== "migration" || !route.payoutAccount || entry.success === false || !hash || height === null || value === null || !EVM_HASH.test(memo)) {
2141
+ if (!paying.length || entry.success === false || !hash || height === null || value === null || refund === null || refund > value || !EVM_HASH.test(memo)) {
1910
2142
  return null;
1911
2143
  }
1912
2144
 
1913
- return { amount: value, at, block: height, hash: /** @type {`0x${string}`} */ (memo), payout: hash, route: route.id };
2145
+ return {
2146
+ amount: value - refund,
2147
+ at,
2148
+ block: height,
2149
+ hash: /** @type {`0x${string}`} */ (memo),
2150
+ payer: /** @type {string} */ (entry.counterparty).trim(),
2151
+ payout: hash,
2152
+ route: paying.length === 1 ? paying[0].id : null,
2153
+ };
1914
2154
  }
1915
2155
 
1916
2156
  /**