@bananapus/nana-sdk-core 2.35.0 → 2.37.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 (49) hide show
  1. package/dist/cjs/review/relayr.d.ts +22 -5
  2. package/dist/cjs/review/relayr.d.ts.map +1 -1
  3. package/dist/cjs/review/relayr.js +56 -29
  4. package/dist/cjs/review/relayr.js.map +1 -1
  5. package/dist/cjs/review/safeRelayr.d.ts +31 -2
  6. package/dist/cjs/review/safeRelayr.d.ts.map +1 -1
  7. package/dist/cjs/review/safeRelayr.js +388 -99
  8. package/dist/cjs/review/safeRelayr.js.map +1 -1
  9. package/dist/cjs/review/tabState.d.ts +23 -0
  10. package/dist/cjs/review/tabState.d.ts.map +1 -0
  11. package/dist/cjs/review/tabState.js +30 -0
  12. package/dist/cjs/review/tabState.js.map +1 -0
  13. package/dist/cjs/review/writeHold.d.ts +31 -5
  14. package/dist/cjs/review/writeHold.d.ts.map +1 -1
  15. package/dist/cjs/review/writeHold.js +260 -42
  16. package/dist/cjs/review/writeHold.js.map +1 -1
  17. package/dist/cjs/review/writeRecovery.d.ts +42 -3
  18. package/dist/cjs/review/writeRecovery.d.ts.map +1 -1
  19. package/dist/cjs/review/writeRecovery.js +168 -14
  20. package/dist/cjs/review/writeRecovery.js.map +1 -1
  21. package/dist/cjs/testing.d.ts +7 -0
  22. package/dist/cjs/testing.d.ts.map +1 -0
  23. package/dist/cjs/testing.js +30 -0
  24. package/dist/cjs/testing.js.map +1 -0
  25. package/dist/esm/review/relayr.d.ts +22 -5
  26. package/dist/esm/review/relayr.d.ts.map +1 -1
  27. package/dist/esm/review/relayr.js +55 -29
  28. package/dist/esm/review/relayr.js.map +1 -1
  29. package/dist/esm/review/safeRelayr.d.ts +31 -2
  30. package/dist/esm/review/safeRelayr.d.ts.map +1 -1
  31. package/dist/esm/review/safeRelayr.js +388 -99
  32. package/dist/esm/review/safeRelayr.js.map +1 -1
  33. package/dist/esm/review/tabState.d.ts +23 -0
  34. package/dist/esm/review/tabState.d.ts.map +1 -0
  35. package/dist/esm/review/tabState.js +26 -0
  36. package/dist/esm/review/tabState.js.map +1 -0
  37. package/dist/esm/review/writeHold.d.ts +31 -5
  38. package/dist/esm/review/writeHold.d.ts.map +1 -1
  39. package/dist/esm/review/writeHold.js +261 -43
  40. package/dist/esm/review/writeHold.js.map +1 -1
  41. package/dist/esm/review/writeRecovery.d.ts +42 -3
  42. package/dist/esm/review/writeRecovery.d.ts.map +1 -1
  43. package/dist/esm/review/writeRecovery.js +167 -16
  44. package/dist/esm/review/writeRecovery.js.map +1 -1
  45. package/dist/esm/testing.d.ts +7 -0
  46. package/dist/esm/testing.d.ts.map +1 -0
  47. package/dist/esm/testing.js +25 -0
  48. package/dist/esm/testing.js.map +1 -0
  49. package/package.json +6 -1
@@ -1,7 +1,8 @@
1
1
  import { decodeFunctionData, encodeFunctionData, isAddress, keccak256, toHex, parseAbi, zeroAddress, } from "viem";
2
2
  import { readBoundedSafeNonce } from "../safe.js";
3
3
  import { SAFE_EXEC_ABI, canonicalSafeTxHash, requireSafeExecutionSuccess, safeExecutionResult, } from "../safeService.js";
4
- import { RELAYR_API, RELAYR_UUID_RE, MAX_RELAYR_SENT_PAYMENTS, atCanonicalFinalizedBlock, bindRelayrQuote, readRelayrBundle, relayrBundleRequest, relayrDeadlinePassed, relayrPaymentOptions, relayrPaymentDetails, relayrPaymentAttemptOutcome, relayrDestinationHash, relayrDestinationHashes, relayrRetryOption, relayrSentPaymentsSnapshot, relayrStateIsPending, relayrStateIsFailed, relayrQuotedOptions, requireRelayrBundleUnpaid, requireRelayrRetry, revertedRelayrQuote, verifyRelayrDestinations, verifyRelayrDestination, verifyRelayrPayment, RelayrPaymentRevertedError, RelayrProofUnavailableError, } from "./relayr.js";
4
+ import { RELAYR_API, RELAYR_UUID_RE, MAX_RELAYR_SENT_PAYMENTS, atCanonicalFinalizedBlock, bindRelayrQuote, readRelayrBundle, relayrBundleRequest, relayrPaymentOptions, relayrPaymentDetails, relayrPaymentAttemptOutcome, relayrDestinationHash, relayrDestinationHashes, relayrRetryOption, relayrSentPaymentsSnapshot, relayrStateIsPending, relayrStateIsFailed, relayrDeadlinePassed, relayrQuotedOptions, relayrQuoteUnfundable, RelayrPaymentRetryError, requireRelayrBundleUnpaid, requireRelayrRetry, revertedRelayrQuote, verifyRelayrDestinations, verifyRelayrDestination, verifyRelayrPayment, RelayrPaymentRevertedError, RelayrProofUnavailableError, } from "./relayr.js";
5
+ import { TAB, tabShared } from "./tabState.js";
5
6
  function hasFundingRecords(records) {
6
7
  if (records === undefined)
7
8
  return false;
@@ -61,6 +62,28 @@ export function canReplaceSafeRelayrQuote(session) {
61
62
  return false;
62
63
  }
63
64
  }
65
+ /** The line a Safe action shows when its payment's status couldn't be read. */
66
+ export const SAFE_RELAYR_STATUS_UNKNOWN_LINE = "Couldn't check this payment's status. Try again in a moment.";
67
+ /** The line a Safe action shows when its quote can no longer be paid but hasn't expired onchain. */
68
+ export const SAFE_RELAYR_QUOTE_CLOSING_LINE = "This quote's payment window has closed. Try again in a few minutes for a new quote.";
69
+ /** The line a Safe action shows with its warned exit while Relayr's funding report is unresolved. */
70
+ export const SAFE_RELAYR_FUNDING_EXIT_LINE = "Relayr reported a payment for this quote. Check your wallet and Relayr before paying again.";
71
+ /** How long after funding was last reported its warned exit waits: an hour. */
72
+ const FUNDING_EXIT_MS = 60 * 60_000;
73
+ /** The line a Safe action shows with its warned exit (see {@link SafeRelayrRecovery.exit}). */
74
+ export const SAFE_RELAYR_UNCONFIRMED_LINE = "We couldn't confirm this payment's status. If your wallet shows no payment, get a new quote.";
75
+ /** A status read's failure that says nothing: no refusal Relayr gave. */
76
+ function statusUnknown(error) {
77
+ return (!(error instanceof RelayrPaymentRetryError) || error.reason === "unknown");
78
+ }
79
+ /** A refusal saying Relayr reports the bundle paid, or a call running or run. */
80
+ function fundingReported(error) {
81
+ return (error instanceof RelayrPaymentRetryError &&
82
+ (error.reason === "paid" || error.reason === "running"));
83
+ }
84
+ /** Reads of a bundle's payment status before an unknown answer is reported. */
85
+ const STATUS_READS = 3;
86
+ const STATUS_RETRY_MS = 250;
64
87
  export class SafeRelayrRecoveryError extends Error {
65
88
  constructor(session, message = "This Safe action already has a payment in progress. Check it before paying again.", recovery) {
66
89
  super(message);
@@ -72,7 +95,9 @@ export class SafeRelayrRecoveryError extends Error {
72
95
  // Review is a page-lifetime capability, never a local-storage claim. Multiple
73
96
  // hook instances can share it; reloading, changing exact bytes, or eviction
74
97
  // requires explicit review again. Eviction only adds a review, never a write.
75
- const reviewedIntents = new Set();
98
+ const reviewedIntents = tabShared(TAB.reviews, () => new Set());
99
+ /** The stop of every running watch, for the test-only reset. */
100
+ const watching = tabShared(TAB.watches, () => new Set());
76
101
  function reviewKey(session) {
77
102
  return keccak256(toHex(JSON.stringify([
78
103
  session.account.toLowerCase(),
@@ -263,7 +288,31 @@ function requireSessionQuote(session) {
263
288
  }
264
289
  /** Shared Safe Relayr state machine. It never signs Safe proposals or submits on prepare/check. */
265
290
  export function createSafeRelayrController(options) {
266
- const { store } = options;
291
+ /**
292
+ * The app's store, with funding evidence sticky in its one save path: a
293
+ * save from an older snapshot never drops `fundingObserved` or an older
294
+ * `fundingObservedAt` another flow saved. Nothing ever clears them; an
295
+ * exit keeps them for reconciliation.
296
+ */
297
+ const store = {
298
+ get scope() {
299
+ return options.store.scope;
300
+ },
301
+ list: (account) => options.store.list(account),
302
+ withLock: (account, run) => options.store.withLock(account, run),
303
+ async save(next) {
304
+ const saved = (await options.store.list(next.account)).find((session) => session.id === next.id);
305
+ const at = [saved?.fundingObservedAt, next.fundingObservedAt].filter((value) => typeof value === "number");
306
+ // Once seen, funding stays, and its clock only moves forward.
307
+ return options.store.save(saved?.fundingObserved || next.fundingObserved
308
+ ? {
309
+ ...next,
310
+ fundingObserved: true,
311
+ ...(at.length ? { fundingObservedAt: Math.max(...at) } : {}),
312
+ }
313
+ : next);
314
+ },
315
+ };
267
316
  const fetchRelayr = options.fetch ?? globalThis.fetch;
268
317
  const displayedExecutions = new Map();
269
318
  const notify = (progress, rechecking = false) => {
@@ -451,18 +500,14 @@ export function createSafeRelayrController(options) {
451
500
  checks,
452
501
  });
453
502
  if (session.bundleUuid) {
454
- try {
455
- await requireRelayrBundleUnpaid(session.bundleUuid, {
456
- fetch: fetchRelayr,
457
- });
458
- }
459
- catch {
503
+ const [status, read] = await bundleUnpaid(session);
504
+ session = read;
505
+ if (status !== "unpaid")
460
506
  return pending({
461
507
  reason: "funding-unresolved",
462
508
  message: "Every saved Safe nonce has been used, but the existing Relayr bundle has not been proven unpaid with all calls pending. Keep its payment evidence and inspect the original bundle before replacing this selection.",
463
509
  checks,
464
510
  });
465
- }
466
511
  }
467
512
  session = {
468
513
  ...session,
@@ -487,6 +532,192 @@ export function createSafeRelayrController(options) {
487
532
  checks,
488
533
  });
489
534
  }
535
+ /**
536
+ * `read` of the bundle's status, tried up to {@link STATUS_READS} times
537
+ * while its failure is unknown. Every status read goes through it.
538
+ */
539
+ async function statusRead(read) {
540
+ for (let attempt = 1;; attempt += 1) {
541
+ try {
542
+ return await read();
543
+ }
544
+ catch (error) {
545
+ if (!statusUnknown(error) || attempt >= STATUS_READS)
546
+ throw error;
547
+ await new Promise((resolve) => setTimeout(resolve, STATUS_RETRY_MS));
548
+ }
549
+ }
550
+ }
551
+ /** `session`, saved with the funding Relayr reported. */
552
+ async function observed(session) {
553
+ session = {
554
+ ...session,
555
+ fundingObserved: true,
556
+ fundingObservedAt: Date.now(),
557
+ };
558
+ await store.save(session);
559
+ return session;
560
+ }
561
+ /**
562
+ * The bundle's payment status, three-valued: proven unpaid, funded (Relayr
563
+ * reports it paid or running, saved as observed funding), or unknown when
564
+ * it couldn't be read. Unknown is never paid, and never free to pay again.
565
+ */
566
+ async function bundleUnpaid(session) {
567
+ try {
568
+ await statusRead(() => requireRelayrBundleUnpaid(session.bundleUuid, { fetch: fetchRelayr }));
569
+ return ["unpaid", session];
570
+ }
571
+ catch (error) {
572
+ if (!fundingReported(error))
573
+ return ["unknown", session];
574
+ return ["funded", await observed(session)];
575
+ }
576
+ }
577
+ /**
578
+ * What a quote whose payments didn't fund it allows
579
+ * ({@link revertedRelayrQuote}), read after {@link bundleUnpaid} proved it
580
+ * unpaid; its own reads are confirmations. Funding they report is saved.
581
+ * Null while unproven, or with `rethrow`, its reason thrown.
582
+ */
583
+ async function disposed(session, quote, chains, rethrow = false) {
584
+ let state;
585
+ /** The last read before release failed after its retries: unknown. */
586
+ let unconfirmed = false;
587
+ try {
588
+ ({ state } = await revertedRelayrQuote(options.clientFor, {
589
+ bundleUuid: quote.bundle_uuid,
590
+ payments: session.payments,
591
+ options: quote.payment_info,
592
+ destinationChainIds: chains,
593
+ account: session.account,
594
+ }, {
595
+ fetch: fetchRelayr,
596
+ // Every read of its bundle, through the same retries.
597
+ statusRead: (read) => statusRead(read).catch((error) => {
598
+ unconfirmed = statusUnknown(error);
599
+ throw error;
600
+ }),
601
+ }));
602
+ }
603
+ catch (error) {
604
+ if (unconfirmed)
605
+ return { state: "unknown", session };
606
+ if (rethrow)
607
+ throw error;
608
+ return null;
609
+ }
610
+ return {
611
+ state,
612
+ session: state === "funded" ? await observed(session) : session,
613
+ };
614
+ }
615
+ /**
616
+ * Held while reported funding is unresolved: never a release or a retry.
617
+ * Once every deadline is final and an hour has passed since funding was
618
+ * last reported, the user may leave it, strongly warned: a spurious
619
+ * report is no dead end.
620
+ */
621
+ async function fundingUnresolved(session) {
622
+ const exit = await fundingExitDue(session, session.quote);
623
+ return {
624
+ ...result("pending", session),
625
+ recovery: exit
626
+ ? {
627
+ reason: "funding-unresolved",
628
+ message: SAFE_RELAYR_FUNDING_EXIT_LINE,
629
+ exit: true,
630
+ }
631
+ : {
632
+ reason: "funding-unresolved",
633
+ message: "This saved selection has reported funding or execution activity. Check the existing bundle and its funding transactions before submitting another payment.",
634
+ },
635
+ };
636
+ }
637
+ /** Reported funding's warned exit is due: an hour since it was last reported, and every deadline final. */
638
+ async function fundingExitDue(session, quote) {
639
+ return (Date.now() - Number(session.fundingObservedAt) >= FUNDING_EXIT_MS &&
640
+ (await deadlinesFinal(session, quote)));
641
+ }
642
+ /** Every deadline of the quote's options and payments passed at a canonical finalized block. */
643
+ async function deadlinesFinal(session, quote) {
644
+ const deadlines = [
645
+ ...relayrQuotedOptions(quote, session.executions.map(({ entry }) => entry.chain)).map(({ details }) => details),
646
+ ...session.payments,
647
+ ];
648
+ for (const { chainId, deadline } of deadlines) {
649
+ const client = options.clientFor(chainId);
650
+ if (!client || (await relayrDeadlinePassed(client, deadline)) !== true)
651
+ return false;
652
+ }
653
+ return true;
654
+ }
655
+ /**
656
+ * A held result that says why: in progress, unknown, or closing. Unknown
657
+ * offers the warned exit once nothing shown can fund the quote.
658
+ */
659
+ async function held(why, session) {
660
+ if (why === "funded")
661
+ return result("pending", session);
662
+ if (why === "closing")
663
+ return {
664
+ ...result("pending", session),
665
+ recovery: {
666
+ reason: "quote-closing",
667
+ message: SAFE_RELAYR_QUOTE_CLOSING_LINE,
668
+ retryable: true,
669
+ },
670
+ };
671
+ const quote = session.quote;
672
+ // With funding reported and unresolved, the exit also waits for the
673
+ // funding exit's rule (see fundingUnresolved), with its stronger warning.
674
+ const funded = hasObservedFunding(session);
675
+ const spent = !!quote &&
676
+ (!funded || (await fundingExitDue(session, quote))) &&
677
+ (await relayrQuoteUnfundable(options.clientFor, {
678
+ bundleUuid: quote.bundle_uuid,
679
+ payments: session.payments,
680
+ options: quote.payment_info,
681
+ destinationChainIds: session.executions.map(({ entry }) => entry.chain),
682
+ account: session.account,
683
+ }, { unseen: true }).catch(() => null)) === true;
684
+ return {
685
+ ...result("pending", session),
686
+ recovery: spent
687
+ ? {
688
+ reason: "status-unknown",
689
+ message: funded
690
+ ? SAFE_RELAYR_FUNDING_EXIT_LINE
691
+ : SAFE_RELAYR_UNCONFIRMED_LINE,
692
+ retryable: true,
693
+ exit: true,
694
+ }
695
+ : {
696
+ reason: "status-unknown",
697
+ message: SAFE_RELAYR_STATUS_UNKNOWN_LINE,
698
+ retryable: true,
699
+ },
700
+ };
701
+ }
702
+ /** The status-unknown refusal, for a status read outside an inspection. */
703
+ const unknownStatus = (session) => new SafeRelayrRecoveryError(session, SAFE_RELAYR_STATUS_UNKNOWN_LINE, {
704
+ reason: "status-unknown",
705
+ message: SAFE_RELAYR_STATUS_UNKNOWN_LINE,
706
+ retryable: true,
707
+ });
708
+ /**
709
+ * Why an overlapping quote is kept, or null when it may be replaced: it is
710
+ * unused (see {@link canReplaceSafeRelayrQuote}) and, once quoted, a status
711
+ * read now proves it unpaid. Funding it reports is saved; unknown keeps it.
712
+ */
713
+ async function unreplaceable(saved) {
714
+ if (!canReplaceSafeRelayrQuote(saved))
715
+ return result("pending", saved);
716
+ if (!saved.bundleUuid)
717
+ return null;
718
+ const [status, read] = await bundleUnpaid(saved);
719
+ return status === "unpaid" ? null : held(status, read);
720
+ }
490
721
  async function inspect(session, signal) {
491
722
  const report = (progress, rechecking = false) => {
492
723
  if (!signal?.aborted)
@@ -498,23 +729,28 @@ export function createSafeRelayrController(options) {
498
729
  return result("released", session);
499
730
  if (session.state === "complete")
500
731
  return result("complete", session);
501
- if (hasObservedFunding(session) && session.fundingObserved !== true) {
502
- session = { ...session, fundingObserved: true };
732
+ if (hasObservedFunding(session) &&
733
+ (session.fundingObserved !== true ||
734
+ session.fundingObservedAt === undefined)) {
735
+ // A record saved before the clock existed starts it now.
736
+ session = {
737
+ ...session,
738
+ fundingObserved: true,
739
+ fundingObservedAt: session.fundingObservedAt ?? Date.now(),
740
+ };
503
741
  await store.save(session);
504
742
  }
505
743
  if (!session.bundleUuid || !session.quote)
506
744
  return inspectMissingQuote(session);
507
745
  const quote = requireSessionQuote(session);
508
746
  const chains = session.executions.map(({ entry }) => entry.chain);
509
- const bundle = await readRelayrBundle(session.bundleUuid, {
510
- fetch: fetchRelayr,
511
- });
512
- if (hasObservedFunding(session) ||
513
- bundle.payment_received === true ||
514
- hasFundingRecords(bundle.transactions)) {
515
- session = { ...session, fundingObserved: true };
516
- await store.save(session);
517
- }
747
+ const bundleUuid = session.bundleUuid;
748
+ const bundle = await statusRead(() => readRelayrBundle(bundleUuid, { fetch: fetchRelayr })).catch(() => null);
749
+ if (!bundle)
750
+ return held("unknown", session);
751
+ if (bundle.payment_received === true ||
752
+ hasFundingRecords(bundle.transactions))
753
+ session = await observed(session);
518
754
  if (!Array.isArray(bundle.transactions))
519
755
  throw new SafeRelayrRecoveryError(session, "Relayr has not returned this bundle's transactions. Check the existing bundle again.");
520
756
  const records = bundle.transactions;
@@ -680,18 +916,28 @@ export function createSafeRelayrController(options) {
680
916
  everyReverted = false;
681
917
  }
682
918
  }
919
+ // Funding reported once stays unresolved until the chain proves a
920
+ // payment funded it (above) or the calls ran: a later unpaid read,
921
+ // or this session's own payments reverting, can't exclude another
922
+ // payment. No release, no retry.
923
+ if (hasObservedFunding(session))
924
+ return await fundingUnresolved(session);
683
925
  if (!everyReverted) {
684
926
  // A payment through a wallet contract that reverted is no
685
927
  // RelayrPaymentRevertedError. Once nothing can fund the quote, it
686
- // is released like an expired one.
687
- const disposition = await revertedRelayrQuote(options.clientFor, {
688
- bundleUuid: quote.bundle_uuid,
689
- payments: session.payments,
690
- options: quote.payment_info,
691
- destinationChainIds: chains,
692
- account: session.account,
693
- }, { fetch: fetchRelayr }).catch(() => null);
694
- if (disposition?.state !== "released")
928
+ // is released like an expired one. A payment that can't be shown
929
+ // reverted (dropped, or unreadable) is unknown: never released on
930
+ // its own, since no node can prove a transaction doesn't exist;
931
+ // the warned exit is offered once the chain says it has none.
932
+ const [status, read] = await bundleUnpaid(session);
933
+ session = read;
934
+ if (status === "funded")
935
+ return held("funded", session);
936
+ const disposition = status === "unpaid" ? await disposed(session, quote, chains) : null;
937
+ if (!disposition || disposition.state === "unknown")
938
+ return held("unknown", session);
939
+ session = disposition.session;
940
+ if (disposition.state !== "released")
695
941
  return result("pending", session);
696
942
  session = {
697
943
  ...session,
@@ -704,13 +950,15 @@ export function createSafeRelayrController(options) {
704
950
  }
705
951
  session = { ...session, paymentStatus: "reverted" };
706
952
  await store.save(session);
707
- const disposition = await revertedRelayrQuote(options.clientFor, {
708
- bundleUuid: quote.bundle_uuid,
709
- payments: session.payments,
710
- options: quote.payment_info,
711
- destinationChainIds: chains,
712
- account: session.account,
713
- }, { fetch: fetchRelayr });
953
+ const [status, read] = await bundleUnpaid(session);
954
+ session = read;
955
+ if (status !== "unpaid")
956
+ return held(status, session);
957
+ // Unpaid, its release may still be unproven: it says why.
958
+ const disposition = (await disposed(session, quote, chains, true));
959
+ session = disposition.session;
960
+ if (disposition.state === "unknown")
961
+ return held("unknown", session);
714
962
  if (disposition.state === "funded")
715
963
  return result("pending", session);
716
964
  if (disposition.state === "released") {
@@ -728,13 +976,7 @@ export function createSafeRelayrController(options) {
728
976
  session.fundingObserved) {
729
977
  // A wallet invocation without a known hash may still have broadcast.
730
978
  return session.fundingObserved && !known.length
731
- ? {
732
- ...result("pending", session),
733
- recovery: {
734
- reason: "funding-unresolved",
735
- message: "This saved selection has reported funding or execution activity. Check the existing bundle and its funding transactions before submitting another payment.",
736
- },
737
- }
979
+ ? await fundingUnresolved(session)
738
980
  : result("pending", session);
739
981
  }
740
982
  const quoted = relayrQuotedOptions(quote, chains);
@@ -745,18 +987,15 @@ export function createSafeRelayrController(options) {
745
987
  ? [false]
746
988
  : await Promise.all(quoted.map(async ({ details }) => {
747
989
  const client = options.clientFor(details.chainId);
748
- return (!!client &&
749
- (await relayrDeadlinePassed(client, details.deadline).catch(() => false)));
990
+ return client
991
+ ? relayrDeadlinePassed(client, details.deadline)
992
+ : false;
750
993
  }));
751
- if (expired.every(Boolean)) {
752
- try {
753
- await requireRelayrBundleUnpaid(quote.bundle_uuid, {
754
- fetch: fetchRelayr,
755
- });
756
- }
757
- catch {
758
- return result("pending", session);
759
- }
994
+ const [unpaid, read] = await bundleUnpaid(session);
995
+ session = read;
996
+ if (unpaid !== "unpaid")
997
+ return held(unpaid, session);
998
+ if (expired.every((passed) => passed === true)) {
760
999
  session = {
761
1000
  ...session,
762
1001
  state: "released",
@@ -766,15 +1005,11 @@ export function createSafeRelayrController(options) {
766
1005
  await store.save(session);
767
1006
  return result("released", session);
768
1007
  }
769
- try {
770
- await requireRelayrBundleUnpaid(quote.bundle_uuid, {
771
- fetch: fetchRelayr,
772
- });
773
- }
774
- catch {
775
- return result("pending", session);
776
- }
777
- const allowed = session.payments.length && payments.length
1008
+ // No option the browser's clock still allows, though the chain hasn't
1009
+ // shown the quote expired: closing, or unknown if the chain couldn't say.
1010
+ if (!payments.length)
1011
+ return held(expired.some((passed) => passed === null) ? "unknown" : "closing", session);
1012
+ const allowed = session.payments.length
778
1013
  ? [relayrRetryOption(session.payments, payments)]
779
1014
  : payments;
780
1015
  return allowed.length
@@ -793,9 +1028,12 @@ export function createSafeRelayrController(options) {
793
1028
  return store.withLock(account, async () => {
794
1029
  checkAccount(account, signal);
795
1030
  const reservations = (await store.list(account)).filter((session) => reserves(session, executions));
796
- for (const saved of reservations) {
797
- if (canReplaceSafeRelayrQuote(saved))
1031
+ for (const found of reservations) {
1032
+ const kept = await unreplaceable(found);
1033
+ if (!kept)
798
1034
  continue;
1035
+ // What its status read saved, a funding report included.
1036
+ const saved = kept.session;
799
1037
  let checked;
800
1038
  try {
801
1039
  checked = await inspect(saved, signal);
@@ -831,8 +1069,9 @@ export function createSafeRelayrController(options) {
831
1069
  for (const saved of await store.list(account)) {
832
1070
  if (!reserves(saved, executions))
833
1071
  continue;
834
- if (!canReplaceSafeRelayrQuote(saved))
835
- throw new SafeRelayrRecoveryError(saved);
1072
+ const kept = await unreplaceable(saved);
1073
+ if (kept)
1074
+ throw new SafeRelayrRecoveryError(kept.session, kept.recovery?.message, kept.recovery);
836
1075
  await store.save({
837
1076
  ...saved,
838
1077
  state: "released",
@@ -927,17 +1166,21 @@ export function createSafeRelayrController(options) {
927
1166
  phase("payment-checking", session, signal);
928
1167
  await revalidate(session.executions, account, onStatus, true, signal);
929
1168
  await assertUnreserved(account, session.executions, session.id);
930
- if (session.payments.length) {
931
- await requireRelayrRetry(options.clientFor, {
1169
+ await statusRead(() => session.payments.length
1170
+ ? requireRelayrRetry(options.clientFor, {
932
1171
  payments: session.payments,
933
1172
  from: account,
934
1173
  bundleUuid: session.bundleUuid,
935
- }, { fetch: fetchRelayr });
936
- }
937
- else
938
- await requireRelayrBundleUnpaid(session.bundleUuid, {
1174
+ }, { fetch: fetchRelayr })
1175
+ : requireRelayrBundleUnpaid(session.bundleUuid, {
939
1176
  fetch: fetchRelayr,
940
- });
1177
+ })).catch(async (error) => {
1178
+ if (statusUnknown(error))
1179
+ throw unknownStatus(session);
1180
+ if (fundingReported(error))
1181
+ session = await observed(session);
1182
+ throw error;
1183
+ });
941
1184
  checkAccount(account, signal);
942
1185
  requireOpenPayment();
943
1186
  verified = true;
@@ -946,6 +1189,17 @@ export function createSafeRelayrController(options) {
946
1189
  checkAccount(account, signal);
947
1190
  if (!verified || sending)
948
1191
  throw new Error("Recheck the Safe execution before funding it.");
1192
+ // The saved record, read again just before the wallet opens: funding
1193
+ // another flow reported, or any change to its payment, refuses.
1194
+ const stored = await load(account, session.id);
1195
+ if (hasObservedFunding(stored) ||
1196
+ stored.state !== session.state ||
1197
+ stored.paymentStatus !== session.paymentStatus ||
1198
+ stored.bundleUuid !== session.bundleUuid ||
1199
+ JSON.stringify(stored.payments) !== JSON.stringify(session.payments))
1200
+ throw new SafeRelayrRecoveryError(stored, hasObservedFunding(stored)
1201
+ ? SAFE_RELAYR_FUNDING_EXIT_LINE
1202
+ : "This saved selection changed while it was being paid. Check it again before paying.");
949
1203
  session = { ...session, paymentStatus: "sending" };
950
1204
  await store.save(session);
951
1205
  try {
@@ -1063,31 +1317,66 @@ export function createSafeRelayrController(options) {
1063
1317
  !Number.isFinite(timeoutMs) ||
1064
1318
  timeoutMs < 0)
1065
1319
  throw new Error("Safe Relayr polling needs a positive interval and a finite timeout.");
1066
- const started = Date.now();
1067
- while (true) {
1068
- signal?.throwIfAborted();
1069
- const next = await check({ account, sessionId, signal });
1070
- signal?.throwIfAborted();
1071
- await onUpdate?.(next);
1072
- const remaining = timeoutMs - (Date.now() - started);
1073
- if (next.state !== "pending" || remaining <= 0)
1074
- return next;
1075
- await new Promise((resolve, reject) => {
1076
- const aborted = () => {
1077
- clearTimeout(timer);
1078
- signal?.removeEventListener("abort", aborted);
1079
- reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
1080
- };
1081
- const timer = setTimeout(() => {
1082
- signal?.removeEventListener("abort", aborted);
1083
- resolve();
1084
- }, Math.min(intervalMs, remaining));
1085
- signal?.addEventListener("abort", aborted, { once: true });
1086
- if (signal?.aborted)
1087
- aborted();
1088
- });
1320
+ // The caller's signal, or a test reset, stops it.
1321
+ const halt = new AbortController();
1322
+ const stop = () => halt.abort(signal?.aborted
1323
+ ? signal.reason
1324
+ : new DOMException("Aborted", "AbortError"));
1325
+ watching.add(stop);
1326
+ signal?.addEventListener("abort", stop, { once: true });
1327
+ if (signal?.aborted)
1328
+ stop();
1329
+ const halted = halt.signal;
1330
+ try {
1331
+ const started = Date.now();
1332
+ while (true) {
1333
+ halted.throwIfAborted();
1334
+ const next = await check({ account, sessionId, signal: halted });
1335
+ halted.throwIfAborted();
1336
+ await onUpdate?.(next);
1337
+ const remaining = timeoutMs - (Date.now() - started);
1338
+ if (next.state !== "pending" || remaining <= 0)
1339
+ return next;
1340
+ await new Promise((resolve, reject) => {
1341
+ const aborted = () => {
1342
+ clearTimeout(timer);
1343
+ reject(halted.reason);
1344
+ };
1345
+ const timer = setTimeout(() => {
1346
+ halted.removeEventListener("abort", aborted);
1347
+ resolve();
1348
+ }, Math.min(intervalMs, remaining));
1349
+ halted.addEventListener("abort", aborted, { once: true });
1350
+ if (halted.aborted)
1351
+ aborted();
1352
+ });
1353
+ }
1354
+ }
1355
+ finally {
1356
+ watching.delete(stop);
1357
+ signal?.removeEventListener("abort", stop);
1089
1358
  }
1090
1359
  }
1091
- return { prepare, check, fund, watch };
1360
+ /**
1361
+ * The user's warned exit from a quote whose status stays unknown (see
1362
+ * {@link SafeRelayrRecovery.exit}): rechecked now, then released, its
1363
+ * record kept for reconciliation, so a new quote can follow.
1364
+ */
1365
+ async function exit({ account, sessionId, signal, }) {
1366
+ return store.withLock(account, async () => {
1367
+ checkAccount(account, signal);
1368
+ const checked = await inspect(await load(account, sessionId), signal);
1369
+ if (!checked.recovery?.exit)
1370
+ throw new SafeRelayrRecoveryError(checked.session, checked.recovery?.message, checked.recovery);
1371
+ const session = {
1372
+ ...checked.session,
1373
+ state: "released",
1374
+ releaseReason: "payment-unconfirmed",
1375
+ };
1376
+ await store.save(session);
1377
+ return result("released", session);
1378
+ });
1379
+ }
1380
+ return { prepare, check, fund, watch, exit };
1092
1381
  }
1093
1382
  //# sourceMappingURL=safeRelayr.js.map