@dusk-network/app-core 0.3.2 → 0.4.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.
@@ -127,6 +127,12 @@ const U32_MAX = (1n << 32n) - 1n;
127
127
  /** The low 240 bits of a withdrawal's nonce: its count, under its version. */
128
128
  const NONCE_COUNT = (1n << 240n) - 1n;
129
129
 
130
+ // The newest final blocks a sync reads again on each run, the new ones
131
+ // among them, and how long after it first covered all the final blocks it
132
+ // reads them all again: each pass after waits as long again as it has been
133
+ // since.
134
+ const L2_OVERLAP = 50_000;
135
+ const L2_RECHECK_MS = 60 * 60_000;
130
136
  // Answers a read is sent again for, a quarter second, then half a second later.
131
137
  const RETRY_STATUSES = new Set([429, 502, 503, 504]);
132
138
  const ATTEMPTS = 3;
@@ -767,6 +773,20 @@ function evmRpc(client, net) {
767
773
  };
768
774
  }
769
775
 
776
+ /**
777
+ * A block number as DuskEVM's RPC gives it, a hex quantity, or as a whole
778
+ * number; null for anything else.
779
+ *
780
+ * @param {unknown} value
781
+ * @returns {bigint | null}
782
+ */
783
+ function blockNumberOf(value) {
784
+ if (typeof value === "bigint") return value >= 0n ? value : null;
785
+ if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null;
786
+
787
+ return typeof value === "string" && /^0x[0-9a-fA-F]+$/.test(value) ? BigInt(value) : null;
788
+ }
789
+
770
790
  // ---------------------------------------------------------------------------
771
791
  // The bridge's contracts on Dusk
772
792
  // ---------------------------------------------------------------------------
@@ -1217,13 +1237,137 @@ export function createWithdrawals(options) {
1217
1237
  ]).then(([status, blacklisted, proper, respected]) => ({ blacklisted, proper, respected, status }));
1218
1238
 
1219
1239
  /**
1240
+ * Where a final receipt is kept: by the network, its Portal and the
1241
+ * transaction's hash, so another network's or deployment's stays apart.
1242
+ *
1243
+ * @param {string} hash - 0x and 64 lower-case hex digits
1244
+ */
1245
+ const receiptKey = (hash) => `receipt:${net.chainId}:${contracts.portal}:${hash}`;
1246
+
1247
+ /** @type {Promise<bigint | null> | null} */
1248
+ let finalizing = null;
1249
+
1250
+ /**
1251
+ * DuskEVM's finalized block's height, or null where it can't be read;
1252
+ * the receipts read at the same time share one read.
1253
+ *
1254
+ * @returns {Promise<bigint | null>}
1255
+ */
1256
+ function finalizedHeight() {
1257
+ if (!finalizing) {
1258
+ const reading = Promise.resolve()
1259
+ .then(() => rpc("eth_getBlockByNumber", ["finalized", false]))
1260
+ .then(
1261
+ (block) => blockNumberOf(block?.number),
1262
+ () => null
1263
+ );
1264
+
1265
+ finalizing = reading;
1266
+ reading.then(() => {
1267
+ if (finalizing === reading) finalizing = null;
1268
+ });
1269
+ }
1270
+
1271
+ return finalizing;
1272
+ }
1273
+
1274
+ /** @type {Map<string, Promise<any>>} */
1275
+ const receiptReads = new Map();
1276
+
1277
+ /**
1278
+ * The withdrawal a kept receipt records, as when it was kept; null when
1279
+ * none is kept, or what is kept isn't this transaction's withdrawal.
1280
+ *
1281
+ * @param {string} hash
1282
+ * @returns {ReceiptWithdrawal | null}
1283
+ */
1284
+ function keptReceipt(hash) {
1285
+ try {
1286
+ const text = cache.get(receiptKey(hash));
1287
+
1288
+ if (typeof text !== "string" || !text) return null;
1289
+
1290
+ const withdrawal = parseWithdrawalReceipt(net, JSON.parse(text));
1291
+
1292
+ return withdrawal.transactionHash === hash ? withdrawal : null;
1293
+ } catch {
1294
+ return null;
1295
+ }
1296
+ }
1297
+
1298
+ /**
1299
+ * Keeps a receipt that can't change any more: a withdrawal's, of this
1300
+ * transaction, in a block at or under `final`, the finalized height read
1301
+ * before the receipt was. A failed or missing receipt, or one that isn't
1302
+ * a withdrawal's, isn't kept. Kept as the RPC gave it, once its text
1303
+ * reads back as the same withdrawal.
1304
+ *
1305
+ * @param {string} hash
1306
+ * @param {any} raw
1307
+ * @param {bigint} final
1308
+ */
1309
+ function keepIfFinal(hash, raw, final) {
1310
+ try {
1311
+ const withdrawal = parseWithdrawalReceipt(net, raw);
1312
+ const block = blockNumberOf(raw?.blockNumber);
1313
+
1314
+ if (block === null || block !== withdrawal.blockNumber || block > final || withdrawal.transactionHash !== hash) return;
1315
+
1316
+ const text = JSON.stringify(raw);
1317
+ const again = parseWithdrawalReceipt(net, JSON.parse(text));
1318
+
1319
+ if (again.withdrawalHash !== withdrawal.withdrawalHash || again.blockNumber !== block || again.transactionHash !== hash) return;
1320
+
1321
+ cache.set(receiptKey(hash), text);
1322
+ } catch {
1323
+ // Not kept: it's read again next time.
1324
+ }
1325
+ }
1326
+
1327
+ /**
1328
+ * A transaction's receipt as DuskEVM gives it, read after DuskEVM's
1329
+ * finalized block: a receipt read once its block is final is the final
1330
+ * chain's, and is kept. The callers asking for one receipt at the same
1331
+ * time share one read.
1332
+ *
1333
+ * @param {string} hash
1334
+ * @returns {Promise<any>}
1335
+ */
1336
+ function readReceipt(hash) {
1337
+ let reading = receiptReads.get(hash);
1338
+
1339
+ if (!reading) {
1340
+ reading = (async () => {
1341
+ const final = await finalizedHeight();
1342
+ const raw = await rpc("eth_getTransactionReceipt", [hash]);
1343
+
1344
+ if (final !== null) keepIfFinal(hash, raw, final);
1345
+
1346
+ return raw;
1347
+ })();
1348
+ receiptReads.set(hash, reading);
1349
+ reading.then(
1350
+ () => receiptReads.delete(hash),
1351
+ () => receiptReads.delete(hash)
1352
+ );
1353
+ }
1354
+
1355
+ return reading;
1356
+ }
1357
+
1358
+ /**
1359
+ * A transaction's withdrawal, from its receipt: the one kept once its
1360
+ * block was final, else the one DuskEVM gives now.
1361
+ *
1220
1362
  * @param {string} txHash
1221
1363
  * @returns {Promise<ReceiptWithdrawal>}
1222
1364
  */
1223
1365
  async function receipt(txHash) {
1224
1366
  if (typeof txHash !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(txHash)) throw new WithdrawalError("bad-hash");
1225
1367
 
1226
- return parseWithdrawalReceipt(net, await rpc("eth_getTransactionReceipt", [txHash.toLowerCase()]));
1368
+ const hash = txHash.toLowerCase();
1369
+
1370
+ return keptReceipt(hash) ?? parseWithdrawalReceipt(net, await readReceipt(hash));
1227
1371
  }
1228
1372
 
1229
1373
  /**
@@ -2086,20 +2230,41 @@ function withdrawalFromLog(log) {
2086
2230
  const tooWide = (error) => /range|limit|too many|exceed/i.test(asError(error).message);
2087
2231
 
2088
2232
  /**
2089
- * The highest DuskEVM block that can't be reorged away.
2233
+ * The highest DuskEVM block that can't be reorged away, and the requests it
2234
+ * took.
2090
2235
  *
2091
2236
  * @param {(method: string, params?: unknown[]) => Promise<any>} rpc
2237
+ * @returns {Promise<{ head: number, requests: number }>}
2092
2238
  */
2093
2239
  async function finalHead(rpc) {
2094
2240
  try {
2095
2241
  const block = await rpc("eth_getBlockByNumber", ["finalized", false]);
2096
2242
 
2097
- if (block?.number) return Number(BigInt(block.number));
2243
+ if (block?.number) return { head: Number(BigInt(block.number)), requests: 1 };
2098
2244
  } catch {
2099
2245
  // An RPC without "finalized": the latest block, less a margin.
2100
2246
  }
2101
2247
 
2102
- return Math.max(0, Number(BigInt(await rpc("eth_blockNumber", []))) - UNFINALIZED_MARGIN);
2248
+ return { head: Math.max(0, Number(BigInt(await rpc("eth_blockNumber", []))) - UNFINALIZED_MARGIN), requests: 2 };
2249
+ }
2250
+
2251
+ /**
2252
+ * A sync's passes over the blocks it covered, as a state gave them, the one
2253
+ * under way no higher than `target`; null for anything else.
2254
+ *
2255
+ * @param {unknown} value
2256
+ * @param {number} target
2257
+ * @returns {NonNullable<WithdrawalScan["recheck"]> | null}
2258
+ */
2259
+ function recheckOf(value, target) {
2260
+ const kept = /** @type {any} */ (value);
2261
+ /** @param {unknown} ms */
2262
+ const time = (ms) => typeof ms === "number" && Number.isFinite(ms);
2263
+
2264
+ if (!kept || !time(kept.at) || !time(kept.since)) return null;
2265
+ if (kept.from !== null && !(Number.isSafeInteger(kept.from) && kept.from >= 0)) return null;
2266
+
2267
+ return { at: kept.at, from: kept.from === null ? null : Math.min(kept.from, target), since: kept.since };
2103
2268
  }
2104
2269
 
2105
2270
  /**
@@ -2114,7 +2279,8 @@ export async function syncWithdrawals({ address, bridge, duskEvm, maxRequests =
2114
2279
  if (!contract) throw new TypeError("No DuskEVM bridge to read");
2115
2280
 
2116
2281
  const rpc = evmRpc(duskEvm, net);
2117
- const target = await finalHead(rpc);
2282
+ const { head: target, requests: asked } = await finalHead(rpc);
2283
+ const time = now();
2118
2284
  // Coverage kept above the final head, as an older run read the latest, is
2119
2285
  // read again, and what was found there is dropped with it: it may have
2120
2286
  // been reorged away, or into another block.
@@ -2124,45 +2290,95 @@ export async function syncWithdrawals({ address, bridge, duskEvm, maxRequests =
2124
2290
  const entries = Object.fromEntries(Object.entries(state?.entries ?? {}).filter(([, entry]) => Number(entry?.blockNumber) <= target));
2125
2291
  /** @type {FoundWithdrawal[]} */
2126
2292
  const found = [];
2127
- let requests = 1;
2293
+ let requests = asked;
2294
+ let recheck = recheckOf(state?.recheck, target);
2128
2295
  const topics = [ETH_BRIDGE_INITIATED_TOPIC, pad(sender)];
2129
2296
 
2130
- while (requests < maxRequests) {
2131
- const gap = topGap(covered, target);
2132
-
2133
- if (!gap) break;
2134
-
2135
- const [a, b] = gap;
2136
- const lo = Math.max(a, b - window + 1);
2297
+ /**
2298
+ * The address's withdrawals in blocks `lo` to `hi`, those blocks covered;
2299
+ * false where the RPC asks for a narrower window, which it then is.
2300
+ *
2301
+ * @param {number} lo
2302
+ * @param {number} hi
2303
+ */
2304
+ const read = async (lo, hi) => {
2137
2305
  /** @type {any[]} */
2138
2306
  let logs;
2139
2307
 
2140
2308
  requests += 1;
2141
2309
 
2142
2310
  try {
2143
- logs = await rpc("eth_getLogs", [{ address: contract, fromBlock: toHex(lo), toBlock: toHex(b), topics }]);
2311
+ logs = await rpc("eth_getLogs", [{ address: contract, fromBlock: toHex(lo), toBlock: toHex(hi), topics }]);
2144
2312
  } catch (error) {
2145
2313
  if (window > 1_000 && tooWide(error)) {
2146
2314
  window = Math.floor(window / 4);
2147
- continue;
2315
+ return false;
2148
2316
  }
2149
2317
 
2150
2318
  throw error;
2151
2319
  }
2152
2320
 
2153
2321
  for (const log of logs ?? []) {
2322
+ // One found before, read again, is kept as it was.
2323
+ if (entries[String(log?.transactionHash).toLowerCase()]) continue;
2324
+
2154
2325
  const entry = withdrawalFromLog(log);
2155
2326
 
2156
- if (!entry || entries[entry.hash]) continue;
2327
+ if (!entry) continue;
2157
2328
 
2158
2329
  entries[entry.hash] = entry;
2159
2330
  found.push(entry);
2160
2331
  }
2161
2332
 
2162
- covered = addCoverage(covered, lo, b);
2333
+ covered = addCoverage(covered, lo, hi);
2334
+
2335
+ return true;
2336
+ };
2337
+
2338
+ // The newest final blocks, read again on each run, from the newest down,
2339
+ // so the new ones first: in as many windows as they take, though no more
2340
+ // than half the requests the run has left (one at least, where nothing
2341
+ // else is to be read). While older blocks are still to be read, a run
2342
+ // under the same final head as the last leaves them all to those.
2343
+ const lowest = Math.max(0, target - L2_OVERLAP + 1);
2344
+ const due = recheck !== null && (recheck.from !== null || time >= recheck.at);
2345
+ const older = topGap(addCoverage(covered, lowest, target), target) !== null;
2346
+ const half = Math.floor((maxRequests - requests) / 2);
2347
+ const share = older && state?.target === target ? 0 : older || due ? half : Math.max(1, half);
2348
+ let top = target;
2349
+
2350
+ for (let windows = 0; windows < share && top >= lowest && requests < maxRequests; windows += 1) {
2351
+ const lo = Math.max(0, top - window + 1);
2352
+
2353
+ if (await read(lo, top)) top = lo - 1;
2354
+ }
2355
+
2356
+ while (requests < maxRequests) {
2357
+ const gap = topGap(covered, target);
2358
+
2359
+ if (gap) {
2360
+ const [a, b] = gap;
2361
+
2362
+ await read(Math.max(a, b - window + 1), b);
2363
+ continue;
2364
+ }
2365
+
2366
+ // All the final blocks covered: they're read again, a pass at a time,
2367
+ // from the newest down, with what a run has left.
2368
+ if (recheck === null) recheck = { at: time + L2_RECHECK_MS, from: null, since: time };
2369
+ if (recheck.from === null && time < recheck.at) break;
2370
+
2371
+ const from = recheck.from ?? target;
2372
+ const lo = Math.max(0, from - window + 1);
2373
+
2374
+ if (!(await read(lo, from))) continue;
2375
+
2376
+ recheck = lo > 0 ? { ...recheck, from: lo - 1 } : { at: time + Math.max(L2_RECHECK_MS, time - recheck.since), from: null, since: recheck.since };
2163
2377
  }
2164
2378
 
2165
- return { found, requests, state: { covered, entries, target, updatedAt: now(), window } };
2379
+ if (recheck === null && !topGap(covered, target)) recheck = { at: time + L2_RECHECK_MS, from: null, since: time };
2380
+
2381
+ return { found, requests, state: { covered, entries, ...(recheck ? { recheck } : {}), target, updatedAt: now(), window } };
2166
2382
  }
2167
2383
 
2168
2384
  /**
package/src/history.d.ts CHANGED
@@ -119,6 +119,12 @@ export type HistoryEntry = {
119
119
  refund?: bigint;
120
120
  /** The memo as text, or as 0x hex when it isn't text; "" for none. */
121
121
  memo: string;
122
+ /**
123
+ * True when `memo` is bytes that aren't text, given as 0x hex, so a memo
124
+ * typed as 0x text isn't taken for them; left out otherwise. An entry an
125
+ * earlier version kept has none.
126
+ */
127
+ memoBinary?: boolean;
122
128
  /** Whether the account paid the fee. */
123
129
  payer: boolean;
124
130
  };
@@ -212,6 +218,13 @@ export type HistoryState = {
212
218
  [field: string]: unknown;
213
219
  };
214
220
 
221
+ /**
222
+ * A store's check before a write, called with the state kept now, in the
223
+ * write's own transaction: when it throws, the store writes nothing, and
224
+ * may reject.
225
+ */
226
+ export type StoreCheck = (kept: HistoryState | null) => void;
227
+
215
228
  /**
216
229
  * Where one account's history is kept on one network: the app's own
217
230
  * storage, which the app scopes and keys. Each method may return a value or
@@ -234,12 +247,21 @@ export type HistoryStore = {
234
247
  entries: KeptEntry[],
235
248
  update: (kept: HistoryState | null) => HistoryState
236
249
  ): Promise<HistoryState | void> | HistoryState | void;
237
- /** Add details to kept entries, by hash; entries that aren't kept are left alone. */
238
- patch(details: Map<string, EntryDetails & Record<string, unknown>>): Promise<void> | void;
250
+ /**
251
+ * Add details to kept entries, by hash; entries that aren't kept are left
252
+ * alone. A store that calls `check` first, in the same transaction, keeps
253
+ * no details on a history another run started over since.
254
+ */
255
+ patch(details: Map<string, EntryDetails & Record<string, unknown>>, check?: StoreCheck): Promise<void> | void;
239
256
  /** Kept entries, newest first: at most `limit`, below the height `before` when it's given. */
240
257
  list(options: { limit: number; before?: number }): Promise<KeptEntry[]> | KeptEntry[];
241
- /** Forget what is kept of the old chain: the node is on another one now. */
242
- clear(): Promise<void> | void;
258
+ /**
259
+ * Forget what is kept of the old chain: the node is on another one now. A
260
+ * store that calls `check` first, in the same transaction, forgets
261
+ * nothing once another run started the history over on the new chain,
262
+ * and keeps what that run read.
263
+ */
264
+ clear(check?: StoreCheck): Promise<void> | void;
243
265
  };
244
266
 
245
267
  /** When nodes last answered without the archive: the app's storage, by URL. */
@@ -472,13 +494,15 @@ export type History = {
472
494
  * One sync run: the newest heights first, then older ones, within its
473
495
  * budgets. Each step is kept as it lands, so a run that stops anywhere is
474
496
  * carried on by the next. A run whose head names another chain than the
475
- * history kept starts it over, on what is left of its budget. Rejects as
476
- * the store does, as after a wallet reset; with a NoArchiveError for a
477
- * node without the archive; with a StaleHistoryError once another run
478
- * overtook it; and with a RangeError, before any request, for fewer than
479
- * 2 history requests.
497
+ * history kept starts it over, on what is left of its budget. A details
498
+ * request that fails ends the run's details, not the run: it resolves
499
+ * with `detailsError`, and the next run asks for them again. Rejects as
500
+ * the store does, as after a wallet reset; as a history request does;
501
+ * with a NoArchiveError for a node without the archive; with a
502
+ * StaleHistoryError once another run overtook it; and with a RangeError,
503
+ * before any request, for fewer than 2 history requests.
480
504
  */
481
- sync(options?: SyncOptions): Promise<{ state: HistoryState; requests: number; added: number }>;
505
+ sync(options?: SyncOptions): Promise<{ state: HistoryState; requests: number; added: number; detailsError?: Error }>;
482
506
  /** Details for kept entries that need them, as those shown before a sync gave them any: one request per 50. */
483
507
  fillDetails(entries: ReadonlyArray<KeptEntry | ReadEntry>, options?: { onStep?: () => void }): Promise<void>;
484
508
  /**
package/src/history.js CHANGED
@@ -395,14 +395,16 @@ function readHead(data) {
395
395
  const utf8 = new TextDecoder("utf-8", { fatal: true });
396
396
 
397
397
  /**
398
- * A memo as text when it is UTF-8 without control characters, else as 0x hex.
398
+ * A memo as text when it is UTF-8 without control characters, else as 0x hex
399
+ * (`binary`).
399
400
  *
400
401
  * @param {unknown} hex
402
+ * @returns {{ memo: string, binary: boolean }}
401
403
  */
402
- function memoText(hex) {
404
+ function memoOf(hex) {
403
405
  const h = String(hex ?? "");
404
406
 
405
- if (!h || h.length % 2 || !/^[0-9a-f]+$/i.test(h)) return "";
407
+ if (!h || h.length % 2 || !/^[0-9a-f]+$/i.test(h)) return { binary: false, memo: "" };
406
408
 
407
409
  const bytes = Uint8Array.from(h.match(/../g) ?? [], (b) => parseInt(b, 16));
408
410
 
@@ -410,12 +412,12 @@ function memoText(hex) {
410
412
  const text = utf8.decode(bytes);
411
413
 
412
414
  // eslint-disable-next-line no-control-regex
413
- if (!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text)) return text;
415
+ if (!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text)) return { binary: false, memo: text };
414
416
  } catch {
415
417
  // not text
416
418
  }
417
419
 
418
- return `0x${h.toLowerCase()}`;
420
+ return { binary: true, memo: `0x${h.toLowerCase()}` };
419
421
  }
420
422
 
421
423
  /** @param {any} receiver */
@@ -443,6 +445,7 @@ export function parseHistoryEntry(account, raw) {
443
445
  let selfValue = null;
444
446
  let payer = false;
445
447
  let memo = "";
448
+ let memoBinary = false;
446
449
  /** @type {EntryKind | null} */
447
450
  let kind = null;
448
451
  /** @type {string | null} */
@@ -476,7 +479,7 @@ export function parseHistoryEntry(account, raw) {
476
479
  const ours = d.sender === account || receiver === account;
477
480
 
478
481
  if (d.sender === account) payer = true;
479
- if (ours) memo = memoText(d.memo);
482
+ if (ours) ({ binary: memoBinary, memo } = memoOf(d.memo));
480
483
 
481
484
  if (d.sender === account && receiver === account) {
482
485
  selfValue = (selfValue ?? 0n) + value;
@@ -574,7 +577,18 @@ export function parseHistoryEntry(account, raw) {
574
577
  else counterparty = direction === "out" ? sentTo : receivedFrom;
575
578
  }
576
579
 
577
- return { blockHeight, counterparty: counterparty || null, direction, hash, kind, memo, payer, value, ...(refunded ? { refund: refunded } : {}) };
580
+ return {
581
+ blockHeight,
582
+ counterparty: counterparty || null,
583
+ direction,
584
+ hash,
585
+ kind,
586
+ memo,
587
+ ...(memoBinary ? { memoBinary: true } : {}),
588
+ payer,
589
+ value,
590
+ ...(refunded ? { refund: refunded } : {}),
591
+ };
578
592
  }
579
593
 
580
594
  /**
@@ -1071,14 +1085,16 @@ export function createHistory({ archive, account, store, network = null, callFac
1071
1085
  /**
1072
1086
  * Details for the newest kept entries that need them, or for `entries`,
1073
1087
  * a batch per request, each kept only while the history is still the one
1074
- * `chain` reads.
1088
+ * `chain` reads. A request that fails rejects, or with `lenient`, as in a
1089
+ * sync run, ends the details there and gives its error.
1075
1090
  *
1076
- * @param {{ chain: { genesis: string, kept: boolean }, entries?: ReadonlyArray<KeptEntry | ReadEntry>, maxRequests: number, onStep?: () => void }} options
1091
+ * @param {{ chain: { genesis: string, kept: boolean }, entries?: ReadonlyArray<KeptEntry | ReadEntry>, maxRequests: number, onStep?: () => void, lenient?: boolean }} options
1092
+ * @returns {Promise<Error | null>}
1077
1093
  */
1078
- async function fillDetails({ chain, entries, maxRequests, onStep }) {
1094
+ async function fillDetails({ chain, entries, maxRequests, onStep, lenient = false }) {
1079
1095
  let requests = Math.max(0, Math.floor(maxRequests));
1080
1096
 
1081
- if (!requests) return;
1097
+ if (!requests) return null;
1082
1098
 
1083
1099
  const due = entries ? entries.filter(needsDetails) : await pendingRows(DETAILS_BATCH * requests);
1084
1100
  // Arguments are asked only of the calls they're read for.
@@ -1087,7 +1103,16 @@ export function createHistory({ archive, account, store, network = null, callFac
1087
1103
 
1088
1104
  for (let i = 0; i < due.length && requests > 0; i += DETAILS_BATCH, requests -= 1) {
1089
1105
  const batch = due.slice(i, i + DETAILS_BATCH).map((e) => e.hash);
1090
- const details = await fetchTxDetails(archive, batch, { account, args, callFacts, network: net, now });
1106
+ /** @type {Map<string, EntryDetails & Record<string, unknown>>} */
1107
+ let details;
1108
+
1109
+ try {
1110
+ details = await fetchTxDetails(archive, batch, { account, args, callFacts, network: net, now });
1111
+ } catch (error) {
1112
+ if (!lenient) throw error;
1113
+
1114
+ return error instanceof Error ? error : new Error(messageOf(error));
1115
+ }
1091
1116
 
1092
1117
  // Asked for the bridge, and its call isn't the bridge's: read, and kept so.
1093
1118
  for (const [hash, found] of details) if (forBridge.has(hash) && found.bridge === undefined) found.bridge = null;
@@ -1096,11 +1121,25 @@ export function createHistory({ archive, account, store, network = null, callFac
1096
1121
  if (details.size) {
1097
1122
  if (staleFor(await store.getState(), chain)) throw new StaleHistoryError();
1098
1123
 
1099
- await store.patch(details);
1124
+ /** @type {StaleHistoryError | null} */
1125
+ let refused = null;
1126
+
1127
+ try {
1128
+ // Checked again in the write's own transaction, by a store that can.
1129
+ await store.patch(details, (kept) => {
1130
+ if (staleFor(kept, chain)) throw (refused = new StaleHistoryError());
1131
+ });
1132
+ } catch (error) {
1133
+ throw refused ?? error;
1134
+ }
1135
+
1136
+ if (refused) throw refused;
1100
1137
  }
1101
1138
 
1102
1139
  onStep?.();
1103
1140
  }
1141
+
1142
+ return null;
1104
1143
  }
1105
1144
 
1106
1145
  /**
@@ -1146,7 +1185,7 @@ export function createHistory({ archive, account, store, network = null, callFac
1146
1185
  * @param {number} maxDetailRequests - 0 or more
1147
1186
  * @param {((state: HistoryState) => void) | undefined} onStep
1148
1187
  * @param {{ budget: { left: number }, head: { finalized: number, genesis: string } } | null} [restart]
1149
- * @returns {Promise<{ state: HistoryState, requests: number, added: number }>}
1188
+ * @returns {Promise<{ state: HistoryState, requests: number, added: number, detailsError?: Error }>}
1150
1189
  */
1151
1190
  async function run(maxRequests, maxDetailRequests, onStep, restart = null) {
1152
1191
  /** @type {HistoryState | null} */
@@ -1154,6 +1193,9 @@ export function createHistory({ archive, account, store, network = null, callFac
1154
1193
  const budget = restart?.budget ?? { left: maxRequests };
1155
1194
  let detailsLeft = maxDetailRequests;
1156
1195
  let added = 0;
1196
+ // A details request that failed: the run reads no more details, and goes on.
1197
+ /** @type {Error | null} */
1198
+ let detailsError = null;
1157
1199
  // The chain this run reads, once its head names it, and whether it has
1158
1200
  // seen the history kept: one cleared since isn't this run's to write.
1159
1201
  const chain = { genesis: "", kept: state !== null };
@@ -1207,7 +1249,8 @@ export function createHistory({ archive, account, store, network = null, callFac
1207
1249
 
1208
1250
  if (entries.length && added === entries.length && detailsLeft > 0) {
1209
1251
  detailsLeft -= 1;
1210
- await fillDetails({ chain, maxRequests: 1, onStep: report });
1252
+ detailsError = await fillDetails({ chain, lenient: true, maxRequests: 1, onStep: report });
1253
+ if (detailsError) detailsLeft = 0;
1211
1254
  }
1212
1255
  };
1213
1256
 
@@ -1237,7 +1280,21 @@ export function createHistory({ archive, account, store, network = null, callFac
1237
1280
  if (state?.genesis && genesis && state.genesis !== genesis) {
1238
1281
  if (restart) throw new Error("The store still keeps another chain's history");
1239
1282
 
1240
- await store.clear();
1283
+ let started = false;
1284
+
1285
+ try {
1286
+ // Only while it is still another chain's, checked in the clear's own
1287
+ // transaction by a store that can: another run may have started it
1288
+ // over on this chain since this one read it, and kept what it read.
1289
+ await store.clear((kept) => {
1290
+ if (kept === null || kept === undefined || kept.genesis === genesis) {
1291
+ started = true;
1292
+ throw new StaleHistoryError();
1293
+ }
1294
+ });
1295
+ } catch (error) {
1296
+ if (!started) throw error;
1297
+ }
1241
1298
 
1242
1299
  return run(maxRequests, maxDetailRequests, onStep, { budget, head: /** @type {{ finalized: number, genesis: string }} */ (first.head) });
1243
1300
  }
@@ -1309,9 +1366,14 @@ export function createHistory({ archive, account, store, network = null, callFac
1309
1366
  await commit(range.entries, { set: { crowded: { height: lo, page: 2 }, window } });
1310
1367
  }
1311
1368
 
1312
- await fillDetails({ chain, maxRequests: detailsLeft, onStep: report });
1369
+ detailsError ??= await fillDetails({ chain, lenient: true, maxRequests: detailsLeft, onStep: report });
1313
1370
 
1314
- return { added, requests: maxRequests - budget.left, state: /** @type {HistoryState} */ (state) };
1371
+ return {
1372
+ added,
1373
+ requests: maxRequests - budget.left,
1374
+ state: /** @type {HistoryState} */ (state),
1375
+ ...(detailsError ? { detailsError } : {}),
1376
+ };
1315
1377
  }
1316
1378
 
1317
1379
  return Object.freeze({
package/src/shielded.d.ts CHANGED
@@ -8,10 +8,10 @@
8
8
  * and the block at that height says which transaction made it: a
9
9
  * `phoenix` event lists the nullifiers its transaction spent and the
10
10
  * notes it made, byte for byte as the leaves hold them, and an event
11
- * paying a shielded receiver from a public account or a contract names
12
- * part of the note. A transaction that spends notes always makes change
13
- * for its sender, so the notes' heights hold every transaction that spent
14
- * the profile's notes too.
11
+ * paying a shielded receiver from a public account or a contract makes
12
+ * one note, and names its address and value. A transaction that spends
13
+ * notes always makes change for its sender, so the notes' heights hold
14
+ * every transaction that spent the profile's notes too.
15
15
  * - Asking for the blocks at those heights alone would tell the node where
16
16
  * the profile's transactions are, so each one is asked for among four
17
17
  * other heights that hold notes, drawn from the note tree's heights. The
@@ -133,7 +133,12 @@ export type ShieldedEntry = {
133
133
  /** The transaction's hash: 64 lower-case hex digits. */
134
134
  hash: string;
135
135
  blockHeight: number;
136
- /** What it did: a transfer, a shield or an unshield, a contract call, or a stake operation. */
136
+ /**
137
+ * What it did: a transfer, a shield or an unshield, a contract call, or a
138
+ * stake operation. A conversion is a shield or an unshield by where its
139
+ * value went, as its event says, also for a profile that only got its
140
+ * change.
141
+ */
137
142
  kind: EntryKind;
138
143
  /**
139
144
  * Which way the value went for the profile's notes: "in" for what they
@@ -182,6 +187,15 @@ export type ShieldedStore = {
182
187
  * the same decoys.
183
188
  */
184
189
  put(record: ShieldedRecord): Promise<void> | void;
190
+ /**
191
+ * Optional: keep `change(kept)` in place of the record kept now, in one
192
+ * transaction, and give what was kept. With it, the decoys are drawn from
193
+ * those kept and kept at once, so two reads of the profile at once (two
194
+ * pages, a popup and a page) ask among the same decoys; without it, they
195
+ * are put, then read back, and two such reads can still draw their own.
196
+ * It rejects and passes over a history as `put` does.
197
+ */
198
+ update?(change: (kept: ShieldedRecord | null) => ShieldedRecord): Promise<ShieldedRecord | void> | ShieldedRecord | void;
185
199
  };
186
200
 
187
201
  /** The note tree's distinct heights, from its first `notes` leaves. */
@@ -333,7 +347,8 @@ export declare function fetchShieldedBlocks(
333
347
  * first. A transaction that spent the profile's notes sent what they held,
334
348
  * less what came back to them (change, the fee's refund) and the fee; one
335
349
  * that only made notes is what they received. A note no event of its block
336
- * names is left out, and reverted events don't count.
350
+ * names is left out, and reverted events don't count; two notes a block
351
+ * made for one address go to a transaction each, by their values.
337
352
  */
338
353
  export declare function shieldedEntries(input: {
339
354
  notes: ReadonlyArray<ValuedNote>;