@bananapus/nana-sdk-core 2.35.0 → 2.36.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.
Files changed (39) hide show
  1. package/dist/cjs/review/safeRelayr.d.ts.map +1 -1
  2. package/dist/cjs/review/safeRelayr.js +42 -24
  3. package/dist/cjs/review/safeRelayr.js.map +1 -1
  4. package/dist/cjs/review/tabState.d.ts +23 -0
  5. package/dist/cjs/review/tabState.d.ts.map +1 -0
  6. package/dist/cjs/review/tabState.js +30 -0
  7. package/dist/cjs/review/tabState.js.map +1 -0
  8. package/dist/cjs/review/writeHold.d.ts +31 -5
  9. package/dist/cjs/review/writeHold.d.ts.map +1 -1
  10. package/dist/cjs/review/writeHold.js +260 -42
  11. package/dist/cjs/review/writeHold.js.map +1 -1
  12. package/dist/cjs/review/writeRecovery.d.ts +42 -3
  13. package/dist/cjs/review/writeRecovery.d.ts.map +1 -1
  14. package/dist/cjs/review/writeRecovery.js +168 -14
  15. package/dist/cjs/review/writeRecovery.js.map +1 -1
  16. package/dist/cjs/testing.d.ts +7 -0
  17. package/dist/cjs/testing.d.ts.map +1 -0
  18. package/dist/cjs/testing.js +30 -0
  19. package/dist/cjs/testing.js.map +1 -0
  20. package/dist/esm/review/safeRelayr.d.ts.map +1 -1
  21. package/dist/esm/review/safeRelayr.js +42 -24
  22. package/dist/esm/review/safeRelayr.js.map +1 -1
  23. package/dist/esm/review/tabState.d.ts +23 -0
  24. package/dist/esm/review/tabState.d.ts.map +1 -0
  25. package/dist/esm/review/tabState.js +26 -0
  26. package/dist/esm/review/tabState.js.map +1 -0
  27. package/dist/esm/review/writeHold.d.ts +31 -5
  28. package/dist/esm/review/writeHold.d.ts.map +1 -1
  29. package/dist/esm/review/writeHold.js +261 -43
  30. package/dist/esm/review/writeHold.js.map +1 -1
  31. package/dist/esm/review/writeRecovery.d.ts +42 -3
  32. package/dist/esm/review/writeRecovery.d.ts.map +1 -1
  33. package/dist/esm/review/writeRecovery.js +167 -16
  34. package/dist/esm/review/writeRecovery.js.map +1 -1
  35. package/dist/esm/testing.d.ts +7 -0
  36. package/dist/esm/testing.d.ts.map +1 -0
  37. package/dist/esm/testing.js +25 -0
  38. package/dist/esm/testing.js.map +1 -0
  39. package/package.json +6 -1
@@ -8,6 +8,18 @@ const messages_js_1 = require("./messages.js");
8
8
  const writeRecovery_js_1 = require("./writeRecovery.js");
9
9
  const untrusted_js_1 = require("../untrusted.js");
10
10
  const relayr_js_1 = require("./relayr.js");
11
+ const tabState_js_1 = require("./tabState.js");
12
+ /** `sent` is the transaction at `hash`, exactly `record`'s call, from its account, on its chain. */
13
+ function sentIsCall(sent, hash, record) {
14
+ return (sent.hash?.toLowerCase() === hash.toLowerCase() &&
15
+ sent.from?.toLowerCase() === record.account &&
16
+ (sent.chainId === undefined || sent.chainId === record.chainId) &&
17
+ sent.to?.toLowerCase() === record.call.to &&
18
+ sent.input?.toLowerCase() === record.call.data &&
19
+ BigInt(sent.value) === BigInt(record.call.value));
20
+ }
21
+ /** The stop of every controller with a follower or tab listener running, for the test-only reset. */
22
+ const liveHolds = (0, tabState_js_1.tabShared)(tabState_js_1.TAB.holds, () => new Set());
11
23
  /**
12
24
  * A state read without the chain (peek, a replay, another tab's change, a
13
25
  * send's progress): its status and line, never the exit. Only the gate in
@@ -67,6 +79,8 @@ const SAFE_EXECUTION_TOPICS = [
67
79
  ];
68
80
  /** A hash the network still hasn't seen this long after the wallet returned it was almost certainly never broadcast. */
69
81
  const UNSEEN_HASH_MS = 30 * 60_000;
82
+ /** The longest one request to Safe's service may take inside the hold. */
83
+ const SAFE_SERVICE_READ_MS = 8_000;
70
84
  /** The 30 minutes have passed since the wallet returned the hash, or since it was first read. A clock that couldn't be saved (NaN) never runs out. */
71
85
  function unseenTooLong(record) {
72
86
  return (Date.now() - Number(record.submittedAt ?? record.firstSeenAt) >=
@@ -183,6 +197,30 @@ function classify(record) {
183
197
  */
184
198
  function createWriteHold(options = {}) {
185
199
  const storage = options.storage ?? (0, writeRecovery_js_1.createHeldWriteStorage)();
200
+ /**
201
+ * Safe's service, every request and its body bounded together to
202
+ * {@link SAFE_SERVICE_READ_MS} whatever its fetch does with the signal: a read that times out is
203
+ * unknown, which never releases or settles, so a hung service can't hold
204
+ * a send or the call's lock.
205
+ */
206
+ const service = {
207
+ ...options.safeService,
208
+ fetch: (url, init) => {
209
+ const request = options.safeService?.fetch ?? globalThis.fetch;
210
+ let timer;
211
+ return Promise.race([
212
+ // The whole read, its body included, under the one deadline.
213
+ request(url, init).then(async (response) => new Response(await response.arrayBuffer(), {
214
+ status: response.status,
215
+ statusText: response.statusText,
216
+ headers: response.headers,
217
+ })),
218
+ new Promise((_, reject) => {
219
+ timer = setTimeout(() => reject(new Error("Safe's service did not answer in time.")), SAFE_SERVICE_READ_MS);
220
+ }),
221
+ ]).finally(() => clearTimeout(timer));
222
+ },
223
+ };
186
224
  const interval = options.settle?.intervalMs ?? SETTLE_POLL_MS;
187
225
  const attempts = options.settle?.attempts ?? SETTLE_POLL_ATTEMPTS;
188
226
  const listeners = new Map();
@@ -226,11 +264,25 @@ function createWriteHold(options = {}) {
226
264
  }
227
265
  };
228
266
  globalThis.addEventListener("storage", onStorage);
267
+ liveHolds.add(dispose);
268
+ }
269
+ /** Stop every settle poll and the tab listener. */
270
+ function dispose() {
271
+ for (const timer of timers.values())
272
+ clearTimeout(timer);
273
+ timers.clear();
274
+ if (onStorage)
275
+ globalThis.removeEventListener?.("storage", onStorage);
276
+ onStorage = undefined;
277
+ liveHolds.delete(dispose);
229
278
  }
230
- function openOne(call, hooks = {}) {
279
+ function openOne(call, hooks = {},
280
+ /** The key a found write was saved under, when it isn't its call's own. */
281
+ at) {
231
282
  const recovery = (0, writeRecovery_js_1.createBrowserWriteRecovery)(call, {
232
283
  storage,
233
284
  locks: options.locks,
285
+ at,
234
286
  });
235
287
  // The canonical recovery scope: a namespace and an exact call never share it.
236
288
  const key = recovery.scopeKey;
@@ -287,6 +339,7 @@ function createWriteHold(options = {}) {
287
339
  function follow(client, id, left = attempts) {
288
340
  if (timers.has(id) || left <= 0)
289
341
  return;
342
+ liveHolds.add(dispose);
290
343
  timers.set(id, setTimeout(() => {
291
344
  void state(client)
292
345
  .catch(() => null)
@@ -328,10 +381,37 @@ function createWriteHold(options = {}) {
328
381
  follow(client, record.id);
329
382
  return classify(recovery.read());
330
383
  }
384
+ /**
385
+ * Whether a previous deploy's `hash` may be this call's: a valid hash
386
+ * read on the call's chain that isn't shown to be another write. A
387
+ * transaction must be the exact call from the account; a Safe proposal,
388
+ * exactly this call. One that can't be read is unknown, not refused.
389
+ */
390
+ async function adoptable(hash, client) {
391
+ if (!(0, untrusted_js_1.isBytes32)(hash) ||
392
+ !client ||
393
+ (await client.getChainId().catch(() => null)) !== call.chainId)
394
+ return false;
395
+ const reviewed = {
396
+ account: call.account.toLowerCase(),
397
+ chainId: call.chainId,
398
+ call: {
399
+ to: call.call.to.toLowerCase(),
400
+ data: call.call.data.toLowerCase(),
401
+ value: String(call.call.value ?? 0),
402
+ },
403
+ };
404
+ if (call.safe) {
405
+ const proposal = await (0, safeService_js_1.readSafeTransaction)(call.chainId, call.account, hash, service).catch(() => null);
406
+ return !proposal || (0, writeRecovery_js_1.safeProposalIsHeldCall)(proposal, call);
407
+ }
408
+ const sent = await client.getTransaction({ hash }).catch(() => null);
409
+ return !sent || sentIsCall(sent, hash, reviewed);
410
+ }
331
411
  /** The authenticated nonce of a Safe proposal, from Safe's service. */
332
412
  async function proposalNonceOf(record) {
333
413
  try {
334
- const proposal = await (0, safeService_js_1.readSafeTransaction)(record.chainId, record.account, record.hash, options.safeService);
414
+ const proposal = await (0, safeService_js_1.readSafeTransaction)(record.chainId, record.account, record.hash, service);
335
415
  return (0, safeService_js_1.safeTransactionMessage)(proposal).nonce;
336
416
  }
337
417
  catch {
@@ -536,13 +616,13 @@ function createWriteHold(options = {}) {
536
616
  }
537
617
  const end = (record.safeNonce !== undefined &&
538
618
  (await (0, safeService_js_1.provenSafeProposalEnd)(client, record.account, hash, BigInt(record.safeNonce)))) ||
539
- (await (0, safeService_js_1.lookAtSafeProposalEnd)(client, record.chainId, record.account, hash, options.safeService));
619
+ (await (0, safeService_js_1.lookAtSafeProposalEnd)(client, record.chainId, record.account, hash, service));
540
620
  // Another Safe transaction took its nonce, even after a saved execution
541
621
  // was reorganized away. It may have run the call too: the exit, never a release.
542
622
  if (end.look === "replaced")
543
623
  return safeReplaced(recovery.replacedOnChain(record));
544
624
  if (end.look === "expired" && !record.executed) {
545
- const expired = await (0, writeRecovery_js_1.verifyReviewedWriteExpiry)(client, record, options.safeService).catch(() => false);
625
+ const expired = await (0, writeRecovery_js_1.verifyReviewedWriteExpiry)(client, record, service).catch(() => false);
546
626
  if (expired && release(record))
547
627
  return clear({ result: "expired" }, record.id);
548
628
  }
@@ -673,9 +753,7 @@ function createWriteHold(options = {}) {
673
753
  }
674
754
  if (record.hash &&
675
755
  record.sentNonce === undefined &&
676
- (record.nonce === undefined ||
677
- record.fromBlock === undefined ||
678
- unseenTooLong(record)) &&
756
+ unseenTooLong(record) &&
679
757
  (await client.getTransaction({ hash: record.hash }).then(() => false, (error) => error?.name === "TransactionNotFoundError")))
680
758
  return unchecked(record);
681
759
  return classify(record);
@@ -687,7 +765,7 @@ function createWriteHold(options = {}) {
687
765
  async function state(client, checks = {}) {
688
766
  lastEffect = checks.effect;
689
767
  const result = await locked(() => inspect(client, checks.effect), false);
690
- return shown(result ? result.value : peek());
768
+ return { ...shown(result ? result.value : peek()), checked: !!result };
691
769
  }
692
770
  return {
693
771
  state,
@@ -873,7 +951,10 @@ function createWriteHold(options = {}) {
873
951
  // what the saved record says, with no exit.
874
952
  }, false);
875
953
  const ended = result?.value ?? { released: false, state: peek() };
876
- return { ...ended, state: shown(ended.state) };
954
+ return {
955
+ ...ended,
956
+ state: { ...shown(ended.state), checked: !!result },
957
+ };
877
958
  },
878
959
  /**
879
960
  * A receipt for the held write: an ordinary write's own hash, or a Safe
@@ -917,6 +998,94 @@ function createWriteHold(options = {}) {
917
998
  * one lock, without releasing it. The hold then follows that hash: no
918
999
  * exit while it is pending, and its receipt settles it.
919
1000
  */
1001
+ /**
1002
+ * Move a write a previous deploy kept (outside these holds) into this
1003
+ * hold. `snapshot` is the state the app read before deciding. Under
1004
+ * the call's lock, it reserves a held record with `evidence` (the
1005
+ * account's next nonce, or the nonce it was sent at, with the block it
1006
+ * was read at; when it was sent; and its `hash`, or for a Safe write
1007
+ * `safe.safeTxHash`) only while nothing is held and no reservation
1008
+ * came since `snapshot`: another tab's hold or settlement wins, and
1009
+ * the current state is returned unchanged. A hash is checked first
1010
+ * with `client`: a transaction that is another call, from another
1011
+ * account or on another chain (or a Safe proposal of another call) is
1012
+ * refused, adopting nothing. One the chain can't show is held, with
1013
+ * the warned exit 30 minutes after it was sent (or adopted). Then the
1014
+ * gate reads it: a mined one settles on its verified receipt. It never
1015
+ * sends or releases; adopting twice holds once.
1016
+ */
1017
+ async adopt(snapshot, evidence = {}, client) {
1018
+ const nonce = evidence.nonce ?? evidence.sentNonce;
1019
+ const hash = (call.safe ? evidence.safe?.safeTxHash : evidence.hash)?.toLowerCase();
1020
+ /** Nothing held, and no reservation since the app's read. */
1021
+ const free = () => snapshot.status === "clear" &&
1022
+ !recovery.read() &&
1023
+ recovery.latestReservation() === snapshot.reservation;
1024
+ const reserve = () => recovery.reserve(undefined, nonce !== undefined && evidence.fromBlock !== undefined
1025
+ ? { nonce, blockNumber: evidence.fromBlock }
1026
+ : undefined, {
1027
+ hash,
1028
+ submittedAt: evidence.submittedAt === undefined
1029
+ ? undefined
1030
+ : String(evidence.submittedAt),
1031
+ });
1032
+ if (hooks.across) {
1033
+ // Under an intent: the same atomic reserve as a send, under every
1034
+ // legacy key's lock, mirrored at each so older versions see it.
1035
+ if (!free() ||
1036
+ (hash !== undefined && !(await adoptable(hash, client))))
1037
+ return shown(peek());
1038
+ let adopted = false;
1039
+ try {
1040
+ const { released } = await hooks.across((work) => recovery.withLock(work), () => {
1041
+ if (!free())
1042
+ throw new Error("Held since the app's read.");
1043
+ reserve();
1044
+ adopted = true;
1045
+ }, recovery.rejected, Promise.resolve(), hooks.generations());
1046
+ await released;
1047
+ }
1048
+ catch {
1049
+ /* Held elsewhere, busy, or no longer free: nothing adopted. */
1050
+ }
1051
+ // A mined hash settles on its verified receipt, through the gate.
1052
+ return adopted && hash && client ? state(client) : shown(peek());
1053
+ }
1054
+ const result = await locked(async () => {
1055
+ if (!free())
1056
+ return peek();
1057
+ if (hash !== undefined && !(await adoptable(hash, client)))
1058
+ return peek();
1059
+ reserve();
1060
+ return hash && client ? inspect(client) : peek();
1061
+ }, true);
1062
+ return shown(result?.value ?? peek());
1063
+ },
1064
+ /**
1065
+ * Save the Safe nonce of a held Safe proposal adopted before it was
1066
+ * known, so a replacement can be proven later. Under the call's lock,
1067
+ * only while the held proposal is `safeTxHash` with no nonce yet, and
1068
+ * only when the call's proposal at `nonce` hashes to it: the hash is
1069
+ * the proof. Then the gate reads it. Never sends or releases.
1070
+ */
1071
+ async attachSafeNonce(client, { safeTxHash, nonce }) {
1072
+ const result = await locked(async () => {
1073
+ const record = recovery.read();
1074
+ if (record?.safe &&
1075
+ !record.mirrorOf &&
1076
+ record.safeNonce === undefined &&
1077
+ record.hash === safeTxHash.toLowerCase())
1078
+ try {
1079
+ (0, safeService_js_1.canonicalSafeTxHash)(call.chainId, call.account, (0, writeRecovery_js_1.heldSafeProposal)(call, nonce), safeTxHash);
1080
+ recovery.proposalNonce(record, BigInt(nonce));
1081
+ }
1082
+ catch {
1083
+ /* Not this proposal at that nonce: nothing saved. */
1084
+ }
1085
+ return inspect(client);
1086
+ }, true);
1087
+ return shown(result?.value ?? peek());
1088
+ },
920
1089
  async attachHash(client, hash) {
921
1090
  const result = await locked(async () => {
922
1091
  const record = recovery.read();
@@ -929,12 +1098,8 @@ function createWriteHold(options = {}) {
929
1098
  const sent = await client
930
1099
  .getTransaction({ hash })
931
1100
  .catch(() => null);
932
- if (sent?.hash?.toLowerCase() === hash.toLowerCase() &&
933
- sent.from?.toLowerCase() === record.account &&
934
- (sent.chainId === undefined || sent.chainId === record.chainId) &&
935
- sent.to?.toLowerCase() === record.call.to &&
936
- sent.input?.toLowerCase() === record.call.data &&
937
- BigInt(sent.value) === BigInt(record.call.value) &&
1101
+ if (sent &&
1102
+ sentIsCall(sent, hash, record) &&
938
1103
  // This attempt, not an earlier identical call: never below the
939
1104
  // account's next nonce or mined before it was reserved.
940
1105
  // With no such evidence, an identical earlier call can't be
@@ -990,7 +1155,13 @@ function createWriteHold(options = {}) {
990
1155
  /**
991
1156
  * `legacy`: for a write held under an intent namespace, the keys an earlier
992
1157
  * version held it under (an old namespace, or `{ call }` for an exact
993
- * call); the current exact call is always checked. While any of them holds
1158
+ * call); the current exact call is always checked. `{ match: { to,
1159
+ * selector } }` finds them instead: every exact-call write this account
1160
+ * saved on this chain to `to` with that 4-byte selector, for a hold saved
1161
+ * before the intent key existed under calldata the retry can't rebuild (an
1162
+ * old minimum, expiration or deadline). Pass one only for a write whose
1163
+ * calldata is volatile: it holds every such call. It is found when the
1164
+ * handle opens, and again right before a send reserves. While any of them holds
994
1165
  * an unresolved write, the intent handle *is* that hold: its state, its
995
1166
  * refusal and its own exit, in place. Each is checked alone, under its own
996
1167
  * lock only, never two at once. Only when all are clear does the intent's
@@ -1000,6 +1171,9 @@ function createWriteHold(options = {}) {
1000
1171
  function open(call, { legacy = [], } = {}) {
1001
1172
  if (call.namespace === undefined)
1002
1173
  return openExact(call);
1174
+ const matches = legacy.flatMap((source) => typeof source === "object" && "match" in source ? [source.match] : []);
1175
+ /** Every exact-call write the matches find in storage now. */
1176
+ const matched = () => matches.flatMap((match) => (0, writeRecovery_js_1.findReviewedWrites)({ chainId: call.chainId, account: call.account, ...match }, { storage }));
1003
1177
  const namespace = call.namespace;
1004
1178
  const own = openOne(call, {
1005
1179
  across: reserveAcross,
@@ -1011,19 +1185,54 @@ function createWriteHold(options = {}) {
1011
1185
  const { namespace: _, ...exact } = call;
1012
1186
  const scopeOf = (input) => (0, writeRecovery_js_1.createBrowserWriteRecovery)(input, { storage }).scopeKey;
1013
1187
  const scopes = new Set([scopeOf(call)]);
1014
- const inputs = [{ call: call.call }, ...legacy]
1015
- .map((source) => typeof source === "string"
1016
- ? { ...call, namespace: source }
1017
- : { ...exact, call: source.call })
1018
- .filter((input) => {
1019
- const scope = scopeOf(input);
1020
- return !scopes.has(scope) && !!scopes.add(scope);
1021
- })
1022
- // One canonical order, by scope, for every writer's locks.
1023
- .sort((a, b) => Number(scopeOf(a) > scopeOf(b)) - Number(scopeOf(a) < scopeOf(b)));
1024
1188
  // Mirror-aware: evidence and exits always act on the source attempt.
1025
- const delegates = inputs.map((input) => openExact(input));
1026
- const journals = inputs.map((input) => (0, writeRecovery_js_1.createBrowserWriteRecovery)(input, { storage, locks: options.locks }));
1189
+ const delegates = [];
1190
+ const journals = [];
1191
+ /** Hold `inputs` too, each once, in one canonical order by scope for every writer's locks. */
1192
+ function include(inputs) {
1193
+ const added = inputs.filter(({ input, at }) => {
1194
+ const scope = at ?? scopeOf(input);
1195
+ return !scopes.has(scope) && !!scopes.add(scope);
1196
+ });
1197
+ if (!added.length)
1198
+ return;
1199
+ const keyed = [
1200
+ ...delegates.map((delegate, index) => ({
1201
+ delegate,
1202
+ journal: journals[index],
1203
+ })),
1204
+ ...added.map(({ input, at }) => ({
1205
+ delegate: openExact(input, at),
1206
+ journal: (0, writeRecovery_js_1.createBrowserWriteRecovery)(input, {
1207
+ storage,
1208
+ locks: options.locks,
1209
+ at,
1210
+ }),
1211
+ })),
1212
+ ].sort((a, b) => Number(a.journal.scopeKey > b.journal.scopeKey) -
1213
+ Number(a.journal.scopeKey < b.journal.scopeKey));
1214
+ delegates.splice(0, delegates.length, ...keyed.map((k) => k.delegate));
1215
+ journals.splice(0, journals.length, ...keyed.map((k) => k.journal));
1216
+ }
1217
+ /**
1218
+ * Look for matching writes again (on every state(), send() and exit());
1219
+ * those found since are held too, each at the exact key it was found
1220
+ * under, with its own Safe flag. Returns the ones that are new.
1221
+ */
1222
+ function rescan() {
1223
+ const found = matched().filter(({ key }) => !scopes.has(key));
1224
+ include(found.map(({ key, record }) => ({
1225
+ input: { ...exact, safe: record.safe, call: record.call },
1226
+ at: key,
1227
+ })));
1228
+ return found.map(({ record }) => record);
1229
+ }
1230
+ include([{ call: call.call }, ...legacy].flatMap((source) => typeof source === "string"
1231
+ ? [{ input: { ...call, namespace: source } }]
1232
+ : "call" in source
1233
+ ? [{ input: { ...exact, call: source.call } }]
1234
+ : []));
1235
+ rescan();
1027
1236
  /** A legacy key holding anything but this intent's own mirror. */
1028
1237
  const foreign = (index) => journals[index].read()?.mirrorOf !== namespace &&
1029
1238
  delegates[index].peek().status !== "clear";
@@ -1031,6 +1240,7 @@ function createWriteHold(options = {}) {
1031
1240
  const active = () => delegates.find((_, index) => foreign(index));
1032
1241
  /** The first legacy key holding an unresolved write, through its own gate. */
1033
1242
  async function delegated(client) {
1243
+ rescan();
1034
1244
  for (const [index, handle] of delegates.entries()) {
1035
1245
  if (!foreign(index))
1036
1246
  continue;
@@ -1066,14 +1276,20 @@ function createWriteHold(options = {}) {
1066
1276
  entered += 1;
1067
1277
  return acquire(index + 1);
1068
1278
  });
1279
+ const refuse = (held) => new WriteHeldError(noticed({
1280
+ ...ungated(classify(held)),
1281
+ reservation: held.id,
1282
+ }));
1069
1283
  for (const journal of journals) {
1070
1284
  const held = journal.read();
1071
1285
  if (held && held.mirrorOf !== namespace)
1072
- throw new WriteHeldError(noticed({
1073
- ...ungated(classify(held)),
1074
- reservation: held.id,
1075
- }));
1286
+ throw refuse(held);
1076
1287
  }
1288
+ // A matching write saved since the last look holds it too, and
1289
+ // its own state() and exit() are reached from now on.
1290
+ const [late] = rescan();
1291
+ if (late)
1292
+ throw refuse(late);
1077
1293
  // Another attempt since this review opened: review it again.
1078
1294
  if (generations() !== reviewed)
1079
1295
  throw new WriteHoldStaleReviewError(ownJournal.settled());
@@ -1161,6 +1377,7 @@ function createWriteHold(options = {}) {
1161
1377
  return own.send(client, run);
1162
1378
  },
1163
1379
  async exit(snapshot, acknowledgement, client) {
1380
+ rescan();
1164
1381
  const owner = snapshot.status === "clear"
1165
1382
  ? undefined
1166
1383
  : delegates.find((handle, index) => {
@@ -1182,6 +1399,10 @@ function createWriteHold(options = {}) {
1182
1399
  onUnproven: async (...args) => active()?.onUnproven(...args) ?? after(await own.onUnproven(...args)),
1183
1400
  onExecuted: async (...args) => active()?.onExecuted(...args) ?? after(await own.onExecuted(...args)),
1184
1401
  attachHash: async (...args) => active()?.attachHash(...args) ?? after(await own.attachHash(...args)),
1402
+ attachSafeNonce: async (...args) => active()?.attachSafeNonce(...args) ??
1403
+ after(await own.attachSafeNonce(...args)),
1404
+ // A legacy hold already holds it: nothing is adopted.
1405
+ adopt: async (...args) => active()?.peek() ?? after(await own.adopt(...args)),
1185
1406
  };
1186
1407
  }
1187
1408
  /**
@@ -1189,11 +1410,12 @@ function createWriteHold(options = {}) {
1189
1410
  * it is that intent's hold: it never sends or releases on its own, and its
1190
1411
  * exit is the intent's. A mirror left after the intent ended is removed.
1191
1412
  */
1192
- function openExact(call) {
1193
- const own = openOne(call);
1413
+ function openExact(call, at) {
1414
+ const own = openOne(call, {}, at);
1194
1415
  const journal = (0, writeRecovery_js_1.createBrowserWriteRecovery)(call, {
1195
1416
  storage,
1196
1417
  locks: options.locks,
1418
+ at,
1197
1419
  });
1198
1420
  /** The intent this key mirrors, from storage alone. */
1199
1421
  const mirrored = () => {
@@ -1257,6 +1479,9 @@ function createWriteHold(options = {}) {
1257
1479
  onUnproven: (...args) => (route() ?? own).onUnproven(...args),
1258
1480
  onExecuted: (...args) => (route() ?? own).onExecuted(...args),
1259
1481
  attachHash: (...args) => (route() ?? own).attachHash(...args),
1482
+ attachSafeNonce: (...args) => (route() ?? own).attachSafeNonce(...args),
1483
+ // An intent's mirror already holds it: nothing is adopted.
1484
+ adopt: async (...args) => route()?.peek() ?? own.adopt(...args),
1260
1485
  };
1261
1486
  }
1262
1487
  return {
@@ -1266,14 +1491,7 @@ function createWriteHold(options = {}) {
1266
1491
  /** The review's line while holds last only in this tab, or null. */
1267
1492
  reviewLine: () => (storage.durable() ? null : messages_js_1.WRITE_HOLD_IN_TAB_LINE),
1268
1493
  /** Stop every settle poll and the tab listener (tests, teardown). */
1269
- dispose() {
1270
- for (const timer of timers.values())
1271
- clearTimeout(timer);
1272
- timers.clear();
1273
- if (onStorage)
1274
- globalThis.removeEventListener?.("storage", onStorage);
1275
- onStorage = undefined;
1276
- },
1494
+ dispose,
1277
1495
  };
1278
1496
  }
1279
1497
  //# sourceMappingURL=writeHold.js.map