@bananapus/nana-sdk-core 2.24.2 → 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,7 @@ 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, relayrStateIsPending, 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
6
  function hasFundingRecords(records) {
7
7
  if (records === undefined)
8
8
  return false;
@@ -221,6 +221,13 @@ function requireSafeRelayrRecords(bindings, records) {
221
221
  throw new Error("Relayr's record does not match the saved Safe transaction binding.");
222
222
  }
223
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
+ }
224
231
  /** Canonical exact destination receipts plus matching, refund-free Safe success events. */
225
232
  export async function verifySafeRelayrLanding(clientFor, { executions, bindings, records, }) {
226
233
  validateExecutions(executions);
@@ -233,13 +240,7 @@ export async function verifySafeRelayrLanding(clientFor, { executions, bindings,
233
240
  bindings,
234
241
  records,
235
242
  });
236
- verified.forEach(({ receipt }, index) => {
237
- const { safe, safeTxHash } = executions[index];
238
- requireSafeExecutionSuccess(receipt, safe, safeTxHash);
239
- const event = safeExecutionResult(receipt, safe, safeTxHash);
240
- if (event.status !== "success" || event.payment !== 0n)
241
- throw new Error("The Safe execution reimbursed its executor. Keep the existing bundle for verification.");
242
- });
243
+ verified.forEach((item, index) => requireSafeLanding(executions[index], item));
243
244
  return verified;
244
245
  }
245
246
  /** Validate persisted bindings again; local storage is not authenticated evidence. */
@@ -265,6 +266,49 @@ function requireSessionQuote(session) {
265
266
  export function createSafeRelayrController(options) {
266
267
  const { store } = options;
267
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
+ };
268
312
  const checkAccount = (account, signal) => {
269
313
  signal?.throwIfAborted();
270
314
  if (!isAddress(account) ||
@@ -444,87 +488,256 @@ export function createSafeRelayrController(options) {
444
488
  checks,
445
489
  });
446
490
  }
447
- async function inspect(session) {
448
- if (session.state === "released")
449
- return result("released", session);
450
- if (session.state === "complete")
451
- return result("complete", session);
452
- if (hasObservedFunding(session) && session.fundingObserved !== true) {
453
- session = { ...session, fundingObserved: true };
454
- await store.save(session);
455
- }
456
- if (!session.bundleUuid || !session.quote)
457
- return inspectMissingQuote(session);
458
- const quote = requireSessionQuote(session);
459
- const chains = session.executions.map(({ entry }) => entry.chain);
460
- const bundle = await readRelayrBundle(session.bundleUuid, {
461
- fetch: fetchRelayr,
462
- });
463
- if (hasObservedFunding(session) ||
464
- bundle.payment_received === true ||
465
- hasFundingRecords(bundle.transactions)) {
466
- session = { ...session, fundingObserved: true };
467
- await store.save(session);
468
- }
469
- if (!Array.isArray(bundle.transactions))
470
- throw new SafeRelayrRecoveryError(session, "Relayr has not returned this bundle's transactions. Check the existing bundle again.");
471
- const records = bundle.transactions;
472
- requireSafeRelayrRecords(quote.expectedTransactions, records);
473
- session = { ...session, records };
474
- await store.save(session);
475
- // A reported hash is only a pointer. Completion requires canonical exact
476
- // calldata/value receipts AND the matching Safe ExecutionSuccess event.
477
- if (records.length &&
478
- records.every((record) => record.status?.data?.hash || record.status?.data?.transaction?.hash)) {
479
- const verified = await verifySafeRelayrLanding(options.clientFor, {
480
- executions: session.executions,
481
- bindings: quote.expectedTransactions,
482
- 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,
483
512
  });
484
- await Promise.all(session.executions.map((execution, index) => options.afterVerified?.(execution, verified[index])));
485
- 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 };
486
524
  await store.save(session);
487
- return result("complete", session);
488
- }
489
- if (session.paymentStatus === "sending")
490
- return result("pending", session);
491
- if (session.payments.some((payment) => payment.bundleUuid !== session.bundleUuid))
492
- throw new SafeRelayrRecoveryError(session, "A saved payment belongs to another bundle. Keep the existing bundle for verification.");
493
- if (session.payments.length) {
494
- let everyReverted = true;
495
- for (const payment of session.payments) {
496
- const client = options.clientFor(payment.chainId);
497
- if (!client)
498
- 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) {
499
536
  try {
500
- await verifyRelayrPayment(client, {
501
- hash: payment.hash,
502
- from: session.account,
503
- payment,
537
+ relayrDestinationHashes({
538
+ bindings: known.map(({ binding }) => binding),
539
+ records: known.map(({ record }) => record),
504
540
  });
505
- session = { ...session, paymentStatus: "confirmed" };
506
- await store.save(session);
507
- return result("pending", session);
508
541
  }
509
542
  catch (error) {
510
- if (!(error instanceof RelayrPaymentRevertedError))
511
- 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);
512
556
  }
557
+ phase("executing", session, signal);
513
558
  }
514
- if (!everyReverted)
515
- return result("pending", session);
516
- session = { ...session, paymentStatus: "reverted" };
517
- await store.save(session);
518
- const disposition = await revertedRelayrQuote(options.clientFor, {
519
- bundleUuid: quote.bundle_uuid,
520
- payments: session.payments,
521
- options: quote.payment_info,
522
- destinationChainIds: chains,
523
- account: session.account,
524
- }, { fetch: fetchRelayr });
525
- 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")
526
659
  return result("pending", session);
527
- 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
+ }
528
741
  session = {
529
742
  ...session,
530
743
  state: "released",
@@ -534,32 +747,6 @@ export function createSafeRelayrController(options) {
534
747
  await store.save(session);
535
748
  return result("released", session);
536
749
  }
537
- }
538
- else if (session.paymentStatus !== "unfunded" ||
539
- session.fundingObserved) {
540
- // A wallet invocation without a known hash may still have broadcast.
541
- return session.fundingObserved
542
- ? {
543
- ...result("pending", session),
544
- recovery: {
545
- reason: "funding-unresolved",
546
- message: "This saved selection has reported funding or execution activity. Check the existing bundle and its funding transactions before submitting another payment.",
547
- },
548
- }
549
- : result("pending", session);
550
- }
551
- const quoted = relayrQuotedOptions(quote, chains);
552
- if (!quoted.length)
553
- return inspectMissingQuote(session);
554
- const payments = relayrPaymentOptions(quote, chains);
555
- const expired = payments.length
556
- ? [false]
557
- : await Promise.all(quoted.map(async ({ details }) => {
558
- const client = options.clientFor(details.chainId);
559
- return (!!client &&
560
- (await relayrDeadlinePassed(client, details.deadline).catch(() => false)));
561
- }));
562
- if (expired.every(Boolean)) {
563
750
  try {
564
751
  await requireRelayrBundleUnpaid(quote.bundle_uuid, {
565
752
  fetch: fetchRelayr,
@@ -568,29 +755,18 @@ export function createSafeRelayrController(options) {
568
755
  catch {
569
756
  return result("pending", session);
570
757
  }
571
- session = {
572
- ...session,
573
- state: "released",
574
- paymentStatus: "expired",
575
- releaseReason: "quote-expired",
576
- };
577
- await store.save(session);
578
- return result("released", session);
579
- }
580
- try {
581
- await requireRelayrBundleUnpaid(quote.bundle_uuid, {
582
- fetch: fetchRelayr,
583
- });
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);
584
764
  }
585
- catch {
586
- 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);
587
769
  }
588
- const allowed = session.payments.length && payments.length
589
- ? [relayrRetryOption(session.payments, payments)]
590
- : payments;
591
- return allowed.length
592
- ? result("ready", session, allowed)
593
- : result("pending", session);
594
770
  }
595
771
  async function prepare({ account, executions: input, signal, onStatus, }) {
596
772
  const executions = structuredClone([...input]);
@@ -603,9 +779,11 @@ export function createSafeRelayrController(options) {
603
779
  continue;
604
780
  let checked;
605
781
  try {
606
- checked = await inspect(saved);
782
+ checked = await inspect(saved, signal);
607
783
  }
608
784
  catch (error) {
785
+ if (error instanceof SafeRelayrRecoveryError)
786
+ throw error;
609
787
  throw new SafeRelayrRecoveryError(saved, error instanceof Error ? error.message : undefined);
610
788
  }
611
789
  if (checked.state === "released" &&
@@ -619,12 +797,14 @@ export function createSafeRelayrController(options) {
619
797
  throw new SafeRelayrRecoveryError(checked.session, checked.recovery?.message, checked.recovery);
620
798
  await assertUnreserved(account, executions, saved.id);
621
799
  await revalidate(saved.executions, account, onStatus, false, signal);
800
+ phase("reviewing", checked.session, signal);
622
801
  await options.review(saved.executions, { resumed: true });
623
802
  checkAccount(account, signal);
624
803
  rememberReview(checked.session);
625
804
  return { ...checked, state: "ready", resumed: true };
626
805
  }
627
806
  await revalidate(executions, account, onStatus, false, signal);
807
+ phase("reviewing", undefined, signal);
628
808
  await options.review(executions, { resumed: false });
629
809
  checkAccount(account, signal);
630
810
  // Re-read under the shared lock after review. A rejected review leaves the
@@ -661,6 +841,7 @@ export function createSafeRelayrController(options) {
661
841
  throw error;
662
842
  }
663
843
  const request = relayrBundleRequest(executions.map(({ entry }) => entry));
844
+ phase("quoting", session, signal);
664
845
  const response = await fetchRelayr(`${RELAYR_API}/v1/bundle/prepaid`, {
665
846
  method: "POST",
666
847
  headers: { "content-type": "application/json" },
@@ -689,14 +870,18 @@ export function createSafeRelayrController(options) {
689
870
  return { state: "ready", session, payments, resumed: false };
690
871
  });
691
872
  }
692
- async function check({ account, sessionId, }) {
693
- 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
+ });
694
879
  }
695
880
  async function fund({ account, sessionId, paymentChainId, signal, onStatus, }) {
696
881
  return store.withLock(account, async () => {
697
882
  checkAccount(account, signal);
698
883
  let session = await load(account, sessionId);
699
- const checked = await inspect(session);
884
+ const checked = await inspect(session, signal);
700
885
  session = checked.session;
701
886
  if (checked.state !== "ready")
702
887
  throw new SafeRelayrRecoveryError(session, checked.recovery?.message, checked.recovery);
@@ -707,6 +892,7 @@ export function createSafeRelayrController(options) {
707
892
  throw new Error("Choose a current payment option from this saved quote.");
708
893
  await assertUnreserved(account, session.executions, session.id);
709
894
  if (!reviewedIntents.has(reviewKey(session))) {
895
+ phase("reviewing", session, signal);
710
896
  await options.review(session.executions, { resumed: true });
711
897
  checkAccount(account, signal);
712
898
  rememberReview(session);
@@ -754,6 +940,7 @@ export function createSafeRelayrController(options) {
754
940
  throw error;
755
941
  }
756
942
  sending = true;
943
+ phase("payment-submitting", session, signal);
757
944
  };
758
945
  const onSent = async (payments) => {
759
946
  const snapshot = relayrSentPaymentsSnapshot(payments);
@@ -791,8 +978,10 @@ export function createSafeRelayrController(options) {
791
978
  paymentStatus: "submitted",
792
979
  };
793
980
  await store.save(session);
981
+ phase("payment-confirming", session, signal);
794
982
  };
795
983
  try {
984
+ phase("payment-review", session, signal);
796
985
  const sent = await options.sendPayment({
797
986
  session: structuredClone(session),
798
987
  payment,
@@ -813,10 +1002,14 @@ export function createSafeRelayrController(options) {
813
1002
  paymentStatus: checked.session.paymentStatus,
814
1003
  };
815
1004
  await store.save(session);
1005
+ throw error;
816
1006
  }
1007
+ if (sending ||
1008
+ session.payments.length > checked.session.payments.length)
1009
+ throw new SafeRelayrRecoveryError(session, error instanceof Error ? error.message : undefined);
817
1010
  throw error;
818
1011
  }
819
- return inspect(session);
1012
+ return inspect(session, signal);
820
1013
  });
821
1014
  }
822
1015
  /** Read-only progress polling; each read releases its lock before the wait. */
@@ -829,7 +1022,7 @@ export function createSafeRelayrController(options) {
829
1022
  const started = Date.now();
830
1023
  while (true) {
831
1024
  signal?.throwIfAborted();
832
- const next = await check({ account, sessionId });
1025
+ const next = await check({ account, sessionId, signal });
833
1026
  signal?.throwIfAborted();
834
1027
  await onUpdate?.(next);
835
1028
  const remaining = timeoutMs - (Date.now() - started);