@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
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Where this tab's shared write state lives on `globalThis`, by symbol, so
3
+ * every copy of the SDK loaded in the tab shares it, and the test-only entry
4
+ * (`@bananapus/nana-sdk-core/testing`) can reset it without a production
5
+ * export. Not part of the public surface.
6
+ */
7
+ export declare const TAB: {
8
+ /** Held writes kept in memory where storage is refused. */
9
+ readonly memory: symbol;
10
+ /** Without Web Locks, one queue per action. */
11
+ readonly queues: symbol;
12
+ /** What each tab memory last saw in durable storage. */
13
+ readonly seen: symbol;
14
+ /** The stop of every write hold controller with a follower or listener running. */
15
+ readonly holds: symbol;
16
+ /** The stop of every running Safe Relayr watch. */
17
+ readonly watches: symbol;
18
+ /** Safe Relayr reviews remembered this session. */
19
+ readonly reviews: symbol;
20
+ };
21
+ /** The tab's shared `T` at `key`, made by `make` the first time. */
22
+ export declare function tabShared<T>(key: symbol, make: () => T): T;
23
+ //# sourceMappingURL=tabState.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tabState.d.ts","sourceRoot":"","sources":["../../../src/review/tabState.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,GAAG;IACd,2DAA2D;;IAE3D,+CAA+C;;IAE/C,wDAAwD;;IAExD,mFAAmF;;IAEnF,mDAAmD;;IAEnD,mDAAmD;;CAE3C,CAAC;AAEX,oEAAoE;AACpE,wBAAgB,SAAS,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,CAAC,CAG1D"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Where this tab's shared write state lives on `globalThis`, by symbol, so
3
+ * every copy of the SDK loaded in the tab shares it, and the test-only entry
4
+ * (`@bananapus/nana-sdk-core/testing`) can reset it without a production
5
+ * export. Not part of the public surface.
6
+ */
7
+ export const TAB = {
8
+ /** Held writes kept in memory where storage is refused. */
9
+ memory: Symbol.for("nana-sdk:held-writes"),
10
+ /** Without Web Locks, one queue per action. */
11
+ queues: Symbol.for("nana-sdk:write-lock-queues"),
12
+ /** What each tab memory last saw in durable storage. */
13
+ seen: Symbol.for("nana-sdk:held-writes-seen"),
14
+ /** The stop of every write hold controller with a follower or listener running. */
15
+ holds: Symbol.for("nana-sdk:write-hold-stops"),
16
+ /** The stop of every running Safe Relayr watch. */
17
+ watches: Symbol.for("nana-sdk:safe-relayr-watches"),
18
+ /** Safe Relayr reviews remembered this session. */
19
+ reviews: Symbol.for("nana-sdk:safe-relayr-reviews"),
20
+ };
21
+ /** The tab's shared `T` at `key`, made by `make` the first time. */
22
+ export function tabShared(key, make) {
23
+ const tab = globalThis;
24
+ return (tab[key] ??= make());
25
+ }
26
+ //# sourceMappingURL=tabState.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tabState.js","sourceRoot":"","sources":["../../../src/review/tabState.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,GAAG,GAAG;IACjB,2DAA2D;IAC3D,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,sBAAsB,CAAC;IAC1C,+CAA+C;IAC/C,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,4BAA4B,CAAC;IAChD,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC,GAAG,CAAC,2BAA2B,CAAC;IAC7C,mFAAmF;IACnF,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,2BAA2B,CAAC;IAC9C,mDAAmD;IACnD,OAAO,EAAE,MAAM,CAAC,GAAG,CAAC,8BAA8B,CAAC;IACnD,mDAAmD;IACnD,OAAO,EAAE,MAAM,CAAC,GAAG,CAAC,8BAA8B,CAAC;CAC3C,CAAC;AAEX,oEAAoE;AACpE,MAAM,UAAU,SAAS,CAAI,GAAW,EAAE,IAAa;IACrD,MAAM,GAAG,GAAG,UAAqC,CAAC;IAClD,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,IAAI,EAAE,CAAM,CAAC;AACpC,CAAC"}
@@ -1,4 +1,4 @@
1
- import { type Hex, type PublicClient, type TransactionReceipt } from "viem";
1
+ import { type Address, type Hex, type PublicClient, type TransactionReceipt } from "viem";
2
2
  import { type SafeQueuedTransaction, type SafeServiceOptions } from "../safeService.js";
3
3
  import { type HeldWriteStorage, type ReviewedWriteRecoveryInput, type ReviewedWriteRecoveryRecord } from "./writeRecovery.js";
4
4
  /** The reads a write hold makes on the call's chain; a viem PublicClient satisfies it. */
@@ -41,9 +41,9 @@ type Held<S extends string, Extra> = {
41
41
  * actually sent at (`sentNonce`, from its authenticated transaction; the
42
42
  * nonce read before the wallet opened only locates the call). The exit is
43
43
  * offered;
44
- * - `held-unchecked`: a saved hash the node doesn't know, on a record saved
45
- * with no nonce to check, or 30 minutes after the wallet returned it (or,
46
- * for a record saved before that time was kept, after it was first read). The
44
+ * - `held-unchecked`: a saved hash the node doesn't know 30 minutes after
45
+ * the wallet returned it (or, for a record saved before that time was
46
+ * kept or adopted without it, after it was first read). The
47
47
  * exit is offered, with a line that says so; a hash that shows up later is
48
48
  * followed as usual. A hash seen with its sent nonce that the node has not
49
49
  * known for 30 minutes, with that nonce unused at a canonical finalized
@@ -72,6 +72,13 @@ export type WriteHoldState = WriteHoldStateOf & {
72
72
  * so an observer can ignore a newer reservation it didn't start.
73
73
  */
74
74
  reservation?: string;
75
+ /**
76
+ * On a result of state() or exit(): true when the gate read the chain
77
+ * under the call's lock; false when the lock was busy and this is only the
78
+ * saved record (its exit hidden). Never let a false one hide an exit
79
+ * already shown.
80
+ */
81
+ checked?: boolean;
75
82
  };
76
83
  type WriteHoldStateOf = {
77
84
  status: "clear";
@@ -185,6 +192,11 @@ export declare function createWriteHold(options?: {
185
192
  open: (call: ReviewedWriteRecoveryInput, { legacy, }?: {
186
193
  legacy?: (string | {
187
194
  call: ReviewedWriteRecoveryInput["call"];
195
+ } | {
196
+ match: {
197
+ to: Address;
198
+ selector: Hex;
199
+ };
188
200
  })[];
189
201
  }) => {
190
202
  peek: () => WriteHoldState;
@@ -205,6 +217,20 @@ export declare function createWriteHold(options?: {
205
217
  onUnproven: (client: WriteHoldClient, proposalHash?: `0x${string}` | undefined) => Promise<WriteHoldState>;
206
218
  onExecuted: (client: WriteHoldClient, executionHash?: `0x${string}` | undefined) => Promise<WriteHoldState>;
207
219
  attachHash: (client: WriteHoldClient, hash: `0x${string}`) => Promise<WriteHoldState>;
220
+ attachSafeNonce: (client: WriteHoldClient, args_1: {
221
+ safeTxHash: Hex;
222
+ nonce: number;
223
+ }) => Promise<WriteHoldState>;
224
+ adopt: (snapshot: WriteHoldState, evidence?: {
225
+ nonce?: number | bigint;
226
+ sentNonce?: number | bigint;
227
+ fromBlock?: bigint;
228
+ submittedAt?: number;
229
+ hash?: Hex;
230
+ safe?: {
231
+ safeTxHash: Hex;
232
+ };
233
+ } | undefined, client?: WriteHoldClient | undefined) => Promise<WriteHoldState>;
208
234
  /**
209
235
  * Every state this controller reads or makes for the call, and other
210
236
  * tabs' changes. The latest one, if any, is replayed at once.
@@ -216,7 +242,7 @@ export declare function createWriteHold(options?: {
216
242
  /** The review's line while holds last only in this tab, or null. */
217
243
  reviewLine: () => "This browser can't remember pending transactions. Check your wallet before repeating one." | null;
218
244
  /** Stop every settle poll and the tab listener (tests, teardown). */
219
- dispose(): void;
245
+ dispose: () => void;
220
246
  };
221
247
  export type WriteHold = ReturnType<typeof createWriteHold>;
222
248
  export type WriteHoldHandle = ReturnType<WriteHold["open"]>;
@@ -1 +1 @@
1
- {"version":3,"file":"writeHold.d.ts","sourceRoot":"","sources":["../../../src/review/writeHold.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"writeHold.d.ts","sourceRoot":"","sources":["../../../src/review/writeHold.ts"],"names":[],"mappings":"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"}
@@ -1,9 +1,21 @@
1
1
  import { isAddress, toEventSelector, } from "viem";
2
2
  import { canonicalSafeTxHash, lookAtSafeProposalEnd, provenSafeProposalEnd, readSafeTransaction, SAFE_PROPOSAL_AWAITING, safeTransactionMessage, } from "../safeService.js";
3
3
  import { SAFE_PROPOSAL_EXECUTED_LINE, WRITE_HOLD_HASHED_EXIT_LINE, WRITE_HOLD_IN_TAB_LINE, WRITE_HOLD_LOOKS_DONE_LINE, WRITE_HOLD_MINED_LINE, WRITE_HOLD_NONCE_LINE, WRITE_HOLD_PENDING_LINE, WRITE_HOLD_UNCHECKED_LINE, WRITE_HOLD_REVERTED_LINE, WRITE_HOLD_SAFE_REPLACED_LINE, WRITE_HOLD_SAFE_UNPROVEN_LINE, WRITE_HOLD_STALE_REVIEW_LINE, writeHeldMessage, } from "./messages.js";
4
- import { createBrowserWriteRecovery, createHeldWriteStorage, findTransactionAtNonce, readReviewedWriteNonce, SubmittedWritePersistenceError, verifyReviewedWriteExpiry, verifyReviewedWriteReceipt, } from "./writeRecovery.js";
4
+ import { createBrowserWriteRecovery, createHeldWriteStorage, findReviewedWrites, heldSafeProposal, findTransactionAtNonce, readReviewedWriteNonce, safeProposalIsHeldCall, SubmittedWritePersistenceError, verifyReviewedWriteExpiry, verifyReviewedWriteReceipt, } from "./writeRecovery.js";
5
5
  import { isBytes32 } from "../untrusted.js";
6
6
  import { atCanonicalFinalizedBlock } from "./relayr.js";
7
+ import { TAB, tabShared } from "./tabState.js";
8
+ /** `sent` is the transaction at `hash`, exactly `record`'s call, from its account, on its chain. */
9
+ function sentIsCall(sent, hash, record) {
10
+ return (sent.hash?.toLowerCase() === hash.toLowerCase() &&
11
+ sent.from?.toLowerCase() === record.account &&
12
+ (sent.chainId === undefined || sent.chainId === record.chainId) &&
13
+ sent.to?.toLowerCase() === record.call.to &&
14
+ sent.input?.toLowerCase() === record.call.data &&
15
+ BigInt(sent.value) === BigInt(record.call.value));
16
+ }
17
+ /** The stop of every controller with a follower or tab listener running, for the test-only reset. */
18
+ const liveHolds = tabShared(TAB.holds, () => new Set());
7
19
  /**
8
20
  * A state read without the chain (peek, a replay, another tab's change, a
9
21
  * send's progress): its status and line, never the exit. Only the gate in
@@ -59,6 +71,8 @@ const SAFE_EXECUTION_TOPICS = [
59
71
  ];
60
72
  /** A hash the network still hasn't seen this long after the wallet returned it was almost certainly never broadcast. */
61
73
  const UNSEEN_HASH_MS = 30 * 60_000;
74
+ /** The longest one request to Safe's service may take inside the hold. */
75
+ const SAFE_SERVICE_READ_MS = 8_000;
62
76
  /** 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. */
63
77
  function unseenTooLong(record) {
64
78
  return (Date.now() - Number(record.submittedAt ?? record.firstSeenAt) >=
@@ -175,6 +189,30 @@ function classify(record) {
175
189
  */
176
190
  export function createWriteHold(options = {}) {
177
191
  const storage = options.storage ?? createHeldWriteStorage();
192
+ /**
193
+ * Safe's service, every request and its body bounded together to
194
+ * {@link SAFE_SERVICE_READ_MS} whatever its fetch does with the signal: a read that times out is
195
+ * unknown, which never releases or settles, so a hung service can't hold
196
+ * a send or the call's lock.
197
+ */
198
+ const service = {
199
+ ...options.safeService,
200
+ fetch: (url, init) => {
201
+ const request = options.safeService?.fetch ?? globalThis.fetch;
202
+ let timer;
203
+ return Promise.race([
204
+ // The whole read, its body included, under the one deadline.
205
+ request(url, init).then(async (response) => new Response(await response.arrayBuffer(), {
206
+ status: response.status,
207
+ statusText: response.statusText,
208
+ headers: response.headers,
209
+ })),
210
+ new Promise((_, reject) => {
211
+ timer = setTimeout(() => reject(new Error("Safe's service did not answer in time.")), SAFE_SERVICE_READ_MS);
212
+ }),
213
+ ]).finally(() => clearTimeout(timer));
214
+ },
215
+ };
178
216
  const interval = options.settle?.intervalMs ?? SETTLE_POLL_MS;
179
217
  const attempts = options.settle?.attempts ?? SETTLE_POLL_ATTEMPTS;
180
218
  const listeners = new Map();
@@ -218,11 +256,25 @@ export function createWriteHold(options = {}) {
218
256
  }
219
257
  };
220
258
  globalThis.addEventListener("storage", onStorage);
259
+ liveHolds.add(dispose);
260
+ }
261
+ /** Stop every settle poll and the tab listener. */
262
+ function dispose() {
263
+ for (const timer of timers.values())
264
+ clearTimeout(timer);
265
+ timers.clear();
266
+ if (onStorage)
267
+ globalThis.removeEventListener?.("storage", onStorage);
268
+ onStorage = undefined;
269
+ liveHolds.delete(dispose);
221
270
  }
222
- function openOne(call, hooks = {}) {
271
+ function openOne(call, hooks = {},
272
+ /** The key a found write was saved under, when it isn't its call's own. */
273
+ at) {
223
274
  const recovery = createBrowserWriteRecovery(call, {
224
275
  storage,
225
276
  locks: options.locks,
277
+ at,
226
278
  });
227
279
  // The canonical recovery scope: a namespace and an exact call never share it.
228
280
  const key = recovery.scopeKey;
@@ -279,6 +331,7 @@ export function createWriteHold(options = {}) {
279
331
  function follow(client, id, left = attempts) {
280
332
  if (timers.has(id) || left <= 0)
281
333
  return;
334
+ liveHolds.add(dispose);
282
335
  timers.set(id, setTimeout(() => {
283
336
  void state(client)
284
337
  .catch(() => null)
@@ -320,10 +373,37 @@ export function createWriteHold(options = {}) {
320
373
  follow(client, record.id);
321
374
  return classify(recovery.read());
322
375
  }
376
+ /**
377
+ * Whether a previous deploy's `hash` may be this call's: a valid hash
378
+ * read on the call's chain that isn't shown to be another write. A
379
+ * transaction must be the exact call from the account; a Safe proposal,
380
+ * exactly this call. One that can't be read is unknown, not refused.
381
+ */
382
+ async function adoptable(hash, client) {
383
+ if (!isBytes32(hash) ||
384
+ !client ||
385
+ (await client.getChainId().catch(() => null)) !== call.chainId)
386
+ return false;
387
+ const reviewed = {
388
+ account: call.account.toLowerCase(),
389
+ chainId: call.chainId,
390
+ call: {
391
+ to: call.call.to.toLowerCase(),
392
+ data: call.call.data.toLowerCase(),
393
+ value: String(call.call.value ?? 0),
394
+ },
395
+ };
396
+ if (call.safe) {
397
+ const proposal = await readSafeTransaction(call.chainId, call.account, hash, service).catch(() => null);
398
+ return !proposal || safeProposalIsHeldCall(proposal, call);
399
+ }
400
+ const sent = await client.getTransaction({ hash }).catch(() => null);
401
+ return !sent || sentIsCall(sent, hash, reviewed);
402
+ }
323
403
  /** The authenticated nonce of a Safe proposal, from Safe's service. */
324
404
  async function proposalNonceOf(record) {
325
405
  try {
326
- const proposal = await readSafeTransaction(record.chainId, record.account, record.hash, options.safeService);
406
+ const proposal = await readSafeTransaction(record.chainId, record.account, record.hash, service);
327
407
  return safeTransactionMessage(proposal).nonce;
328
408
  }
329
409
  catch {
@@ -528,13 +608,13 @@ export function createWriteHold(options = {}) {
528
608
  }
529
609
  const end = (record.safeNonce !== undefined &&
530
610
  (await provenSafeProposalEnd(client, record.account, hash, BigInt(record.safeNonce)))) ||
531
- (await lookAtSafeProposalEnd(client, record.chainId, record.account, hash, options.safeService));
611
+ (await lookAtSafeProposalEnd(client, record.chainId, record.account, hash, service));
532
612
  // Another Safe transaction took its nonce, even after a saved execution
533
613
  // was reorganized away. It may have run the call too: the exit, never a release.
534
614
  if (end.look === "replaced")
535
615
  return safeReplaced(recovery.replacedOnChain(record));
536
616
  if (end.look === "expired" && !record.executed) {
537
- const expired = await verifyReviewedWriteExpiry(client, record, options.safeService).catch(() => false);
617
+ const expired = await verifyReviewedWriteExpiry(client, record, service).catch(() => false);
538
618
  if (expired && release(record))
539
619
  return clear({ result: "expired" }, record.id);
540
620
  }
@@ -665,9 +745,7 @@ export function createWriteHold(options = {}) {
665
745
  }
666
746
  if (record.hash &&
667
747
  record.sentNonce === undefined &&
668
- (record.nonce === undefined ||
669
- record.fromBlock === undefined ||
670
- unseenTooLong(record)) &&
748
+ unseenTooLong(record) &&
671
749
  (await client.getTransaction({ hash: record.hash }).then(() => false, (error) => error?.name === "TransactionNotFoundError")))
672
750
  return unchecked(record);
673
751
  return classify(record);
@@ -679,7 +757,7 @@ export function createWriteHold(options = {}) {
679
757
  async function state(client, checks = {}) {
680
758
  lastEffect = checks.effect;
681
759
  const result = await locked(() => inspect(client, checks.effect), false);
682
- return shown(result ? result.value : peek());
760
+ return { ...shown(result ? result.value : peek()), checked: !!result };
683
761
  }
684
762
  return {
685
763
  state,
@@ -865,7 +943,10 @@ export function createWriteHold(options = {}) {
865
943
  // what the saved record says, with no exit.
866
944
  }, false);
867
945
  const ended = result?.value ?? { released: false, state: peek() };
868
- return { ...ended, state: shown(ended.state) };
946
+ return {
947
+ ...ended,
948
+ state: { ...shown(ended.state), checked: !!result },
949
+ };
869
950
  },
870
951
  /**
871
952
  * A receipt for the held write: an ordinary write's own hash, or a Safe
@@ -909,6 +990,94 @@ export function createWriteHold(options = {}) {
909
990
  * one lock, without releasing it. The hold then follows that hash: no
910
991
  * exit while it is pending, and its receipt settles it.
911
992
  */
993
+ /**
994
+ * Move a write a previous deploy kept (outside these holds) into this
995
+ * hold. `snapshot` is the state the app read before deciding. Under
996
+ * the call's lock, it reserves a held record with `evidence` (the
997
+ * account's next nonce, or the nonce it was sent at, with the block it
998
+ * was read at; when it was sent; and its `hash`, or for a Safe write
999
+ * `safe.safeTxHash`) only while nothing is held and no reservation
1000
+ * came since `snapshot`: another tab's hold or settlement wins, and
1001
+ * the current state is returned unchanged. A hash is checked first
1002
+ * with `client`: a transaction that is another call, from another
1003
+ * account or on another chain (or a Safe proposal of another call) is
1004
+ * refused, adopting nothing. One the chain can't show is held, with
1005
+ * the warned exit 30 minutes after it was sent (or adopted). Then the
1006
+ * gate reads it: a mined one settles on its verified receipt. It never
1007
+ * sends or releases; adopting twice holds once.
1008
+ */
1009
+ async adopt(snapshot, evidence = {}, client) {
1010
+ const nonce = evidence.nonce ?? evidence.sentNonce;
1011
+ const hash = (call.safe ? evidence.safe?.safeTxHash : evidence.hash)?.toLowerCase();
1012
+ /** Nothing held, and no reservation since the app's read. */
1013
+ const free = () => snapshot.status === "clear" &&
1014
+ !recovery.read() &&
1015
+ recovery.latestReservation() === snapshot.reservation;
1016
+ const reserve = () => recovery.reserve(undefined, nonce !== undefined && evidence.fromBlock !== undefined
1017
+ ? { nonce, blockNumber: evidence.fromBlock }
1018
+ : undefined, {
1019
+ hash,
1020
+ submittedAt: evidence.submittedAt === undefined
1021
+ ? undefined
1022
+ : String(evidence.submittedAt),
1023
+ });
1024
+ if (hooks.across) {
1025
+ // Under an intent: the same atomic reserve as a send, under every
1026
+ // legacy key's lock, mirrored at each so older versions see it.
1027
+ if (!free() ||
1028
+ (hash !== undefined && !(await adoptable(hash, client))))
1029
+ return shown(peek());
1030
+ let adopted = false;
1031
+ try {
1032
+ const { released } = await hooks.across((work) => recovery.withLock(work), () => {
1033
+ if (!free())
1034
+ throw new Error("Held since the app's read.");
1035
+ reserve();
1036
+ adopted = true;
1037
+ }, recovery.rejected, Promise.resolve(), hooks.generations());
1038
+ await released;
1039
+ }
1040
+ catch {
1041
+ /* Held elsewhere, busy, or no longer free: nothing adopted. */
1042
+ }
1043
+ // A mined hash settles on its verified receipt, through the gate.
1044
+ return adopted && hash && client ? state(client) : shown(peek());
1045
+ }
1046
+ const result = await locked(async () => {
1047
+ if (!free())
1048
+ return peek();
1049
+ if (hash !== undefined && !(await adoptable(hash, client)))
1050
+ return peek();
1051
+ reserve();
1052
+ return hash && client ? inspect(client) : peek();
1053
+ }, true);
1054
+ return shown(result?.value ?? peek());
1055
+ },
1056
+ /**
1057
+ * Save the Safe nonce of a held Safe proposal adopted before it was
1058
+ * known, so a replacement can be proven later. Under the call's lock,
1059
+ * only while the held proposal is `safeTxHash` with no nonce yet, and
1060
+ * only when the call's proposal at `nonce` hashes to it: the hash is
1061
+ * the proof. Then the gate reads it. Never sends or releases.
1062
+ */
1063
+ async attachSafeNonce(client, { safeTxHash, nonce }) {
1064
+ const result = await locked(async () => {
1065
+ const record = recovery.read();
1066
+ if (record?.safe &&
1067
+ !record.mirrorOf &&
1068
+ record.safeNonce === undefined &&
1069
+ record.hash === safeTxHash.toLowerCase())
1070
+ try {
1071
+ canonicalSafeTxHash(call.chainId, call.account, heldSafeProposal(call, nonce), safeTxHash);
1072
+ recovery.proposalNonce(record, BigInt(nonce));
1073
+ }
1074
+ catch {
1075
+ /* Not this proposal at that nonce: nothing saved. */
1076
+ }
1077
+ return inspect(client);
1078
+ }, true);
1079
+ return shown(result?.value ?? peek());
1080
+ },
912
1081
  async attachHash(client, hash) {
913
1082
  const result = await locked(async () => {
914
1083
  const record = recovery.read();
@@ -921,12 +1090,8 @@ export function createWriteHold(options = {}) {
921
1090
  const sent = await client
922
1091
  .getTransaction({ hash })
923
1092
  .catch(() => null);
924
- if (sent?.hash?.toLowerCase() === hash.toLowerCase() &&
925
- sent.from?.toLowerCase() === record.account &&
926
- (sent.chainId === undefined || sent.chainId === record.chainId) &&
927
- sent.to?.toLowerCase() === record.call.to &&
928
- sent.input?.toLowerCase() === record.call.data &&
929
- BigInt(sent.value) === BigInt(record.call.value) &&
1093
+ if (sent &&
1094
+ sentIsCall(sent, hash, record) &&
930
1095
  // This attempt, not an earlier identical call: never below the
931
1096
  // account's next nonce or mined before it was reserved.
932
1097
  // With no such evidence, an identical earlier call can't be
@@ -982,7 +1147,13 @@ export function createWriteHold(options = {}) {
982
1147
  /**
983
1148
  * `legacy`: for a write held under an intent namespace, the keys an earlier
984
1149
  * version held it under (an old namespace, or `{ call }` for an exact
985
- * call); the current exact call is always checked. While any of them holds
1150
+ * call); the current exact call is always checked. `{ match: { to,
1151
+ * selector } }` finds them instead: every exact-call write this account
1152
+ * saved on this chain to `to` with that 4-byte selector, for a hold saved
1153
+ * before the intent key existed under calldata the retry can't rebuild (an
1154
+ * old minimum, expiration or deadline). Pass one only for a write whose
1155
+ * calldata is volatile: it holds every such call. It is found when the
1156
+ * handle opens, and again right before a send reserves. While any of them holds
986
1157
  * an unresolved write, the intent handle *is* that hold: its state, its
987
1158
  * refusal and its own exit, in place. Each is checked alone, under its own
988
1159
  * lock only, never two at once. Only when all are clear does the intent's
@@ -992,6 +1163,9 @@ export function createWriteHold(options = {}) {
992
1163
  function open(call, { legacy = [], } = {}) {
993
1164
  if (call.namespace === undefined)
994
1165
  return openExact(call);
1166
+ const matches = legacy.flatMap((source) => typeof source === "object" && "match" in source ? [source.match] : []);
1167
+ /** Every exact-call write the matches find in storage now. */
1168
+ const matched = () => matches.flatMap((match) => findReviewedWrites({ chainId: call.chainId, account: call.account, ...match }, { storage }));
995
1169
  const namespace = call.namespace;
996
1170
  const own = openOne(call, {
997
1171
  across: reserveAcross,
@@ -1003,19 +1177,54 @@ export function createWriteHold(options = {}) {
1003
1177
  const { namespace: _, ...exact } = call;
1004
1178
  const scopeOf = (input) => createBrowserWriteRecovery(input, { storage }).scopeKey;
1005
1179
  const scopes = new Set([scopeOf(call)]);
1006
- const inputs = [{ call: call.call }, ...legacy]
1007
- .map((source) => typeof source === "string"
1008
- ? { ...call, namespace: source }
1009
- : { ...exact, call: source.call })
1010
- .filter((input) => {
1011
- const scope = scopeOf(input);
1012
- return !scopes.has(scope) && !!scopes.add(scope);
1013
- })
1014
- // One canonical order, by scope, for every writer's locks.
1015
- .sort((a, b) => Number(scopeOf(a) > scopeOf(b)) - Number(scopeOf(a) < scopeOf(b)));
1016
1180
  // Mirror-aware: evidence and exits always act on the source attempt.
1017
- const delegates = inputs.map((input) => openExact(input));
1018
- const journals = inputs.map((input) => createBrowserWriteRecovery(input, { storage, locks: options.locks }));
1181
+ const delegates = [];
1182
+ const journals = [];
1183
+ /** Hold `inputs` too, each once, in one canonical order by scope for every writer's locks. */
1184
+ function include(inputs) {
1185
+ const added = inputs.filter(({ input, at }) => {
1186
+ const scope = at ?? scopeOf(input);
1187
+ return !scopes.has(scope) && !!scopes.add(scope);
1188
+ });
1189
+ if (!added.length)
1190
+ return;
1191
+ const keyed = [
1192
+ ...delegates.map((delegate, index) => ({
1193
+ delegate,
1194
+ journal: journals[index],
1195
+ })),
1196
+ ...added.map(({ input, at }) => ({
1197
+ delegate: openExact(input, at),
1198
+ journal: createBrowserWriteRecovery(input, {
1199
+ storage,
1200
+ locks: options.locks,
1201
+ at,
1202
+ }),
1203
+ })),
1204
+ ].sort((a, b) => Number(a.journal.scopeKey > b.journal.scopeKey) -
1205
+ Number(a.journal.scopeKey < b.journal.scopeKey));
1206
+ delegates.splice(0, delegates.length, ...keyed.map((k) => k.delegate));
1207
+ journals.splice(0, journals.length, ...keyed.map((k) => k.journal));
1208
+ }
1209
+ /**
1210
+ * Look for matching writes again (on every state(), send() and exit());
1211
+ * those found since are held too, each at the exact key it was found
1212
+ * under, with its own Safe flag. Returns the ones that are new.
1213
+ */
1214
+ function rescan() {
1215
+ const found = matched().filter(({ key }) => !scopes.has(key));
1216
+ include(found.map(({ key, record }) => ({
1217
+ input: { ...exact, safe: record.safe, call: record.call },
1218
+ at: key,
1219
+ })));
1220
+ return found.map(({ record }) => record);
1221
+ }
1222
+ include([{ call: call.call }, ...legacy].flatMap((source) => typeof source === "string"
1223
+ ? [{ input: { ...call, namespace: source } }]
1224
+ : "call" in source
1225
+ ? [{ input: { ...exact, call: source.call } }]
1226
+ : []));
1227
+ rescan();
1019
1228
  /** A legacy key holding anything but this intent's own mirror. */
1020
1229
  const foreign = (index) => journals[index].read()?.mirrorOf !== namespace &&
1021
1230
  delegates[index].peek().status !== "clear";
@@ -1023,6 +1232,7 @@ export function createWriteHold(options = {}) {
1023
1232
  const active = () => delegates.find((_, index) => foreign(index));
1024
1233
  /** The first legacy key holding an unresolved write, through its own gate. */
1025
1234
  async function delegated(client) {
1235
+ rescan();
1026
1236
  for (const [index, handle] of delegates.entries()) {
1027
1237
  if (!foreign(index))
1028
1238
  continue;
@@ -1058,14 +1268,20 @@ export function createWriteHold(options = {}) {
1058
1268
  entered += 1;
1059
1269
  return acquire(index + 1);
1060
1270
  });
1271
+ const refuse = (held) => new WriteHeldError(noticed({
1272
+ ...ungated(classify(held)),
1273
+ reservation: held.id,
1274
+ }));
1061
1275
  for (const journal of journals) {
1062
1276
  const held = journal.read();
1063
1277
  if (held && held.mirrorOf !== namespace)
1064
- throw new WriteHeldError(noticed({
1065
- ...ungated(classify(held)),
1066
- reservation: held.id,
1067
- }));
1278
+ throw refuse(held);
1068
1279
  }
1280
+ // A matching write saved since the last look holds it too, and
1281
+ // its own state() and exit() are reached from now on.
1282
+ const [late] = rescan();
1283
+ if (late)
1284
+ throw refuse(late);
1069
1285
  // Another attempt since this review opened: review it again.
1070
1286
  if (generations() !== reviewed)
1071
1287
  throw new WriteHoldStaleReviewError(ownJournal.settled());
@@ -1153,6 +1369,7 @@ export function createWriteHold(options = {}) {
1153
1369
  return own.send(client, run);
1154
1370
  },
1155
1371
  async exit(snapshot, acknowledgement, client) {
1372
+ rescan();
1156
1373
  const owner = snapshot.status === "clear"
1157
1374
  ? undefined
1158
1375
  : delegates.find((handle, index) => {
@@ -1174,6 +1391,10 @@ export function createWriteHold(options = {}) {
1174
1391
  onUnproven: async (...args) => active()?.onUnproven(...args) ?? after(await own.onUnproven(...args)),
1175
1392
  onExecuted: async (...args) => active()?.onExecuted(...args) ?? after(await own.onExecuted(...args)),
1176
1393
  attachHash: async (...args) => active()?.attachHash(...args) ?? after(await own.attachHash(...args)),
1394
+ attachSafeNonce: async (...args) => active()?.attachSafeNonce(...args) ??
1395
+ after(await own.attachSafeNonce(...args)),
1396
+ // A legacy hold already holds it: nothing is adopted.
1397
+ adopt: async (...args) => active()?.peek() ?? after(await own.adopt(...args)),
1177
1398
  };
1178
1399
  }
1179
1400
  /**
@@ -1181,11 +1402,12 @@ export function createWriteHold(options = {}) {
1181
1402
  * it is that intent's hold: it never sends or releases on its own, and its
1182
1403
  * exit is the intent's. A mirror left after the intent ended is removed.
1183
1404
  */
1184
- function openExact(call) {
1185
- const own = openOne(call);
1405
+ function openExact(call, at) {
1406
+ const own = openOne(call, {}, at);
1186
1407
  const journal = createBrowserWriteRecovery(call, {
1187
1408
  storage,
1188
1409
  locks: options.locks,
1410
+ at,
1189
1411
  });
1190
1412
  /** The intent this key mirrors, from storage alone. */
1191
1413
  const mirrored = () => {
@@ -1249,6 +1471,9 @@ export function createWriteHold(options = {}) {
1249
1471
  onUnproven: (...args) => (route() ?? own).onUnproven(...args),
1250
1472
  onExecuted: (...args) => (route() ?? own).onExecuted(...args),
1251
1473
  attachHash: (...args) => (route() ?? own).attachHash(...args),
1474
+ attachSafeNonce: (...args) => (route() ?? own).attachSafeNonce(...args),
1475
+ // An intent's mirror already holds it: nothing is adopted.
1476
+ adopt: async (...args) => route()?.peek() ?? own.adopt(...args),
1252
1477
  };
1253
1478
  }
1254
1479
  return {
@@ -1258,14 +1483,7 @@ export function createWriteHold(options = {}) {
1258
1483
  /** The review's line while holds last only in this tab, or null. */
1259
1484
  reviewLine: () => (storage.durable() ? null : WRITE_HOLD_IN_TAB_LINE),
1260
1485
  /** Stop every settle poll and the tab listener (tests, teardown). */
1261
- dispose() {
1262
- for (const timer of timers.values())
1263
- clearTimeout(timer);
1264
- timers.clear();
1265
- if (onStorage)
1266
- globalThis.removeEventListener?.("storage", onStorage);
1267
- onStorage = undefined;
1268
- },
1486
+ dispose,
1269
1487
  };
1270
1488
  }
1271
1489
  //# sourceMappingURL=writeHold.js.map