@bananapus/nana-sdk-core 2.29.0 → 2.30.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 (49) 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 +43 -0
  6. package/dist/cjs/review/messages.d.ts.map +1 -0
  7. package/dist/cjs/review/messages.js +55 -0
  8. package/dist/cjs/review/messages.js.map +1 -0
  9. package/dist/cjs/review/relayr.d.ts.map +1 -1
  10. package/dist/cjs/review/relayr.js +23 -1
  11. package/dist/cjs/review/relayr.js.map +1 -1
  12. package/dist/cjs/review/safeRelayr.d.ts.map +1 -1
  13. package/dist/cjs/review/safeRelayr.js +23 -3
  14. package/dist/cjs/review/safeRelayr.js.map +1 -1
  15. package/dist/cjs/review/transaction-message.js +2 -2
  16. package/dist/cjs/review/transaction-message.js.map +1 -1
  17. package/dist/cjs/review/writeRecovery.d.ts +92 -7
  18. package/dist/cjs/review/writeRecovery.d.ts.map +1 -1
  19. package/dist/cjs/review/writeRecovery.js +473 -70
  20. package/dist/cjs/review/writeRecovery.js.map +1 -1
  21. package/dist/cjs/safeService.d.ts +18 -12
  22. package/dist/cjs/safeService.d.ts.map +1 -1
  23. package/dist/cjs/safeService.js +97 -9
  24. package/dist/cjs/safeService.js.map +1 -1
  25. package/dist/esm/review/index.d.ts +1 -0
  26. package/dist/esm/review/index.d.ts.map +1 -1
  27. package/dist/esm/review/index.js +1 -0
  28. package/dist/esm/review/index.js.map +1 -1
  29. package/dist/esm/review/messages.d.ts +43 -0
  30. package/dist/esm/review/messages.d.ts.map +1 -0
  31. package/dist/esm/review/messages.js +48 -0
  32. package/dist/esm/review/messages.js.map +1 -0
  33. package/dist/esm/review/relayr.d.ts.map +1 -1
  34. package/dist/esm/review/relayr.js +23 -1
  35. package/dist/esm/review/relayr.js.map +1 -1
  36. package/dist/esm/review/safeRelayr.d.ts.map +1 -1
  37. package/dist/esm/review/safeRelayr.js +23 -3
  38. package/dist/esm/review/safeRelayr.js.map +1 -1
  39. package/dist/esm/review/transaction-message.js +2 -2
  40. package/dist/esm/review/transaction-message.js.map +1 -1
  41. package/dist/esm/review/writeRecovery.d.ts +92 -7
  42. package/dist/esm/review/writeRecovery.d.ts.map +1 -1
  43. package/dist/esm/review/writeRecovery.js +472 -72
  44. package/dist/esm/review/writeRecovery.js.map +1 -1
  45. package/dist/esm/safeService.d.ts +18 -12
  46. package/dist/esm/safeService.d.ts.map +1 -1
  47. package/dist/esm/safeService.js +97 -9
  48. package/dist/esm/safeService.js.map +1 -1
  49. package/package.json +3 -2
@@ -1,7 +1,7 @@
1
- import { isAddress, } from "viem";
1
+ import { isAddress, keccak256, } from "viem";
2
2
  import { lookAtSafeProposal, readSafeAppExecution, readSafeTransaction, safeTransactionMatchesCall, } from "../safeService.js";
3
3
  import { isBytes32, isHexBytes, uint256 } from "../untrusted.js";
4
- import { atCanonicalFinalizedBlock } from "./relayr.js";
4
+ import { atCanonicalFinalizedBlock, } from "./relayr.js";
5
5
  // A returned hash must survive a component remount even when its durable update
6
6
  // failed. It is visible only while the exact durable reservation still exists.
7
7
  const unpersistedSubmissions = new WeakMap();
@@ -50,7 +50,10 @@ function recordOf(value) {
50
50
  !record.id ||
51
51
  typeof record.call?.value !== "string" ||
52
52
  !/^(0|[1-9]\d*)$/.test(record.call.value) ||
53
- (record.hash !== undefined && !isBytes32(record.hash)))
53
+ (record.hash !== undefined && !isBytes32(record.hash)) ||
54
+ !optionalDecimal(record.nonce) ||
55
+ !optionalDecimal(record.fromBlock) ||
56
+ !optionalDecimal(record.sentNonce))
54
57
  throw new Error("Invalid saved write recovery record. Keep this action pending.");
55
58
  return {
56
59
  version: 1,
@@ -59,12 +62,26 @@ function recordOf(value) {
59
62
  ...(record.hash === undefined
60
63
  ? {}
61
64
  : { hash: record.hash.toLowerCase() }),
65
+ ...(record.nonce === undefined ? {} : { nonce: record.nonce }),
66
+ ...(record.fromBlock === undefined ? {} : { fromBlock: record.fromBlock }),
67
+ ...(record.sentNonce === undefined ? {} : { sentNonce: record.sentNonce }),
62
68
  };
63
69
  }
70
+ function optionalDecimal(value) {
71
+ return (value === undefined ||
72
+ (typeof value === "string" && /^(0|[1-9]\d*)$/.test(value)));
73
+ }
64
74
  /** Validate an existing domain journal's recovered record with the shared storage rules. */
65
75
  export { recordOf as parseReviewedWriteRecoveryRecord };
66
- /** Amounts and quote changes must not evade an unresolved action. */
76
+ /**
77
+ * An ordinary write holds only its exact call: a changed amount or quote is a
78
+ * different call. A namespace holds a whole action instead.
79
+ */
67
80
  function callScope(input) {
81
+ return `nana-sdk:reviewed-write:v2:${input.chainId}:${input.account}:${input.call.to}:${input.call.value ?? 0}:${keccak256(input.call.data)}`;
82
+ }
83
+ /** Before 2.30, an ordinary write held its target and selector. Read only, for its exact call. */
84
+ function legacyScope(input) {
68
85
  return `nana-sdk:reviewed-write:v1:${input.chainId}:${input.account}:${input.call.to}:${input.call.data.slice(0, 10)}`;
69
86
  }
70
87
  function namespaceOf(value) {
@@ -101,16 +118,26 @@ export function readBrowserWriteRecovery(input, options = {}) {
101
118
  throw new Error("Provide a write recovery namespace.");
102
119
  return readRecovery(recoveryStorage(options.storage), namespaceScope({ ...identity, namespace }));
103
120
  }
104
- function readStored(raw, key) {
121
+ /**
122
+ * The key that marks the record `id` saved at `key` as released by the user.
123
+ * An exit writes this instead of removing `key`, so it can never remove a
124
+ * newer record another tab saved there.
125
+ */
126
+ function releasedKey(key, id) {
127
+ return `${key}:released:${id}`;
128
+ }
129
+ function readStored(raw, key, scopeOf = scope, store) {
105
130
  if (raw === null)
106
131
  return null;
107
132
  const record = recordOf(JSON.parse(raw));
108
- if (scope(record) !== key)
133
+ if (scopeOf(record) !== key)
109
134
  throw new Error("Saved write recovery scope does not match.");
135
+ if (store && store.getItem(releasedKey(key, record.id)) !== null)
136
+ return null;
110
137
  return record;
111
138
  }
112
- function readRecovery(store, key) {
113
- const record = readStored(store.getItem(key), key);
139
+ function readRecovery(store, key, raw = store.getItem(key), scopeOf = scope) {
140
+ const record = readStored(raw, key, scopeOf, store);
114
141
  const memory = unpersistedSubmissions.get(store)?.get(key);
115
142
  if (!record ||
116
143
  !memory ||
@@ -129,31 +156,190 @@ function readRecovery(store, key) {
129
156
  export function sameReviewedWrite(record, input) {
130
157
  return (JSON.stringify(normalized(record)) === JSON.stringify(normalized(input)));
131
158
  }
159
+ // Without Web Locks, writes in this tab still run one at a time per action.
160
+ const tabQueues = new Map();
161
+ let tabLocksNoted = false;
162
+ const tabLocks = {
163
+ async request(name, options, work) {
164
+ const held = tabQueues.get(name);
165
+ if (held && options.ifAvailable)
166
+ return work(null);
167
+ let release;
168
+ const mine = new Promise((resolve) => (release = resolve));
169
+ const queue = (held ?? Promise.resolve()).then(() => mine);
170
+ tabQueues.set(name, queue);
171
+ await held;
172
+ try {
173
+ return await work({ name, mode: "exclusive" });
174
+ }
175
+ finally {
176
+ release();
177
+ if (tabQueues.get(name) === queue)
178
+ tabQueues.delete(name);
179
+ }
180
+ },
181
+ };
182
+ // Without Web Locks, tabs share an IndexedDB lease per action. A readwrite
183
+ // transaction is atomic across the origin's tabs, so claiming a lease is a
184
+ // compare-and-set. The holder renews it while it works; a tab that closed or
185
+ // froze loses it once it lapses.
186
+ const LEASE_DB = "nana-sdk-write-locks";
187
+ const LEASE_STORE = "leases";
188
+ const LEASE_MS = 15_000;
189
+ const LEASE_RENEW_MS = 5_000;
190
+ const LEASE_POLL_MS = 50;
191
+ function leaseTransaction(db, run) {
192
+ return new Promise((resolve, reject) => {
193
+ const transaction = db.transaction(LEASE_STORE, "readwrite");
194
+ let result;
195
+ run(transaction.objectStore(LEASE_STORE), (value) => (result = value));
196
+ transaction.oncomplete = () => resolve(result);
197
+ transaction.onerror = transaction.onabort = () => reject(transaction.error);
198
+ });
199
+ }
200
+ /** Take or renew `name`'s lease for `owner`; false while another tab holds it. */
201
+ function claimLease(db, name, owner) {
202
+ return leaseTransaction(db, (store, done) => {
203
+ done(false);
204
+ const read = store.get(name);
205
+ read.onsuccess = () => {
206
+ const lease = read.result;
207
+ const now = Date.now();
208
+ if (lease && lease.owner !== owner && lease.expires > now)
209
+ return;
210
+ store.put({ owner, expires: now + LEASE_MS }, name);
211
+ done(true);
212
+ };
213
+ });
214
+ }
215
+ function dropLease(db, name, owner) {
216
+ return leaseTransaction(db, (store) => {
217
+ const read = store.get(name);
218
+ read.onsuccess = () => {
219
+ if (read.result?.owner === owner)
220
+ store.delete(name);
221
+ };
222
+ });
223
+ }
224
+ function leaseLocks(factory) {
225
+ let opened;
226
+ const database = () => (opened ??= new Promise((resolve, reject) => {
227
+ const request = factory.open(LEASE_DB, 1);
228
+ request.onupgradeneeded = () => request.result.createObjectStore(LEASE_STORE);
229
+ request.onsuccess = () => resolve(request.result);
230
+ request.onerror = () => reject(request.error);
231
+ }).catch((error) => {
232
+ opened = undefined;
233
+ throw error;
234
+ }));
235
+ return {
236
+ request: (name, options, work) => tabLocks.request(name, options, async (tabLock) => {
237
+ if (!tabLock)
238
+ return work(null);
239
+ let db;
240
+ try {
241
+ db = await database();
242
+ }
243
+ catch {
244
+ // IndexedDB refused (as in some private windows): this tab only.
245
+ return work(tabLock);
246
+ }
247
+ const owner = globalThis.crypto.randomUUID();
248
+ while (!(await claimLease(db, name, owner))) {
249
+ if (options.ifAvailable)
250
+ return work(null);
251
+ await new Promise((resolve) => setTimeout(resolve, LEASE_POLL_MS));
252
+ }
253
+ const renew = setInterval(() => void claimLease(db, name, owner).catch(() => { }), LEASE_RENEW_MS);
254
+ try {
255
+ return await work(tabLock);
256
+ }
257
+ finally {
258
+ clearInterval(renew);
259
+ await dropLease(db, name, owner).catch(() => { });
260
+ }
261
+ }),
262
+ };
263
+ }
264
+ let fallbackLocks;
265
+ /**
266
+ * The browser's Web Locks. Where a browser or wallet webview has none, an
267
+ * IndexedDB lease shared by the origin's tabs (one transaction, no visible
268
+ * delay; it waits only while another tab holds the action), with a one-time
269
+ * console note. With neither, a lock within this tab. Never refuses.
270
+ */
271
+ export function browserLocks() {
272
+ const native = globalThis.navigator?.locks;
273
+ if (native)
274
+ return native;
275
+ if (!tabLocksNoted) {
276
+ tabLocksNoted = true;
277
+ console.info(globalThis.indexedDB
278
+ ? "Web Locks are unavailable, so wallet writes are coordinated across tabs through IndexedDB."
279
+ : "Web Locks are unavailable, so wallet writes are coordinated within this tab only.");
280
+ }
281
+ if (!globalThis.indexedDB)
282
+ return tabLocks;
283
+ return (fallbackLocks ??= leaseLocks(globalThis.indexedDB));
284
+ }
132
285
  /**
133
- * One ordinary-write reservation per account/chain/target/selector by default,
134
- * or per account/chain/explicit stable namespace when provided. Use the
135
- * existing domain journal instead when an action already has one. There is no
136
- * expiry or nonce-based release: an unknown broadcast remains unknown.
286
+ * One ordinary-write reservation per exact call (account, chain, target,
287
+ * value and calldata) by default, or per account/chain/explicit stable
288
+ * namespace when provided. Use the existing domain journal instead when an
289
+ * action already has one. A hold ends on proof ({@link checkReviewedWrite} via
290
+ * `resolve`) or when the user confirms they checked their wallet (`abandon`).
137
291
  *
138
292
  * Every mutation must run inside withLock, including receipt-based clearing.
139
- * Storage and native cross-tab exclusion are mandatory before opening a wallet.
293
+ * Storage is mandatory before opening a wallet; without Web Locks the lock
294
+ * is an IndexedDB lease across tabs ({@link browserLocks}).
140
295
  */
141
296
  export function createBrowserWriteRecovery(input, options = {}) {
142
297
  const reviewed = normalized(input);
143
298
  const key = scope(reviewed);
299
+ const legacyKey = reviewed.namespace === undefined ? legacyScope(reviewed) : undefined;
144
300
  let owned;
145
301
  let locked = false;
146
302
  let lastStorage;
303
+ /** An adopted call (a reused Safe proposal) is kept under its reviewed call's key. */
304
+ function keyOf(record) {
305
+ return record.namespace === undefined &&
306
+ legacyKey !== undefined &&
307
+ legacyScope(record) === legacyKey
308
+ ? key
309
+ : scope(record);
310
+ }
311
+ /**
312
+ * Where this call's record lives, with its raw value: its own key, or an
313
+ * exact match saved under the old key. Reads its own key exactly once.
314
+ */
315
+ function locate(store) {
316
+ const raw = store.getItem(key);
317
+ if (raw !== null || !legacyKey)
318
+ return { at: key, raw };
319
+ try {
320
+ const old = store.getItem(legacyKey);
321
+ const legacy = readStored(old, legacyKey, legacyScope, store);
322
+ if (legacy && sameReviewedWrite(legacy, reviewed))
323
+ return { at: legacyKey, raw: old };
324
+ }
325
+ catch {
326
+ // An unreadable old record names no call, so it holds none.
327
+ }
328
+ return { at: key, raw };
329
+ }
330
+ function scopeAt(at) {
331
+ return at === key ? keyOf : legacyScope;
332
+ }
147
333
  function storage() {
148
334
  const store = recoveryStorage(options.storage);
149
335
  lastStorage = store;
150
336
  return store;
151
337
  }
152
- function rememberSubmission(record, verifiedReservation = false) {
338
+ function rememberSubmission(record, at, verifiedReservation = false) {
153
339
  if (!lastStorage)
154
340
  return;
155
341
  let pending = unpersistedSubmissions.get(lastStorage);
156
- const previous = pending?.get(key);
342
+ const previous = pending?.get(at);
157
343
  // An unreadable stale callback cannot replace another attempt's only hash.
158
344
  if (previous &&
159
345
  !verifiedReservation &&
@@ -161,37 +347,44 @@ export function createBrowserWriteRecovery(input, options = {}) {
161
347
  return;
162
348
  if (!pending)
163
349
  unpersistedSubmissions.set(lastStorage, (pending = new Map()));
164
- pending.set(key, recordOf(record));
350
+ pending.set(at, recordOf(record));
165
351
  }
166
352
  function requireLock() {
167
353
  if (!locked)
168
354
  throw new Error("Write recovery changes require the browser lock.");
169
355
  }
170
356
  function read() {
171
- return readRecovery(storage(), key);
357
+ const store = storage();
358
+ const { at, raw } = locate(store);
359
+ return readRecovery(store, at, raw, scopeAt(at));
172
360
  }
173
- function persist(record) {
361
+ function persist(record, at) {
174
362
  const raw = JSON.stringify(record);
175
363
  const store = storage();
176
- store.setItem(key, raw);
177
- if (store.getItem(key) !== raw)
364
+ store.setItem(at, raw);
365
+ if (store.getItem(at) !== raw)
178
366
  throw new Error("Write recovery storage did not retain the action.");
179
367
  }
180
368
  function clear(expected) {
181
369
  requireLock();
370
+ release(expected);
371
+ }
372
+ /** Remove exactly `expected`, refusing any other saved record. */
373
+ function release(expected) {
182
374
  // A volatile returned hash is useful for tracking, but is not a successful
183
375
  // durable commit. submitted(hash, record) must retry that commit first.
184
- const current = readStored(storage().getItem(key), key);
376
+ const store = storage();
377
+ const { at, raw } = locate(store);
378
+ const current = readStored(raw, at, scopeAt(at), store);
185
379
  if (!current)
186
380
  return;
187
381
  if (JSON.stringify(current) !== JSON.stringify(recordOf(expected))) {
188
382
  throw new Error("Write recovery changed. Keep the current action pending.");
189
383
  }
190
- const store = storage();
191
- store.removeItem(key);
192
- if (store.getItem(key) !== null)
384
+ store.removeItem(at);
385
+ if (store.getItem(at) !== null)
193
386
  throw new Error("Write recovery storage did not clear the action.");
194
- unpersistedSubmissions.get(store)?.delete(key);
387
+ unpersistedSubmissions.get(store)?.delete(at);
195
388
  }
196
389
  function rejected() {
197
390
  requireLock();
@@ -204,27 +397,113 @@ export function createBrowserWriteRecovery(input, options = {}) {
204
397
  clear(owned);
205
398
  owned = undefined;
206
399
  }
400
+ /**
401
+ * Save the nonce the chain showed for the saved hash. It is extra evidence:
402
+ * a failed save keeps the record as it was.
403
+ */
404
+ function bind(saved, sentNonce) {
405
+ const bound = recordOf({ ...saved, sentNonce });
406
+ try {
407
+ const store = storage();
408
+ const { at, raw } = locate(store);
409
+ const current = readStored(raw, at, scopeAt(at));
410
+ if (JSON.stringify(current) !== JSON.stringify(recordOf(saved)))
411
+ return saved;
412
+ persist(bound, at);
413
+ return bound;
414
+ }
415
+ catch {
416
+ return saved;
417
+ }
418
+ }
419
+ /** `replace`: the exact call was found at its nonce under another hash, as after a wallet speed-up. */
420
+ function record(hash, expected, replace) {
421
+ requireLock();
422
+ if (!isBytes32(hash))
423
+ throw new Error("The wallet returned an invalid transaction identity.");
424
+ const previous = expected && recordOf(expected);
425
+ const submittedHash = hash.toLowerCase();
426
+ if (!previous ||
427
+ keyOf(previous) !== key ||
428
+ (!replace && previous.hash && previous.hash !== submittedHash)) {
429
+ throw new Error("Write recovery changed before submission was recorded.");
430
+ }
431
+ const submitted = { ...previous, hash: submittedHash };
432
+ let raw;
433
+ let at = key;
434
+ try {
435
+ ({ at, raw } = locate(storage()));
436
+ }
437
+ catch (cause) {
438
+ owned = submitted;
439
+ rememberSubmission(submitted, at);
440
+ throw new SubmittedWritePersistenceError(submitted, cause);
441
+ }
442
+ // A record the user released keeps its marker: a late hash leaves it released.
443
+ const current = readStored(raw, at, scopeAt(at));
444
+ if (!current ||
445
+ JSON.stringify({ ...current, hash: undefined }) !==
446
+ JSON.stringify({ ...previous, hash: undefined }) ||
447
+ (!replace && current.hash && current.hash !== submittedHash)) {
448
+ throw new Error("Write recovery changed before submission was recorded.");
449
+ }
450
+ owned = recordOf(submitted);
451
+ if (current.hash !== submittedHash) {
452
+ try {
453
+ persist(submitted, at);
454
+ }
455
+ catch (cause) {
456
+ rememberSubmission(submitted, at, true);
457
+ throw new SubmittedWritePersistenceError(submitted, cause);
458
+ }
459
+ }
460
+ if (lastStorage)
461
+ unpersistedSubmissions.get(lastStorage)?.delete(at);
462
+ return submitted;
463
+ }
207
464
  return {
208
465
  read,
209
- reserve(actualCall) {
466
+ /**
467
+ * Save the attempt before the wallet opens. `evidence`, the account's next
468
+ * nonce from {@link readReviewedWriteNonce}, lets `resolve` release it
469
+ * on proof from the chain.
470
+ */
471
+ reserve(actualCall, evidence) {
210
472
  requireLock();
211
473
  // A reused Safe proposal may have a different, already verified deadline.
212
474
  // Retain its actual call, without widening the unresolved-action scope.
213
475
  const actual = actualCall
214
476
  ? normalized({ ...reviewed, call: actualCall })
215
477
  : reviewed;
216
- if (callScope(actual) !== callScope(reviewed))
478
+ if (legacyScope(actual) !== legacyScope(reviewed))
217
479
  throw new Error("The adopted write has a different recovery scope.");
218
- if (read())
480
+ const nonce = evidence && uint256(evidence.nonce);
481
+ const fromBlock = evidence && uint256(evidence.blockNumber);
482
+ if (evidence && (nonce === null || fromBlock === null))
483
+ throw new Error("Invalid write recovery nonce evidence.");
484
+ const store = storage();
485
+ const { at, raw } = locate(store);
486
+ if (readRecovery(store, at, raw, scopeAt(at)))
219
487
  throw new Error("An earlier wallet write is still unresolved. Check it before trying again.");
488
+ // The record this one replaces, if the user released it: its marker goes too.
489
+ let replaced;
490
+ try {
491
+ replaced = at === key && raw !== null ? JSON.parse(raw)?.id : undefined;
492
+ }
493
+ catch {
494
+ /* An unreadable record leaves no marker to clean. */
495
+ }
220
496
  const record = {
221
497
  version: 1,
222
498
  id: globalThis.crypto.randomUUID(),
223
499
  ...actual,
500
+ ...(evidence
501
+ ? { nonce: nonce.toString(), fromBlock: fromBlock.toString() }
502
+ : {}),
224
503
  };
225
504
  owned = recordOf(record);
226
505
  try {
227
- persist(record);
506
+ persist(record, key);
228
507
  }
229
508
  catch (error) {
230
509
  // The wallet has not been invoked. A failed readback may still have
@@ -237,56 +516,74 @@ export function createBrowserWriteRecovery(input, options = {}) {
237
516
  }
238
517
  throw error;
239
518
  }
240
- return recordOf(record);
241
- },
242
- submitted(hash, expected = owned) {
243
- requireLock();
244
- if (!isBytes32(hash))
245
- throw new Error("The wallet returned an invalid transaction identity.");
246
- const previous = expected && recordOf(expected);
247
- const submittedHash = hash.toLowerCase();
248
- if (!previous ||
249
- scope(previous) !== key ||
250
- (previous.hash && previous.hash !== submittedHash)) {
251
- throw new Error("Write recovery changed before submission was recorded.");
252
- }
253
- const submitted = { ...previous, hash: submittedHash };
254
- let raw;
255
- try {
256
- raw = storage().getItem(key);
257
- }
258
- catch (cause) {
259
- owned = submitted;
260
- rememberSubmission(submitted);
261
- throw new SubmittedWritePersistenceError(submitted, cause);
262
- }
263
- const current = readStored(raw, key);
264
- if (!current ||
265
- JSON.stringify({ ...current, hash: undefined }) !==
266
- JSON.stringify({ ...previous, hash: undefined }) ||
267
- (current.hash && current.hash !== submittedHash)) {
268
- throw new Error("Write recovery changed before submission was recorded.");
269
- }
270
- owned = recordOf(submitted);
271
- if (!current.hash) {
519
+ if (typeof replaced === "string" && replaced !== record.id)
272
520
  try {
273
- persist(submitted);
521
+ store.removeItem(releasedKey(key, replaced));
274
522
  }
275
- catch (cause) {
276
- rememberSubmission(submitted, true);
277
- throw new SubmittedWritePersistenceError(submitted, cause);
523
+ catch {
524
+ /* A leftover marker names only the old record. */
278
525
  }
279
- }
280
- if (lastStorage)
281
- unpersistedSubmissions.get(lastStorage)?.delete(key);
282
- return submitted;
526
+ return recordOf(record);
527
+ },
528
+ submitted(hash, expected = owned) {
529
+ return record(hash, expected, false);
283
530
  },
284
531
  rejected,
285
532
  clear,
533
+ /**
534
+ * Check the saved write against the chain ({@link checkReviewedWrite}) and
535
+ * apply the finding: `sent` records the hash to track, `ran` releases it.
536
+ * Null when nothing is saved.
537
+ */
538
+ async resolve(client, checkOptions) {
539
+ requireLock();
540
+ let saved = read();
541
+ if (!saved)
542
+ return null;
543
+ const { check, sentNonce } = await inspectWrite(client, saved, checkOptions?.effect);
544
+ if (sentNonce !== undefined && saved.sentNonce !== sentNonce)
545
+ saved = bind(saved, sentNonce);
546
+ if (check.status === "sent") {
547
+ if (check.hash !== saved.hash)
548
+ record(check.hash, saved, true);
549
+ }
550
+ else if (check.status !== "pending")
551
+ clear(saved);
552
+ return check;
553
+ },
554
+ /**
555
+ * The user confirmed they checked their wallet; release the write. It
556
+ * marks only the record `expected` names (by its ID) as released and never
557
+ * removes the saved key, so a stale exit can't erase a newer write another
558
+ * tab saved there. It needs no lock and still works when the cross-tab lock
559
+ * can't be taken; new sends still need `withLock`. `expected` may be what
560
+ * `read()` showed, including a returned hash kept only in memory.
561
+ */
562
+ abandon(expected, acknowledgement) {
563
+ if (acknowledgement?.checkedWallet !== true)
564
+ throw new Error("Release a held write only after the user confirms they checked their wallet.");
565
+ const wanted = JSON.stringify(recordOf(expected));
566
+ const store = storage();
567
+ const { at, raw } = locate(store);
568
+ const durable = readStored(raw, at, scopeAt(at), store);
569
+ if (!durable)
570
+ return;
571
+ const shown = readRecovery(store, at, raw, scopeAt(at));
572
+ if (JSON.stringify(durable) !== wanted &&
573
+ JSON.stringify(shown) !== wanted)
574
+ throw new Error("Write recovery changed. Keep the current action pending.");
575
+ const marker = releasedKey(at, durable.id);
576
+ store.setItem(marker, "1");
577
+ if (store.getItem(marker) !== "1")
578
+ throw new Error("Write recovery storage did not release the action.");
579
+ const memory = unpersistedSubmissions.get(store);
580
+ if (memory?.get(at)?.id === durable.id)
581
+ memory.delete(at);
582
+ if (owned?.id === durable.id)
583
+ owned = undefined;
584
+ },
286
585
  async withLock(work, wait = false) {
287
- const locks = options.locks ?? globalThis.navigator?.locks;
288
- if (!locks)
289
- throw new Error("This browser cannot safely coordinate wallet writes.");
586
+ const locks = options.locks ?? browserLocks();
290
587
  return locks.request(key, { mode: "exclusive", ...(!wait && { ifAvailable: true }) }, async (lock) => {
291
588
  if (!lock)
292
589
  throw new Error("This wallet action is already open in another tab.");
@@ -301,6 +598,109 @@ export function createBrowserWriteRecovery(input, options = {}) {
301
598
  },
302
599
  };
303
600
  }
601
+ /** The account's next nonce, counting its pending transactions, and the block it was read at. Read it right before `reserve`. */
602
+ export async function readReviewedWriteNonce(client, account) {
603
+ const blockNumber = await client.getBlockNumber();
604
+ const nonce = await client.getTransactionCount({
605
+ address: account,
606
+ blockTag: "pending",
607
+ });
608
+ return { nonce, blockNumber };
609
+ }
610
+ /**
611
+ * What the chain shows of a saved ordinary write. Every read is on the
612
+ * record's chain, at or below a canonical finalized block where it matters.
613
+ *
614
+ * - The saved hash, when the node knows it, is the transaction: `sent` once
615
+ * mined (its receipt decides, wallet wrappers included), `pending` while in
616
+ * the mempool. Its nonce is the nonce it was sent at (`sentNonce`).
617
+ * - Otherwise, once the account's nonce is past the nonce to look at, the
618
+ * block where it moved holds the transaction that used it. The saved hash or
619
+ * the exact call there is `sent`. Any other transaction there may have run
620
+ * the call inside it (a wrapper, or an EIP-7702 wallet that ran code and
621
+ * cleared its delegation in the same block), so it stays `pending` and the
622
+ * user's exit (`abandon`) ends it.
623
+ * - `effect`, the app's read of the call's result on the saved chain, ends it
624
+ * as `ran` when true. The client's chain is checked first.
625
+ *
626
+ * Anything else stays `pending`: a Safe write, a record without nonce
627
+ * evidence, another chain, or a node without the history to answer.
628
+ */
629
+ export async function checkReviewedWrite(client, saved, { effect } = {}) {
630
+ return (await inspectWrite(client, saved, effect)).check;
631
+ }
632
+ async function inspectWrite(client, saved, effect) {
633
+ const record = recordOf(saved);
634
+ const pending = { check: { status: "pending" } };
635
+ // No proof, the app's effect read included, from another chain.
636
+ if ((await client.getChainId().catch(() => null)) !== record.chainId)
637
+ return pending;
638
+ if (effect && (await effect(record.chainId).catch(() => false)))
639
+ return { check: { status: "ran" } };
640
+ if (record.safe)
641
+ return pending;
642
+ if (record.hash) {
643
+ const sent = await client.getTransaction({ hash: record.hash }).then((tx) => tx, (error) => error?.name === "TransactionNotFoundError" ? null : undefined);
644
+ if (sent === undefined)
645
+ return pending;
646
+ if (sent) {
647
+ if (sent.from?.toLowerCase() !== record.account)
648
+ return pending;
649
+ return {
650
+ check: sent.blockNumber !== null && sent.blockNumber !== undefined
651
+ ? { status: "sent", hash: record.hash }
652
+ : { status: "pending" },
653
+ sentNonce: BigInt(sent.nonce).toString(),
654
+ };
655
+ }
656
+ }
657
+ const bound = record.hash !== undefined && record.sentNonce !== undefined;
658
+ const target = bound ? record.sentNonce : record.nonce;
659
+ if (target === undefined || record.fromBlock === undefined)
660
+ return pending;
661
+ const nonce = BigInt(target);
662
+ const count = async (blockNumber) => BigInt(await client.getTransactionCount({
663
+ address: record.account,
664
+ blockNumber,
665
+ }));
666
+ const proof = await atCanonicalFinalizedBlock(client, async (finalized) => {
667
+ let low = BigInt(record.fromBlock);
668
+ let high = finalized;
669
+ if (high <= low ||
670
+ (await count(high)) <= nonce ||
671
+ (await count(low)) > nonce)
672
+ return null;
673
+ while (high - low > 1n) {
674
+ const middle = (low + high) / 2n;
675
+ if ((await count(middle)) > nonce)
676
+ high = middle;
677
+ else
678
+ low = middle;
679
+ }
680
+ const block = await client.getBlock({
681
+ blockNumber: high,
682
+ includeTransactions: true,
683
+ });
684
+ const used = block.number === high
685
+ ? block.transactions.find((tx) => tx.from?.toLowerCase() === record.account &&
686
+ BigInt(tx.nonce) === nonce)
687
+ : undefined;
688
+ if (!used || !isBytes32(used.hash))
689
+ return null;
690
+ const hash = used.hash.toLowerCase();
691
+ if (hash === record.hash)
692
+ return { status: "sent", hash };
693
+ if (used.to?.toLowerCase() === record.call.to &&
694
+ used.input?.toLowerCase() === record.call.data &&
695
+ BigInt(used.value ?? 0) === BigInt(record.call.value))
696
+ return { status: "sent", hash };
697
+ // Another transaction at that nonce may have run the call inside it
698
+ // (a wrapper, or an EIP-7702 wallet that ran code and then cleared its
699
+ // delegation), so it proves nothing. The user's exit ends it.
700
+ return null;
701
+ });
702
+ return { check: proof?.value ?? pending.check };
703
+ }
304
704
  /**
305
705
  * Authenticate the saved wallet identity, canonical inclusion and Safe effect.
306
706
  * Ordinary wallet wrappers are allowed: the wallet's returned hash binds their