@bananapus/nana-sdk-core 2.31.0 → 2.33.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 (41) hide show
  1. package/dist/cjs/review/index.d.ts +1 -0
  2. package/dist/cjs/review/index.d.ts.map +1 -1
  3. package/dist/cjs/review/index.js +1 -0
  4. package/dist/cjs/review/index.js.map +1 -1
  5. package/dist/cjs/review/messages.d.ts +18 -0
  6. package/dist/cjs/review/messages.d.ts.map +1 -1
  7. package/dist/cjs/review/messages.js +19 -1
  8. package/dist/cjs/review/messages.js.map +1 -1
  9. package/dist/cjs/review/writeHold.d.ts +211 -0
  10. package/dist/cjs/review/writeHold.d.ts.map +1 -0
  11. package/dist/cjs/review/writeHold.js +620 -0
  12. package/dist/cjs/review/writeHold.js.map +1 -0
  13. package/dist/cjs/review/writeRecovery.d.ts +113 -9
  14. package/dist/cjs/review/writeRecovery.d.ts.map +1 -1
  15. package/dist/cjs/review/writeRecovery.js +357 -49
  16. package/dist/cjs/review/writeRecovery.js.map +1 -1
  17. package/dist/cjs/safeService.d.ts +40 -0
  18. package/dist/cjs/safeService.d.ts.map +1 -1
  19. package/dist/cjs/safeService.js +69 -12
  20. package/dist/cjs/safeService.js.map +1 -1
  21. package/dist/esm/review/index.d.ts +1 -0
  22. package/dist/esm/review/index.d.ts.map +1 -1
  23. package/dist/esm/review/index.js +1 -0
  24. package/dist/esm/review/index.js.map +1 -1
  25. package/dist/esm/review/messages.d.ts +18 -0
  26. package/dist/esm/review/messages.d.ts.map +1 -1
  27. package/dist/esm/review/messages.js +18 -0
  28. package/dist/esm/review/messages.js.map +1 -1
  29. package/dist/esm/review/writeHold.d.ts +211 -0
  30. package/dist/esm/review/writeHold.d.ts.map +1 -0
  31. package/dist/esm/review/writeHold.js +613 -0
  32. package/dist/esm/review/writeHold.js.map +1 -0
  33. package/dist/esm/review/writeRecovery.d.ts +113 -9
  34. package/dist/esm/review/writeRecovery.d.ts.map +1 -1
  35. package/dist/esm/review/writeRecovery.js +355 -49
  36. package/dist/esm/review/writeRecovery.js.map +1 -1
  37. package/dist/esm/safeService.d.ts +40 -0
  38. package/dist/esm/safeService.d.ts.map +1 -1
  39. package/dist/esm/safeService.js +66 -12
  40. package/dist/esm/safeService.js.map +1 -1
  41. 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,17 @@ 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.unproven !== undefined && record.unproven !== true) ||
65
+ (record.sentFrom !== undefined &&
66
+ !isAddress(record.sentFrom, { strict: false })) ||
67
+ (record.executed !== undefined && record.executed !== true) ||
68
+ (record.executionHash !== undefined &&
69
+ (record.executed !== true || !isBytes32(record.executionHash))))
57
70
  throw new Error("Invalid saved write recovery record. Keep this action pending.");
58
71
  return {
59
72
  version: 1,
@@ -65,6 +78,22 @@ function recordOf(value) {
65
78
  ...(record.nonce === undefined ? {} : { nonce: record.nonce }),
66
79
  ...(record.fromBlock === undefined ? {} : { fromBlock: record.fromBlock }),
67
80
  ...(record.sentNonce === undefined ? {} : { sentNonce: record.sentNonce }),
81
+ ...(record.submittedAt === undefined
82
+ ? {}
83
+ : { submittedAt: record.submittedAt }),
84
+ ...(record.firstSeenAt === undefined
85
+ ? {}
86
+ : { firstSeenAt: record.firstSeenAt }),
87
+ ...(record.safeNonce === undefined ? {} : { safeNonce: record.safeNonce }),
88
+ ...(record.sentFrom === undefined
89
+ ? {}
90
+ : { sentFrom: record.sentFrom.toLowerCase() }),
91
+ ...(record.replaced ? { replaced: true } : {}),
92
+ ...(record.unproven ? { unproven: true } : {}),
93
+ ...(record.executed ? { executed: true } : {}),
94
+ ...(record.executionHash === undefined
95
+ ? {}
96
+ : { executionHash: record.executionHash.toLowerCase() }),
68
97
  };
69
98
  }
70
99
  function optionalDecimal(value) {
@@ -106,6 +135,136 @@ function recoveryStorage(store) {
106
135
  throw new Error("Write recovery storage is unavailable.");
107
136
  return result;
108
137
  }
138
+ const PROBE_KEY = "nana-sdk:reviewed-write:probe";
139
+ function idOf(raw) {
140
+ try {
141
+ const id = JSON.parse(raw)?.id;
142
+ return typeof id === "string" ? id : undefined;
143
+ }
144
+ catch {
145
+ return undefined;
146
+ }
147
+ }
148
+ /**
149
+ * The browser's storage, shared by its tabs and kept across reloads, while it
150
+ * can be written; this tab's memory while it can't (private browsing, a
151
+ * sandboxed frame, some wallet webviews), so a write is held either way and
152
+ * never refused. Every read looks at both. A record kept in memory moves into
153
+ * the browser's storage as soon as it can be written there, after any other
154
+ * live record saved at the same key ends. While the browser's storage can't
155
+ * be read, what this tab last read or wrote there still holds.
156
+ *
157
+ * `memory` defaults to one map per tab, shared by every copy of this module
158
+ * loaded there.
159
+ */
160
+ export function createHeldWriteStorage(options = {}) {
161
+ const durable = options.durable ??
162
+ (() => globalThis.localStorage);
163
+ const tab = globalThis;
164
+ const memory = options.memory ?? (tab[TAB_MEMORY] ??= new Map());
165
+ /**
166
+ * What this tab last saw in, or wrote to, durable storage (records,
167
+ * releases and deletions), shared by every controller and copy of this
168
+ * module using the same tab memory.
169
+ */
170
+ const registry = (globalThis[TAB_SEEN] ??= new WeakMap());
171
+ let seen = registry.get(memory);
172
+ if (!seen)
173
+ registry.set(memory, (seen = new Map()));
174
+ const read = (key) => {
175
+ try {
176
+ const store = durable();
177
+ if (!store)
178
+ return undefined;
179
+ const value = store.getItem(key);
180
+ if (value === null)
181
+ seen.delete(key);
182
+ else
183
+ seen.set(key, value);
184
+ return value;
185
+ }
186
+ catch {
187
+ return undefined;
188
+ }
189
+ };
190
+ const write = (key, value) => {
191
+ try {
192
+ const store = durable();
193
+ if (!store)
194
+ return false;
195
+ store.setItem(key, value);
196
+ if (store.getItem(key) !== value)
197
+ return false;
198
+ seen.set(key, value);
199
+ return true;
200
+ }
201
+ catch {
202
+ return false;
203
+ }
204
+ };
205
+ const getItem = (key) => {
206
+ const saved = read(key);
207
+ const kept = memory.get(key);
208
+ if (saved === undefined)
209
+ return kept ?? seen.get(key) ?? null;
210
+ if (kept === undefined)
211
+ return saved;
212
+ if (saved === kept) {
213
+ memory.delete(key);
214
+ return saved;
215
+ }
216
+ const savedId = saved === null ? undefined : idOf(saved);
217
+ // Another live record saved there first is held first; this one waits.
218
+ if (saved !== null &&
219
+ savedId !== idOf(kept) &&
220
+ !(savedId !== undefined && getItem(releasedKey(key, savedId)) !== null))
221
+ return saved;
222
+ if (write(key, kept))
223
+ memory.delete(key);
224
+ return kept;
225
+ };
226
+ return {
227
+ getItem,
228
+ setItem(key, value) {
229
+ const kept = memory.get(key);
230
+ if (write(key, value)) {
231
+ if (kept === value || (kept && idOf(kept) === idOf(value)))
232
+ memory.delete(key);
233
+ }
234
+ else
235
+ memory.set(key, value);
236
+ },
237
+ removeItem(key) {
238
+ const shown = getItem(key);
239
+ if (shown !== null && memory.get(key) === shown) {
240
+ memory.delete(key);
241
+ if (read(key) !== shown)
242
+ return;
243
+ }
244
+ try {
245
+ durable()?.removeItem(key);
246
+ }
247
+ catch {
248
+ /* The readback below decides. */
249
+ }
250
+ if (read(key) === null)
251
+ seen.delete(key);
252
+ },
253
+ durable() {
254
+ const probe = String(Date.now());
255
+ if (!write(PROBE_KEY, probe))
256
+ return false;
257
+ try {
258
+ durable().removeItem(PROBE_KEY);
259
+ }
260
+ catch {
261
+ /* A leftover probe names no write. */
262
+ }
263
+ seen.delete(PROBE_KEY);
264
+ return true;
265
+ },
266
+ };
267
+ }
109
268
  /**
110
269
  * Open the original namespaced action before fresh quotation or phase checks.
111
270
  * No call is invented to locate evidence. Legacy actions retain factory.read().
@@ -141,8 +300,8 @@ function readRecovery(store, key, raw = store.getItem(key), scopeOf = scope) {
141
300
  const memory = unpersistedSubmissions.get(store)?.get(key);
142
301
  if (!record ||
143
302
  !memory ||
144
- JSON.stringify({ ...record, hash: undefined }) !==
145
- JSON.stringify({ ...memory, hash: undefined }) ||
303
+ JSON.stringify({ ...record, hash: undefined, submittedAt: undefined }) !==
304
+ JSON.stringify({ ...memory, hash: undefined, submittedAt: undefined }) ||
146
305
  (record.hash && record.hash !== memory.hash)) {
147
306
  // A readable durable snapshot also proves whether cached evidence is stale.
148
307
  unpersistedSubmissions.get(store)?.delete(key);
@@ -156,8 +315,9 @@ function readRecovery(store, key, raw = store.getItem(key), scopeOf = scope) {
156
315
  export function sameReviewedWrite(record, input) {
157
316
  return (JSON.stringify(normalized(record)) === JSON.stringify(normalized(input)));
158
317
  }
159
- // Without Web Locks, writes in this tab still run one at a time per action.
160
- const tabQueues = new Map();
318
+ // Without Web Locks, writes in this tab still run one at a time per action,
319
+ // across every copy of this module loaded in the tab.
320
+ const tabQueues = (globalThis[TAB_QUEUES] ??= new Map());
161
321
  let tabLocksNoted = false;
162
322
  const tabLocks = {
163
323
  async request(name, options, work) {
@@ -386,6 +546,23 @@ export function createBrowserWriteRecovery(input, options = {}) {
386
546
  throw new Error("Write recovery storage did not clear the action.");
387
547
  unpersistedSubmissions.get(store)?.delete(at);
388
548
  }
549
+ /** Add `fields` to exactly `expected`, a hashed record `when` allows; extra evidence only. */
550
+ function amend(expected, when, fields) {
551
+ requireLock();
552
+ const current = read();
553
+ if (!current?.hash ||
554
+ !when(current) ||
555
+ JSON.stringify(current) !== JSON.stringify(recordOf(expected)))
556
+ return current ?? recordOf(expected);
557
+ const next = recordOf({ ...current, ...fields });
558
+ try {
559
+ persist(next, locate(storage()).at);
560
+ return next;
561
+ }
562
+ catch {
563
+ return current;
564
+ }
565
+ }
389
566
  function rejected() {
390
567
  requireLock();
391
568
  // A rejection callback from another attempt must never release this one.
@@ -428,7 +605,15 @@ export function createBrowserWriteRecovery(input, options = {}) {
428
605
  (!replace && previous.hash && previous.hash !== submittedHash)) {
429
606
  throw new Error("Write recovery changed before submission was recorded.");
430
607
  }
431
- const submitted = { ...previous, hash: submittedHash };
608
+ // A hash the wallet returns now starts its clock; a speed-up found at its nonce keeps it.
609
+ const submittedAt = replace || previous.hash === submittedHash
610
+ ? previous.submittedAt
611
+ : String(Date.now());
612
+ const submitted = recordOf({
613
+ ...previous,
614
+ hash: submittedHash,
615
+ ...(submittedAt === undefined ? {} : { submittedAt }),
616
+ });
432
617
  let raw;
433
618
  let at = key;
434
619
  try {
@@ -442,8 +627,16 @@ export function createBrowserWriteRecovery(input, options = {}) {
442
627
  // A record the user released keeps its marker: a late hash leaves it released.
443
628
  const current = readStored(raw, at, scopeAt(at));
444
629
  if (!current ||
445
- JSON.stringify({ ...current, hash: undefined }) !==
446
- JSON.stringify({ ...previous, hash: undefined }) ||
630
+ JSON.stringify({
631
+ ...current,
632
+ hash: undefined,
633
+ submittedAt: undefined,
634
+ }) !==
635
+ JSON.stringify({
636
+ ...previous,
637
+ hash: undefined,
638
+ submittedAt: undefined,
639
+ }) ||
447
640
  (!replace && current.hash && current.hash !== submittedHash)) {
448
641
  throw new Error("Write recovery changed before submission was recorded.");
449
642
  }
@@ -530,10 +723,93 @@ export function createBrowserWriteRecovery(input, options = {}) {
530
723
  },
531
724
  rejected,
532
725
  clear,
726
+ /**
727
+ * Start the clock of a hash saved before `submittedAt` existed: save now
728
+ * as its `firstSeenAt`, once. Extra evidence: a stale snapshot or a failed
729
+ * save keeps the record as it was.
730
+ */
731
+ firstSeen(expected) {
732
+ return amend(expected, (current) => current.submittedAt === undefined &&
733
+ current.firstSeenAt === undefined, { firstSeenAt: String(Date.now()) });
734
+ },
735
+ /**
736
+ * Save who sent a Safe execution the wallet returned at once, and at which
737
+ * nonce, from its authenticated transaction, once: its canonical
738
+ * replacement can then be proven. Extra evidence only.
739
+ */
740
+ sentBy(expected, from, nonce) {
741
+ return amend(expected, (current) => current.safe && current.sentFrom === undefined, { sentFrom: from, sentNonce: nonce.toString() });
742
+ },
743
+ /**
744
+ * Drop the proof that `expected` ran, once its execution is gone from the
745
+ * chain: it is held again as an unproven write. Extra evidence only.
746
+ */
747
+ unexecuted(expected) {
748
+ return amend(expected, (current) => current.executed === true, {
749
+ executed: undefined,
750
+ executionHash: undefined,
751
+ });
752
+ },
753
+ /**
754
+ * Save that another Safe transaction used this Safe write's nonce. Any
755
+ * saved execution goes: the user's exit, not the chain, ends it.
756
+ */
757
+ replacedOnChain(expected) {
758
+ return amend(expected, (current) => current.safe && !current.replaced, {
759
+ executed: undefined,
760
+ executionHash: undefined,
761
+ replaced: true,
762
+ });
763
+ },
764
+ /**
765
+ * Save that the app's follower of this Safe write ended without proof.
766
+ * A proven execution or replacement keeps its own state.
767
+ */
768
+ unprovenEnd(expected) {
769
+ return amend(expected, (current) => current.safe &&
770
+ !current.executed &&
771
+ !current.replaced &&
772
+ !current.unproven, { unproven: true });
773
+ },
774
+ /**
775
+ * Save a Safe proposal's nonce from its authenticated record, once. Extra
776
+ * evidence: a stale snapshot or a failed save keeps the record as it was.
777
+ */
778
+ proposalNonce(expected, nonce) {
779
+ return amend(expected, (current) => current.safe && current.safeNonce === undefined, { safeNonce: nonce.toString() });
780
+ },
781
+ /**
782
+ * Save the chain's proof that `expected` ran, with the transaction it ran
783
+ * in when known. From then on the user's exit refuses it: only its result
784
+ * ({@link verifyReviewedWriteReceipt}) releases it.
785
+ */
786
+ executed(expected, executionHash) {
787
+ requireLock();
788
+ if (executionHash !== undefined && !isBytes32(executionHash))
789
+ throw new Error("Invalid execution transaction identity.");
790
+ const store = storage();
791
+ const { at, raw } = locate(store);
792
+ const current = readRecovery(store, at, raw, scopeAt(at));
793
+ const wanted = recordOf(expected);
794
+ if (!current?.hash ||
795
+ current.id !== wanted.id ||
796
+ current.hash !== wanted.hash)
797
+ throw new Error("Write recovery changed. Keep the current action pending.");
798
+ const next = recordOf({
799
+ ...current,
800
+ executed: true,
801
+ ...((executionHash ?? current.executionHash)
802
+ ? { executionHash: executionHash ?? current.executionHash }
803
+ : {}),
804
+ });
805
+ persist(next, at);
806
+ unpersistedSubmissions.get(store)?.delete(at);
807
+ return next;
808
+ },
533
809
  /**
534
810
  * Check the saved write against the chain ({@link checkReviewedWrite}) and
535
- * apply the finding: `sent` records the hash to track, `ran` releases it.
536
- * Null when nothing is saved.
811
+ * apply the finding: `sent` records the hash to track. Nothing here
812
+ * releases a write; null when nothing is saved.
537
813
  */
538
814
  async resolve(client, checkOptions) {
539
815
  requireLock();
@@ -543,12 +819,8 @@ export function createBrowserWriteRecovery(input, options = {}) {
543
819
  const { check, sentNonce } = await inspectWrite(client, saved, checkOptions?.effect);
544
820
  if (sentNonce !== undefined && saved.sentNonce !== sentNonce)
545
821
  saved = bind(saved, sentNonce);
546
- if (check.status === "sent") {
547
- if (check.hash !== saved.hash)
548
- record(check.hash, saved, true);
549
- }
550
- else if (check.status !== "pending")
551
- clear(saved);
822
+ if (check.status === "sent" && check.hash !== saved.hash)
823
+ record(check.hash, saved, true);
552
824
  return check;
553
825
  },
554
826
  /**
@@ -568,6 +840,8 @@ export function createBrowserWriteRecovery(input, options = {}) {
568
840
  const durable = readStored(raw, at, scopeAt(at), store);
569
841
  if (!durable)
570
842
  return;
843
+ if (durable.executed)
844
+ throw new Error("This write ran onchain. Keep it held until its result is known.");
571
845
  const shown = readRecovery(store, at, raw, scopeAt(at));
572
846
  if (JSON.stringify(durable) !== wanted &&
573
847
  JSON.stringify(shown) !== wanted)
@@ -631,20 +905,29 @@ export async function checkReviewedWrite(client, saved, { effect } = {}) {
631
905
  }
632
906
  async function inspectWrite(client, saved, effect) {
633
907
  const record = recordOf(saved);
908
+ // No proof, the app's effect read included, from another chain, and none
909
+ // here for a Safe write.
910
+ if (record.safe ||
911
+ (await client.getChainId().catch(() => null)) !== record.chainId)
912
+ return { check: { status: "pending" } };
913
+ const found = await inspectOrdinary(client, record);
914
+ if (found.check.status === "pending" &&
915
+ effect &&
916
+ (await effect(record.chainId).catch(() => false)))
917
+ return { ...found, check: { ...found.check, effect: true } };
918
+ return found;
919
+ }
920
+ async function inspectOrdinary(client, record) {
634
921
  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
922
  if (record.hash) {
643
923
  const sent = await client.getTransaction({ hash: record.hash }).then((tx) => tx, (error) => error?.name === "TransactionNotFoundError" ? null : undefined);
644
924
  if (sent === undefined)
645
925
  return pending;
646
926
  if (sent) {
647
- if (sent.from?.toLowerCase() !== record.account)
927
+ // Only the saved hash's own transaction, on the saved chain, is evidence.
928
+ if (sent.from?.toLowerCase() !== record.account ||
929
+ sent.hash?.toLowerCase() !== record.hash ||
930
+ (sent.chainId !== undefined && sent.chainId !== record.chainId))
648
931
  return pending;
649
932
  return {
650
933
  check: sent.blockNumber !== null && sent.blockNumber !== undefined
@@ -656,15 +939,39 @@ async function inspectWrite(client, saved, effect) {
656
939
  }
657
940
  const bound = record.hash !== undefined && record.sentNonce !== undefined;
658
941
  const target = bound ? record.sentNonce : record.nonce;
659
- if (target === undefined || record.fromBlock === undefined)
942
+ // The nonce a hash was sent at is authenticated by its own transaction, so
943
+ // a record saved without a starting block searches from genesis instead.
944
+ const from = record.fromBlock ?? (bound ? "0" : undefined);
945
+ if (target === undefined || from === undefined)
660
946
  return pending;
661
- const nonce = BigInt(target);
662
- const count = async (blockNumber) => BigInt(await client.getTransactionCount({
663
- address: record.account,
664
- blockNumber,
665
- }));
947
+ const used = await findTransactionAtNonce(client, record.account, BigInt(target), BigInt(from));
948
+ if (!used)
949
+ return pending;
950
+ if (used.hash === record.hash)
951
+ return { check: { status: "sent", hash: used.hash } };
952
+ const sameCall = used.to?.toLowerCase() === record.call.to &&
953
+ used.input?.toLowerCase() === record.call.data &&
954
+ BigInt(used.value ?? 0) === BigInt(record.call.value);
955
+ // Another hash is adopted only at the nonce the saved hash's own
956
+ // transaction was sent at; the pre-wallet nonce only locates the call.
957
+ if (sameCall)
958
+ return bound ? { check: { status: "sent", hash: used.hash } } : pending;
959
+ // Another transaction at that nonce may have run the call inside it
960
+ // (a wrapper, or an EIP-7702 wallet that ran code and then cleared its
961
+ // delegation), so it proves nothing. The user's exit ends it.
962
+ return { check: { status: "pending", usedBy: used.hash } };
963
+ }
964
+ /**
965
+ * The transaction `account` sent at `nonce`, once the account's nonce is past
966
+ * it at a canonical finalized block: a search of its nonce from `fromBlock`
967
+ * finds the block where it moved. Null while that is unproven or the node
968
+ * can't say (no history, a reorganized answer).
969
+ */
970
+ export async function findTransactionAtNonce(client, account, nonce, fromBlock = 0n) {
971
+ const from = account.toLowerCase();
972
+ const count = async (blockNumber) => BigInt(await client.getTransactionCount({ address: account, blockNumber }));
666
973
  const proof = await atCanonicalFinalizedBlock(client, async (finalized) => {
667
- let low = BigInt(record.fromBlock);
974
+ let low = fromBlock;
668
975
  let high = finalized;
669
976
  if (high <= low ||
670
977
  (await count(high)) <= nonce ||
@@ -682,34 +989,24 @@ async function inspectWrite(client, saved, effect) {
682
989
  includeTransactions: true,
683
990
  });
684
991
  const used = block.number === high
685
- ? block.transactions.find((tx) => tx.from?.toLowerCase() === record.account &&
686
- BigInt(tx.nonce) === nonce)
992
+ ? block.transactions.find((tx) => tx.from?.toLowerCase() === from && BigInt(tx.nonce) === nonce)
687
993
  : undefined;
688
994
  if (!used || !isBytes32(used.hash))
689
995
  return null;
690
- const hash = used.hash.toLowerCase();
691
- if (hash === record.hash)
692
- return { status: "sent", hash };
693
- if (used.to?.toLowerCase() === record.call.to &&
694
- used.input?.toLowerCase() === record.call.data &&
695
- BigInt(used.value ?? 0) === BigInt(record.call.value))
696
- return { status: "sent", hash };
697
- // Another transaction at that nonce may have run the call inside it
698
- // (a wrapper, or an EIP-7702 wallet that ran code and then cleared its
699
- // delegation), so it proves nothing. The user's exit ends it.
700
- return null;
996
+ return { ...used, hash: used.hash.toLowerCase() };
701
997
  });
702
- return { check: proof?.value ?? pending.check };
998
+ return proof?.value ?? null;
703
999
  }
704
1000
  /**
705
1001
  * Authenticate the saved wallet identity, canonical inclusion and Safe effect.
706
1002
  * Ordinary wallet wrappers are allowed: the wallet's returned hash binds their
707
1003
  * execution. A wallet-returned Safe proposal hash authenticates its execution
708
1004
  * event even through a wrapper; an immediate execution hash additionally needs
709
- * exact reviewed calldata. A failure releases only after finality.
1005
+ * exact reviewed calldata. A failure releases only after finality;
1006
+ * `finality: false` authenticates a canonical failure before it is final.
710
1007
  * Throws on missing or inconsistent evidence; the caller retains its record.
711
1008
  */
712
- export async function verifyReviewedWriteReceipt(client, saved, receipt, proof) {
1009
+ export async function verifyReviewedWriteReceipt(client, saved, receipt, proof, { finality = true } = {}) {
713
1010
  const record = recordOf(saved);
714
1011
  const pending = () => new Error("The saved wallet write is not yet proven. Keep this action pending.");
715
1012
  let calls = [record.call];
@@ -756,7 +1053,16 @@ export async function verifyReviewedWriteReceipt(client, saved, receipt, proof)
756
1053
  (canonical.status !== "success" && canonical.status !== "reverted"))
757
1054
  throw pending();
758
1055
  let status = canonical.status === "success" ? "success" : "failed";
759
- if (record.safe) {
1056
+ // An execution the wallet returned at once whose own transaction reverted
1057
+ // ran nothing on the Safe: a failure, once final, when it was sent to it.
1058
+ const atOnceReverted = record.safe &&
1059
+ canonical.status === "reverted" &&
1060
+ hash.toLowerCase() === record.hash;
1061
+ if (atOnceReverted) {
1062
+ if (transaction.to?.toLowerCase() !== record.account)
1063
+ throw pending();
1064
+ }
1065
+ else if (record.safe) {
760
1066
  if (!Array.isArray(canonical.logs) ||
761
1067
  canonical.logs.some((log) => log.removed !== false ||
762
1068
  log.transactionHash?.toLowerCase() !== hash.toLowerCase() ||
@@ -782,7 +1088,7 @@ export async function verifyReviewedWriteReceipt(client, saved, receipt, proof)
782
1088
  return (block.number === canonical.blockNumber &&
783
1089
  block.hash?.toLowerCase() === canonical.blockHash.toLowerCase());
784
1090
  };
785
- if (status === "failed") {
1091
+ if (status === "failed" && finality) {
786
1092
  const finalized = await atCanonicalFinalizedBlock(client, async (number) => number >= canonical.blockNumber && (await isCanonical()));
787
1093
  if (!finalized?.value)
788
1094
  throw pending();