@bananapus/nana-sdk-core 2.30.0 → 2.32.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 +16 -0
- package/dist/cjs/review/messages.d.ts.map +1 -1
- package/dist/cjs/review/messages.js +17 -1
- package/dist/cjs/review/messages.js.map +1 -1
- 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 +118 -7
- package/dist/cjs/review/relayr.js.map +1 -1
- package/dist/cjs/review/writeHold.d.ts +197 -0
- package/dist/cjs/review/writeHold.d.ts.map +1 -0
- package/dist/cjs/review/writeHold.js +578 -0
- package/dist/cjs/review/writeHold.js.map +1 -0
- package/dist/cjs/review/writeRecovery.d.ts +102 -8
- package/dist/cjs/review/writeRecovery.d.ts.map +1 -1
- package/dist/cjs/review/writeRecovery.js +341 -46
- package/dist/cjs/review/writeRecovery.js.map +1 -1
- package/dist/cjs/safeService.d.ts +40 -0
- package/dist/cjs/safeService.d.ts.map +1 -1
- package/dist/cjs/safeService.js +69 -12
- 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 +16 -0
- package/dist/esm/review/messages.d.ts.map +1 -1
- package/dist/esm/review/messages.js +16 -0
- package/dist/esm/review/messages.js.map +1 -1
- 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 +116 -6
- package/dist/esm/review/relayr.js.map +1 -1
- package/dist/esm/review/writeHold.d.ts +197 -0
- package/dist/esm/review/writeHold.d.ts.map +1 -0
- package/dist/esm/review/writeHold.js +571 -0
- package/dist/esm/review/writeHold.js.map +1 -0
- package/dist/esm/review/writeRecovery.d.ts +102 -8
- package/dist/esm/review/writeRecovery.d.ts.map +1 -1
- package/dist/esm/review/writeRecovery.js +339 -46
- package/dist/esm/review/writeRecovery.js.map +1 -1
- package/dist/esm/safeService.d.ts +40 -0
- package/dist/esm/safeService.d.ts.map +1 -1
- package/dist/esm/safeService.js +66 -12
- package/dist/esm/safeService.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,6 +2,9 @@ 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
4
|
import { atCanonicalFinalizedBlock, } from "./relayr.js";
|
|
5
|
+
const TAB_MEMORY = Symbol.for("nana-sdk:held-writes");
|
|
6
|
+
const TAB_QUEUES = Symbol.for("nana-sdk:write-lock-queues");
|
|
7
|
+
const TAB_SEEN = Symbol.for("nana-sdk:held-writes-seen");
|
|
5
8
|
// A returned hash must survive a component remount even when its durable update
|
|
6
9
|
// failed. It is visible only while the exact durable reservation still exists.
|
|
7
10
|
const unpersistedSubmissions = new WeakMap();
|
|
@@ -53,7 +56,16 @@ function recordOf(value) {
|
|
|
53
56
|
(record.hash !== undefined && !isBytes32(record.hash)) ||
|
|
54
57
|
!optionalDecimal(record.nonce) ||
|
|
55
58
|
!optionalDecimal(record.fromBlock) ||
|
|
56
|
-
!optionalDecimal(record.sentNonce)
|
|
59
|
+
!optionalDecimal(record.sentNonce) ||
|
|
60
|
+
!optionalDecimal(record.submittedAt) ||
|
|
61
|
+
!optionalDecimal(record.firstSeenAt) ||
|
|
62
|
+
!optionalDecimal(record.safeNonce) ||
|
|
63
|
+
(record.replaced !== undefined && record.replaced !== true) ||
|
|
64
|
+
(record.sentFrom !== undefined &&
|
|
65
|
+
!isAddress(record.sentFrom, { strict: false })) ||
|
|
66
|
+
(record.executed !== undefined && record.executed !== true) ||
|
|
67
|
+
(record.executionHash !== undefined &&
|
|
68
|
+
(record.executed !== true || !isBytes32(record.executionHash))))
|
|
57
69
|
throw new Error("Invalid saved write recovery record. Keep this action pending.");
|
|
58
70
|
return {
|
|
59
71
|
version: 1,
|
|
@@ -65,6 +77,21 @@ function recordOf(value) {
|
|
|
65
77
|
...(record.nonce === undefined ? {} : { nonce: record.nonce }),
|
|
66
78
|
...(record.fromBlock === undefined ? {} : { fromBlock: record.fromBlock }),
|
|
67
79
|
...(record.sentNonce === undefined ? {} : { sentNonce: record.sentNonce }),
|
|
80
|
+
...(record.submittedAt === undefined
|
|
81
|
+
? {}
|
|
82
|
+
: { submittedAt: record.submittedAt }),
|
|
83
|
+
...(record.firstSeenAt === undefined
|
|
84
|
+
? {}
|
|
85
|
+
: { firstSeenAt: record.firstSeenAt }),
|
|
86
|
+
...(record.safeNonce === undefined ? {} : { safeNonce: record.safeNonce }),
|
|
87
|
+
...(record.sentFrom === undefined
|
|
88
|
+
? {}
|
|
89
|
+
: { sentFrom: record.sentFrom.toLowerCase() }),
|
|
90
|
+
...(record.replaced ? { replaced: true } : {}),
|
|
91
|
+
...(record.executed ? { executed: true } : {}),
|
|
92
|
+
...(record.executionHash === undefined
|
|
93
|
+
? {}
|
|
94
|
+
: { executionHash: record.executionHash.toLowerCase() }),
|
|
68
95
|
};
|
|
69
96
|
}
|
|
70
97
|
function optionalDecimal(value) {
|
|
@@ -106,6 +133,136 @@ function recoveryStorage(store) {
|
|
|
106
133
|
throw new Error("Write recovery storage is unavailable.");
|
|
107
134
|
return result;
|
|
108
135
|
}
|
|
136
|
+
const PROBE_KEY = "nana-sdk:reviewed-write:probe";
|
|
137
|
+
function idOf(raw) {
|
|
138
|
+
try {
|
|
139
|
+
const id = JSON.parse(raw)?.id;
|
|
140
|
+
return typeof id === "string" ? id : undefined;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The browser's storage, shared by its tabs and kept across reloads, while it
|
|
148
|
+
* can be written; this tab's memory while it can't (private browsing, a
|
|
149
|
+
* sandboxed frame, some wallet webviews), so a write is held either way and
|
|
150
|
+
* never refused. Every read looks at both. A record kept in memory moves into
|
|
151
|
+
* the browser's storage as soon as it can be written there, after any other
|
|
152
|
+
* live record saved at the same key ends. While the browser's storage can't
|
|
153
|
+
* be read, what this tab last read or wrote there still holds.
|
|
154
|
+
*
|
|
155
|
+
* `memory` defaults to one map per tab, shared by every copy of this module
|
|
156
|
+
* loaded there.
|
|
157
|
+
*/
|
|
158
|
+
export function createHeldWriteStorage(options = {}) {
|
|
159
|
+
const durable = options.durable ??
|
|
160
|
+
(() => globalThis.localStorage);
|
|
161
|
+
const tab = globalThis;
|
|
162
|
+
const memory = options.memory ?? (tab[TAB_MEMORY] ??= new Map());
|
|
163
|
+
/**
|
|
164
|
+
* What this tab last saw in, or wrote to, durable storage (records,
|
|
165
|
+
* releases and deletions), shared by every controller and copy of this
|
|
166
|
+
* module using the same tab memory.
|
|
167
|
+
*/
|
|
168
|
+
const registry = (globalThis[TAB_SEEN] ??= new WeakMap());
|
|
169
|
+
let seen = registry.get(memory);
|
|
170
|
+
if (!seen)
|
|
171
|
+
registry.set(memory, (seen = new Map()));
|
|
172
|
+
const read = (key) => {
|
|
173
|
+
try {
|
|
174
|
+
const store = durable();
|
|
175
|
+
if (!store)
|
|
176
|
+
return undefined;
|
|
177
|
+
const value = store.getItem(key);
|
|
178
|
+
if (value === null)
|
|
179
|
+
seen.delete(key);
|
|
180
|
+
else
|
|
181
|
+
seen.set(key, value);
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const write = (key, value) => {
|
|
189
|
+
try {
|
|
190
|
+
const store = durable();
|
|
191
|
+
if (!store)
|
|
192
|
+
return false;
|
|
193
|
+
store.setItem(key, value);
|
|
194
|
+
if (store.getItem(key) !== value)
|
|
195
|
+
return false;
|
|
196
|
+
seen.set(key, value);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const getItem = (key) => {
|
|
204
|
+
const saved = read(key);
|
|
205
|
+
const kept = memory.get(key);
|
|
206
|
+
if (saved === undefined)
|
|
207
|
+
return kept ?? seen.get(key) ?? null;
|
|
208
|
+
if (kept === undefined)
|
|
209
|
+
return saved;
|
|
210
|
+
if (saved === kept) {
|
|
211
|
+
memory.delete(key);
|
|
212
|
+
return saved;
|
|
213
|
+
}
|
|
214
|
+
const savedId = saved === null ? undefined : idOf(saved);
|
|
215
|
+
// Another live record saved there first is held first; this one waits.
|
|
216
|
+
if (saved !== null &&
|
|
217
|
+
savedId !== idOf(kept) &&
|
|
218
|
+
!(savedId !== undefined && getItem(releasedKey(key, savedId)) !== null))
|
|
219
|
+
return saved;
|
|
220
|
+
if (write(key, kept))
|
|
221
|
+
memory.delete(key);
|
|
222
|
+
return kept;
|
|
223
|
+
};
|
|
224
|
+
return {
|
|
225
|
+
getItem,
|
|
226
|
+
setItem(key, value) {
|
|
227
|
+
const kept = memory.get(key);
|
|
228
|
+
if (write(key, value)) {
|
|
229
|
+
if (kept === value || (kept && idOf(kept) === idOf(value)))
|
|
230
|
+
memory.delete(key);
|
|
231
|
+
}
|
|
232
|
+
else
|
|
233
|
+
memory.set(key, value);
|
|
234
|
+
},
|
|
235
|
+
removeItem(key) {
|
|
236
|
+
const shown = getItem(key);
|
|
237
|
+
if (shown !== null && memory.get(key) === shown) {
|
|
238
|
+
memory.delete(key);
|
|
239
|
+
if (read(key) !== shown)
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
durable()?.removeItem(key);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
/* The readback below decides. */
|
|
247
|
+
}
|
|
248
|
+
if (read(key) === null)
|
|
249
|
+
seen.delete(key);
|
|
250
|
+
},
|
|
251
|
+
durable() {
|
|
252
|
+
const probe = String(Date.now());
|
|
253
|
+
if (!write(PROBE_KEY, probe))
|
|
254
|
+
return false;
|
|
255
|
+
try {
|
|
256
|
+
durable().removeItem(PROBE_KEY);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
/* A leftover probe names no write. */
|
|
260
|
+
}
|
|
261
|
+
seen.delete(PROBE_KEY);
|
|
262
|
+
return true;
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
109
266
|
/**
|
|
110
267
|
* Open the original namespaced action before fresh quotation or phase checks.
|
|
111
268
|
* No call is invented to locate evidence. Legacy actions retain factory.read().
|
|
@@ -141,8 +298,8 @@ function readRecovery(store, key, raw = store.getItem(key), scopeOf = scope) {
|
|
|
141
298
|
const memory = unpersistedSubmissions.get(store)?.get(key);
|
|
142
299
|
if (!record ||
|
|
143
300
|
!memory ||
|
|
144
|
-
JSON.stringify({ ...record, hash: undefined }) !==
|
|
145
|
-
JSON.stringify({ ...memory, hash: undefined }) ||
|
|
301
|
+
JSON.stringify({ ...record, hash: undefined, submittedAt: undefined }) !==
|
|
302
|
+
JSON.stringify({ ...memory, hash: undefined, submittedAt: undefined }) ||
|
|
146
303
|
(record.hash && record.hash !== memory.hash)) {
|
|
147
304
|
// A readable durable snapshot also proves whether cached evidence is stale.
|
|
148
305
|
unpersistedSubmissions.get(store)?.delete(key);
|
|
@@ -156,8 +313,9 @@ function readRecovery(store, key, raw = store.getItem(key), scopeOf = scope) {
|
|
|
156
313
|
export function sameReviewedWrite(record, input) {
|
|
157
314
|
return (JSON.stringify(normalized(record)) === JSON.stringify(normalized(input)));
|
|
158
315
|
}
|
|
159
|
-
// Without Web Locks, writes in this tab still run one at a time per action
|
|
160
|
-
|
|
316
|
+
// Without Web Locks, writes in this tab still run one at a time per action,
|
|
317
|
+
// across every copy of this module loaded in the tab.
|
|
318
|
+
const tabQueues = (globalThis[TAB_QUEUES] ??= new Map());
|
|
161
319
|
let tabLocksNoted = false;
|
|
162
320
|
const tabLocks = {
|
|
163
321
|
async request(name, options, work) {
|
|
@@ -386,6 +544,23 @@ export function createBrowserWriteRecovery(input, options = {}) {
|
|
|
386
544
|
throw new Error("Write recovery storage did not clear the action.");
|
|
387
545
|
unpersistedSubmissions.get(store)?.delete(at);
|
|
388
546
|
}
|
|
547
|
+
/** Add `fields` to exactly `expected`, a hashed record `when` allows; extra evidence only. */
|
|
548
|
+
function amend(expected, when, fields) {
|
|
549
|
+
requireLock();
|
|
550
|
+
const current = read();
|
|
551
|
+
if (!current?.hash ||
|
|
552
|
+
!when(current) ||
|
|
553
|
+
JSON.stringify(current) !== JSON.stringify(recordOf(expected)))
|
|
554
|
+
return current ?? recordOf(expected);
|
|
555
|
+
const next = recordOf({ ...current, ...fields });
|
|
556
|
+
try {
|
|
557
|
+
persist(next, locate(storage()).at);
|
|
558
|
+
return next;
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
return current;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
389
564
|
function rejected() {
|
|
390
565
|
requireLock();
|
|
391
566
|
// A rejection callback from another attempt must never release this one.
|
|
@@ -428,7 +603,15 @@ export function createBrowserWriteRecovery(input, options = {}) {
|
|
|
428
603
|
(!replace && previous.hash && previous.hash !== submittedHash)) {
|
|
429
604
|
throw new Error("Write recovery changed before submission was recorded.");
|
|
430
605
|
}
|
|
431
|
-
|
|
606
|
+
// A hash the wallet returns now starts its clock; a speed-up found at its nonce keeps it.
|
|
607
|
+
const submittedAt = replace || previous.hash === submittedHash
|
|
608
|
+
? previous.submittedAt
|
|
609
|
+
: String(Date.now());
|
|
610
|
+
const submitted = recordOf({
|
|
611
|
+
...previous,
|
|
612
|
+
hash: submittedHash,
|
|
613
|
+
...(submittedAt === undefined ? {} : { submittedAt }),
|
|
614
|
+
});
|
|
432
615
|
let raw;
|
|
433
616
|
let at = key;
|
|
434
617
|
try {
|
|
@@ -442,8 +625,16 @@ export function createBrowserWriteRecovery(input, options = {}) {
|
|
|
442
625
|
// A record the user released keeps its marker: a late hash leaves it released.
|
|
443
626
|
const current = readStored(raw, at, scopeAt(at));
|
|
444
627
|
if (!current ||
|
|
445
|
-
JSON.stringify({
|
|
446
|
-
|
|
628
|
+
JSON.stringify({
|
|
629
|
+
...current,
|
|
630
|
+
hash: undefined,
|
|
631
|
+
submittedAt: undefined,
|
|
632
|
+
}) !==
|
|
633
|
+
JSON.stringify({
|
|
634
|
+
...previous,
|
|
635
|
+
hash: undefined,
|
|
636
|
+
submittedAt: undefined,
|
|
637
|
+
}) ||
|
|
447
638
|
(!replace && current.hash && current.hash !== submittedHash)) {
|
|
448
639
|
throw new Error("Write recovery changed before submission was recorded.");
|
|
449
640
|
}
|
|
@@ -530,10 +721,83 @@ export function createBrowserWriteRecovery(input, options = {}) {
|
|
|
530
721
|
},
|
|
531
722
|
rejected,
|
|
532
723
|
clear,
|
|
724
|
+
/**
|
|
725
|
+
* Start the clock of a hash saved before `submittedAt` existed: save now
|
|
726
|
+
* as its `firstSeenAt`, once. Extra evidence: a stale snapshot or a failed
|
|
727
|
+
* save keeps the record as it was.
|
|
728
|
+
*/
|
|
729
|
+
firstSeen(expected) {
|
|
730
|
+
return amend(expected, (current) => current.submittedAt === undefined &&
|
|
731
|
+
current.firstSeenAt === undefined, { firstSeenAt: String(Date.now()) });
|
|
732
|
+
},
|
|
733
|
+
/**
|
|
734
|
+
* Save who sent a Safe execution the wallet returned at once, and at which
|
|
735
|
+
* nonce, from its authenticated transaction, once: its canonical
|
|
736
|
+
* replacement can then be proven. Extra evidence only.
|
|
737
|
+
*/
|
|
738
|
+
sentBy(expected, from, nonce) {
|
|
739
|
+
return amend(expected, (current) => current.safe && current.sentFrom === undefined, { sentFrom: from, sentNonce: nonce.toString() });
|
|
740
|
+
},
|
|
741
|
+
/**
|
|
742
|
+
* Drop the proof that `expected` ran, once its execution is gone from the
|
|
743
|
+
* chain: it is held again as an unproven write. Extra evidence only.
|
|
744
|
+
*/
|
|
745
|
+
unexecuted(expected) {
|
|
746
|
+
return amend(expected, (current) => current.executed === true, {
|
|
747
|
+
executed: undefined,
|
|
748
|
+
executionHash: undefined,
|
|
749
|
+
});
|
|
750
|
+
},
|
|
751
|
+
/**
|
|
752
|
+
* Save that another Safe transaction used this Safe write's nonce. Any
|
|
753
|
+
* saved execution goes: the user's exit, not the chain, ends it.
|
|
754
|
+
*/
|
|
755
|
+
replacedOnChain(expected) {
|
|
756
|
+
return amend(expected, (current) => current.safe && !current.replaced, {
|
|
757
|
+
executed: undefined,
|
|
758
|
+
executionHash: undefined,
|
|
759
|
+
replaced: true,
|
|
760
|
+
});
|
|
761
|
+
},
|
|
762
|
+
/**
|
|
763
|
+
* Save a Safe proposal's nonce from its authenticated record, once. Extra
|
|
764
|
+
* evidence: a stale snapshot or a failed save keeps the record as it was.
|
|
765
|
+
*/
|
|
766
|
+
proposalNonce(expected, nonce) {
|
|
767
|
+
return amend(expected, (current) => current.safe && current.safeNonce === undefined, { safeNonce: nonce.toString() });
|
|
768
|
+
},
|
|
769
|
+
/**
|
|
770
|
+
* Save the chain's proof that `expected` ran, with the transaction it ran
|
|
771
|
+
* in when known. From then on the user's exit refuses it: only its result
|
|
772
|
+
* ({@link verifyReviewedWriteReceipt}) releases it.
|
|
773
|
+
*/
|
|
774
|
+
executed(expected, executionHash) {
|
|
775
|
+
requireLock();
|
|
776
|
+
if (executionHash !== undefined && !isBytes32(executionHash))
|
|
777
|
+
throw new Error("Invalid execution transaction identity.");
|
|
778
|
+
const store = storage();
|
|
779
|
+
const { at, raw } = locate(store);
|
|
780
|
+
const current = readRecovery(store, at, raw, scopeAt(at));
|
|
781
|
+
const wanted = recordOf(expected);
|
|
782
|
+
if (!current?.hash ||
|
|
783
|
+
current.id !== wanted.id ||
|
|
784
|
+
current.hash !== wanted.hash)
|
|
785
|
+
throw new Error("Write recovery changed. Keep the current action pending.");
|
|
786
|
+
const next = recordOf({
|
|
787
|
+
...current,
|
|
788
|
+
executed: true,
|
|
789
|
+
...((executionHash ?? current.executionHash)
|
|
790
|
+
? { executionHash: executionHash ?? current.executionHash }
|
|
791
|
+
: {}),
|
|
792
|
+
});
|
|
793
|
+
persist(next, at);
|
|
794
|
+
unpersistedSubmissions.get(store)?.delete(at);
|
|
795
|
+
return next;
|
|
796
|
+
},
|
|
533
797
|
/**
|
|
534
798
|
* Check the saved write against the chain ({@link checkReviewedWrite}) and
|
|
535
|
-
* apply the finding: `sent` records the hash to track
|
|
536
|
-
*
|
|
799
|
+
* apply the finding: `sent` records the hash to track. Nothing here
|
|
800
|
+
* releases a write; null when nothing is saved.
|
|
537
801
|
*/
|
|
538
802
|
async resolve(client, checkOptions) {
|
|
539
803
|
requireLock();
|
|
@@ -543,12 +807,8 @@ export function createBrowserWriteRecovery(input, options = {}) {
|
|
|
543
807
|
const { check, sentNonce } = await inspectWrite(client, saved, checkOptions?.effect);
|
|
544
808
|
if (sentNonce !== undefined && saved.sentNonce !== sentNonce)
|
|
545
809
|
saved = bind(saved, sentNonce);
|
|
546
|
-
if (check.status === "sent")
|
|
547
|
-
|
|
548
|
-
record(check.hash, saved, true);
|
|
549
|
-
}
|
|
550
|
-
else if (check.status !== "pending")
|
|
551
|
-
clear(saved);
|
|
810
|
+
if (check.status === "sent" && check.hash !== saved.hash)
|
|
811
|
+
record(check.hash, saved, true);
|
|
552
812
|
return check;
|
|
553
813
|
},
|
|
554
814
|
/**
|
|
@@ -568,6 +828,8 @@ export function createBrowserWriteRecovery(input, options = {}) {
|
|
|
568
828
|
const durable = readStored(raw, at, scopeAt(at), store);
|
|
569
829
|
if (!durable)
|
|
570
830
|
return;
|
|
831
|
+
if (durable.executed)
|
|
832
|
+
throw new Error("This write ran onchain. Keep it held until its result is known.");
|
|
571
833
|
const shown = readRecovery(store, at, raw, scopeAt(at));
|
|
572
834
|
if (JSON.stringify(durable) !== wanted &&
|
|
573
835
|
JSON.stringify(shown) !== wanted)
|
|
@@ -631,20 +893,29 @@ export async function checkReviewedWrite(client, saved, { effect } = {}) {
|
|
|
631
893
|
}
|
|
632
894
|
async function inspectWrite(client, saved, effect) {
|
|
633
895
|
const record = recordOf(saved);
|
|
896
|
+
// No proof, the app's effect read included, from another chain, and none
|
|
897
|
+
// here for a Safe write.
|
|
898
|
+
if (record.safe ||
|
|
899
|
+
(await client.getChainId().catch(() => null)) !== record.chainId)
|
|
900
|
+
return { check: { status: "pending" } };
|
|
901
|
+
const found = await inspectOrdinary(client, record);
|
|
902
|
+
if (found.check.status === "pending" &&
|
|
903
|
+
effect &&
|
|
904
|
+
(await effect(record.chainId).catch(() => false)))
|
|
905
|
+
return { ...found, check: { ...found.check, effect: true } };
|
|
906
|
+
return found;
|
|
907
|
+
}
|
|
908
|
+
async function inspectOrdinary(client, record) {
|
|
634
909
|
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
910
|
if (record.hash) {
|
|
643
911
|
const sent = await client.getTransaction({ hash: record.hash }).then((tx) => tx, (error) => error?.name === "TransactionNotFoundError" ? null : undefined);
|
|
644
912
|
if (sent === undefined)
|
|
645
913
|
return pending;
|
|
646
914
|
if (sent) {
|
|
647
|
-
|
|
915
|
+
// Only the saved hash's own transaction, on the saved chain, is evidence.
|
|
916
|
+
if (sent.from?.toLowerCase() !== record.account ||
|
|
917
|
+
sent.hash?.toLowerCase() !== record.hash ||
|
|
918
|
+
(sent.chainId !== undefined && sent.chainId !== record.chainId))
|
|
648
919
|
return pending;
|
|
649
920
|
return {
|
|
650
921
|
check: sent.blockNumber !== null && sent.blockNumber !== undefined
|
|
@@ -656,15 +927,39 @@ async function inspectWrite(client, saved, effect) {
|
|
|
656
927
|
}
|
|
657
928
|
const bound = record.hash !== undefined && record.sentNonce !== undefined;
|
|
658
929
|
const target = bound ? record.sentNonce : record.nonce;
|
|
659
|
-
|
|
930
|
+
// The nonce a hash was sent at is authenticated by its own transaction, so
|
|
931
|
+
// a record saved without a starting block searches from genesis instead.
|
|
932
|
+
const from = record.fromBlock ?? (bound ? "0" : undefined);
|
|
933
|
+
if (target === undefined || from === undefined)
|
|
660
934
|
return pending;
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
935
|
+
const used = await findTransactionAtNonce(client, record.account, BigInt(target), BigInt(from));
|
|
936
|
+
if (!used)
|
|
937
|
+
return pending;
|
|
938
|
+
if (used.hash === record.hash)
|
|
939
|
+
return { check: { status: "sent", hash: used.hash } };
|
|
940
|
+
const sameCall = used.to?.toLowerCase() === record.call.to &&
|
|
941
|
+
used.input?.toLowerCase() === record.call.data &&
|
|
942
|
+
BigInt(used.value ?? 0) === BigInt(record.call.value);
|
|
943
|
+
// Another hash is adopted only at the nonce the saved hash's own
|
|
944
|
+
// transaction was sent at; the pre-wallet nonce only locates the call.
|
|
945
|
+
if (sameCall)
|
|
946
|
+
return bound ? { check: { status: "sent", hash: used.hash } } : pending;
|
|
947
|
+
// Another transaction at that nonce may have run the call inside it
|
|
948
|
+
// (a wrapper, or an EIP-7702 wallet that ran code and then cleared its
|
|
949
|
+
// delegation), so it proves nothing. The user's exit ends it.
|
|
950
|
+
return { check: { status: "pending", usedBy: used.hash } };
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* The transaction `account` sent at `nonce`, once the account's nonce is past
|
|
954
|
+
* it at a canonical finalized block: a search of its nonce from `fromBlock`
|
|
955
|
+
* finds the block where it moved. Null while that is unproven or the node
|
|
956
|
+
* can't say (no history, a reorganized answer).
|
|
957
|
+
*/
|
|
958
|
+
export async function findTransactionAtNonce(client, account, nonce, fromBlock = 0n) {
|
|
959
|
+
const from = account.toLowerCase();
|
|
960
|
+
const count = async (blockNumber) => BigInt(await client.getTransactionCount({ address: account, blockNumber }));
|
|
666
961
|
const proof = await atCanonicalFinalizedBlock(client, async (finalized) => {
|
|
667
|
-
let low =
|
|
962
|
+
let low = fromBlock;
|
|
668
963
|
let high = finalized;
|
|
669
964
|
if (high <= low ||
|
|
670
965
|
(await count(high)) <= nonce ||
|
|
@@ -682,24 +977,13 @@ async function inspectWrite(client, saved, effect) {
|
|
|
682
977
|
includeTransactions: true,
|
|
683
978
|
});
|
|
684
979
|
const used = block.number === high
|
|
685
|
-
? block.transactions.find((tx) => tx.from?.toLowerCase() ===
|
|
686
|
-
BigInt(tx.nonce) === nonce)
|
|
980
|
+
? block.transactions.find((tx) => tx.from?.toLowerCase() === from && BigInt(tx.nonce) === nonce)
|
|
687
981
|
: undefined;
|
|
688
982
|
if (!used || !isBytes32(used.hash))
|
|
689
983
|
return null;
|
|
690
|
-
|
|
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;
|
|
984
|
+
return { ...used, hash: used.hash.toLowerCase() };
|
|
701
985
|
});
|
|
702
|
-
return
|
|
986
|
+
return proof?.value ?? null;
|
|
703
987
|
}
|
|
704
988
|
/**
|
|
705
989
|
* Authenticate the saved wallet identity, canonical inclusion and Safe effect.
|
|
@@ -756,7 +1040,16 @@ export async function verifyReviewedWriteReceipt(client, saved, receipt, proof)
|
|
|
756
1040
|
(canonical.status !== "success" && canonical.status !== "reverted"))
|
|
757
1041
|
throw pending();
|
|
758
1042
|
let status = canonical.status === "success" ? "success" : "failed";
|
|
759
|
-
|
|
1043
|
+
// An execution the wallet returned at once whose own transaction reverted
|
|
1044
|
+
// ran nothing on the Safe: a failure, once final, when it was sent to it.
|
|
1045
|
+
const atOnceReverted = record.safe &&
|
|
1046
|
+
canonical.status === "reverted" &&
|
|
1047
|
+
hash.toLowerCase() === record.hash;
|
|
1048
|
+
if (atOnceReverted) {
|
|
1049
|
+
if (transaction.to?.toLowerCase() !== record.account)
|
|
1050
|
+
throw pending();
|
|
1051
|
+
}
|
|
1052
|
+
else if (record.safe) {
|
|
760
1053
|
if (!Array.isArray(canonical.logs) ||
|
|
761
1054
|
canonical.logs.some((log) => log.removed !== false ||
|
|
762
1055
|
log.transactionHash?.toLowerCase() !== hash.toLowerCase() ||
|