@daski/pay 0.5.1 → 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.
Files changed (59) hide show
  1. package/README.md +5 -6
  2. package/dist/bin/daski.js +5 -1
  3. package/dist/bin/daski.js.map +1 -1
  4. package/dist/chain/easProfiles.d.ts +1 -1
  5. package/dist/chain/easProfiles.d.ts.map +1 -1
  6. package/dist/chain/easProfiles.js +4 -2
  7. package/dist/chain/easProfiles.js.map +1 -1
  8. package/dist/chain/reader.d.ts +42 -0
  9. package/dist/chain/reader.d.ts.map +1 -1
  10. package/dist/chain/reader.js +107 -13
  11. package/dist/chain/reader.js.map +1 -1
  12. package/dist/cli/output.d.ts.map +1 -1
  13. package/dist/cli/output.js +3 -1
  14. package/dist/cli/output.js.map +1 -1
  15. package/dist/cli/redact.d.ts +8 -1
  16. package/dist/cli/redact.d.ts.map +1 -1
  17. package/dist/cli/redact.js +19 -1
  18. package/dist/cli/redact.js.map +1 -1
  19. package/dist/commands/buy.d.ts.map +1 -1
  20. package/dist/commands/buy.js +31 -4
  21. package/dist/commands/buy.js.map +1 -1
  22. package/dist/commands/confirmation.d.ts.map +1 -1
  23. package/dist/commands/confirmation.js +279 -70
  24. package/dist/commands/confirmation.js.map +1 -1
  25. package/dist/commands/doctor.d.ts.map +1 -1
  26. package/dist/commands/doctor.js +2 -1
  27. package/dist/commands/doctor.js.map +1 -1
  28. package/dist/gateway/balance.d.ts.map +1 -1
  29. package/dist/gateway/balance.js +3 -1
  30. package/dist/gateway/balance.js.map +1 -1
  31. package/dist/gateway/catalog.d.ts +8 -2
  32. package/dist/gateway/catalog.d.ts.map +1 -1
  33. package/dist/gateway/catalog.js +7 -3
  34. package/dist/gateway/catalog.js.map +1 -1
  35. package/dist/gateway/client.d.ts +8 -0
  36. package/dist/gateway/client.d.ts.map +1 -1
  37. package/dist/gateway/client.js +20 -0
  38. package/dist/gateway/client.js.map +1 -1
  39. package/dist/gateway/lifecycle.d.ts.map +1 -1
  40. package/dist/gateway/lifecycle.js +2 -2
  41. package/dist/gateway/lifecycle.js.map +1 -1
  42. package/dist/index.d.ts +1 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +1 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/signers/circleReview.d.ts +43 -15
  47. package/dist/signers/circleReview.d.ts.map +1 -1
  48. package/dist/signers/circleReview.js +128 -29
  49. package/dist/signers/circleReview.js.map +1 -1
  50. package/dist/signers/circleReviewChild.js +8 -4
  51. package/dist/signers/circleReviewChild.js.map +1 -1
  52. package/dist/signers/circleReviewTransport.d.ts +58 -1
  53. package/dist/signers/circleReviewTransport.d.ts.map +1 -1
  54. package/dist/signers/circleReviewTransport.js +145 -6
  55. package/dist/signers/circleReviewTransport.js.map +1 -1
  56. package/dist/store/orders.d.ts +6 -0
  57. package/dist/store/orders.d.ts.map +1 -1
  58. package/dist/store/orders.js.map +1 -1
  59. package/package.json +2 -2
@@ -43,12 +43,14 @@
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
- import { finalityTagFor } from "../chain/reader.js";
51
+ import { finalityTagFor, readContracts } from "../chain/reader.js";
51
52
  import { CliError } from "../cli/errors.js";
53
+ import { reviewNeedsOperator } from "../gateway/client.js";
52
54
  import { callAuthorizedLifecycleTool } from "../gateway/lifecycle.js";
53
55
  import { confirmationPinsMissing, easAddressMismatch } from "../gateway/metadata.js";
54
56
  import { findByIntent, updateOrder, withOrderLock, } from "../store/orders.js";
@@ -331,8 +333,7 @@ export async function confirmOrder(context, record, options, factsReader = readC
331
333
  const gateway = error.details.gateway;
332
334
  if (gateway?.expected?.operationId !== submission.operationId)
333
335
  throw invalidPreparation();
334
- return { orderHandle: handle, mode: "sponsored", status: "pending", operationId: submission.operationId,
335
- next: "The saved signature was reaffirmed. Use --resume to reconcile its execution." };
336
+ return unfinishedSponsoredReview(handle, context.profile.chainId, gateway.expected, { operationId: submission.operationId }, `The saved signature was reaffirmed. Use daski order confirm ${handle} --resume to reconcile its execution.`);
336
337
  }
337
338
  throw error;
338
339
  }
@@ -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")
@@ -439,12 +455,11 @@ export async function confirmOrder(context, record, options, factsReader = readC
439
455
  updateOrder(record.intentId, { confirmationSubmission: undefined,
440
456
  confirmationHistory: [...(latest(record).confirmationHistory ?? []), { submission, outcome: detail, archivedAt: new Date().toISOString() }] });
441
457
  }
442
- if (error.code === "CONFIRMATION_SUBMISSION_PENDING")
443
- return {
444
- orderHandle: record.handle, mode: "sponsored", status: "pending", preparationId: submission.request.preparationId,
445
- ...(submission.operationId ? { operationId: submission.operationId } : {}),
446
- next: `Run daski order confirm ${options.handle} --resume to check the same submission.`
447
- };
458
+ // A delegated signature without a deadline (EAS 1.0.1, Base mainnet)
459
+ // stays live while parked, so the gateway answers still-live for it.
460
+ if (error.code === "CONFIRMATION_SUBMISSION_PENDING" ||
461
+ (error.code === "CONFIRMATION_AUTHORIZATION_STILL_LIVE" && detail?.disposition === "operator_attention"))
462
+ return unfinishedSponsoredReview(handle, context.profile.chainId, detail, { preparationId: submission.request.preparationId, operationId: submission.operationId }, `Run daski order confirm ${options.handle} --resume to check the same submission.`);
448
463
  }
449
464
  if (error instanceof CliError && error.code === "CONFIRMATION_PREPARATION_STALE") {
450
465
  throw new CliError({
@@ -478,7 +493,9 @@ async function manageCircleReview(context, record, options, signer, factsReader,
478
493
  if (!tracked?.call || !tracked.choice || tracked.state === "abandoned")
479
494
  throw new CliError({
480
495
  code: "DASKI_CONFIRMATION_NOT_PREPARED", message: "There is no saved validated call for this direct review.",
481
- 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."
482
499
  });
483
500
  if (record.confirmationSubmission)
484
501
  throw new CliError({ code: "DASKI_CONFIRMATION_PENDING",
@@ -492,40 +509,19 @@ async function manageCircleReview(context, record, options, signer, factsReader,
492
509
  !isAddressEqual(tracked.call.to, context.profile.easAddress) ||
493
510
  (tracked.vendor && (!isAddressEqual(tracked.vendor.wallet, context.payerAddress) || tracked.vendor.chainId !== context.profile.chainId)))
494
511
  throw invalidCall("saved call binding differs");
512
+ if (options.resume)
513
+ return resumeCircleReview(context, record, tracked, handle, adapterFactory);
495
514
  const adapter = adapterFactory();
496
- if (options.resume) {
497
- if (!tracked.vendor)
498
- throw new CliError({ code: "DASKI_CONFIRMATION_NOT_PENDING",
499
- message: "Circle submission has not started.", remediation: "Preparation is not approval; use --estimate or explicitly approve --submit." });
500
- const request = { mode: "lookup", wallet: context.payerAddress, call: tracked.call, idempotencyKey: tracked.vendor.idempotencyKey };
501
- const found = await adapter.lookup(request, tracked.vendor.transactionId);
502
- const hashes = [...new Set([...tracked.vendor.hashes, ...found.hashes])];
503
- const updated = { ...tracked, vendor: { ...tracked.vendor, ...(found.transactionId ? { transactionId: found.transactionId } : {}), hashes },
504
- ...(hashes.length ? { txHash: hashes[hashes.length - 1] } : {}) };
505
- updateOrder(record.intentId, { confirmationTx: updated });
506
- for (const txHash of hashes) {
507
- try {
508
- const result = await checkDirectRecord(context, record, { ...updated, txHash }, handle, { orderHandle: handle, mode: "direct" });
509
- if (result.state === "observed")
510
- return result;
511
- }
512
- catch (error) {
513
- if (!(error instanceof CliError) || error.code !== "DASKI_CONFIRMATION_RECEIPT_UNRELATED")
514
- throw error;
515
- // An ERC-4337 outer success can contain an inner revert. Keep the journal.
516
- }
517
- }
518
- return { orderHandle: handle, mode: "direct", state: "submitted", transactionId: updated.vendor.transactionId ?? null,
519
- hashes, next: hashes.length ? "Wait for finalized EAS evidence, then use --resume again."
520
- : "Vendor identity or exact-call discovery is unresolved. Keep this journal; --resume only reads and never executes again." };
521
- }
522
515
  if (tracked.vendor || tracked.txHash || tracked.state !== "prepared")
523
516
  throw directPending(handle, tracked);
524
517
  const capabilities = (await context.metadata()).confirmation?.directReview;
525
518
  if (options.submit && capabilities?.circleExecute !== true && !options.qualifyCircleExecution)
526
519
  throw new CliError({
527
- code: "DASKI_CIRCLE_EXECUTION_NOT_QUALIFIED", message: "Circle review execution is not yet qualified for this gateway.",
528
- 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."
529
525
  });
530
526
  if (options.estimate && capabilities?.circleEstimate !== true)
531
527
  throw new CliError({
@@ -547,17 +543,184 @@ async function manageCircleReview(context, record, options, signer, factsReader,
547
543
  if (options.estimate)
548
544
  return { orderHandle: handle, mode: "direct", state: "prepared", callHash: tracked.callHash,
549
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();
550
549
  const started = { ...tracked, state: "submitted", vendor: {
551
550
  provider: "circle-agent", packageVersion: adapter.packageVersion, idempotencyKey, wallet: context.payerAddress,
552
- 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: []
553
553
  } };
554
554
  updateOrder(record.intentId, { confirmationTx: started });
555
- const result = await adapter.submit(request);
556
- const updated = { ...started, ...(result.txHash ? { txHash: result.txHash } : {}),
557
- 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);
558
579
  updateOrder(record.intentId, { confirmationTx: updated });
559
- return { orderHandle: handle, mode: "direct", state: "submitted", transactionId: result.transactionId ?? null,
560
- 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 [];
561
724
  }
562
725
  function assertCapacity(facts, choice, handle) {
563
726
  if (choice === "revoke") {
@@ -581,6 +744,14 @@ function assertCapacity(facts, choice, handle) {
581
744
  function isPendingDirect(tracked) {
582
745
  return tracked !== undefined && (tracked.state === "prepared" || tracked.state === "submitted");
583
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
+ }
584
755
  /** A direct record that --tx, --check, or --abandon can act on: anything but none or abandoned. */
585
756
  function isDirectTracked(tracked) {
586
757
  return tracked !== undefined && tracked.state !== "abandoned";
@@ -697,7 +868,10 @@ async function manageDirectRecord(context, record, options) {
697
868
  message: `This confirmation was already observed as ${tracked.txHash}.`,
698
869
  remediation: "Nothing to record. Prepare a new confirmation if another submission is wanted." });
699
870
  let corrected;
700
- 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) {
701
875
  // Replacing a recorded hash is a journal correction, allowed only once the
702
876
  // recorded transaction is canonical, final, and provably not the
703
877
  // prepared call.
@@ -709,7 +883,8 @@ async function manageDirectRecord(context, record, options) {
709
883
  "transaction is final and carries no event that binds to the prepared call; if it reverted, --abandon clears it first." });
710
884
  corrected = { previousTxHash: tracked.txHash, reason: unrelated };
711
885
  }
712
- 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" } });
713
888
  return { ...base, txHash, state: "submitted", verification: "recorded, not yet verified",
714
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}` } : {}),
715
890
  next: `Run daski order confirm ${handle} --check once the transaction is mined. ${finalityNote(context.profile.chainId)}` };
@@ -721,23 +896,28 @@ async function manageDirectRecord(context, record, options) {
721
896
  // transaction provably not this call. A revert in a block that is not yet final
722
897
  // settles nothing: a reorganization can re-include the transaction against
723
898
  // different state.
724
- 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 ?? "")))
725
902
  throw new CliError({
726
- code: "DASKI_CONFIRMATION_TX_MAY_EXECUTE", message: "Circle execution was started and has no resolved transaction hash.",
727
- 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."
728
906
  });
907
+ const hashes = [...new Set([...(tracked.vendor?.hashes ?? []), ...(tracked.txHash ? [tracked.txHash] : [])])];
729
908
  let unrelatedReceipt;
730
- if (tracked.txHash) {
731
- 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);
732
912
  if (!receipt)
733
913
  throw new CliError({ code: "DASKI_CONFIRMATION_TX_MAY_EXECUTE",
734
- message: `Transaction ${tracked.txHash} has no receipt yet and may still execute.`,
914
+ message: `Transaction ${txHash} has no receipt yet and may still execute.`,
735
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.` });
736
916
  if (receipt.status === "success") {
737
- const unrelated = await provenUnrelated(context, tracked, handle, receipt);
917
+ const unrelated = await provenUnrelated(context, pinned, handle, receipt);
738
918
  if (!unrelated)
739
919
  throw new CliError({ code: "DASKI_CONFIRMATION_TX_MAY_EXECUTE",
740
- message: `Transaction ${tracked.txHash} executed; the record cannot be abandoned.`,
920
+ message: `Transaction ${txHash} executed; the record cannot be abandoned.`,
741
921
  remediation: `Verify it with daski order confirm ${handle} --check.` });
742
922
  unrelatedReceipt = unrelated;
743
923
  }
@@ -745,7 +925,7 @@ async function manageDirectRecord(context, record, options) {
745
925
  const view = await receiptView(context, receipt);
746
926
  if (!view.final || !view.canonical)
747
927
  throw new CliError({ code: "DASKI_CONFIRMATION_TX_MAY_EXECUTE",
748
- message: `Transaction ${tracked.txHash} reverted in ${view.final
928
+ message: `Transaction ${txHash} reverted in ${view.final
749
929
  ? "a block that is not the chain's canonical block at its height"
750
930
  : `block ${receipt.blockNumber}, which is not final yet (final block ${view.finalBlock})`}; it may still be re-included.`,
751
931
  remediation: `Wait for the block to become final. ${finalityNote(context.profile.chainId)} Then run daski order confirm ${handle} --check and --abandon.` });
@@ -764,6 +944,29 @@ function finalityNote(chainId) {
764
944
  ? "Finality on Base mainnet (the finalized tag) takes minutes to tens of minutes."
765
945
  : "The sandbox treats Base Sepolia's safe tag as final; it lags the head by minutes.";
766
946
  }
947
+ /** How long to wait before resuming a pending sponsored review: a fraction of the chain's finality lag. */
948
+ function reviewPollSeconds(chainId) {
949
+ return finalityTagFor(chainId) === "finalized" ? 120 : 30;
950
+ }
951
+ /**
952
+ * A sponsored review the gateway has not finished. `pending` waits for the
953
+ * chain, so it says when to look again. `attention` means the gateway has
954
+ * parked it for its operator: resuming does not move it, and the saved
955
+ * signature must be kept, since the operator may find it can still execute.
956
+ * Both use `state`, the key a final review reports too.
957
+ */
958
+ function unfinishedSponsoredReview(handle, chainId, gateway, ids, pendingNext) {
959
+ const disposition = typeof gateway?.disposition === "string" ? gateway.disposition : undefined;
960
+ const base = { orderHandle: handle, mode: "sponsored",
961
+ ...(ids.preparationId === undefined ? {} : { preparationId: ids.preparationId }),
962
+ ...(ids.operationId ? { operationId: ids.operationId } : {}),
963
+ ...(disposition ? { disposition } : {}) };
964
+ if (disposition === "operator_attention")
965
+ return { ...base, state: "attention",
966
+ next: `${reviewNeedsOperator(ids.operationId)} Once it is resolved, daski order confirm ${handle} --resume reports the outcome.` };
967
+ return { ...base, state: "pending", pollAfterSeconds: reviewPollSeconds(chainId),
968
+ next: `${pendingNext} ${finalityNote(chainId)}` };
969
+ }
767
970
  /**
768
971
  * The one final view every canonical read uses (R04): the RPC's final height
769
972
  * is read first, and the canonical block at the receipt's height is compared
@@ -818,7 +1021,9 @@ async function checkDirectRecord(context, record, tracked, handle, base) {
818
1021
  if (!tracked.txHash)
819
1022
  throw new CliError({ code: "DASKI_CONFIRMATION_TX_NOT_RECORDED",
820
1023
  message: "The prepared call has no transaction hash recorded yet.",
821
- 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>.` });
822
1027
  const receipt = await context.chain.getTransactionReceipt(tracked.txHash);
823
1028
  if (!receipt) {
824
1029
  return { ...base, txHash: tracked.txHash, state: "submitted", receipt: "pending",
@@ -1008,9 +1213,13 @@ export async function readConfirmationFacts(context, record, profileReader = dis
1008
1213
  if (!isAddressEqual(pins.eas, context.profile.easAddress))
1009
1214
  throw easAddressMismatch(pins.eas, context.profile.easAddress, context.profile.chainId);
1010
1215
  const orderKey = status.orderKey;
1011
- const [current, nonce, profile] = await Promise.all([
1012
- context.chain.readContract({ address: pins.reputationStorage, abi: REPUTATION_ABI, functionName: "getRecord", args: [orderKey] }),
1013
- context.chain.readContract({ address: pins.eas, abi: EAS_ABI, functionName: "getNonce", args: [context.payerAddress] }),
1216
+ // The record and nonce share one batched eth_call, and the profile check
1217
+ // another: a public RPC refuses a burst of separate ones.
1218
+ const [[current, nonce], profile] = await Promise.all([
1219
+ readContracts(context.chain, [
1220
+ { address: pins.reputationStorage, abi: REPUTATION_ABI, functionName: "getRecord", args: [orderKey] },
1221
+ { address: pins.eas, abi: EAS_ABI, functionName: "getNonce", args: [context.payerAddress] },
1222
+ ]),
1014
1223
  profileReader(context.chain, pins.chainId, pins.eas),
1015
1224
  ]);
1016
1225
  if (current.orderKey !== orderKey || getAddress(current.payer) !== context.payerAddress ||