@dusk-network/app-core 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -2086,20 +2092,41 @@ function withdrawalFromLog(log) {
2086
2092
  const tooWide = (error) => /range|limit|too many|exceed/i.test(asError(error).message);
2087
2093
 
2088
2094
  /**
2089
- * The highest DuskEVM block that can't be reorged away.
2095
+ * The highest DuskEVM block that can't be reorged away, and the requests it
2096
+ * took.
2090
2097
  *
2091
2098
  * @param {(method: string, params?: unknown[]) => Promise<any>} rpc
2099
+ * @returns {Promise<{ head: number, requests: number }>}
2092
2100
  */
2093
2101
  async function finalHead(rpc) {
2094
2102
  try {
2095
2103
  const block = await rpc("eth_getBlockByNumber", ["finalized", false]);
2096
2104
 
2097
- if (block?.number) return Number(BigInt(block.number));
2105
+ if (block?.number) return { head: Number(BigInt(block.number)), requests: 1 };
2098
2106
  } catch {
2099
2107
  // An RPC without "finalized": the latest block, less a margin.
2100
2108
  }
2101
2109
 
2102
- return Math.max(0, Number(BigInt(await rpc("eth_blockNumber", []))) - UNFINALIZED_MARGIN);
2110
+ return { head: Math.max(0, Number(BigInt(await rpc("eth_blockNumber", []))) - UNFINALIZED_MARGIN), requests: 2 };
2111
+ }
2112
+
2113
+ /**
2114
+ * A sync's passes over the blocks it covered, as a state gave them, the one
2115
+ * under way no higher than `target`; null for anything else.
2116
+ *
2117
+ * @param {unknown} value
2118
+ * @param {number} target
2119
+ * @returns {NonNullable<WithdrawalScan["recheck"]> | null}
2120
+ */
2121
+ function recheckOf(value, target) {
2122
+ const kept = /** @type {any} */ (value);
2123
+ /** @param {unknown} ms */
2124
+ const time = (ms) => typeof ms === "number" && Number.isFinite(ms);
2125
+
2126
+ if (!kept || !time(kept.at) || !time(kept.since)) return null;
2127
+ if (kept.from !== null && !(Number.isSafeInteger(kept.from) && kept.from >= 0)) return null;
2128
+
2129
+ return { at: kept.at, from: kept.from === null ? null : Math.min(kept.from, target), since: kept.since };
2103
2130
  }
2104
2131
 
2105
2132
  /**
@@ -2114,7 +2141,8 @@ export async function syncWithdrawals({ address, bridge, duskEvm, maxRequests =
2114
2141
  if (!contract) throw new TypeError("No DuskEVM bridge to read");
2115
2142
 
2116
2143
  const rpc = evmRpc(duskEvm, net);
2117
- const target = await finalHead(rpc);
2144
+ const { head: target, requests: asked } = await finalHead(rpc);
2145
+ const time = now();
2118
2146
  // Coverage kept above the final head, as an older run read the latest, is
2119
2147
  // read again, and what was found there is dropped with it: it may have
2120
2148
  // been reorged away, or into another block.
@@ -2124,45 +2152,95 @@ export async function syncWithdrawals({ address, bridge, duskEvm, maxRequests =
2124
2152
  const entries = Object.fromEntries(Object.entries(state?.entries ?? {}).filter(([, entry]) => Number(entry?.blockNumber) <= target));
2125
2153
  /** @type {FoundWithdrawal[]} */
2126
2154
  const found = [];
2127
- let requests = 1;
2155
+ let requests = asked;
2156
+ let recheck = recheckOf(state?.recheck, target);
2128
2157
  const topics = [ETH_BRIDGE_INITIATED_TOPIC, pad(sender)];
2129
2158
 
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);
2159
+ /**
2160
+ * The address's withdrawals in blocks `lo` to `hi`, those blocks covered;
2161
+ * false where the RPC asks for a narrower window, which it then is.
2162
+ *
2163
+ * @param {number} lo
2164
+ * @param {number} hi
2165
+ */
2166
+ const read = async (lo, hi) => {
2137
2167
  /** @type {any[]} */
2138
2168
  let logs;
2139
2169
 
2140
2170
  requests += 1;
2141
2171
 
2142
2172
  try {
2143
- logs = await rpc("eth_getLogs", [{ address: contract, fromBlock: toHex(lo), toBlock: toHex(b), topics }]);
2173
+ logs = await rpc("eth_getLogs", [{ address: contract, fromBlock: toHex(lo), toBlock: toHex(hi), topics }]);
2144
2174
  } catch (error) {
2145
2175
  if (window > 1_000 && tooWide(error)) {
2146
2176
  window = Math.floor(window / 4);
2147
- continue;
2177
+ return false;
2148
2178
  }
2149
2179
 
2150
2180
  throw error;
2151
2181
  }
2152
2182
 
2153
2183
  for (const log of logs ?? []) {
2184
+ // One found before, read again, is kept as it was.
2185
+ if (entries[String(log?.transactionHash).toLowerCase()]) continue;
2186
+
2154
2187
  const entry = withdrawalFromLog(log);
2155
2188
 
2156
- if (!entry || entries[entry.hash]) continue;
2189
+ if (!entry) continue;
2157
2190
 
2158
2191
  entries[entry.hash] = entry;
2159
2192
  found.push(entry);
2160
2193
  }
2161
2194
 
2162
- covered = addCoverage(covered, lo, b);
2195
+ covered = addCoverage(covered, lo, hi);
2196
+
2197
+ return true;
2198
+ };
2199
+
2200
+ // The newest final blocks, read again on each run, from the newest down,
2201
+ // so the new ones first: in as many windows as they take, though no more
2202
+ // than half the requests the run has left (one at least, where nothing
2203
+ // else is to be read). While older blocks are still to be read, a run
2204
+ // under the same final head as the last leaves them all to those.
2205
+ const lowest = Math.max(0, target - L2_OVERLAP + 1);
2206
+ const due = recheck !== null && (recheck.from !== null || time >= recheck.at);
2207
+ const older = topGap(addCoverage(covered, lowest, target), target) !== null;
2208
+ const half = Math.floor((maxRequests - requests) / 2);
2209
+ const share = older && state?.target === target ? 0 : older || due ? half : Math.max(1, half);
2210
+ let top = target;
2211
+
2212
+ for (let windows = 0; windows < share && top >= lowest && requests < maxRequests; windows += 1) {
2213
+ const lo = Math.max(0, top - window + 1);
2214
+
2215
+ if (await read(lo, top)) top = lo - 1;
2163
2216
  }
2164
2217
 
2165
- return { found, requests, state: { covered, entries, target, updatedAt: now(), window } };
2218
+ while (requests < maxRequests) {
2219
+ const gap = topGap(covered, target);
2220
+
2221
+ if (gap) {
2222
+ const [a, b] = gap;
2223
+
2224
+ await read(Math.max(a, b - window + 1), b);
2225
+ continue;
2226
+ }
2227
+
2228
+ // All the final blocks covered: they're read again, a pass at a time,
2229
+ // from the newest down, with what a run has left.
2230
+ if (recheck === null) recheck = { at: time + L2_RECHECK_MS, from: null, since: time };
2231
+ if (recheck.from === null && time < recheck.at) break;
2232
+
2233
+ const from = recheck.from ?? target;
2234
+ const lo = Math.max(0, from - window + 1);
2235
+
2236
+ if (!(await read(lo, from))) continue;
2237
+
2238
+ recheck = lo > 0 ? { ...recheck, from: lo - 1 } : { at: time + Math.max(L2_RECHECK_MS, time - recheck.since), from: null, since: recheck.since };
2239
+ }
2240
+
2241
+ if (recheck === null && !topGap(covered, target)) recheck = { at: time + L2_RECHECK_MS, from: null, since: time };
2242
+
2243
+ return { found, requests, state: { covered, entries, ...(recheck ? { recheck } : {}), target, updatedAt: now(), window } };
2166
2244
  }
2167
2245
 
2168
2246
  /**
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>;