@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.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SafeRelayrRecoveryError = void 0;
4
+ exports.canReplaceSafeRelayrQuote = canReplaceSafeRelayrQuote;
4
5
  exports.safeRelayrReservationKey = safeRelayrReservationKey;
5
6
  exports.sameSafeRelayrIntents = sameSafeRelayrIntents;
6
7
  exports.requireSafeRelayrExecution = requireSafeRelayrExecution;
@@ -12,6 +13,65 @@ const safe_js_1 = require("../safe.js");
12
13
  const safeService_js_1 = require("../safeService.js");
13
14
  const contractWrite_js_1 = require("./contractWrite.js");
14
15
  const relayr_js_1 = require("./relayr.js");
16
+ function hasFundingRecords(records) {
17
+ if (records === undefined)
18
+ return false;
19
+ if (!Array.isArray(records))
20
+ return true;
21
+ return records.some((record) => {
22
+ if (!record || typeof record !== "object" || Array.isArray(record))
23
+ return true;
24
+ const status = record.status;
25
+ if (status === undefined)
26
+ return false;
27
+ if (!status || typeof status !== "object" || Array.isArray(status))
28
+ return true;
29
+ const data = status.data;
30
+ if (data !== undefined &&
31
+ data !== null &&
32
+ (typeof data !== "object" || Array.isArray(data)))
33
+ return true;
34
+ const transaction = data?.transaction;
35
+ if (transaction !== undefined &&
36
+ (transaction === null ||
37
+ typeof transaction !== "object" ||
38
+ Array.isArray(transaction)))
39
+ return true;
40
+ if (status.data?.hash !== undefined ||
41
+ status.data?.transaction?.hash !== undefined)
42
+ return true;
43
+ return status.state !== undefined && !(0, relayr_js_1.relayrStateIsPending)(status.state);
44
+ });
45
+ }
46
+ function hasObservedFunding(session) {
47
+ return ((session.fundingObserved !== undefined &&
48
+ session.fundingObserved !== false) ||
49
+ hasFundingRecords(session.records) ||
50
+ hasFundingRecords(session.quote?.transactions));
51
+ }
52
+ /**
53
+ * Safe signatures already authorize a nonce-protected call. Publishing or losing
54
+ * an unused quote is not a funding attempt. Only this Safe-specific owner may
55
+ * replace such a quote; raw/forwarded authorizations keep their existing rules.
56
+ */
57
+ function canReplaceSafeRelayrQuote(session) {
58
+ if ((session.state !== "publishing" && session.state !== "active") ||
59
+ session.paymentStatus !== "unfunded" ||
60
+ !Array.isArray(session.payments) ||
61
+ session.payments.length !== 0 ||
62
+ (session.reservationKeys !== undefined &&
63
+ !Array.isArray(session.reservationKeys)) ||
64
+ hasObservedFunding(session))
65
+ return false;
66
+ try {
67
+ validateExecutions(session.executions);
68
+ const identities = new Set(session.executions.map(safeRelayrReservationKey));
69
+ return !session.reservationKeys?.some((key) => !identities.has(key.toLowerCase()));
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
15
75
  class SafeRelayrRecoveryError extends Error {
16
76
  constructor(session, message = "This Safe execution has an existing Relayr bundle. Check the existing bundle before submitting another payment.", recovery) {
17
77
  super(message);
@@ -172,6 +232,13 @@ function requireSafeRelayrRecords(bindings, records) {
172
232
  throw new Error("Relayr's record does not match the saved Safe transaction binding.");
173
233
  }
174
234
  }
235
+ function requireSafeLanding(execution, { receipt }) {
236
+ const { safe, safeTxHash } = execution;
237
+ (0, safeService_js_1.requireSafeExecutionSuccess)(receipt, safe, safeTxHash);
238
+ const event = (0, safeService_js_1.safeExecutionResult)(receipt, safe, safeTxHash);
239
+ if (event.status !== "success" || event.payment !== 0n)
240
+ throw new Error("The Safe execution reimbursed its executor. Keep the existing bundle for verification.");
241
+ }
175
242
  /** Canonical exact destination receipts plus matching, refund-free Safe success events. */
176
243
  async function verifySafeRelayrLanding(clientFor, { executions, bindings, records, }) {
177
244
  validateExecutions(executions);
@@ -184,13 +251,7 @@ async function verifySafeRelayrLanding(clientFor, { executions, bindings, record
184
251
  bindings,
185
252
  records,
186
253
  });
187
- verified.forEach(({ receipt }, index) => {
188
- const { safe, safeTxHash } = executions[index];
189
- (0, safeService_js_1.requireSafeExecutionSuccess)(receipt, safe, safeTxHash);
190
- const event = (0, safeService_js_1.safeExecutionResult)(receipt, safe, safeTxHash);
191
- if (event.status !== "success" || event.payment !== 0n)
192
- throw new Error("The Safe execution reimbursed its executor. Keep the existing bundle for verification.");
193
- });
254
+ verified.forEach((item, index) => requireSafeLanding(executions[index], item));
194
255
  return verified;
195
256
  }
196
257
  /** Validate persisted bindings again; local storage is not authenticated evidence. */
@@ -216,6 +277,49 @@ function requireSessionQuote(session) {
216
277
  function createSafeRelayrController(options) {
217
278
  const { store } = options;
218
279
  const fetchRelayr = options.fetch ?? globalThis.fetch;
280
+ const displayedExecutions = new Map();
281
+ const notify = (progress, rechecking = false) => {
282
+ if (progress.type === "execution") {
283
+ const key = `${progress.session.id}:${progress.index}`;
284
+ const display = JSON.stringify([
285
+ progress.status,
286
+ progress.hash,
287
+ progress.message,
288
+ ]);
289
+ const previous = displayedExecutions.get(key);
290
+ // Rechecking does not undo a proven display result. A later unavailable,
291
+ // contradictory, or changed-hash result explicitly updates it below.
292
+ if (previous === display ||
293
+ (rechecking &&
294
+ previous === JSON.stringify(["executed", progress.hash, undefined])))
295
+ return;
296
+ displayedExecutions.set(key, display);
297
+ }
298
+ try {
299
+ // A view must not mutate frozen calls or turn a submitted payment into a
300
+ // rejected operation. Also consume rejection from an async UI callback.
301
+ void Promise.resolve(options.onProgress?.(structuredClone(progress))).catch(() => undefined);
302
+ }
303
+ catch {
304
+ // Display observers cannot change the durable execution lifecycle.
305
+ }
306
+ };
307
+ const phase = (phase, session, signal) => {
308
+ if (!signal?.aborted)
309
+ notify({ type: "phase", phase, session });
310
+ };
311
+ const savedPhase = (session, signal) => {
312
+ if (session.state === "released")
313
+ return;
314
+ if (session.state === "complete")
315
+ phase("complete", session, signal);
316
+ else if (session.paymentStatus === "confirmed" ||
317
+ hasObservedFunding(session))
318
+ phase("executing", session, signal);
319
+ else if (session.paymentStatus === "submitted" ||
320
+ session.paymentStatus === "sending")
321
+ phase("payment-confirming", session, signal);
322
+ };
219
323
  const checkAccount = (account, signal) => {
220
324
  signal?.throwIfAborted();
221
325
  if (!(0, viem_1.isAddress)(account) ||
@@ -273,10 +377,12 @@ function createSafeRelayrController(options) {
273
377
  };
274
378
  const unidentified = (session) => !session.executions.length && !session.reservationKeys?.length;
275
379
  const unresolved = (session) => session.state !== "complete" && session.state !== "released";
380
+ const reserves = (session, executions) => unresolved(session) &&
381
+ (store.scope === "single-session" ||
382
+ unidentified(session) ||
383
+ overlap(session, executions));
276
384
  const assertUnreserved = async (account, executions, exceptId) => {
277
- const conflict = (await store.list(account)).find((session) => session.id !== exceptId &&
278
- unresolved(session) &&
279
- (unidentified(session) || overlap(session, executions)));
385
+ const conflict = (await store.list(account)).find((session) => session.id !== exceptId && reserves(session, executions));
280
386
  if (conflict)
281
387
  throw new SafeRelayrRecoveryError(conflict);
282
388
  };
@@ -349,7 +455,8 @@ function createSafeRelayrController(options) {
349
455
  // session per Safe and replacing it would erase the unresolved evidence.
350
456
  if (session.paymentStatus !== "unfunded" ||
351
457
  !Array.isArray(session.payments) ||
352
- session.payments.length)
458
+ session.payments.length ||
459
+ hasObservedFunding(session))
353
460
  return pending({
354
461
  reason: "funding-unresolved",
355
462
  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.",
@@ -392,77 +499,256 @@ function createSafeRelayrController(options) {
392
499
  checks,
393
500
  });
394
501
  }
395
- async function inspect(session) {
396
- if (session.state === "released")
397
- return result("released", session);
398
- if (session.state === "complete")
399
- return result("complete", session);
400
- if (!session.bundleUuid || !session.quote)
401
- return inspectMissingQuote(session);
402
- const quote = requireSessionQuote(session);
403
- const chains = session.executions.map(({ entry }) => entry.chain);
404
- const bundle = await (0, relayr_js_1.readRelayrBundle)(session.bundleUuid, {
405
- fetch: fetchRelayr,
406
- });
407
- if (!Array.isArray(bundle.transactions))
408
- throw new SafeRelayrRecoveryError(session, "Relayr has not returned this bundle's transactions. Check the existing bundle again.");
409
- const records = bundle.transactions;
410
- requireSafeRelayrRecords(quote.expectedTransactions, records);
411
- session = { ...session, records };
412
- await store.save(session);
413
- // A reported hash is only a pointer. Completion requires canonical exact
414
- // calldata/value receipts AND the matching Safe ExecutionSuccess event.
415
- if (records.length &&
416
- records.every((record) => record.status?.data?.hash || record.status?.data?.transaction?.hash)) {
417
- const verified = await verifySafeRelayrLanding(options.clientFor, {
418
- executions: session.executions,
419
- bindings: quote.expectedTransactions,
420
- records,
502
+ async function inspect(session, signal) {
503
+ const report = (progress, rechecking = false) => {
504
+ if (!signal?.aborted)
505
+ notify(progress, rechecking);
506
+ };
507
+ try {
508
+ savedPhase(session, signal);
509
+ if (session.state === "released")
510
+ return result("released", session);
511
+ if (session.state === "complete")
512
+ return result("complete", session);
513
+ if (hasObservedFunding(session) && session.fundingObserved !== true) {
514
+ session = { ...session, fundingObserved: true };
515
+ await store.save(session);
516
+ }
517
+ if (!session.bundleUuid || !session.quote)
518
+ return inspectMissingQuote(session);
519
+ const quote = requireSessionQuote(session);
520
+ const chains = session.executions.map(({ entry }) => entry.chain);
521
+ const bundle = await (0, relayr_js_1.readRelayrBundle)(session.bundleUuid, {
522
+ fetch: fetchRelayr,
421
523
  });
422
- await Promise.all(session.executions.map((execution, index) => options.afterVerified?.(execution, verified[index])));
423
- session = { ...session, state: "complete" };
524
+ if (hasObservedFunding(session) ||
525
+ bundle.payment_received === true ||
526
+ hasFundingRecords(bundle.transactions)) {
527
+ session = { ...session, fundingObserved: true };
528
+ await store.save(session);
529
+ }
530
+ if (!Array.isArray(bundle.transactions))
531
+ throw new SafeRelayrRecoveryError(session, "Relayr has not returned this bundle's transactions. Check the existing bundle again.");
532
+ const records = bundle.transactions;
533
+ requireSafeRelayrRecords(quote.expectedTransactions, records);
534
+ session = { ...session, records };
424
535
  await store.save(session);
425
- return result("complete", session);
426
- }
427
- if (session.paymentStatus === "sending")
428
- return result("pending", session);
429
- if (session.payments.some((payment) => payment.bundleUuid !== session.bundleUuid))
430
- throw new SafeRelayrRecoveryError(session, "A saved payment belongs to another bundle. Keep the existing bundle for verification.");
431
- if (session.payments.length) {
432
- let everyReverted = true;
433
- for (const payment of session.payments) {
434
- const client = options.clientFor(payment.chainId);
435
- if (!client)
436
- return result("pending", session);
536
+ savedPhase(session, signal);
537
+ // Each hash is only a pointer. Check available chains independently, using
538
+ // the same complete binding validator and exact receipt proof as completion.
539
+ const indexed = quote.expectedTransactions.map((binding, index) => ({
540
+ binding,
541
+ index,
542
+ record: records.find((record) => record.tx_uuid?.toLowerCase() === binding.txUuid.toLowerCase()),
543
+ }));
544
+ const known = indexed.filter(({ record }) => record.status?.data?.hash !== undefined ||
545
+ record.status?.data?.transaction?.hash !== undefined);
546
+ if (known.length) {
437
547
  try {
438
- await (0, relayr_js_1.verifyRelayrPayment)(client, {
439
- hash: payment.hash,
440
- from: session.account,
441
- payment,
548
+ (0, relayr_js_1.relayrDestinationHashes)({
549
+ bindings: known.map(({ binding }) => binding),
550
+ records: known.map(({ record }) => record),
442
551
  });
443
- session = { ...session, paymentStatus: "confirmed" };
444
- await store.save(session);
445
- return result("pending", session);
446
552
  }
447
553
  catch (error) {
448
- if (!(error instanceof relayr_js_1.RelayrPaymentRevertedError))
449
- everyReverted = false;
554
+ for (const { index, record } of known)
555
+ report({
556
+ type: "execution",
557
+ session,
558
+ index,
559
+ execution: session.executions[index],
560
+ status: "failed",
561
+ hash: (0, relayr_js_1.relayrDestinationHash)(record) ?? undefined,
562
+ message: error instanceof Error
563
+ ? error.message
564
+ : "The destination records do not match this quote.",
565
+ });
566
+ throw new SafeRelayrRecoveryError(session, error instanceof Error ? error.message : undefined);
450
567
  }
568
+ phase("executing", session, signal);
451
569
  }
452
- if (!everyReverted)
453
- return result("pending", session);
454
- session = { ...session, paymentStatus: "reverted" };
455
- await store.save(session);
456
- const disposition = await (0, relayr_js_1.revertedRelayrQuote)(options.clientFor, {
457
- bundleUuid: quote.bundle_uuid,
458
- payments: session.payments,
459
- options: quote.payment_info,
460
- destinationChainIds: chains,
461
- account: session.account,
462
- }, { fetch: fetchRelayr });
463
- if (disposition.state === "funded")
570
+ if (known.length ||
571
+ session.paymentStatus !== "unfunded" ||
572
+ session.fundingObserved) {
573
+ for (const { index, record } of indexed) {
574
+ const hash = (0, relayr_js_1.relayrDestinationHash)(record) ?? undefined;
575
+ report({
576
+ type: "execution",
577
+ session,
578
+ index,
579
+ execution: session.executions[index],
580
+ status: hash
581
+ ? "confirming"
582
+ : (0, relayr_js_1.relayrStateIsFailed)(record.status?.state)
583
+ ? "failed"
584
+ : "pending",
585
+ hash,
586
+ ...(!hash && (0, relayr_js_1.relayrStateIsFailed)(record.status?.state)
587
+ ? {
588
+ message: "Relayr reported this execution failed. Its onchain result has not been verified.",
589
+ }
590
+ : {}),
591
+ }, true);
592
+ }
593
+ }
594
+ const landed = await Promise.allSettled(known.map(async ({ binding, index, record }) => {
595
+ const execution = session.executions[index];
596
+ const hash = (0, relayr_js_1.relayrDestinationHash)(record);
597
+ const client = options.clientFor(binding.chain);
598
+ if (!client) {
599
+ report({
600
+ type: "execution",
601
+ session,
602
+ index,
603
+ execution,
604
+ status: "confirming",
605
+ hash,
606
+ message: "Waiting for a connection to this network.",
607
+ });
608
+ return false;
609
+ }
610
+ try {
611
+ const receipt = await (0, relayr_js_1.verifyRelayrDestination)(client, {
612
+ entry: binding.entry,
613
+ hash,
614
+ });
615
+ const verified = {
616
+ txUuid: binding.txUuid,
617
+ chainId: binding.chain,
618
+ receipt,
619
+ };
620
+ requireSafeLanding(execution, verified);
621
+ await options.afterVerified?.(execution, verified);
622
+ report({
623
+ type: "execution",
624
+ session,
625
+ index,
626
+ execution,
627
+ status: "executed",
628
+ hash,
629
+ });
630
+ return true;
631
+ }
632
+ catch (error) {
633
+ if (error instanceof relayr_js_1.RelayrProofUnavailableError) {
634
+ report({
635
+ type: "execution",
636
+ session,
637
+ index,
638
+ execution,
639
+ status: "confirming",
640
+ hash,
641
+ message: "Waiting for the network to confirm this transaction.",
642
+ });
643
+ return false;
644
+ }
645
+ report({
646
+ type: "execution",
647
+ session,
648
+ index,
649
+ execution,
650
+ status: "failed",
651
+ hash,
652
+ message: error instanceof Error
653
+ ? error.message
654
+ : "The Safe execution could not be verified.",
655
+ });
656
+ throw error;
657
+ }
658
+ }));
659
+ const failure = landed.find((item) => item.status === "rejected");
660
+ if (failure?.status === "rejected")
661
+ throw new SafeRelayrRecoveryError(session, failure.reason instanceof Error ? failure.reason.message : undefined);
662
+ if (known.length === session.executions.length &&
663
+ landed.every((item) => item.status === "fulfilled" && item.value)) {
664
+ session = { ...session, state: "complete" };
665
+ await store.save(session);
666
+ phase("complete", session, signal);
667
+ return result("complete", session);
668
+ }
669
+ if (session.paymentStatus === "sending")
464
670
  return result("pending", session);
465
- if (disposition.state === "released") {
671
+ if (session.payments.some((payment) => payment.bundleUuid !== session.bundleUuid))
672
+ throw new SafeRelayrRecoveryError(session, "A saved payment belongs to another bundle. Keep the existing bundle for verification.");
673
+ if (session.payments.length) {
674
+ let everyReverted = true;
675
+ for (const payment of session.payments) {
676
+ const client = options.clientFor(payment.chainId);
677
+ if (!client)
678
+ return result("pending", session);
679
+ try {
680
+ await (0, relayr_js_1.verifyRelayrPayment)(client, {
681
+ hash: payment.hash,
682
+ from: session.account,
683
+ payment,
684
+ });
685
+ session = { ...session, paymentStatus: "confirmed" };
686
+ await store.save(session);
687
+ phase("executing", session, signal);
688
+ return result("pending", session);
689
+ }
690
+ catch (error) {
691
+ if (!(error instanceof relayr_js_1.RelayrPaymentRevertedError))
692
+ everyReverted = false;
693
+ }
694
+ }
695
+ if (!everyReverted)
696
+ return result("pending", session);
697
+ session = { ...session, paymentStatus: "reverted" };
698
+ await store.save(session);
699
+ const disposition = await (0, relayr_js_1.revertedRelayrQuote)(options.clientFor, {
700
+ bundleUuid: quote.bundle_uuid,
701
+ payments: session.payments,
702
+ options: quote.payment_info,
703
+ destinationChainIds: chains,
704
+ account: session.account,
705
+ }, { fetch: fetchRelayr });
706
+ if (disposition.state === "funded")
707
+ return result("pending", session);
708
+ if (disposition.state === "released") {
709
+ session = {
710
+ ...session,
711
+ state: "released",
712
+ paymentStatus: "expired",
713
+ releaseReason: "quote-expired",
714
+ };
715
+ await store.save(session);
716
+ return result("released", session);
717
+ }
718
+ }
719
+ else if (session.paymentStatus !== "unfunded" ||
720
+ session.fundingObserved) {
721
+ // A wallet invocation without a known hash may still have broadcast.
722
+ return session.fundingObserved && !known.length
723
+ ? {
724
+ ...result("pending", session),
725
+ recovery: {
726
+ reason: "funding-unresolved",
727
+ message: "This saved selection has reported funding or execution activity. Check the existing bundle and its funding transactions before submitting another payment.",
728
+ },
729
+ }
730
+ : result("pending", session);
731
+ }
732
+ const quoted = (0, relayr_js_1.relayrQuotedOptions)(quote, chains);
733
+ if (!quoted.length)
734
+ return inspectMissingQuote(session);
735
+ const payments = (0, relayr_js_1.relayrPaymentOptions)(quote, chains);
736
+ const expired = payments.length
737
+ ? [false]
738
+ : await Promise.all(quoted.map(async ({ details }) => {
739
+ const client = options.clientFor(details.chainId);
740
+ return (!!client &&
741
+ (await (0, relayr_js_1.relayrDeadlinePassed)(client, details.deadline).catch(() => false)));
742
+ }));
743
+ if (expired.every(Boolean)) {
744
+ try {
745
+ await (0, relayr_js_1.requireRelayrBundleUnpaid)(quote.bundle_uuid, {
746
+ fetch: fetchRelayr,
747
+ });
748
+ }
749
+ catch {
750
+ return result("pending", session);
751
+ }
466
752
  session = {
467
753
  ...session,
468
754
  state: "released",
@@ -472,23 +758,6 @@ function createSafeRelayrController(options) {
472
758
  await store.save(session);
473
759
  return result("released", session);
474
760
  }
475
- }
476
- else if (session.paymentStatus !== "unfunded") {
477
- // A wallet invocation without a known hash may still have broadcast.
478
- return result("pending", session);
479
- }
480
- const quoted = (0, relayr_js_1.relayrQuotedOptions)(quote, chains);
481
- if (!quoted.length)
482
- return inspectMissingQuote(session);
483
- const payments = (0, relayr_js_1.relayrPaymentOptions)(quote, chains);
484
- const expired = payments.length
485
- ? [false]
486
- : await Promise.all(quoted.map(async ({ details }) => {
487
- const client = options.clientFor(details.chainId);
488
- return (!!client &&
489
- (await (0, relayr_js_1.relayrDeadlinePassed)(client, details.deadline).catch(() => false)));
490
- }));
491
- if (expired.every(Boolean)) {
492
761
  try {
493
762
  await (0, relayr_js_1.requireRelayrBundleUnpaid)(quote.bundle_uuid, {
494
763
  fetch: fetchRelayr,
@@ -497,43 +766,35 @@ function createSafeRelayrController(options) {
497
766
  catch {
498
767
  return result("pending", session);
499
768
  }
500
- session = {
501
- ...session,
502
- state: "released",
503
- paymentStatus: "expired",
504
- releaseReason: "quote-expired",
505
- };
506
- await store.save(session);
507
- return result("released", session);
508
- }
509
- try {
510
- await (0, relayr_js_1.requireRelayrBundleUnpaid)(quote.bundle_uuid, {
511
- fetch: fetchRelayr,
512
- });
769
+ const allowed = session.payments.length && payments.length
770
+ ? [(0, relayr_js_1.relayrRetryOption)(session.payments, payments)]
771
+ : payments;
772
+ return allowed.length
773
+ ? result("ready", session, allowed)
774
+ : result("pending", session);
513
775
  }
514
- catch {
515
- return result("pending", session);
776
+ catch (error) {
777
+ if (error instanceof SafeRelayrRecoveryError)
778
+ throw error;
779
+ throw new SafeRelayrRecoveryError(session, error instanceof Error ? error.message : undefined);
516
780
  }
517
- const allowed = session.payments.length && payments.length
518
- ? [(0, relayr_js_1.relayrRetryOption)(session.payments, payments)]
519
- : payments;
520
- return allowed.length
521
- ? result("ready", session, allowed)
522
- : result("pending", session);
523
781
  }
524
782
  async function prepare({ account, executions: input, signal, onStatus, }) {
525
783
  const executions = structuredClone([...input]);
526
784
  validateExecutions(executions);
527
785
  return store.withLock(account, async () => {
528
786
  checkAccount(account, signal);
529
- const reservations = (await store.list(account)).filter((session) => unresolved(session) &&
530
- (unidentified(session) || overlap(session, executions)));
787
+ const reservations = (await store.list(account)).filter((session) => reserves(session, executions));
531
788
  for (const saved of reservations) {
789
+ if (canReplaceSafeRelayrQuote(saved))
790
+ continue;
532
791
  let checked;
533
792
  try {
534
- checked = await inspect(saved);
793
+ checked = await inspect(saved, signal);
535
794
  }
536
795
  catch (error) {
796
+ if (error instanceof SafeRelayrRecoveryError)
797
+ throw error;
537
798
  throw new SafeRelayrRecoveryError(saved, error instanceof Error ? error.message : undefined);
538
799
  }
539
800
  if (checked.state === "released" &&
@@ -547,14 +808,29 @@ function createSafeRelayrController(options) {
547
808
  throw new SafeRelayrRecoveryError(checked.session, checked.recovery?.message, checked.recovery);
548
809
  await assertUnreserved(account, executions, saved.id);
549
810
  await revalidate(saved.executions, account, onStatus, false, signal);
811
+ phase("reviewing", checked.session, signal);
550
812
  await options.review(saved.executions, { resumed: true });
551
813
  checkAccount(account, signal);
552
814
  rememberReview(checked.session);
553
815
  return { ...checked, state: "ready", resumed: true };
554
816
  }
555
817
  await revalidate(executions, account, onStatus, false, signal);
818
+ phase("reviewing", undefined, signal);
556
819
  await options.review(executions, { resumed: false });
557
820
  checkAccount(account, signal);
821
+ // Re-read under the shared lock after review. A rejected review leaves the
822
+ // old quote untouched; a recorded wallet invocation can never be retired.
823
+ for (const saved of await store.list(account)) {
824
+ if (!reserves(saved, executions))
825
+ continue;
826
+ if (!canReplaceSafeRelayrQuote(saved))
827
+ throw new SafeRelayrRecoveryError(saved);
828
+ await store.save({
829
+ ...saved,
830
+ state: "released",
831
+ releaseReason: "quote-replaced",
832
+ });
833
+ }
558
834
  await assertUnreserved(account, executions);
559
835
  let session = {
560
836
  id: options.createId?.() ?? globalThis.crypto.randomUUID(),
@@ -565,8 +841,8 @@ function createSafeRelayrController(options) {
565
841
  payments: [],
566
842
  createdAt: Date.now(),
567
843
  };
568
- // Persist before POST. An interrupted response may have published the Safe
569
- // signatures; loss of the bundle ID is never permission to publish twice.
844
+ // Persist before POST so the quote can be recovered or retired later.
845
+ // Safe publication alone is not evidence that the wallet funded it.
570
846
  await store.save(session);
571
847
  try {
572
848
  checkAccount(account, signal);
@@ -576,6 +852,7 @@ function createSafeRelayrController(options) {
576
852
  throw error;
577
853
  }
578
854
  const request = (0, relayr_js_1.relayrBundleRequest)(executions.map(({ entry }) => entry));
855
+ phase("quoting", session, signal);
579
856
  const response = await fetchRelayr(`${relayr_js_1.RELAYR_API}/v1/bundle/prepaid`, {
580
857
  method: "POST",
581
858
  headers: { "content-type": "application/json" },
@@ -604,14 +881,18 @@ function createSafeRelayrController(options) {
604
881
  return { state: "ready", session, payments, resumed: false };
605
882
  });
606
883
  }
607
- async function check({ account, sessionId, }) {
608
- return store.withLock(account, async () => inspect(await load(account, sessionId)));
884
+ async function check({ account, sessionId, signal, }) {
885
+ return store.withLock(account, async () => {
886
+ signal?.throwIfAborted();
887
+ const session = await load(account, sessionId);
888
+ return inspect(session, signal);
889
+ });
609
890
  }
610
891
  async function fund({ account, sessionId, paymentChainId, signal, onStatus, }) {
611
892
  return store.withLock(account, async () => {
612
893
  checkAccount(account, signal);
613
894
  let session = await load(account, sessionId);
614
- const checked = await inspect(session);
895
+ const checked = await inspect(session, signal);
615
896
  session = checked.session;
616
897
  if (checked.state !== "ready")
617
898
  throw new SafeRelayrRecoveryError(session, checked.recovery?.message, checked.recovery);
@@ -622,6 +903,7 @@ function createSafeRelayrController(options) {
622
903
  throw new Error("Choose a current payment option from this saved quote.");
623
904
  await assertUnreserved(account, session.executions, session.id);
624
905
  if (!reviewedIntents.has(reviewKey(session))) {
906
+ phase("reviewing", session, signal);
625
907
  await options.review(session.executions, { resumed: true });
626
908
  checkAccount(account, signal);
627
909
  rememberReview(session);
@@ -669,6 +951,7 @@ function createSafeRelayrController(options) {
669
951
  throw error;
670
952
  }
671
953
  sending = true;
954
+ phase("payment-submitting", session, signal);
672
955
  };
673
956
  const onSent = async (payments) => {
674
957
  const snapshot = (0, relayr_js_1.relayrSentPaymentsSnapshot)(payments);
@@ -706,8 +989,10 @@ function createSafeRelayrController(options) {
706
989
  paymentStatus: "submitted",
707
990
  };
708
991
  await store.save(session);
992
+ phase("payment-confirming", session, signal);
709
993
  };
710
994
  try {
995
+ phase("payment-review", session, signal);
711
996
  const sent = await options.sendPayment({
712
997
  session: structuredClone(session),
713
998
  payment,
@@ -728,10 +1013,14 @@ function createSafeRelayrController(options) {
728
1013
  paymentStatus: checked.session.paymentStatus,
729
1014
  };
730
1015
  await store.save(session);
1016
+ throw error;
731
1017
  }
1018
+ if (sending ||
1019
+ session.payments.length > checked.session.payments.length)
1020
+ throw new SafeRelayrRecoveryError(session, error instanceof Error ? error.message : undefined);
732
1021
  throw error;
733
1022
  }
734
- return inspect(session);
1023
+ return inspect(session, signal);
735
1024
  });
736
1025
  }
737
1026
  /** Read-only progress polling; each read releases its lock before the wait. */
@@ -744,7 +1033,7 @@ function createSafeRelayrController(options) {
744
1033
  const started = Date.now();
745
1034
  while (true) {
746
1035
  signal?.throwIfAborted();
747
- const next = await check({ account, sessionId });
1036
+ const next = await check({ account, sessionId, signal });
748
1037
  signal?.throwIfAborted();
749
1038
  await onUpdate?.(next);
750
1039
  const remaining = timeoutMs - (Date.now() - started);