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