@daski/pay 0.5.2 → 0.5.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.
@@ -43,9 +43,10 @@
43
43
  * before the lock, so no passphrase prompt runs inside it.
44
44
  */
45
45
  import { randomUUID } from "node:crypto";
46
- import { createCircleReviewAdapter } from "../signers/circleReview.js";
46
+ import { circleProgressOf, createCircleReviewAdapter, } from "../signers/circleReview.js";
47
+ import { circleChainName, loginHint } from "../signers/circleAgent.js";
47
48
  import { canonicalHash } from "@daski/x402-scheme";
48
- import { decodeEventLog, encodeAbiParameters, encodeFunctionData, getAddress, isAddressEqual, keccak256, parseAbi, parseAbiParameters, } from "viem";
49
+ import { decodeEventLog, encodeAbiParameters, encodeEventTopics, encodeFunctionData, getAddress, isAddressEqual, keccak256, parseAbi, parseAbiParameters, } from "viem";
49
50
  import { discoverEasReviewProfile } from "../chain/easProfiles.js";
50
51
  import { finalityTagFor, readContracts } from "../chain/reader.js";
51
52
  import { CliError } from "../cli/errors.js";
@@ -342,12 +343,15 @@ export async function confirmOrder(context, record, options, factsReader = readC
342
343
  throw new CliError({ code: "DASKI_CONFIRMATION_NOT_PENDING", message: "There is no pending review submission.",
343
344
  remediation: "Check order status, then supply the user's review choice if a new review is wanted." });
344
345
  const action = options.revoke ? "revoke-confirmation" : "confirmation";
346
+ const mode = selectConfirmationMode(signer.describe(), options.submission);
345
347
  // One review journal per order: a direct submission still prepared or
346
348
  // submitted blocks any new preparation, sponsored included, so a second
347
- // review cannot be signed while the first is unresolved on chain.
348
- if (isPendingDirect(record.confirmationTx))
349
+ // review cannot be signed while the first is unresolved on chain. The
350
+ // one exception is a call an earlier buyer prepared without saving it:
351
+ // preparing it again restores it, and only when it is the same call.
352
+ const legacy = mode === "direct" && isLegacyPrepared(record.confirmationTx) ? record.confirmationTx : undefined;
353
+ if (isPendingDirect(record.confirmationTx) && !legacy)
349
354
  throw directPending(handle, record.confirmationTx);
350
- const mode = selectConfirmationMode(signer.describe(), options.submission);
351
355
  if (superseding && mode !== "sponsored")
352
356
  throw new CliError({
353
357
  code: "DASKI_CONFIRMATION_SUPERSESSION_INVALID", message: "Same-nonce supersession requires a sponsored delegated review.",
@@ -383,25 +387,37 @@ export async function confirmOrder(context, record, options, factsReader = readC
383
387
  const warning = final ? { code: "FINAL_ATTESTATION", message: FINAL_ATTESTATION_WARNING } : undefined;
384
388
  if (mode === "direct") {
385
389
  const validated = validateDirectCall(prepared.call, facts, choice, context.profile.easAddress);
390
+ const callHash = canonicalHash(validated.call);
391
+ if (legacy && legacy.callHash !== callHash)
392
+ throw new CliError({ code: "DASKI_CONFIRMATION_TX_PENDING",
393
+ message: "A direct review an earlier buyer prepared is still saved, and this choice prepares a different call.",
394
+ remediation: `If that call was never sent, clear it with daski order confirm ${handle} --abandon and prepare again. ` +
395
+ `If it was sent, record its hash with daski order confirm ${handle} --tx <hash>.` });
386
396
  const expected = await expectedBinding(context, facts, validated, signer);
387
397
  const tracked = {
388
398
  action: validated.action,
389
- callHash: canonicalHash(validated.call),
399
+ callHash,
390
400
  expected,
391
401
  call: validated.call,
392
402
  choice: choice,
393
403
  state: "prepared",
394
- preparedAt: new Date().toISOString(),
404
+ preparedAt: legacy?.preparedAt ?? new Date().toISOString(),
395
405
  };
396
406
  updateOrder(record.intentId, { confirmationTx: tracked });
407
+ const circle = signer.describe().provider === "circle-agent";
397
408
  return { orderHandle: record.handle, mode, action: validated.action, ...summary,
398
409
  ...(warning ? { warning } : {}),
399
410
  call: validated.call,
400
411
  callHash: tracked.callHash,
401
- note: "This call was validated against chain facts. Preparation sends no transaction.",
402
- next: "For Circle, use --estimate, then --submit --approve-call <callHash> when explicitly approved and execution is qualified. Or submit with your wallet tool. " +
403
- `Then record the hash: daski order confirm ${handle} --tx <hash>, and verify it: ` +
404
- `daski order confirm ${handle} --check.` };
412
+ ...(legacy ? { restored: true } : {}),
413
+ note: "This call was validated against chain facts. Preparation sends no transaction." +
414
+ (legacy ? " It is the call an earlier buyer prepared for this order, now saved so it can be estimated and submitted." : ""),
415
+ next: circle
416
+ ? `Show the call to the user. Estimate it with daski order confirm ${handle} --estimate. Once the user approves this exact call ` +
417
+ "and the gateway advertises confirmation.directReview.circleExecute, submit it with " +
418
+ `daski order confirm ${handle} --submit --approve-call <callHash>, then run --resume until it is observed.`
419
+ : `Submit the call with the wallet's own tool, then record the hash: daski order confirm ${handle} --tx <hash>, ` +
420
+ `and verify it: daski order confirm ${handle} --check.` };
405
421
  }
406
422
  const typedData = validateConfirmationPreparation(prepared, facts, choice, acknowledged);
407
423
  if (typeof prepared.preparationId !== "string")
@@ -477,7 +493,9 @@ async function manageCircleReview(context, record, options, signer, factsReader,
477
493
  if (!tracked?.call || !tracked.choice || tracked.state === "abandoned")
478
494
  throw new CliError({
479
495
  code: "DASKI_CONFIRMATION_NOT_PREPARED", message: "There is no saved validated call for this direct review.",
480
- remediation: "Prepare the chosen review first, then approve its displayed callHash."
496
+ remediation: isLegacyPrepared(tracked)
497
+ ? `An earlier buyer prepared this review without saving its call. Prepare the same choice again (daski order confirm ${handle} --choice <the same choice>) to restore it, then approve its displayed callHash.`
498
+ : "Prepare the chosen review first, then approve its displayed callHash."
481
499
  });
482
500
  if (record.confirmationSubmission)
483
501
  throw new CliError({ code: "DASKI_CONFIRMATION_PENDING",
@@ -491,40 +509,19 @@ async function manageCircleReview(context, record, options, signer, factsReader,
491
509
  !isAddressEqual(tracked.call.to, context.profile.easAddress) ||
492
510
  (tracked.vendor && (!isAddressEqual(tracked.vendor.wallet, context.payerAddress) || tracked.vendor.chainId !== context.profile.chainId)))
493
511
  throw invalidCall("saved call binding differs");
512
+ if (options.resume)
513
+ return resumeCircleReview(context, record, tracked, handle, adapterFactory);
494
514
  const adapter = adapterFactory();
495
- if (options.resume) {
496
- if (!tracked.vendor)
497
- throw new CliError({ code: "DASKI_CONFIRMATION_NOT_PENDING",
498
- message: "Circle submission has not started.", remediation: "Preparation is not approval; use --estimate or explicitly approve --submit." });
499
- const request = { mode: "lookup", wallet: context.payerAddress, call: tracked.call, idempotencyKey: tracked.vendor.idempotencyKey };
500
- const found = await adapter.lookup(request, tracked.vendor.transactionId);
501
- const hashes = [...new Set([...tracked.vendor.hashes, ...found.hashes])];
502
- const updated = { ...tracked, vendor: { ...tracked.vendor, ...(found.transactionId ? { transactionId: found.transactionId } : {}), hashes },
503
- ...(hashes.length ? { txHash: hashes[hashes.length - 1] } : {}) };
504
- updateOrder(record.intentId, { confirmationTx: updated });
505
- for (const txHash of hashes) {
506
- try {
507
- const result = await checkDirectRecord(context, record, { ...updated, txHash }, handle, { orderHandle: handle, mode: "direct" });
508
- if (result.state === "observed")
509
- return result;
510
- }
511
- catch (error) {
512
- if (!(error instanceof CliError) || error.code !== "DASKI_CONFIRMATION_RECEIPT_UNRELATED")
513
- throw error;
514
- // An ERC-4337 outer success can contain an inner revert. Keep the journal.
515
- }
516
- }
517
- return { orderHandle: handle, mode: "direct", state: "submitted", transactionId: updated.vendor.transactionId ?? null,
518
- hashes, next: hashes.length ? "Wait for finalized EAS evidence, then use --resume again."
519
- : "Vendor identity or exact-call discovery is unresolved. Keep this journal; --resume only reads and never executes again." };
520
- }
521
515
  if (tracked.vendor || tracked.txHash || tracked.state !== "prepared")
522
516
  throw directPending(handle, tracked);
523
517
  const capabilities = (await context.metadata()).confirmation?.directReview;
524
518
  if (options.submit && capabilities?.circleExecute !== true && !options.qualifyCircleExecution)
525
519
  throw new CliError({
526
- code: "DASKI_CIRCLE_EXECUTION_NOT_QUALIFIED", message: "Circle review execution is not yet qualified for this gateway.",
527
- remediation: "Execution requires a recorded live conformance result for the shipped adapter. Estimation remains separate and does not submit."
520
+ code: "DASKI_CIRCLE_EXECUTION_NOT_QUALIFIED",
521
+ message: "This gateway does not advertise Circle review execution (confirmation.directReview.circleExecute) yet.",
522
+ remediation: `Nothing was sent and the prepared review is kept. Repeat --submit --approve-call ${tracked.callHash} once ` +
523
+ "daski doctor --json shows gateway.confirmation.directReview.circleExecute: true; estimation is separate and sends nothing. " +
524
+ "Do not send this call with circle wallet execute: that command passes the tuple argument as a string, which this adapter corrects."
528
525
  });
529
526
  if (options.estimate && capabilities?.circleEstimate !== true)
530
527
  throw new CliError({
@@ -546,17 +543,184 @@ async function manageCircleReview(context, record, options, signer, factsReader,
546
543
  if (options.estimate)
547
544
  return { orderHandle: handle, mode: "direct", state: "prepared", callHash: tracked.callHash,
548
545
  estimate: await adapter.estimate(request), note: "Estimation did not submit the review." };
546
+ // The final height before anything is sent: the review cannot land below
547
+ // it, so a later --resume can find it on chain without the vendor.
548
+ const fromBlock = await context.chain.getFinalBlockNumber();
549
549
  const started = { ...tracked, state: "submitted", vendor: {
550
550
  provider: "circle-agent", packageVersion: adapter.packageVersion, idempotencyKey, wallet: context.payerAddress,
551
- chainId: context.profile.chainId, conformanceCandidate: options.qualifyCircleExecution === true, submissionStarted: new Date().toISOString(), hashes: []
551
+ chainId: context.profile.chainId, conformanceCandidate: options.qualifyCircleExecution === true, submissionStarted: new Date().toISOString(),
552
+ fromBlock: fromBlock.toString(), hashes: []
552
553
  } };
553
554
  updateOrder(record.intentId, { confirmationTx: started });
554
- const result = await adapter.submit(request);
555
- const updated = { ...started, ...(result.txHash ? { txHash: result.txHash } : {}),
556
- vendor: { ...started.vendor, ...(result.transactionId ? { transactionId: result.transactionId } : {}), hashes: result.txHash ? [result.txHash] : [] } };
555
+ let result;
556
+ try {
557
+ result = await adapter.submit(request);
558
+ }
559
+ catch (error) {
560
+ const progress = circleProgressOf(error);
561
+ if (progress && !progress.approvalSent && !progress.uncertain) {
562
+ // Circle executes only an approved challenge, and no approval left the
563
+ // child: nothing can run, so the prepared review is restored as it was.
564
+ updateOrder(record.intentId, { confirmationTx: tracked });
565
+ throw new CliError({ code: "DASKI_CIRCLE_REVIEW_NOT_STARTED",
566
+ message: progress.executionRequested
567
+ ? "Circle refused or did not answer the review request before any approval; nothing was sent."
568
+ : "The Circle review did not reach Circle; nothing was sent.",
569
+ remediation: `The prepared review is kept. Check the Circle session with circle wallet status; if it expired, ${loginHint(circleChainName(context.profile.chainId))}. ` +
570
+ `Then repeat daski order confirm ${handle} --submit --approve-call ${tracked.callHash}.`,
571
+ details: { circleProgress: progress, reason: error.message } });
572
+ }
573
+ // Possibly executing: keep the journal with every identity Circle gave.
574
+ if (progress)
575
+ updateOrder(record.intentId, { confirmationTx: withCircleProgress(started, progress) });
576
+ throw error;
577
+ }
578
+ const updated = withCircleProgress(started, result.progress, result);
557
579
  updateOrder(record.intentId, { confirmationTx: updated });
558
- return { orderHandle: handle, mode: "direct", state: "submitted", transactionId: result.transactionId ?? null,
559
- txHash: result.txHash ?? null, conformanceCandidate: options.qualifyCircleExecution === true, next: "Use --resume to verify final EAS evidence. Vendor completion alone is not review success." };
580
+ return { orderHandle: handle, mode: "direct", state: "submitted", transactionId: updated.vendor.transactionId ?? null,
581
+ txHash: updated.txHash ?? null, vendorState: updated.vendor.state ?? null, conformanceCandidate: options.qualifyCircleExecution === true,
582
+ next: `Use daski order confirm ${handle} --resume to verify final EAS evidence. Vendor completion alone is not review success.` };
583
+ }
584
+ /** Folds what a vendor run reported into the journal; identities only accumulate. */
585
+ function withCircleProgress(tracked, progress, result) {
586
+ const vendor = tracked.vendor;
587
+ const transactionId = vendor.transactionId ?? result?.transactionId ?? progress.transactionId;
588
+ const txHash = result?.txHash ?? progress.txHash;
589
+ const state = result?.state ?? progress.state;
590
+ const hashes = txHash ? [...new Set([...vendor.hashes, txHash])] : vendor.hashes;
591
+ return { ...tracked, ...(hashes.length ? { txHash: hashes[hashes.length - 1] } : {}),
592
+ vendor: { ...vendor, ...(progress.challengeId ? { challengeId: progress.challengeId } : {}),
593
+ ...(transactionId ? { transactionId } : {}), ...(state ? { state } : {}), hashes } };
594
+ }
595
+ /** Circle states after which its transaction never executes. */
596
+ const CIRCLE_FAILED_STATES = new Set(["FAILED", "DENIED", "CANCELLED"]);
597
+ /**
598
+ * `--resume` for a started Circle review: read-only. Circle's history is
599
+ * asked for the transaction (by the challenge's transaction ID, the
600
+ * idempotency key, or the exact call after the submission started), the
601
+ * chain is searched for the review itself, and every candidate hash is
602
+ * verified exactly as --check verifies one. It never executes, cancels or
603
+ * accelerates anything.
604
+ */
605
+ async function resumeCircleReview(context, record, tracked, handle, adapterFactory) {
606
+ const vendor = tracked.vendor;
607
+ if (!vendor)
608
+ throw new CliError({ code: "DASKI_CONFIRMATION_NOT_PENDING",
609
+ message: "Circle submission has not started.", remediation: "Preparation is not approval; use --estimate or explicitly approve --submit." });
610
+ const request = { mode: "lookup", wallet: context.payerAddress, call: tracked.call, idempotencyKey: vendor.idempotencyKey };
611
+ let found = { hashes: [] };
612
+ let vendorLookup = "unmatched";
613
+ try {
614
+ found = await adapterFactory().lookup(request, { transactionId: vendor.transactionId, notBefore: vendor.submissionStarted });
615
+ if (found.transactionId || found.hashes.length)
616
+ vendorLookup = "matched";
617
+ }
618
+ catch (error) {
619
+ // The chain still answers when Circle cannot; a lookup failure decides nothing.
620
+ if (!(error instanceof CliError))
621
+ throw error;
622
+ vendorLookup = "unavailable";
623
+ }
624
+ let updated = { ...tracked,
625
+ vendor: { ...vendor, ...(!vendor.transactionId && found.transactionId ? { transactionId: found.transactionId } : {}),
626
+ ...(found.state ? { state: found.state } : {}), hashes: [...new Set([...vendor.hashes, ...found.hashes])] } };
627
+ const verify = async (candidates) => {
628
+ if (!candidates.length)
629
+ return undefined;
630
+ updated = { ...updated, vendor: { ...updated.vendor, hashes: [...new Set([...updated.vendor.hashes, ...candidates])] } };
631
+ updated = { ...updated, txHash: updated.vendor.hashes[updated.vendor.hashes.length - 1] };
632
+ updateOrder(record.intentId, { confirmationTx: updated });
633
+ for (const txHash of candidates) {
634
+ try {
635
+ const result = await checkDirectRecord(context, record, { ...updated, txHash }, handle, { orderHandle: handle, mode: "direct" });
636
+ if (result.state === "observed")
637
+ return result;
638
+ }
639
+ catch (error) {
640
+ if (!(error instanceof CliError) || error.code !== "DASKI_CONFIRMATION_RECEIPT_UNRELATED")
641
+ throw error;
642
+ // An ERC-4337 outer success can contain an inner revert. Keep the journal.
643
+ }
644
+ }
645
+ return undefined;
646
+ };
647
+ updateOrder(record.intentId, { confirmationTx: updated });
648
+ const byVendor = await verify(updated.vendor.hashes);
649
+ if (byVendor)
650
+ return byVendor;
651
+ // Circle's history may be unreadable or not name the hash; the review itself is on chain.
652
+ let chainSearch = updated.vendor.fromBlock === undefined ? "not-recorded" : "searched";
653
+ let discovered = [];
654
+ try {
655
+ discovered = (await discoverReviewTransactions(context, tracked)).filter(hash => !updated.vendor.hashes.includes(hash));
656
+ }
657
+ catch (error) {
658
+ if (!(error instanceof CliError))
659
+ throw error;
660
+ chainSearch = "unavailable";
661
+ }
662
+ const byChain = await verify(discovered);
663
+ if (byChain)
664
+ return byChain;
665
+ const hashes = updated.vendor.hashes;
666
+ const failed = CIRCLE_FAILED_STATES.has(updated.vendor.state ?? "");
667
+ return { orderHandle: handle, mode: "direct", state: "submitted", transactionId: updated.vendor.transactionId ?? null,
668
+ vendorState: updated.vendor.state ?? null, vendorLookup, chainSearch, hashes,
669
+ next: failed && updated.vendor.transactionId
670
+ ? `Circle reports the transaction ${updated.vendor.state}, and nothing observed on chain carries this review. ` +
671
+ `Clear it with daski order confirm ${handle} --abandon, then prepare the review again.`
672
+ : hashes.length ? "Wait for final EAS evidence, then use --resume again."
673
+ : "Neither Circle nor the chain shows this review yet. Keep this journal; --resume only reads and never executes again." };
674
+ }
675
+ /**
676
+ * How far past the final height recorded before submission the chain is
677
+ * searched (about 100 minutes of Base blocks, covering mainnet's finality lag
678
+ * and Circle's own polling), and in what steps: Base's public RPC refuses an
679
+ * eth_getLogs range over 500 blocks. A later landing is found through Circle's
680
+ * history or a hash recorded with --tx.
681
+ */
682
+ const DISCOVERY_SPAN = 3000n;
683
+ const DISCOVERY_STEP = 500n;
684
+ /**
685
+ * Transactions that carry this review, found on chain without the vendor:
686
+ * the pinned EAS's Attested (or Revoked) events for the payer as attester,
687
+ * the prepared recipient and the confirmation schema, from the final height
688
+ * recorded before submission, whose attestation binds to the prepared call
689
+ * at the RPC's final height. --check then verifies each one in full.
690
+ */
691
+ async function discoverReviewTransactions(context, tracked) {
692
+ const from = tracked.vendor?.fromBlock;
693
+ if (!context.chain.getLogs || from === undefined || !/^\d+$/.test(from))
694
+ return [];
695
+ const finalBlock = await context.chain.getFinalBlockNumber();
696
+ const start = BigInt(from);
697
+ if (finalBlock < start)
698
+ return [];
699
+ const end = finalBlock < start + DISCOVERY_SPAN ? finalBlock : start + DISCOVERY_SPAN;
700
+ const eventName = tracked.action === "attest" ? "Attested" : "Revoked";
701
+ const topics = encodeEventTopics({ abi: EAS_ABI, eventName,
702
+ args: { recipient: tracked.expected.recipient, attester: context.payerAddress, schemaUID: tracked.expected.schema } });
703
+ for (let low = start; low <= end; low += DISCOVERY_STEP) {
704
+ const high = low + DISCOVERY_STEP - 1n < end ? low + DISCOVERY_STEP - 1n : end;
705
+ for (const log of await context.chain.getLogs({ address: context.profile.easAddress, topics, fromBlock: low, toBlock: high })) {
706
+ if (!isAddressEqual(log.address, context.profile.easAddress))
707
+ continue;
708
+ let uid;
709
+ try {
710
+ uid = decodeEventLog({ abi: EAS_ABI, data: log.data, topics: log.topics }).args.uid;
711
+ }
712
+ catch {
713
+ continue;
714
+ }
715
+ if (tracked.action === "revoke" && !sameHex(uid, tracked.expected.uid ?? ""))
716
+ continue;
717
+ // One attestation binds to the prepared call: its refUID admits no second one.
718
+ if (!bindingMismatch(await readAttestation(context.chain, context.profile.easAddress, uid, finalBlock), tracked, uid)) {
719
+ return [log.transactionHash.toLowerCase()];
720
+ }
721
+ }
722
+ }
723
+ return [];
560
724
  }
561
725
  function assertCapacity(facts, choice, handle) {
562
726
  if (choice === "revoke") {
@@ -580,6 +744,14 @@ function assertCapacity(facts, choice, handle) {
580
744
  function isPendingDirect(tracked) {
581
745
  return tracked !== undefined && (tracked.state === "prepared" || tracked.state === "submitted");
582
746
  }
747
+ /**
748
+ * A call prepared before 0.5.0, which saved only its hash and binding: nothing
749
+ * was recorded as sent, and the call itself must be prepared again to be
750
+ * estimated or submitted.
751
+ */
752
+ function isLegacyPrepared(tracked) {
753
+ return tracked !== undefined && tracked.state === "prepared" && !tracked.call && !tracked.txHash && !tracked.vendor;
754
+ }
583
755
  /** A direct record that --tx, --check, or --abandon can act on: anything but none or abandoned. */
584
756
  function isDirectTracked(tracked) {
585
757
  return tracked !== undefined && tracked.state !== "abandoned";
@@ -696,7 +868,10 @@ async function manageDirectRecord(context, record, options) {
696
868
  message: `This confirmation was already observed as ${tracked.txHash}.`,
697
869
  remediation: "Nothing to record. Prepare a new confirmation if another submission is wanted." });
698
870
  let corrected;
699
- if (tracked.state === "submitted" && tracked.txHash !== txHash) {
871
+ // A started Circle execution may carry several hashes (Circle replaces a
872
+ // stuck transaction), so a hash is added to it, never swapped; each one is
873
+ // still bound on chain before the journal closes.
874
+ if (!tracked.vendor && tracked.state === "submitted" && tracked.txHash !== txHash) {
700
875
  // Replacing a recorded hash is a journal correction, allowed only once the
701
876
  // recorded transaction is canonical, final, and provably not the
702
877
  // prepared call.
@@ -708,7 +883,8 @@ async function manageDirectRecord(context, record, options) {
708
883
  "transaction is final and carries no event that binds to the prepared call; if it reverted, --abandon clears it first." });
709
884
  corrected = { previousTxHash: tracked.txHash, reason: unrelated };
710
885
  }
711
- updateOrder(record.intentId, { confirmationTx: { ...tracked, txHash, state: "submitted" } });
886
+ const vendor = tracked.vendor ? { vendor: { ...tracked.vendor, hashes: [...new Set([...tracked.vendor.hashes, txHash])] } } : {};
887
+ updateOrder(record.intentId, { confirmationTx: { ...tracked, ...vendor, txHash, state: "submitted" } });
712
888
  return { ...base, txHash, state: "submitted", verification: "recorded, not yet verified",
713
889
  ...(corrected ? { corrected, note: `The previously recorded transaction is final and unrelated to the prepared call (${corrected.reason}); the record now tracks the new hash. ${cancelsNothing}` } : {}),
714
890
  next: `Run daski order confirm ${handle} --check once the transaction is mined. ${finalityNote(context.profile.chainId)}` };
@@ -720,23 +896,28 @@ async function manageDirectRecord(context, record, options) {
720
896
  // transaction provably not this call. A revert in a block that is not yet final
721
897
  // settles nothing: a reorganization can re-include the transaction against
722
898
  // different state.
723
- if (tracked.vendor)
899
+ // A started Circle execution is abandoned only once Circle reports its
900
+ // transaction failed, and then only on the same receipt rules as any hash.
901
+ if (tracked.vendor && !(tracked.vendor.transactionId !== undefined && CIRCLE_FAILED_STATES.has(tracked.vendor.state ?? "")))
724
902
  throw new CliError({
725
- code: "DASKI_CONFIRMATION_TX_MAY_EXECUTE", message: "Circle execution was started and has no resolved transaction hash.",
726
- remediation: "Use --resume for read-only vendor lookup. Keep the saved idempotency key; do not abandon or execute again."
903
+ code: "DASKI_CONFIRMATION_TX_MAY_EXECUTE", message: "Circle execution was started and Circle has not reported it failed.",
904
+ remediation: `Use daski order confirm ${handle} --resume: it reads Circle's record of the transaction and searches the chain, ` +
905
+ "and never executes. Keep the saved idempotency key; do not abandon or execute again."
727
906
  });
907
+ const hashes = [...new Set([...(tracked.vendor?.hashes ?? []), ...(tracked.txHash ? [tracked.txHash] : [])])];
728
908
  let unrelatedReceipt;
729
- if (tracked.txHash) {
730
- const receipt = await context.chain.getTransactionReceipt(tracked.txHash);
909
+ for (const txHash of hashes) {
910
+ const pinned = { ...tracked, txHash };
911
+ const receipt = await context.chain.getTransactionReceipt(txHash);
731
912
  if (!receipt)
732
913
  throw new CliError({ code: "DASKI_CONFIRMATION_TX_MAY_EXECUTE",
733
- message: `Transaction ${tracked.txHash} has no receipt yet and may still execute.`,
914
+ message: `Transaction ${txHash} has no receipt yet and may still execute.`,
734
915
  remediation: `Wait for it to be mined, then run daski order confirm ${handle} --check. Abandon is allowed only when the receipt is a final revert or the transaction is final and unrelated to this call.` });
735
916
  if (receipt.status === "success") {
736
- const unrelated = await provenUnrelated(context, tracked, handle, receipt);
917
+ const unrelated = await provenUnrelated(context, pinned, handle, receipt);
737
918
  if (!unrelated)
738
919
  throw new CliError({ code: "DASKI_CONFIRMATION_TX_MAY_EXECUTE",
739
- message: `Transaction ${tracked.txHash} executed; the record cannot be abandoned.`,
920
+ message: `Transaction ${txHash} executed; the record cannot be abandoned.`,
740
921
  remediation: `Verify it with daski order confirm ${handle} --check.` });
741
922
  unrelatedReceipt = unrelated;
742
923
  }
@@ -744,7 +925,7 @@ async function manageDirectRecord(context, record, options) {
744
925
  const view = await receiptView(context, receipt);
745
926
  if (!view.final || !view.canonical)
746
927
  throw new CliError({ code: "DASKI_CONFIRMATION_TX_MAY_EXECUTE",
747
- message: `Transaction ${tracked.txHash} reverted in ${view.final
928
+ message: `Transaction ${txHash} reverted in ${view.final
748
929
  ? "a block that is not the chain's canonical block at its height"
749
930
  : `block ${receipt.blockNumber}, which is not final yet (final block ${view.finalBlock})`}; it may still be re-included.`,
750
931
  remediation: `Wait for the block to become final. ${finalityNote(context.profile.chainId)} Then run daski order confirm ${handle} --check and --abandon.` });
@@ -840,7 +1021,9 @@ async function checkDirectRecord(context, record, tracked, handle, base) {
840
1021
  if (!tracked.txHash)
841
1022
  throw new CliError({ code: "DASKI_CONFIRMATION_TX_NOT_RECORDED",
842
1023
  message: "The prepared call has no transaction hash recorded yet.",
843
- remediation: `Submit it with the wallet's own tool, then record the hash: daski order confirm ${handle} --tx <hash>.` });
1024
+ remediation: tracked.vendor
1025
+ ? `Circle execution was started; use daski order confirm ${handle} --resume to find its transaction through Circle and the chain.`
1026
+ : `Submit it with the wallet's own tool, then record the hash: daski order confirm ${handle} --tx <hash>.` });
844
1027
  const receipt = await context.chain.getTransactionReceipt(tracked.txHash);
845
1028
  if (!receipt) {
846
1029
  return { ...base, txHash: tracked.txHash, state: "submitted", receipt: "pending",