@bananapus/nana-sdk-core 2.24.1 → 2.24.3

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.
@@ -2,7 +2,66 @@ import { decodeFunctionData, encodeFunctionData, isAddress, keccak256, toHex, pa
2
2
  import { readBoundedSafeNonce } from "../safe.js";
3
3
  import { SAFE_EXEC_ABI, canonicalSafeTxHash, requireSafeExecutionSuccess, safeExecutionResult, } from "../safeService.js";
4
4
  import { isDefiniteWalletRejection } from "./contractWrite.js";
5
- import { RELAYR_API, RELAYR_UUID_RE, MAX_RELAYR_SENT_PAYMENTS, atCanonicalFinalizedBlock, bindRelayrQuote, readRelayrBundle, relayrBundleRequest, relayrDeadlinePassed, relayrPaymentOptions, relayrPaymentDetails, relayrRetryOption, relayrSentPaymentsSnapshot, relayrQuotedOptions, requireRelayrBundleUnpaid, requireRelayrRetry, revertedRelayrQuote, verifyRelayrDestinations, verifyRelayrPayment, RelayrPaymentRevertedError, } from "./relayr.js";
5
+ import { RELAYR_API, RELAYR_UUID_RE, MAX_RELAYR_SENT_PAYMENTS, atCanonicalFinalizedBlock, bindRelayrQuote, readRelayrBundle, relayrBundleRequest, relayrDeadlinePassed, relayrPaymentOptions, relayrPaymentDetails, relayrDestinationHash, relayrDestinationHashes, relayrRetryOption, relayrSentPaymentsSnapshot, relayrStateIsPending, relayrStateIsFailed, relayrQuotedOptions, requireRelayrBundleUnpaid, requireRelayrRetry, revertedRelayrQuote, verifyRelayrDestinations, verifyRelayrDestination, verifyRelayrPayment, RelayrPaymentRevertedError, RelayrProofUnavailableError, } from "./relayr.js";
6
+ function hasFundingRecords(records) {
7
+ if (records === undefined)
8
+ return false;
9
+ if (!Array.isArray(records))
10
+ return true;
11
+ return records.some((record) => {
12
+ if (!record || typeof record !== "object" || Array.isArray(record))
13
+ return true;
14
+ const status = record.status;
15
+ if (status === undefined)
16
+ return false;
17
+ if (!status || typeof status !== "object" || Array.isArray(status))
18
+ return true;
19
+ const data = status.data;
20
+ if (data !== undefined &&
21
+ data !== null &&
22
+ (typeof data !== "object" || Array.isArray(data)))
23
+ return true;
24
+ const transaction = data?.transaction;
25
+ if (transaction !== undefined &&
26
+ (transaction === null ||
27
+ typeof transaction !== "object" ||
28
+ Array.isArray(transaction)))
29
+ return true;
30
+ if (status.data?.hash !== undefined ||
31
+ status.data?.transaction?.hash !== undefined)
32
+ return true;
33
+ return status.state !== undefined && !relayrStateIsPending(status.state);
34
+ });
35
+ }
36
+ function hasObservedFunding(session) {
37
+ return ((session.fundingObserved !== undefined &&
38
+ session.fundingObserved !== false) ||
39
+ hasFundingRecords(session.records) ||
40
+ hasFundingRecords(session.quote?.transactions));
41
+ }
42
+ /**
43
+ * Safe signatures already authorize a nonce-protected call. Publishing or losing
44
+ * an unused quote is not a funding attempt. Only this Safe-specific owner may
45
+ * replace such a quote; raw/forwarded authorizations keep their existing rules.
46
+ */
47
+ export function canReplaceSafeRelayrQuote(session) {
48
+ if ((session.state !== "publishing" && session.state !== "active") ||
49
+ session.paymentStatus !== "unfunded" ||
50
+ !Array.isArray(session.payments) ||
51
+ session.payments.length !== 0 ||
52
+ (session.reservationKeys !== undefined &&
53
+ !Array.isArray(session.reservationKeys)) ||
54
+ hasObservedFunding(session))
55
+ return false;
56
+ try {
57
+ validateExecutions(session.executions);
58
+ const identities = new Set(session.executions.map(safeRelayrReservationKey));
59
+ return !session.reservationKeys?.some((key) => !identities.has(key.toLowerCase()));
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
6
65
  export class SafeRelayrRecoveryError extends Error {
7
66
  constructor(session, message = "This Safe execution has an existing Relayr bundle. Check the existing bundle before submitting another payment.", recovery) {
8
67
  super(message);
@@ -162,6 +221,13 @@ function requireSafeRelayrRecords(bindings, records) {
162
221
  throw new Error("Relayr's record does not match the saved Safe transaction binding.");
163
222
  }
164
223
  }
224
+ function requireSafeLanding(execution, { receipt }) {
225
+ const { safe, safeTxHash } = execution;
226
+ requireSafeExecutionSuccess(receipt, safe, safeTxHash);
227
+ const event = safeExecutionResult(receipt, safe, safeTxHash);
228
+ if (event.status !== "success" || event.payment !== 0n)
229
+ throw new Error("The Safe execution reimbursed its executor. Keep the existing bundle for verification.");
230
+ }
165
231
  /** Canonical exact destination receipts plus matching, refund-free Safe success events. */
166
232
  export async function verifySafeRelayrLanding(clientFor, { executions, bindings, records, }) {
167
233
  validateExecutions(executions);
@@ -174,13 +240,7 @@ export async function verifySafeRelayrLanding(clientFor, { executions, bindings,
174
240
  bindings,
175
241
  records,
176
242
  });
177
- verified.forEach(({ receipt }, index) => {
178
- const { safe, safeTxHash } = executions[index];
179
- requireSafeExecutionSuccess(receipt, safe, safeTxHash);
180
- const event = safeExecutionResult(receipt, safe, safeTxHash);
181
- if (event.status !== "success" || event.payment !== 0n)
182
- throw new Error("The Safe execution reimbursed its executor. Keep the existing bundle for verification.");
183
- });
243
+ verified.forEach((item, index) => requireSafeLanding(executions[index], item));
184
244
  return verified;
185
245
  }
186
246
  /** Validate persisted bindings again; local storage is not authenticated evidence. */
@@ -206,6 +266,49 @@ function requireSessionQuote(session) {
206
266
  export function createSafeRelayrController(options) {
207
267
  const { store } = options;
208
268
  const fetchRelayr = options.fetch ?? globalThis.fetch;
269
+ const displayedExecutions = new Map();
270
+ const notify = (progress, rechecking = false) => {
271
+ if (progress.type === "execution") {
272
+ const key = `${progress.session.id}:${progress.index}`;
273
+ const display = JSON.stringify([
274
+ progress.status,
275
+ progress.hash,
276
+ progress.message,
277
+ ]);
278
+ const previous = displayedExecutions.get(key);
279
+ // Rechecking does not undo a proven display result. A later unavailable,
280
+ // contradictory, or changed-hash result explicitly updates it below.
281
+ if (previous === display ||
282
+ (rechecking &&
283
+ previous === JSON.stringify(["executed", progress.hash, undefined])))
284
+ return;
285
+ displayedExecutions.set(key, display);
286
+ }
287
+ try {
288
+ // A view must not mutate frozen calls or turn a submitted payment into a
289
+ // rejected operation. Also consume rejection from an async UI callback.
290
+ void Promise.resolve(options.onProgress?.(structuredClone(progress))).catch(() => undefined);
291
+ }
292
+ catch {
293
+ // Display observers cannot change the durable execution lifecycle.
294
+ }
295
+ };
296
+ const phase = (phase, session, signal) => {
297
+ if (!signal?.aborted)
298
+ notify({ type: "phase", phase, session });
299
+ };
300
+ const savedPhase = (session, signal) => {
301
+ if (session.state === "released")
302
+ return;
303
+ if (session.state === "complete")
304
+ phase("complete", session, signal);
305
+ else if (session.paymentStatus === "confirmed" ||
306
+ hasObservedFunding(session))
307
+ phase("executing", session, signal);
308
+ else if (session.paymentStatus === "submitted" ||
309
+ session.paymentStatus === "sending")
310
+ phase("payment-confirming", session, signal);
311
+ };
209
312
  const checkAccount = (account, signal) => {
210
313
  signal?.throwIfAborted();
211
314
  if (!isAddress(account) ||
@@ -263,10 +366,12 @@ export function createSafeRelayrController(options) {
263
366
  };
264
367
  const unidentified = (session) => !session.executions.length && !session.reservationKeys?.length;
265
368
  const unresolved = (session) => session.state !== "complete" && session.state !== "released";
369
+ const reserves = (session, executions) => unresolved(session) &&
370
+ (store.scope === "single-session" ||
371
+ unidentified(session) ||
372
+ overlap(session, executions));
266
373
  const assertUnreserved = async (account, executions, exceptId) => {
267
- const conflict = (await store.list(account)).find((session) => session.id !== exceptId &&
268
- unresolved(session) &&
269
- (unidentified(session) || overlap(session, executions)));
374
+ const conflict = (await store.list(account)).find((session) => session.id !== exceptId && reserves(session, executions));
270
375
  if (conflict)
271
376
  throw new SafeRelayrRecoveryError(conflict);
272
377
  };
@@ -339,7 +444,8 @@ export function createSafeRelayrController(options) {
339
444
  // session per Safe and replacing it would erase the unresolved evidence.
340
445
  if (session.paymentStatus !== "unfunded" ||
341
446
  !Array.isArray(session.payments) ||
342
- session.payments.length)
447
+ session.payments.length ||
448
+ hasObservedFunding(session))
343
449
  return pending({
344
450
  reason: "funding-unresolved",
345
451
  message: "Every saved Safe nonce has been used, but this selection still has unresolved payment evidence. Inspect the original Safe proposals and funding transactions before replacing it. No refund or successful execution has been confirmed.",
@@ -382,77 +488,256 @@ export function createSafeRelayrController(options) {
382
488
  checks,
383
489
  });
384
490
  }
385
- async function inspect(session) {
386
- if (session.state === "released")
387
- return result("released", session);
388
- if (session.state === "complete")
389
- return result("complete", session);
390
- if (!session.bundleUuid || !session.quote)
391
- return inspectMissingQuote(session);
392
- const quote = requireSessionQuote(session);
393
- const chains = session.executions.map(({ entry }) => entry.chain);
394
- const bundle = await readRelayrBundle(session.bundleUuid, {
395
- fetch: fetchRelayr,
396
- });
397
- if (!Array.isArray(bundle.transactions))
398
- throw new SafeRelayrRecoveryError(session, "Relayr has not returned this bundle's transactions. Check the existing bundle again.");
399
- const records = bundle.transactions;
400
- requireSafeRelayrRecords(quote.expectedTransactions, records);
401
- session = { ...session, records };
402
- await store.save(session);
403
- // A reported hash is only a pointer. Completion requires canonical exact
404
- // calldata/value receipts AND the matching Safe ExecutionSuccess event.
405
- if (records.length &&
406
- records.every((record) => record.status?.data?.hash || record.status?.data?.transaction?.hash)) {
407
- const verified = await verifySafeRelayrLanding(options.clientFor, {
408
- executions: session.executions,
409
- bindings: quote.expectedTransactions,
410
- records,
491
+ async function inspect(session, signal) {
492
+ const report = (progress, rechecking = false) => {
493
+ if (!signal?.aborted)
494
+ notify(progress, rechecking);
495
+ };
496
+ try {
497
+ savedPhase(session, signal);
498
+ if (session.state === "released")
499
+ return result("released", session);
500
+ if (session.state === "complete")
501
+ return result("complete", session);
502
+ if (hasObservedFunding(session) && session.fundingObserved !== true) {
503
+ session = { ...session, fundingObserved: true };
504
+ await store.save(session);
505
+ }
506
+ if (!session.bundleUuid || !session.quote)
507
+ return inspectMissingQuote(session);
508
+ const quote = requireSessionQuote(session);
509
+ const chains = session.executions.map(({ entry }) => entry.chain);
510
+ const bundle = await readRelayrBundle(session.bundleUuid, {
511
+ fetch: fetchRelayr,
411
512
  });
412
- await Promise.all(session.executions.map((execution, index) => options.afterVerified?.(execution, verified[index])));
413
- session = { ...session, state: "complete" };
513
+ if (hasObservedFunding(session) ||
514
+ bundle.payment_received === true ||
515
+ hasFundingRecords(bundle.transactions)) {
516
+ session = { ...session, fundingObserved: true };
517
+ await store.save(session);
518
+ }
519
+ if (!Array.isArray(bundle.transactions))
520
+ throw new SafeRelayrRecoveryError(session, "Relayr has not returned this bundle's transactions. Check the existing bundle again.");
521
+ const records = bundle.transactions;
522
+ requireSafeRelayrRecords(quote.expectedTransactions, records);
523
+ session = { ...session, records };
414
524
  await store.save(session);
415
- return result("complete", session);
416
- }
417
- if (session.paymentStatus === "sending")
418
- return result("pending", session);
419
- if (session.payments.some((payment) => payment.bundleUuid !== session.bundleUuid))
420
- throw new SafeRelayrRecoveryError(session, "A saved payment belongs to another bundle. Keep the existing bundle for verification.");
421
- if (session.payments.length) {
422
- let everyReverted = true;
423
- for (const payment of session.payments) {
424
- const client = options.clientFor(payment.chainId);
425
- if (!client)
426
- return result("pending", session);
525
+ savedPhase(session, signal);
526
+ // Each hash is only a pointer. Check available chains independently, using
527
+ // the same complete binding validator and exact receipt proof as completion.
528
+ const indexed = quote.expectedTransactions.map((binding, index) => ({
529
+ binding,
530
+ index,
531
+ record: records.find((record) => record.tx_uuid?.toLowerCase() === binding.txUuid.toLowerCase()),
532
+ }));
533
+ const known = indexed.filter(({ record }) => record.status?.data?.hash !== undefined ||
534
+ record.status?.data?.transaction?.hash !== undefined);
535
+ if (known.length) {
427
536
  try {
428
- await verifyRelayrPayment(client, {
429
- hash: payment.hash,
430
- from: session.account,
431
- payment,
537
+ relayrDestinationHashes({
538
+ bindings: known.map(({ binding }) => binding),
539
+ records: known.map(({ record }) => record),
432
540
  });
433
- session = { ...session, paymentStatus: "confirmed" };
434
- await store.save(session);
435
- return result("pending", session);
436
541
  }
437
542
  catch (error) {
438
- if (!(error instanceof RelayrPaymentRevertedError))
439
- everyReverted = false;
543
+ for (const { index, record } of known)
544
+ report({
545
+ type: "execution",
546
+ session,
547
+ index,
548
+ execution: session.executions[index],
549
+ status: "failed",
550
+ hash: relayrDestinationHash(record) ?? undefined,
551
+ message: error instanceof Error
552
+ ? error.message
553
+ : "The destination records do not match this quote.",
554
+ });
555
+ throw new SafeRelayrRecoveryError(session, error instanceof Error ? error.message : undefined);
440
556
  }
557
+ phase("executing", session, signal);
441
558
  }
442
- if (!everyReverted)
443
- return result("pending", session);
444
- session = { ...session, paymentStatus: "reverted" };
445
- await store.save(session);
446
- const disposition = await revertedRelayrQuote(options.clientFor, {
447
- bundleUuid: quote.bundle_uuid,
448
- payments: session.payments,
449
- options: quote.payment_info,
450
- destinationChainIds: chains,
451
- account: session.account,
452
- }, { fetch: fetchRelayr });
453
- if (disposition.state === "funded")
559
+ if (known.length ||
560
+ session.paymentStatus !== "unfunded" ||
561
+ session.fundingObserved) {
562
+ for (const { index, record } of indexed) {
563
+ const hash = relayrDestinationHash(record) ?? undefined;
564
+ report({
565
+ type: "execution",
566
+ session,
567
+ index,
568
+ execution: session.executions[index],
569
+ status: hash
570
+ ? "confirming"
571
+ : relayrStateIsFailed(record.status?.state)
572
+ ? "failed"
573
+ : "pending",
574
+ hash,
575
+ ...(!hash && relayrStateIsFailed(record.status?.state)
576
+ ? {
577
+ message: "Relayr reported this execution failed. Its onchain result has not been verified.",
578
+ }
579
+ : {}),
580
+ }, true);
581
+ }
582
+ }
583
+ const landed = await Promise.allSettled(known.map(async ({ binding, index, record }) => {
584
+ const execution = session.executions[index];
585
+ const hash = relayrDestinationHash(record);
586
+ const client = options.clientFor(binding.chain);
587
+ if (!client) {
588
+ report({
589
+ type: "execution",
590
+ session,
591
+ index,
592
+ execution,
593
+ status: "confirming",
594
+ hash,
595
+ message: "Waiting for a connection to this network.",
596
+ });
597
+ return false;
598
+ }
599
+ try {
600
+ const receipt = await verifyRelayrDestination(client, {
601
+ entry: binding.entry,
602
+ hash,
603
+ });
604
+ const verified = {
605
+ txUuid: binding.txUuid,
606
+ chainId: binding.chain,
607
+ receipt,
608
+ };
609
+ requireSafeLanding(execution, verified);
610
+ await options.afterVerified?.(execution, verified);
611
+ report({
612
+ type: "execution",
613
+ session,
614
+ index,
615
+ execution,
616
+ status: "executed",
617
+ hash,
618
+ });
619
+ return true;
620
+ }
621
+ catch (error) {
622
+ if (error instanceof RelayrProofUnavailableError) {
623
+ report({
624
+ type: "execution",
625
+ session,
626
+ index,
627
+ execution,
628
+ status: "confirming",
629
+ hash,
630
+ message: "Waiting for the network to confirm this transaction.",
631
+ });
632
+ return false;
633
+ }
634
+ report({
635
+ type: "execution",
636
+ session,
637
+ index,
638
+ execution,
639
+ status: "failed",
640
+ hash,
641
+ message: error instanceof Error
642
+ ? error.message
643
+ : "The Safe execution could not be verified.",
644
+ });
645
+ throw error;
646
+ }
647
+ }));
648
+ const failure = landed.find((item) => item.status === "rejected");
649
+ if (failure?.status === "rejected")
650
+ throw new SafeRelayrRecoveryError(session, failure.reason instanceof Error ? failure.reason.message : undefined);
651
+ if (known.length === session.executions.length &&
652
+ landed.every((item) => item.status === "fulfilled" && item.value)) {
653
+ session = { ...session, state: "complete" };
654
+ await store.save(session);
655
+ phase("complete", session, signal);
656
+ return result("complete", session);
657
+ }
658
+ if (session.paymentStatus === "sending")
454
659
  return result("pending", session);
455
- if (disposition.state === "released") {
660
+ if (session.payments.some((payment) => payment.bundleUuid !== session.bundleUuid))
661
+ throw new SafeRelayrRecoveryError(session, "A saved payment belongs to another bundle. Keep the existing bundle for verification.");
662
+ if (session.payments.length) {
663
+ let everyReverted = true;
664
+ for (const payment of session.payments) {
665
+ const client = options.clientFor(payment.chainId);
666
+ if (!client)
667
+ return result("pending", session);
668
+ try {
669
+ await verifyRelayrPayment(client, {
670
+ hash: payment.hash,
671
+ from: session.account,
672
+ payment,
673
+ });
674
+ session = { ...session, paymentStatus: "confirmed" };
675
+ await store.save(session);
676
+ phase("executing", session, signal);
677
+ return result("pending", session);
678
+ }
679
+ catch (error) {
680
+ if (!(error instanceof RelayrPaymentRevertedError))
681
+ everyReverted = false;
682
+ }
683
+ }
684
+ if (!everyReverted)
685
+ return result("pending", session);
686
+ session = { ...session, paymentStatus: "reverted" };
687
+ await store.save(session);
688
+ const disposition = await revertedRelayrQuote(options.clientFor, {
689
+ bundleUuid: quote.bundle_uuid,
690
+ payments: session.payments,
691
+ options: quote.payment_info,
692
+ destinationChainIds: chains,
693
+ account: session.account,
694
+ }, { fetch: fetchRelayr });
695
+ if (disposition.state === "funded")
696
+ return result("pending", session);
697
+ if (disposition.state === "released") {
698
+ session = {
699
+ ...session,
700
+ state: "released",
701
+ paymentStatus: "expired",
702
+ releaseReason: "quote-expired",
703
+ };
704
+ await store.save(session);
705
+ return result("released", session);
706
+ }
707
+ }
708
+ else if (session.paymentStatus !== "unfunded" ||
709
+ session.fundingObserved) {
710
+ // A wallet invocation without a known hash may still have broadcast.
711
+ return session.fundingObserved && !known.length
712
+ ? {
713
+ ...result("pending", session),
714
+ recovery: {
715
+ reason: "funding-unresolved",
716
+ message: "This saved selection has reported funding or execution activity. Check the existing bundle and its funding transactions before submitting another payment.",
717
+ },
718
+ }
719
+ : result("pending", session);
720
+ }
721
+ const quoted = relayrQuotedOptions(quote, chains);
722
+ if (!quoted.length)
723
+ return inspectMissingQuote(session);
724
+ const payments = relayrPaymentOptions(quote, chains);
725
+ const expired = payments.length
726
+ ? [false]
727
+ : await Promise.all(quoted.map(async ({ details }) => {
728
+ const client = options.clientFor(details.chainId);
729
+ return (!!client &&
730
+ (await relayrDeadlinePassed(client, details.deadline).catch(() => false)));
731
+ }));
732
+ if (expired.every(Boolean)) {
733
+ try {
734
+ await requireRelayrBundleUnpaid(quote.bundle_uuid, {
735
+ fetch: fetchRelayr,
736
+ });
737
+ }
738
+ catch {
739
+ return result("pending", session);
740
+ }
456
741
  session = {
457
742
  ...session,
458
743
  state: "released",
@@ -462,23 +747,6 @@ export function createSafeRelayrController(options) {
462
747
  await store.save(session);
463
748
  return result("released", session);
464
749
  }
465
- }
466
- else if (session.paymentStatus !== "unfunded") {
467
- // A wallet invocation without a known hash may still have broadcast.
468
- return result("pending", session);
469
- }
470
- const quoted = relayrQuotedOptions(quote, chains);
471
- if (!quoted.length)
472
- return inspectMissingQuote(session);
473
- const payments = relayrPaymentOptions(quote, chains);
474
- const expired = payments.length
475
- ? [false]
476
- : await Promise.all(quoted.map(async ({ details }) => {
477
- const client = options.clientFor(details.chainId);
478
- return (!!client &&
479
- (await relayrDeadlinePassed(client, details.deadline).catch(() => false)));
480
- }));
481
- if (expired.every(Boolean)) {
482
750
  try {
483
751
  await requireRelayrBundleUnpaid(quote.bundle_uuid, {
484
752
  fetch: fetchRelayr,
@@ -487,43 +755,35 @@ export function createSafeRelayrController(options) {
487
755
  catch {
488
756
  return result("pending", session);
489
757
  }
490
- session = {
491
- ...session,
492
- state: "released",
493
- paymentStatus: "expired",
494
- releaseReason: "quote-expired",
495
- };
496
- await store.save(session);
497
- return result("released", session);
498
- }
499
- try {
500
- await requireRelayrBundleUnpaid(quote.bundle_uuid, {
501
- fetch: fetchRelayr,
502
- });
758
+ const allowed = session.payments.length && payments.length
759
+ ? [relayrRetryOption(session.payments, payments)]
760
+ : payments;
761
+ return allowed.length
762
+ ? result("ready", session, allowed)
763
+ : result("pending", session);
503
764
  }
504
- catch {
505
- return result("pending", session);
765
+ catch (error) {
766
+ if (error instanceof SafeRelayrRecoveryError)
767
+ throw error;
768
+ throw new SafeRelayrRecoveryError(session, error instanceof Error ? error.message : undefined);
506
769
  }
507
- const allowed = session.payments.length && payments.length
508
- ? [relayrRetryOption(session.payments, payments)]
509
- : payments;
510
- return allowed.length
511
- ? result("ready", session, allowed)
512
- : result("pending", session);
513
770
  }
514
771
  async function prepare({ account, executions: input, signal, onStatus, }) {
515
772
  const executions = structuredClone([...input]);
516
773
  validateExecutions(executions);
517
774
  return store.withLock(account, async () => {
518
775
  checkAccount(account, signal);
519
- const reservations = (await store.list(account)).filter((session) => unresolved(session) &&
520
- (unidentified(session) || overlap(session, executions)));
776
+ const reservations = (await store.list(account)).filter((session) => reserves(session, executions));
521
777
  for (const saved of reservations) {
778
+ if (canReplaceSafeRelayrQuote(saved))
779
+ continue;
522
780
  let checked;
523
781
  try {
524
- checked = await inspect(saved);
782
+ checked = await inspect(saved, signal);
525
783
  }
526
784
  catch (error) {
785
+ if (error instanceof SafeRelayrRecoveryError)
786
+ throw error;
527
787
  throw new SafeRelayrRecoveryError(saved, error instanceof Error ? error.message : undefined);
528
788
  }
529
789
  if (checked.state === "released" &&
@@ -537,14 +797,29 @@ export function createSafeRelayrController(options) {
537
797
  throw new SafeRelayrRecoveryError(checked.session, checked.recovery?.message, checked.recovery);
538
798
  await assertUnreserved(account, executions, saved.id);
539
799
  await revalidate(saved.executions, account, onStatus, false, signal);
800
+ phase("reviewing", checked.session, signal);
540
801
  await options.review(saved.executions, { resumed: true });
541
802
  checkAccount(account, signal);
542
803
  rememberReview(checked.session);
543
804
  return { ...checked, state: "ready", resumed: true };
544
805
  }
545
806
  await revalidate(executions, account, onStatus, false, signal);
807
+ phase("reviewing", undefined, signal);
546
808
  await options.review(executions, { resumed: false });
547
809
  checkAccount(account, signal);
810
+ // Re-read under the shared lock after review. A rejected review leaves the
811
+ // old quote untouched; a recorded wallet invocation can never be retired.
812
+ for (const saved of await store.list(account)) {
813
+ if (!reserves(saved, executions))
814
+ continue;
815
+ if (!canReplaceSafeRelayrQuote(saved))
816
+ throw new SafeRelayrRecoveryError(saved);
817
+ await store.save({
818
+ ...saved,
819
+ state: "released",
820
+ releaseReason: "quote-replaced",
821
+ });
822
+ }
548
823
  await assertUnreserved(account, executions);
549
824
  let session = {
550
825
  id: options.createId?.() ?? globalThis.crypto.randomUUID(),
@@ -555,8 +830,8 @@ export function createSafeRelayrController(options) {
555
830
  payments: [],
556
831
  createdAt: Date.now(),
557
832
  };
558
- // Persist before POST. An interrupted response may have published the Safe
559
- // signatures; loss of the bundle ID is never permission to publish twice.
833
+ // Persist before POST so the quote can be recovered or retired later.
834
+ // Safe publication alone is not evidence that the wallet funded it.
560
835
  await store.save(session);
561
836
  try {
562
837
  checkAccount(account, signal);
@@ -566,6 +841,7 @@ export function createSafeRelayrController(options) {
566
841
  throw error;
567
842
  }
568
843
  const request = relayrBundleRequest(executions.map(({ entry }) => entry));
844
+ phase("quoting", session, signal);
569
845
  const response = await fetchRelayr(`${RELAYR_API}/v1/bundle/prepaid`, {
570
846
  method: "POST",
571
847
  headers: { "content-type": "application/json" },
@@ -594,14 +870,18 @@ export function createSafeRelayrController(options) {
594
870
  return { state: "ready", session, payments, resumed: false };
595
871
  });
596
872
  }
597
- async function check({ account, sessionId, }) {
598
- return store.withLock(account, async () => inspect(await load(account, sessionId)));
873
+ async function check({ account, sessionId, signal, }) {
874
+ return store.withLock(account, async () => {
875
+ signal?.throwIfAborted();
876
+ const session = await load(account, sessionId);
877
+ return inspect(session, signal);
878
+ });
599
879
  }
600
880
  async function fund({ account, sessionId, paymentChainId, signal, onStatus, }) {
601
881
  return store.withLock(account, async () => {
602
882
  checkAccount(account, signal);
603
883
  let session = await load(account, sessionId);
604
- const checked = await inspect(session);
884
+ const checked = await inspect(session, signal);
605
885
  session = checked.session;
606
886
  if (checked.state !== "ready")
607
887
  throw new SafeRelayrRecoveryError(session, checked.recovery?.message, checked.recovery);
@@ -612,6 +892,7 @@ export function createSafeRelayrController(options) {
612
892
  throw new Error("Choose a current payment option from this saved quote.");
613
893
  await assertUnreserved(account, session.executions, session.id);
614
894
  if (!reviewedIntents.has(reviewKey(session))) {
895
+ phase("reviewing", session, signal);
615
896
  await options.review(session.executions, { resumed: true });
616
897
  checkAccount(account, signal);
617
898
  rememberReview(session);
@@ -659,6 +940,7 @@ export function createSafeRelayrController(options) {
659
940
  throw error;
660
941
  }
661
942
  sending = true;
943
+ phase("payment-submitting", session, signal);
662
944
  };
663
945
  const onSent = async (payments) => {
664
946
  const snapshot = relayrSentPaymentsSnapshot(payments);
@@ -696,8 +978,10 @@ export function createSafeRelayrController(options) {
696
978
  paymentStatus: "submitted",
697
979
  };
698
980
  await store.save(session);
981
+ phase("payment-confirming", session, signal);
699
982
  };
700
983
  try {
984
+ phase("payment-review", session, signal);
701
985
  const sent = await options.sendPayment({
702
986
  session: structuredClone(session),
703
987
  payment,
@@ -718,10 +1002,14 @@ export function createSafeRelayrController(options) {
718
1002
  paymentStatus: checked.session.paymentStatus,
719
1003
  };
720
1004
  await store.save(session);
1005
+ throw error;
721
1006
  }
1007
+ if (sending ||
1008
+ session.payments.length > checked.session.payments.length)
1009
+ throw new SafeRelayrRecoveryError(session, error instanceof Error ? error.message : undefined);
722
1010
  throw error;
723
1011
  }
724
- return inspect(session);
1012
+ return inspect(session, signal);
725
1013
  });
726
1014
  }
727
1015
  /** Read-only progress polling; each read releases its lock before the wait. */
@@ -734,7 +1022,7 @@ export function createSafeRelayrController(options) {
734
1022
  const started = Date.now();
735
1023
  while (true) {
736
1024
  signal?.throwIfAborted();
737
- const next = await check({ account, sessionId });
1025
+ const next = await check({ account, sessionId, signal });
738
1026
  signal?.throwIfAborted();
739
1027
  await onUpdate?.(next);
740
1028
  const remaining = timeoutMs - (Date.now() - started);