@bananapus/nana-sdk-core 2.29.0 → 2.31.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.
- package/dist/cjs/review/index.d.ts +1 -0
- package/dist/cjs/review/index.d.ts.map +1 -1
- package/dist/cjs/review/index.js +1 -0
- package/dist/cjs/review/index.js.map +1 -1
- package/dist/cjs/review/messages.d.ts +43 -0
- package/dist/cjs/review/messages.d.ts.map +1 -0
- package/dist/cjs/review/messages.js +55 -0
- package/dist/cjs/review/messages.js.map +1 -0
- package/dist/cjs/review/relayr.d.ts +43 -7
- package/dist/cjs/review/relayr.d.ts.map +1 -1
- package/dist/cjs/review/relayr.js +141 -8
- package/dist/cjs/review/relayr.js.map +1 -1
- package/dist/cjs/review/safeRelayr.d.ts.map +1 -1
- package/dist/cjs/review/safeRelayr.js +23 -3
- package/dist/cjs/review/safeRelayr.js.map +1 -1
- package/dist/cjs/review/transaction-message.js +2 -2
- package/dist/cjs/review/transaction-message.js.map +1 -1
- package/dist/cjs/review/writeRecovery.d.ts +92 -7
- package/dist/cjs/review/writeRecovery.d.ts.map +1 -1
- package/dist/cjs/review/writeRecovery.js +473 -70
- package/dist/cjs/review/writeRecovery.js.map +1 -1
- package/dist/cjs/safeService.d.ts +18 -12
- package/dist/cjs/safeService.d.ts.map +1 -1
- package/dist/cjs/safeService.js +97 -9
- package/dist/cjs/safeService.js.map +1 -1
- package/dist/esm/review/index.d.ts +1 -0
- package/dist/esm/review/index.d.ts.map +1 -1
- package/dist/esm/review/index.js +1 -0
- package/dist/esm/review/index.js.map +1 -1
- package/dist/esm/review/messages.d.ts +43 -0
- package/dist/esm/review/messages.d.ts.map +1 -0
- package/dist/esm/review/messages.js +48 -0
- package/dist/esm/review/messages.js.map +1 -0
- package/dist/esm/review/relayr.d.ts +43 -7
- package/dist/esm/review/relayr.d.ts.map +1 -1
- package/dist/esm/review/relayr.js +139 -7
- package/dist/esm/review/relayr.js.map +1 -1
- package/dist/esm/review/safeRelayr.d.ts.map +1 -1
- package/dist/esm/review/safeRelayr.js +23 -3
- package/dist/esm/review/safeRelayr.js.map +1 -1
- package/dist/esm/review/transaction-message.js +2 -2
- package/dist/esm/review/transaction-message.js.map +1 -1
- package/dist/esm/review/writeRecovery.d.ts +92 -7
- package/dist/esm/review/writeRecovery.d.ts.map +1 -1
- package/dist/esm/review/writeRecovery.js +472 -72
- package/dist/esm/review/writeRecovery.js.map +1 -1
- package/dist/esm/safeService.d.ts +18 -12
- package/dist/esm/safeService.d.ts.map +1 -1
- package/dist/esm/safeService.js +97 -9
- package/dist/esm/safeService.js.map +1 -1
- package/package.json +3 -2
|
@@ -4,7 +4,10 @@ exports.SubmittedWritePersistenceError = void 0;
|
|
|
4
4
|
exports.parseReviewedWriteRecoveryRecord = recordOf;
|
|
5
5
|
exports.readBrowserWriteRecovery = readBrowserWriteRecovery;
|
|
6
6
|
exports.sameReviewedWrite = sameReviewedWrite;
|
|
7
|
+
exports.browserLocks = browserLocks;
|
|
7
8
|
exports.createBrowserWriteRecovery = createBrowserWriteRecovery;
|
|
9
|
+
exports.readReviewedWriteNonce = readReviewedWriteNonce;
|
|
10
|
+
exports.checkReviewedWrite = checkReviewedWrite;
|
|
8
11
|
exports.verifyReviewedWriteReceipt = verifyReviewedWriteReceipt;
|
|
9
12
|
exports.verifyReviewedWriteExpiry = verifyReviewedWriteExpiry;
|
|
10
13
|
const viem_1 = require("viem");
|
|
@@ -60,7 +63,10 @@ function recordOf(value) {
|
|
|
60
63
|
!record.id ||
|
|
61
64
|
typeof record.call?.value !== "string" ||
|
|
62
65
|
!/^(0|[1-9]\d*)$/.test(record.call.value) ||
|
|
63
|
-
(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))
|
|
64
70
|
throw new Error("Invalid saved write recovery record. Keep this action pending.");
|
|
65
71
|
return {
|
|
66
72
|
version: 1,
|
|
@@ -69,10 +75,24 @@ function recordOf(value) {
|
|
|
69
75
|
...(record.hash === undefined
|
|
70
76
|
? {}
|
|
71
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 }),
|
|
72
81
|
};
|
|
73
82
|
}
|
|
74
|
-
|
|
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
|
+
*/
|
|
75
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) {
|
|
76
96
|
return `nana-sdk:reviewed-write:v1:${input.chainId}:${input.account}:${input.call.to}:${input.call.data.slice(0, 10)}`;
|
|
77
97
|
}
|
|
78
98
|
function namespaceOf(value) {
|
|
@@ -109,16 +129,26 @@ function readBrowserWriteRecovery(input, options = {}) {
|
|
|
109
129
|
throw new Error("Provide a write recovery namespace.");
|
|
110
130
|
return readRecovery(recoveryStorage(options.storage), namespaceScope({ ...identity, namespace }));
|
|
111
131
|
}
|
|
112
|
-
|
|
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) {
|
|
113
141
|
if (raw === null)
|
|
114
142
|
return null;
|
|
115
143
|
const record = recordOf(JSON.parse(raw));
|
|
116
|
-
if (
|
|
144
|
+
if (scopeOf(record) !== key)
|
|
117
145
|
throw new Error("Saved write recovery scope does not match.");
|
|
146
|
+
if (store && store.getItem(releasedKey(key, record.id)) !== null)
|
|
147
|
+
return null;
|
|
118
148
|
return record;
|
|
119
149
|
}
|
|
120
|
-
function readRecovery(store, key) {
|
|
121
|
-
const record = readStored(
|
|
150
|
+
function readRecovery(store, key, raw = store.getItem(key), scopeOf = scope) {
|
|
151
|
+
const record = readStored(raw, key, scopeOf, store);
|
|
122
152
|
const memory = unpersistedSubmissions.get(store)?.get(key);
|
|
123
153
|
if (!record ||
|
|
124
154
|
!memory ||
|
|
@@ -137,31 +167,190 @@ function readRecovery(store, key) {
|
|
|
137
167
|
function sameReviewedWrite(record, input) {
|
|
138
168
|
return (JSON.stringify(normalized(record)) === JSON.stringify(normalized(input)));
|
|
139
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;
|
|
276
|
+
/**
|
|
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
|
+
}
|
|
140
296
|
/**
|
|
141
|
-
* One ordinary-write reservation per account
|
|
142
|
-
* or per account/chain/explicit stable
|
|
143
|
-
* existing domain journal instead when an
|
|
144
|
-
*
|
|
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`).
|
|
145
302
|
*
|
|
146
303
|
* Every mutation must run inside withLock, including receipt-based clearing.
|
|
147
|
-
* Storage
|
|
304
|
+
* Storage is mandatory before opening a wallet; without Web Locks the lock
|
|
305
|
+
* is an IndexedDB lease across tabs ({@link browserLocks}).
|
|
148
306
|
*/
|
|
149
307
|
function createBrowserWriteRecovery(input, options = {}) {
|
|
150
308
|
const reviewed = normalized(input);
|
|
151
309
|
const key = scope(reviewed);
|
|
310
|
+
const legacyKey = reviewed.namespace === undefined ? legacyScope(reviewed) : undefined;
|
|
152
311
|
let owned;
|
|
153
312
|
let locked = false;
|
|
154
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
|
+
}
|
|
155
344
|
function storage() {
|
|
156
345
|
const store = recoveryStorage(options.storage);
|
|
157
346
|
lastStorage = store;
|
|
158
347
|
return store;
|
|
159
348
|
}
|
|
160
|
-
function rememberSubmission(record, verifiedReservation = false) {
|
|
349
|
+
function rememberSubmission(record, at, verifiedReservation = false) {
|
|
161
350
|
if (!lastStorage)
|
|
162
351
|
return;
|
|
163
352
|
let pending = unpersistedSubmissions.get(lastStorage);
|
|
164
|
-
const previous = pending?.get(
|
|
353
|
+
const previous = pending?.get(at);
|
|
165
354
|
// An unreadable stale callback cannot replace another attempt's only hash.
|
|
166
355
|
if (previous &&
|
|
167
356
|
!verifiedReservation &&
|
|
@@ -169,37 +358,44 @@ function createBrowserWriteRecovery(input, options = {}) {
|
|
|
169
358
|
return;
|
|
170
359
|
if (!pending)
|
|
171
360
|
unpersistedSubmissions.set(lastStorage, (pending = new Map()));
|
|
172
|
-
pending.set(
|
|
361
|
+
pending.set(at, recordOf(record));
|
|
173
362
|
}
|
|
174
363
|
function requireLock() {
|
|
175
364
|
if (!locked)
|
|
176
365
|
throw new Error("Write recovery changes require the browser lock.");
|
|
177
366
|
}
|
|
178
367
|
function read() {
|
|
179
|
-
|
|
368
|
+
const store = storage();
|
|
369
|
+
const { at, raw } = locate(store);
|
|
370
|
+
return readRecovery(store, at, raw, scopeAt(at));
|
|
180
371
|
}
|
|
181
|
-
function persist(record) {
|
|
372
|
+
function persist(record, at) {
|
|
182
373
|
const raw = JSON.stringify(record);
|
|
183
374
|
const store = storage();
|
|
184
|
-
store.setItem(
|
|
185
|
-
if (store.getItem(
|
|
375
|
+
store.setItem(at, raw);
|
|
376
|
+
if (store.getItem(at) !== raw)
|
|
186
377
|
throw new Error("Write recovery storage did not retain the action.");
|
|
187
378
|
}
|
|
188
379
|
function clear(expected) {
|
|
189
380
|
requireLock();
|
|
381
|
+
release(expected);
|
|
382
|
+
}
|
|
383
|
+
/** Remove exactly `expected`, refusing any other saved record. */
|
|
384
|
+
function release(expected) {
|
|
190
385
|
// A volatile returned hash is useful for tracking, but is not a successful
|
|
191
386
|
// durable commit. submitted(hash, record) must retry that commit first.
|
|
192
|
-
const
|
|
387
|
+
const store = storage();
|
|
388
|
+
const { at, raw } = locate(store);
|
|
389
|
+
const current = readStored(raw, at, scopeAt(at), store);
|
|
193
390
|
if (!current)
|
|
194
391
|
return;
|
|
195
392
|
if (JSON.stringify(current) !== JSON.stringify(recordOf(expected))) {
|
|
196
393
|
throw new Error("Write recovery changed. Keep the current action pending.");
|
|
197
394
|
}
|
|
198
|
-
|
|
199
|
-
store.
|
|
200
|
-
if (store.getItem(key) !== null)
|
|
395
|
+
store.removeItem(at);
|
|
396
|
+
if (store.getItem(at) !== null)
|
|
201
397
|
throw new Error("Write recovery storage did not clear the action.");
|
|
202
|
-
unpersistedSubmissions.get(store)?.delete(
|
|
398
|
+
unpersistedSubmissions.get(store)?.delete(at);
|
|
203
399
|
}
|
|
204
400
|
function rejected() {
|
|
205
401
|
requireLock();
|
|
@@ -212,27 +408,113 @@ function createBrowserWriteRecovery(input, options = {}) {
|
|
|
212
408
|
clear(owned);
|
|
213
409
|
owned = undefined;
|
|
214
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
|
+
}
|
|
215
475
|
return {
|
|
216
476
|
read,
|
|
217
|
-
|
|
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) {
|
|
218
483
|
requireLock();
|
|
219
484
|
// A reused Safe proposal may have a different, already verified deadline.
|
|
220
485
|
// Retain its actual call, without widening the unresolved-action scope.
|
|
221
486
|
const actual = actualCall
|
|
222
487
|
? normalized({ ...reviewed, call: actualCall })
|
|
223
488
|
: reviewed;
|
|
224
|
-
if (
|
|
489
|
+
if (legacyScope(actual) !== legacyScope(reviewed))
|
|
225
490
|
throw new Error("The adopted write has a different recovery scope.");
|
|
226
|
-
|
|
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)))
|
|
227
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
|
+
}
|
|
228
507
|
const record = {
|
|
229
508
|
version: 1,
|
|
230
509
|
id: globalThis.crypto.randomUUID(),
|
|
231
510
|
...actual,
|
|
511
|
+
...(evidence
|
|
512
|
+
? { nonce: nonce.toString(), fromBlock: fromBlock.toString() }
|
|
513
|
+
: {}),
|
|
232
514
|
};
|
|
233
515
|
owned = recordOf(record);
|
|
234
516
|
try {
|
|
235
|
-
persist(record);
|
|
517
|
+
persist(record, key);
|
|
236
518
|
}
|
|
237
519
|
catch (error) {
|
|
238
520
|
// The wallet has not been invoked. A failed readback may still have
|
|
@@ -245,56 +527,74 @@ function createBrowserWriteRecovery(input, options = {}) {
|
|
|
245
527
|
}
|
|
246
528
|
throw error;
|
|
247
529
|
}
|
|
248
|
-
|
|
249
|
-
},
|
|
250
|
-
submitted(hash, expected = owned) {
|
|
251
|
-
requireLock();
|
|
252
|
-
if (!(0, untrusted_js_1.isBytes32)(hash))
|
|
253
|
-
throw new Error("The wallet returned an invalid transaction identity.");
|
|
254
|
-
const previous = expected && recordOf(expected);
|
|
255
|
-
const submittedHash = hash.toLowerCase();
|
|
256
|
-
if (!previous ||
|
|
257
|
-
scope(previous) !== key ||
|
|
258
|
-
(previous.hash && previous.hash !== submittedHash)) {
|
|
259
|
-
throw new Error("Write recovery changed before submission was recorded.");
|
|
260
|
-
}
|
|
261
|
-
const submitted = { ...previous, hash: submittedHash };
|
|
262
|
-
let raw;
|
|
263
|
-
try {
|
|
264
|
-
raw = storage().getItem(key);
|
|
265
|
-
}
|
|
266
|
-
catch (cause) {
|
|
267
|
-
owned = submitted;
|
|
268
|
-
rememberSubmission(submitted);
|
|
269
|
-
throw new SubmittedWritePersistenceError(submitted, cause);
|
|
270
|
-
}
|
|
271
|
-
const current = readStored(raw, key);
|
|
272
|
-
if (!current ||
|
|
273
|
-
JSON.stringify({ ...current, hash: undefined }) !==
|
|
274
|
-
JSON.stringify({ ...previous, hash: undefined }) ||
|
|
275
|
-
(current.hash && current.hash !== submittedHash)) {
|
|
276
|
-
throw new Error("Write recovery changed before submission was recorded.");
|
|
277
|
-
}
|
|
278
|
-
owned = recordOf(submitted);
|
|
279
|
-
if (!current.hash) {
|
|
530
|
+
if (typeof replaced === "string" && replaced !== record.id)
|
|
280
531
|
try {
|
|
281
|
-
|
|
532
|
+
store.removeItem(releasedKey(key, replaced));
|
|
282
533
|
}
|
|
283
|
-
catch
|
|
284
|
-
|
|
285
|
-
throw new SubmittedWritePersistenceError(submitted, cause);
|
|
534
|
+
catch {
|
|
535
|
+
/* A leftover marker names only the old record. */
|
|
286
536
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
return
|
|
537
|
+
return recordOf(record);
|
|
538
|
+
},
|
|
539
|
+
submitted(hash, expected = owned) {
|
|
540
|
+
return record(hash, expected, false);
|
|
291
541
|
},
|
|
292
542
|
rejected,
|
|
293
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
|
+
},
|
|
294
596
|
async withLock(work, wait = false) {
|
|
295
|
-
const locks = options.locks ??
|
|
296
|
-
if (!locks)
|
|
297
|
-
throw new Error("This browser cannot safely coordinate wallet writes.");
|
|
597
|
+
const locks = options.locks ?? browserLocks();
|
|
298
598
|
return locks.request(key, { mode: "exclusive", ...(!wait && { ifAvailable: true }) }, async (lock) => {
|
|
299
599
|
if (!lock)
|
|
300
600
|
throw new Error("This wallet action is already open in another tab.");
|
|
@@ -309,6 +609,109 @@ function createBrowserWriteRecovery(input, options = {}) {
|
|
|
309
609
|
},
|
|
310
610
|
};
|
|
311
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
|
+
}
|
|
312
715
|
/**
|
|
313
716
|
* Authenticate the saved wallet identity, canonical inclusion and Safe effect.
|
|
314
717
|
* Ordinary wallet wrappers are allowed: the wallet's returned hash binds their
|