@bananapus/nana-sdk-core 2.34.1 → 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 (63) hide show
  1. package/dist/cjs/review/index.d.ts +1 -0
  2. package/dist/cjs/review/index.d.ts.map +1 -1
  3. package/dist/cjs/review/index.js +1 -0
  4. package/dist/cjs/review/index.js.map +1 -1
  5. package/dist/cjs/review/messages.d.ts +2 -0
  6. package/dist/cjs/review/messages.d.ts.map +1 -1
  7. package/dist/cjs/review/messages.js +3 -1
  8. package/dist/cjs/review/messages.js.map +1 -1
  9. package/dist/cjs/review/safeRelayr.d.ts.map +1 -1
  10. package/dist/cjs/review/safeRelayr.js +42 -24
  11. package/dist/cjs/review/safeRelayr.js.map +1 -1
  12. package/dist/cjs/review/tabState.d.ts +23 -0
  13. package/dist/cjs/review/tabState.d.ts.map +1 -0
  14. package/dist/cjs/review/tabState.js +30 -0
  15. package/dist/cjs/review/tabState.js.map +1 -0
  16. package/dist/cjs/review/writeHold.d.ts +74 -58
  17. package/dist/cjs/review/writeHold.d.ts.map +1 -1
  18. package/dist/cjs/review/writeHold.js +651 -43
  19. package/dist/cjs/review/writeHold.js.map +1 -1
  20. package/dist/cjs/review/writeIntent.d.ts +41 -0
  21. package/dist/cjs/review/writeIntent.d.ts.map +1 -0
  22. package/dist/cjs/review/writeIntent.js +77 -0
  23. package/dist/cjs/review/writeIntent.js.map +1 -0
  24. package/dist/cjs/review/writeRecovery.d.ts +63 -3
  25. package/dist/cjs/review/writeRecovery.d.ts.map +1 -1
  26. package/dist/cjs/review/writeRecovery.js +233 -14
  27. package/dist/cjs/review/writeRecovery.js.map +1 -1
  28. package/dist/cjs/testing.d.ts +7 -0
  29. package/dist/cjs/testing.d.ts.map +1 -0
  30. package/dist/cjs/testing.js +30 -0
  31. package/dist/cjs/testing.js.map +1 -0
  32. package/dist/esm/review/index.d.ts +1 -0
  33. package/dist/esm/review/index.d.ts.map +1 -1
  34. package/dist/esm/review/index.js +1 -0
  35. package/dist/esm/review/index.js.map +1 -1
  36. package/dist/esm/review/messages.d.ts +2 -0
  37. package/dist/esm/review/messages.d.ts.map +1 -1
  38. package/dist/esm/review/messages.js +2 -0
  39. package/dist/esm/review/messages.js.map +1 -1
  40. package/dist/esm/review/safeRelayr.d.ts.map +1 -1
  41. package/dist/esm/review/safeRelayr.js +42 -24
  42. package/dist/esm/review/safeRelayr.js.map +1 -1
  43. package/dist/esm/review/tabState.d.ts +23 -0
  44. package/dist/esm/review/tabState.d.ts.map +1 -0
  45. package/dist/esm/review/tabState.js +26 -0
  46. package/dist/esm/review/tabState.js.map +1 -0
  47. package/dist/esm/review/writeHold.d.ts +74 -58
  48. package/dist/esm/review/writeHold.d.ts.map +1 -1
  49. package/dist/esm/review/writeHold.js +651 -44
  50. package/dist/esm/review/writeHold.js.map +1 -1
  51. package/dist/esm/review/writeIntent.d.ts +41 -0
  52. package/dist/esm/review/writeIntent.d.ts.map +1 -0
  53. package/dist/esm/review/writeIntent.js +73 -0
  54. package/dist/esm/review/writeIntent.js.map +1 -0
  55. package/dist/esm/review/writeRecovery.d.ts +63 -3
  56. package/dist/esm/review/writeRecovery.d.ts.map +1 -1
  57. package/dist/esm/review/writeRecovery.js +232 -16
  58. package/dist/esm/review/writeRecovery.js.map +1 -1
  59. package/dist/esm/testing.d.ts +7 -0
  60. package/dist/esm/testing.d.ts.map +1 -0
  61. package/dist/esm/testing.js +25 -0
  62. package/dist/esm/testing.js.map +1 -0
  63. package/package.json +6 -1
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.WriteHoldBusyError = exports.WriteHoldNonceError = exports.WriteHeldError = void 0;
3
+ exports.WriteHoldBusyError = exports.WriteHoldStaleReviewError = exports.WriteHoldNonceError = exports.WriteHeldError = void 0;
4
4
  exports.createWriteHold = createWriteHold;
5
5
  const viem_1 = require("viem");
6
6
  const safeService_js_1 = require("../safeService.js");
@@ -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
@@ -36,6 +48,19 @@ class WriteHoldNonceError extends Error {
36
48
  }
37
49
  }
38
50
  exports.WriteHoldNonceError = WriteHoldNonceError;
51
+ /**
52
+ * The review went stale: another window's attempt of the same write was
53
+ * reserved, and maybe settled (`settled`), while it was open. Review again;
54
+ * the next review sends normally.
55
+ */
56
+ class WriteHoldStaleReviewError extends Error {
57
+ constructor(settled) {
58
+ super(messages_js_1.WRITE_HOLD_STALE_REVIEW_LINE);
59
+ this.settled = settled;
60
+ this.name = "WriteHoldStaleReviewError";
61
+ }
62
+ }
63
+ exports.WriteHoldStaleReviewError = WriteHoldStaleReviewError;
39
64
  /** The exact call is already being sent from another flow or tab. */
40
65
  class WriteHoldBusyError extends Error {
41
66
  constructor() {
@@ -45,6 +70,8 @@ class WriteHoldBusyError extends Error {
45
70
  }
46
71
  exports.WriteHoldBusyError = WriteHoldBusyError;
47
72
  const SETTLE_POLL_MS = 15_000;
73
+ /** How long a reservation retries a lock another writer briefly holds. */
74
+ const RESERVE_RETRY_MS = 2_000;
48
75
  /** A Safe's execution events, in its 1.3 and 1.4 layouts alike. */
49
76
  const SAFE_EXECUTION_TOPICS = [
50
77
  (0, viem_1.toEventSelector)("ExecutionSuccess(bytes32,uint256)"),
@@ -52,6 +79,8 @@ const SAFE_EXECUTION_TOPICS = [
52
79
  ];
53
80
  /** A hash the network still hasn't seen this long after the wallet returned it was almost certainly never broadcast. */
54
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;
55
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. */
56
85
  function unseenTooLong(record) {
57
86
  return (Date.now() - Number(record.submittedAt ?? record.firstSeenAt) >=
@@ -99,8 +128,12 @@ function unchecked(record) {
99
128
  }
100
129
  /** About an hour of polls; a later state() looks again. */
101
130
  const SETTLE_POLL_ATTEMPTS = 240;
102
- function clear(settled) {
103
- return settled ? { status: "clear", settled } : { status: "clear" };
131
+ function clear(settled, reservation) {
132
+ return {
133
+ status: "clear",
134
+ ...(settled ? { settled } : {}),
135
+ ...(reservation === undefined ? {} : { reservation }),
136
+ };
104
137
  }
105
138
  function ranExecuted(record, executionHash = record.executionHash, result) {
106
139
  return {
@@ -164,12 +197,47 @@ function classify(record) {
164
197
  */
165
198
  function createWriteHold(options = {}) {
166
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
+ };
167
224
  const interval = options.settle?.intervalMs ?? SETTLE_POLL_MS;
168
225
  const attempts = options.settle?.attempts ?? SETTLE_POLL_ATTEMPTS;
169
226
  const listeners = new Map();
170
227
  const peekers = new Map();
171
228
  const timers = new Map();
172
229
  let onStorage;
230
+ /**
231
+ * The one decorator every state leaves through for its storage notice:
232
+ * while holds last in this tab alone, a held state carries
233
+ * {@link WRITE_HOLD_IN_TAB_LINE}; otherwise none.
234
+ */
235
+ function noticed(state) {
236
+ const { notice: _, ...rest } = state;
237
+ return state.status !== "clear" && !storage.durable()
238
+ ? { ...rest, notice: messages_js_1.WRITE_HOLD_IN_TAB_LINE }
239
+ : rest;
240
+ }
173
241
  /** The latest state emitted for each call, replayed to a late subscriber. */
174
242
  const latest = new Map();
175
243
  function emit(key, state) {
@@ -182,7 +250,9 @@ function createWriteHold(options = {}) {
182
250
  if (onStorage || typeof globalThis.addEventListener !== "function")
183
251
  return;
184
252
  onStorage = (event) => {
185
- if (event.key && !event.key.startsWith("nana-sdk:reviewed-write"))
253
+ if (event.key &&
254
+ (!event.key.startsWith("nana-sdk:reviewed-write") ||
255
+ event.key === "nana-sdk:reviewed-write:probe"))
186
256
  return;
187
257
  for (const [key, peek] of peekers) {
188
258
  try {
@@ -194,11 +264,25 @@ function createWriteHold(options = {}) {
194
264
  }
195
265
  };
196
266
  globalThis.addEventListener("storage", onStorage);
267
+ liveHolds.add(dispose);
197
268
  }
198
- function open(call) {
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);
278
+ }
279
+ function openOne(call, hooks = {},
280
+ /** The key a found write was saved under, when it isn't its call's own. */
281
+ at) {
199
282
  const recovery = (0, writeRecovery_js_1.createBrowserWriteRecovery)(call, {
200
283
  storage,
201
284
  locks: options.locks,
285
+ at,
202
286
  });
203
287
  // The canonical recovery scope: a namespace and an exact call never share it.
204
288
  const key = recovery.scopeKey;
@@ -206,11 +290,26 @@ function createWriteHold(options = {}) {
206
290
  let lastEffect;
207
291
  /** Nothing held: how the call's last write settled, if saved. */
208
292
  const cleared = () => clear(recovery.settled());
293
+ /** Every state leaves with the reservation it belongs to. */
294
+ const tagged = (state) => {
295
+ const reservation = state.reservation ??
296
+ (state.status === "clear"
297
+ ? recovery.latestReservation()
298
+ : state.record.id);
299
+ return noticed({
300
+ ...state,
301
+ ...(reservation === undefined ? {} : { reservation }),
302
+ });
303
+ };
209
304
  const peek = () => {
210
305
  const record = recovery.read();
211
- return record ? ungated(classify(record)) : cleared();
306
+ return tagged(record ? ungated(classify(record)) : cleared());
307
+ };
308
+ const shown = (state) => {
309
+ const out = tagged(state);
310
+ emit(key, out);
311
+ return out;
212
312
  };
213
- const shown = (state) => (emit(key, state), state);
214
313
  async function locked(work, wait) {
215
314
  let entered = false;
216
315
  try {
@@ -240,6 +339,7 @@ function createWriteHold(options = {}) {
240
339
  function follow(client, id, left = attempts) {
241
340
  if (timers.has(id) || left <= 0)
242
341
  return;
342
+ liveHolds.add(dispose);
243
343
  timers.set(id, setTimeout(() => {
244
344
  void state(client)
245
345
  .catch(() => null)
@@ -259,7 +359,7 @@ function createWriteHold(options = {}) {
259
359
  };
260
360
  // Kept for any later reader: another tab, a reload, a follow timer.
261
361
  recovery.markSettled(record, settled);
262
- return clear(settled);
362
+ return clear(settled, record.id);
263
363
  }
264
364
  if (!record.safe &&
265
365
  receipt.status === "reverted" &&
@@ -281,10 +381,37 @@ function createWriteHold(options = {}) {
281
381
  follow(client, record.id);
282
382
  return classify(recovery.read());
283
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
+ }
284
411
  /** The authenticated nonce of a Safe proposal, from Safe's service. */
285
412
  async function proposalNonceOf(record) {
286
413
  try {
287
- 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);
288
415
  return (0, safeService_js_1.safeTransactionMessage)(proposal).nonce;
289
416
  }
290
417
  catch {
@@ -351,8 +478,22 @@ function createWriteHold(options = {}) {
351
478
  // Only positive pending evidence stops the clock; a read that fails
352
479
  // keeps the one saved.
353
480
  if (look === "pending")
354
- return { ...state, record: recovery.mined(state.record, false) };
481
+ return {
482
+ ...state,
483
+ record: recovery.seenPending(recovery.mined(state.record, false)),
484
+ };
355
485
  const record = look === "mined" ? recovery.mined(state.record, true) : state.record;
486
+ // Unknown or not found is not pending: with no read showing it pending
487
+ // or mined for 30 minutes (since it was last seen pending, or else
488
+ // submitted), the warned explicit exit, never a release on its own. A
489
+ // Safe proposal's hash isn't a transaction to read.
490
+ if ((look === "unknown" || look === null) &&
491
+ !record.safe &&
492
+ record.minedAt === undefined &&
493
+ Date.now() -
494
+ Number(record.pendingSeenAt ?? record.submittedAt ?? record.firstSeenAt) >=
495
+ UNSEEN_HASH_MS)
496
+ return unchecked(record);
356
497
  if (record.minedAt === undefined)
357
498
  return state;
358
499
  if (Date.now() - Number(record.minedAt) >= UNSEEN_HASH_MS)
@@ -361,7 +502,9 @@ function createWriteHold(options = {}) {
361
502
  }
362
503
  /** What the node shows of `hash`: positive pending or mined metadata, or nothing it can say. */
363
504
  async function lookAt(hash, client) {
364
- const sent = await client.getTransaction({ hash }).catch(() => null);
505
+ const sent = await client.getTransaction({ hash }).then((tx) => tx, (error) => error?.name === "TransactionNotFoundError" ? null : undefined);
506
+ if (sent === undefined)
507
+ return "unknown";
365
508
  if (sent?.hash?.toLowerCase() !== hash)
366
509
  return null;
367
510
  return sent.blockNumber === null ? "pending" : "mined";
@@ -382,7 +525,8 @@ function createWriteHold(options = {}) {
382
525
  for (const hash of seen) {
383
526
  const own = hash === record.hash;
384
527
  const look = await lookAt(hash, client);
385
- if (look !== null)
528
+ // Unknown changes nothing: only positive metadata moves a clock.
529
+ if (look === "pending" || look === "mined")
386
530
  record = own
387
531
  ? recovery.mined(record, look === "mined")
388
532
  : recovery.candidateMined(record, hash, look === "mined");
@@ -472,15 +616,15 @@ function createWriteHold(options = {}) {
472
616
  }
473
617
  const end = (record.safeNonce !== undefined &&
474
618
  (await (0, safeService_js_1.provenSafeProposalEnd)(client, record.account, hash, BigInt(record.safeNonce)))) ||
475
- (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));
476
620
  // Another Safe transaction took its nonce, even after a saved execution
477
621
  // was reorganized away. It may have run the call too: the exit, never a release.
478
622
  if (end.look === "replaced")
479
623
  return safeReplaced(recovery.replacedOnChain(record));
480
624
  if (end.look === "expired" && !record.executed) {
481
- 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);
482
626
  if (expired && release(record))
483
- return clear({ result: "expired" });
627
+ return clear({ result: "expired" }, record.id);
484
628
  }
485
629
  if (end.look === "executed") {
486
630
  if (!record.executed ||
@@ -502,7 +646,7 @@ function createWriteHold(options = {}) {
502
646
  hash: end.execution.executionHash,
503
647
  };
504
648
  recovery.markSettled(record, settled);
505
- return clear(settled);
649
+ return clear(settled, record.id);
506
650
  }
507
651
  }
508
652
  if (!record.executed &&
@@ -543,6 +687,9 @@ function createWriteHold(options = {}) {
543
687
  let record = recovery.read();
544
688
  if (!record)
545
689
  return cleared();
690
+ // A mirror carries no evidence of its own: its source attempt does.
691
+ if (record.mirrorOf)
692
+ return classify(record);
546
693
  // No evidence from another chain.
547
694
  if ((await client.getChainId().catch(() => null)) !== record.chainId)
548
695
  return classify(record);
@@ -606,9 +753,7 @@ function createWriteHold(options = {}) {
606
753
  }
607
754
  if (record.hash &&
608
755
  record.sentNonce === undefined &&
609
- (record.nonce === undefined ||
610
- record.fromBlock === undefined ||
611
- unseenTooLong(record)) &&
756
+ unseenTooLong(record) &&
612
757
  (await client.getTransaction({ hash: record.hash }).then(() => false, (error) => error?.name === "TransactionNotFoundError")))
613
758
  return unchecked(record);
614
759
  return classify(record);
@@ -620,7 +765,7 @@ function createWriteHold(options = {}) {
620
765
  async function state(client, checks = {}) {
621
766
  lastEffect = checks.effect;
622
767
  const result = await locked(() => inspect(client, checks.effect), false);
623
- return shown(result ? result.value : peek());
768
+ return { ...shown(result ? result.value : peek()), checked: !!result };
624
769
  }
625
770
  return {
626
771
  state,
@@ -644,8 +789,11 @@ function createWriteHold(options = {}) {
644
789
  if (last) {
645
790
  const now = peek();
646
791
  // A clear is compared by its settlement's identity, a hold by its record.
647
- const identity = (state) => JSON.stringify(state.status === "clear" ? (state.settled ?? null) : state.record);
648
- listener(ungated(identity(now) === identity(last) ? last : now));
792
+ const identity = (state) => JSON.stringify([
793
+ state.reservation ?? null,
794
+ state.status === "clear" ? (state.settled ?? null) : state.record,
795
+ ]);
796
+ listener(noticed(ungated(identity(now) === identity(last) ? last : now)));
649
797
  }
650
798
  return () => {
651
799
  set.delete(listener);
@@ -664,6 +812,10 @@ function createWriteHold(options = {}) {
664
812
  */
665
813
  async send(client, run) {
666
814
  let attempt;
815
+ let ended;
816
+ const until = new Promise((resolve) => (ended = resolve));
817
+ let released;
818
+ let reviewed = "";
667
819
  const gate = {
668
820
  async beforeSend(actualCall) {
669
821
  let evidence;
@@ -677,12 +829,31 @@ function createWriteHold(options = {}) {
677
829
  throw new WriteHoldNonceError(cause);
678
830
  }
679
831
  }
680
- try {
832
+ const reserve = () => {
681
833
  attempt = recovery.reserve(actualCall, evidence);
834
+ };
835
+ try {
836
+ if (hooks.across)
837
+ ({ released } = await hooks.across((work) => recovery.withLock(work), reserve, () => {
838
+ recovery.rejected();
839
+ attempt = undefined;
840
+ }, until, reviewed));
841
+ else
842
+ reserve();
682
843
  }
683
844
  catch (error) {
684
- const now = await finish(classify(recovery.read()), client);
685
- shown(now);
845
+ if (error instanceof WriteHeldError ||
846
+ error instanceof WriteHoldBusyError ||
847
+ error instanceof WriteHoldStaleReviewError)
848
+ throw error;
849
+ if (hooks.across) {
850
+ // No lock is held here: what the saved record says.
851
+ const now = shown(peek());
852
+ if (now.status !== "clear")
853
+ throw new WriteHeldError(now);
854
+ throw error;
855
+ }
856
+ const now = shown(await finish(classify(recovery.read()), client));
686
857
  if (now.status !== "clear")
687
858
  throw new WriteHeldError(now);
688
859
  throw error;
@@ -717,6 +888,25 @@ function createWriteHold(options = {}) {
717
888
  shown(peek());
718
889
  },
719
890
  };
891
+ if (hooks.across) {
892
+ // Checked under this key's lock, which is then let go: the final
893
+ // check and reservation take every lock again, in order.
894
+ const checked = await locked(async () => shown(await inspect(client)), false);
895
+ if (!checked)
896
+ throw new WriteHoldBusyError();
897
+ if (checked.value.status !== "clear")
898
+ throw new WriteHeldError(checked.value);
899
+ // Given with `across`: the generations the review is checked against.
900
+ reviewed = hooks.generations();
901
+ try {
902
+ return await run(gate);
903
+ }
904
+ finally {
905
+ // The send ends: every lock it reserved under is let go first.
906
+ ended();
907
+ await released;
908
+ }
909
+ }
720
910
  const result = await locked(async () => {
721
911
  const now = shown(await inspect(client));
722
912
  if (now.status !== "clear")
@@ -753,11 +943,18 @@ function createWriteHold(options = {}) {
753
943
  state: await finish(classify(recovery.read()), client),
754
944
  };
755
945
  }
756
- return { released: true, state: clear() };
757
- }, true);
946
+ return {
947
+ released: true,
948
+ state: clear(undefined, snapshot.record.id),
949
+ };
950
+ // Never waits behind another send's wallet prompt: on contention,
951
+ // what the saved record says, with no exit.
952
+ }, false);
758
953
  const ended = result?.value ?? { released: false, state: peek() };
759
- shown(ended.state);
760
- return ended;
954
+ return {
955
+ ...ended,
956
+ state: { ...shown(ended.state), checked: !!result },
957
+ };
761
958
  },
762
959
  /**
763
960
  * A receipt for the held write: an ordinary write's own hash, or a Safe
@@ -768,6 +965,7 @@ function createWriteHold(options = {}) {
768
965
  const result = await locked(async () => {
769
966
  const record = recovery.read();
770
967
  if (!record ||
968
+ record.mirrorOf ||
771
969
  (!record.safe &&
772
970
  record.hash !== receipt?.transactionHash?.toLowerCase()))
773
971
  return finish(classify(record), client);
@@ -800,23 +998,108 @@ function createWriteHold(options = {}) {
800
998
  * one lock, without releasing it. The hold then follows that hash: no
801
999
  * exit while it is pending, and its receipt settles it.
802
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
+ },
803
1089
  async attachHash(client, hash) {
804
1090
  const result = await locked(async () => {
805
1091
  const record = recovery.read();
806
1092
  if (record &&
807
1093
  !record.safe &&
808
1094
  !record.hash &&
1095
+ !record.mirrorOf &&
809
1096
  (0, untrusted_js_1.isBytes32)(hash) &&
810
1097
  (await client.getChainId().catch(() => null)) === record.chainId) {
811
1098
  const sent = await client
812
1099
  .getTransaction({ hash })
813
1100
  .catch(() => null);
814
- if (sent?.hash?.toLowerCase() === hash.toLowerCase() &&
815
- sent.from?.toLowerCase() === record.account &&
816
- (sent.chainId === undefined || sent.chainId === record.chainId) &&
817
- sent.to?.toLowerCase() === record.call.to &&
818
- sent.input?.toLowerCase() === record.call.data &&
819
- BigInt(sent.value) === BigInt(record.call.value) &&
1101
+ if (sent &&
1102
+ sentIsCall(sent, hash, record) &&
820
1103
  // This attempt, not an earlier identical call: never below the
821
1104
  // account's next nonce or mined before it was reserved.
822
1105
  // With no such evidence, an identical earlier call can't be
@@ -869,6 +1152,338 @@ function createWriteHold(options = {}) {
869
1152
  },
870
1153
  };
871
1154
  }
1155
+ /**
1156
+ * `legacy`: for a write held under an intent namespace, the keys an earlier
1157
+ * version held it under (an old namespace, or `{ call }` for an exact
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
1165
+ * an unresolved write, the intent handle *is* that hold: its state, its
1166
+ * refusal and its own exit, in place. Each is checked alone, under its own
1167
+ * lock only, never two at once. Only when all are clear does the intent's
1168
+ * own record apply, and only then can a send reserve; they are checked
1169
+ * again right before it does.
1170
+ */
1171
+ function open(call, { legacy = [], } = {}) {
1172
+ if (call.namespace === undefined)
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 }));
1177
+ const namespace = call.namespace;
1178
+ const own = openOne(call, {
1179
+ across: reserveAcross,
1180
+ generations: () => generations(),
1181
+ });
1182
+ const ownJournal = (0, writeRecovery_js_1.createBrowserWriteRecovery)(call, { storage });
1183
+ /** Every involved key's latest reservation id, its generation. */
1184
+ const generations = () => JSON.stringify([ownJournal, ...journals].map((journal) => journal.latestReservation()));
1185
+ const { namespace: _, ...exact } = call;
1186
+ const scopeOf = (input) => (0, writeRecovery_js_1.createBrowserWriteRecovery)(input, { storage }).scopeKey;
1187
+ const scopes = new Set([scopeOf(call)]);
1188
+ // Mirror-aware: evidence and exits always act on the source attempt.
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();
1236
+ /** A legacy key holding anything but this intent's own mirror. */
1237
+ const foreign = (index) => journals[index].read()?.mirrorOf !== namespace &&
1238
+ delegates[index].peek().status !== "clear";
1239
+ /** The legacy hold the intent stands for right now, from storage alone. */
1240
+ const active = () => delegates.find((_, index) => foreign(index));
1241
+ /** The first legacy key holding an unresolved write, through its own gate. */
1242
+ async function delegated(client) {
1243
+ rescan();
1244
+ for (const [index, handle] of delegates.entries()) {
1245
+ if (!foreign(index))
1246
+ continue;
1247
+ const state = await handle.state(client);
1248
+ if (state.status !== "clear")
1249
+ return state;
1250
+ }
1251
+ return null;
1252
+ }
1253
+ /**
1254
+ * The final check and reservation, under every legacy key's lock taken
1255
+ * in one sorted order (the intent's own is held by its send), each
1256
+ * without waiting: a key another writer holds refuses as busy. A legacy
1257
+ * hold refuses; otherwise the intent reserves, and mirrors itself at each
1258
+ * legacy key so its readers, older versions included, see it held.
1259
+ */
1260
+ async function reserveAcross(ownLock, reserve, unreserve, until, reviewed) {
1261
+ const locks = [
1262
+ { scope: scopeOf(call), take: ownLock },
1263
+ ...journals.map((journal) => ({
1264
+ scope: journal.scopeKey,
1265
+ take: (work) => journal.withLock(work),
1266
+ })),
1267
+ ].sort((a, b) => Number(a.scope > b.scope) - Number(a.scope < b.scope));
1268
+ const deadline = Date.now() + RESERVE_RETRY_MS;
1269
+ for (;;) {
1270
+ let entered = 0;
1271
+ let reserved;
1272
+ const done = new Promise((resolve) => (reserved = resolve));
1273
+ const acquire = async (index) => {
1274
+ if (index < locks.length)
1275
+ return locks[index].take(async () => {
1276
+ entered += 1;
1277
+ return acquire(index + 1);
1278
+ });
1279
+ const refuse = (held) => new WriteHeldError(noticed({
1280
+ ...ungated(classify(held)),
1281
+ reservation: held.id,
1282
+ }));
1283
+ for (const journal of journals) {
1284
+ const held = journal.read();
1285
+ if (held && held.mirrorOf !== namespace)
1286
+ throw refuse(held);
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);
1293
+ // Another attempt since this review opened: review it again.
1294
+ if (generations() !== reviewed)
1295
+ throw new WriteHoldStaleReviewError(ownJournal.settled());
1296
+ reserve();
1297
+ const record = own.peek().record;
1298
+ // Every mirror is saved, in the store the hold itself uses (its
1299
+ // memory fallback included), before the wallet opens; otherwise
1300
+ // this unsent reservation and its partial mirrors are undone.
1301
+ const mirrors = [];
1302
+ try {
1303
+ for (const journal of journals)
1304
+ mirrors.push([journal, journal.mirror(record, namespace)]);
1305
+ }
1306
+ catch (error) {
1307
+ for (const [journal, mirror] of mirrors)
1308
+ try {
1309
+ journal.clear(mirror);
1310
+ }
1311
+ catch {
1312
+ /* Held for old readers until their explicit exit. */
1313
+ }
1314
+ try {
1315
+ unreserve();
1316
+ }
1317
+ catch {
1318
+ /* Unsent, it stays held with its explicit exit. */
1319
+ }
1320
+ throw error;
1321
+ }
1322
+ // Reserved: every lock is kept until the send ends.
1323
+ reserved();
1324
+ await until;
1325
+ };
1326
+ const holding = acquire(0);
1327
+ const failed = await Promise.race([
1328
+ done.then(() => null),
1329
+ holding.then(() => null, (error) => ({ error })),
1330
+ ]);
1331
+ if (!failed)
1332
+ return { released: holding.catch(() => { }) };
1333
+ // Not reserved: every lock, its own included, is let go before any
1334
+ // retry; the checks run again each time.
1335
+ const { error } = failed;
1336
+ if (entered === locks.length ||
1337
+ error instanceof WriteHeldError ||
1338
+ error instanceof WriteHoldStaleReviewError)
1339
+ throw error;
1340
+ if (Date.now() >= deadline)
1341
+ throw new WriteHoldBusyError();
1342
+ await new Promise((resolve) => setTimeout(resolve, 25 + Math.random() * 75));
1343
+ }
1344
+ }
1345
+ /** Remove this intent's mirrors of the reservation that ended, by its id only. */
1346
+ async function clearMirrors(ended) {
1347
+ for (const journal of journals) {
1348
+ if (journal.read()?.id !== ended)
1349
+ continue;
1350
+ await journal
1351
+ .withLock(async () => {
1352
+ const current = journal.read();
1353
+ if (current?.mirrorOf === namespace && current.id === ended)
1354
+ journal.clear(current);
1355
+ })
1356
+ .catch(() => {
1357
+ /* Kept for old readers until their explicit exit. */
1358
+ });
1359
+ }
1360
+ }
1361
+ /** After a call that ended this intent's reservation, its mirrors go. */
1362
+ const after = async (value) => {
1363
+ if (value.status === "clear" && value.reservation)
1364
+ await clearMirrors(value.reservation);
1365
+ return value;
1366
+ };
1367
+ return {
1368
+ ...own,
1369
+ peek: () => active()?.peek() ?? own.peek(),
1370
+ async state(client, checks) {
1371
+ return ((await delegated(client)) ?? after(await own.state(client, checks)));
1372
+ },
1373
+ async send(client, run) {
1374
+ const held = await delegated(client);
1375
+ if (held)
1376
+ throw new WriteHeldError(held);
1377
+ return own.send(client, run);
1378
+ },
1379
+ async exit(snapshot, acknowledgement, client) {
1380
+ rescan();
1381
+ const owner = snapshot.status === "clear"
1382
+ ? undefined
1383
+ : delegates.find((handle, index) => {
1384
+ const now = handle.peek();
1385
+ return (foreign(index) &&
1386
+ now.status !== "clear" &&
1387
+ now.record.id === snapshot.record.id);
1388
+ });
1389
+ if (owner)
1390
+ return owner.exit(snapshot, acknowledgement, client);
1391
+ const held = await delegated(client);
1392
+ if (held)
1393
+ return { released: false, state: held };
1394
+ const ended = await own.exit(snapshot, acknowledgement, client);
1395
+ await after(ended.state);
1396
+ return ended;
1397
+ },
1398
+ onReceipt: async (...args) => active()?.onReceipt(...args) ?? after(await own.onReceipt(...args)),
1399
+ onUnproven: async (...args) => active()?.onUnproven(...args) ?? after(await own.onUnproven(...args)),
1400
+ onExecuted: async (...args) => active()?.onExecuted(...args) ?? after(await own.onExecuted(...args)),
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)),
1406
+ };
1407
+ }
1408
+ /**
1409
+ * An exact-call handle. When its key holds a mirror of an intent's write,
1410
+ * it is that intent's hold: it never sends or releases on its own, and its
1411
+ * exit is the intent's. A mirror left after the intent ended is removed.
1412
+ */
1413
+ function openExact(call, at) {
1414
+ const own = openOne(call, {}, at);
1415
+ const journal = (0, writeRecovery_js_1.createBrowserWriteRecovery)(call, {
1416
+ storage,
1417
+ locks: options.locks,
1418
+ at,
1419
+ });
1420
+ /** The intent this key mirrors, from storage alone. */
1421
+ const mirrored = () => {
1422
+ const record = journal.read();
1423
+ return record?.mirrorOf
1424
+ ? { record, intent: openOne({ ...call, namespace: record.mirrorOf }) }
1425
+ : undefined;
1426
+ };
1427
+ /** Remove a mirror whose intent holds nothing any more. */
1428
+ async function clearStale(found) {
1429
+ await journal
1430
+ .withLock(async () => {
1431
+ const current = journal.read();
1432
+ if (current?.id === found.record.id)
1433
+ journal.clear(current);
1434
+ })
1435
+ .catch(() => {
1436
+ /* The stale mirror still holds; its intent is clear. */
1437
+ });
1438
+ }
1439
+ /** The intent's state while it holds anything: this key is its mirror. */
1440
+ async function intentState(client) {
1441
+ const found = mirrored();
1442
+ if (!found)
1443
+ return null;
1444
+ const state = await found.intent.state(client);
1445
+ if (state.status !== "clear")
1446
+ return state;
1447
+ await clearStale(found);
1448
+ return null;
1449
+ }
1450
+ /** The intent in charge, from storage alone. */
1451
+ const route = () => {
1452
+ const found = mirrored();
1453
+ return found && found.intent.peek().status !== "clear"
1454
+ ? found.intent
1455
+ : undefined;
1456
+ };
1457
+ return {
1458
+ ...own,
1459
+ peek: () => route()?.peek() ?? own.peek(),
1460
+ async state(client, checks) {
1461
+ return (await intentState(client)) ?? own.state(client, checks);
1462
+ },
1463
+ async send(client, run) {
1464
+ const held = await intentState(client);
1465
+ if (held)
1466
+ throw new WriteHeldError(held);
1467
+ return own.send(client, run);
1468
+ },
1469
+ async exit(snapshot, acknowledgement, client) {
1470
+ const found = mirrored();
1471
+ if (!found || found.intent.peek().status === "clear")
1472
+ return own.exit(snapshot, acknowledgement, client);
1473
+ const ended = await found.intent.exit(snapshot, acknowledgement, client);
1474
+ if (ended.released)
1475
+ await clearStale(found);
1476
+ return ended;
1477
+ },
1478
+ onReceipt: (...args) => (route() ?? own).onReceipt(...args),
1479
+ onUnproven: (...args) => (route() ?? own).onUnproven(...args),
1480
+ onExecuted: (...args) => (route() ?? own).onExecuted(...args),
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),
1485
+ };
1486
+ }
872
1487
  return {
873
1488
  open,
874
1489
  /** Whether holds outlive this tab right now. */
@@ -876,14 +1491,7 @@ function createWriteHold(options = {}) {
876
1491
  /** The review's line while holds last only in this tab, or null. */
877
1492
  reviewLine: () => (storage.durable() ? null : messages_js_1.WRITE_HOLD_IN_TAB_LINE),
878
1493
  /** Stop every settle poll and the tab listener (tests, teardown). */
879
- dispose() {
880
- for (const timer of timers.values())
881
- clearTimeout(timer);
882
- timers.clear();
883
- if (onStorage)
884
- globalThis.removeEventListener?.("storage", onStorage);
885
- onStorage = undefined;
886
- },
1494
+ dispose,
887
1495
  };
888
1496
  }
889
1497
  //# sourceMappingURL=writeHold.js.map