@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.
- package/README.md +5 -6
- package/dist/bin/daski.js +5 -1
- package/dist/bin/daski.js.map +1 -1
- package/dist/chain/easProfiles.d.ts +1 -1
- package/dist/chain/easProfiles.d.ts.map +1 -1
- package/dist/chain/easProfiles.js +4 -2
- package/dist/chain/easProfiles.js.map +1 -1
- package/dist/chain/reader.d.ts +42 -0
- package/dist/chain/reader.d.ts.map +1 -1
- package/dist/chain/reader.js +107 -13
- package/dist/chain/reader.js.map +1 -1
- package/dist/cli/output.d.ts.map +1 -1
- package/dist/cli/output.js +3 -1
- package/dist/cli/output.js.map +1 -1
- package/dist/cli/redact.d.ts +8 -1
- package/dist/cli/redact.d.ts.map +1 -1
- package/dist/cli/redact.js +19 -1
- package/dist/cli/redact.js.map +1 -1
- package/dist/commands/buy.d.ts.map +1 -1
- package/dist/commands/buy.js +31 -4
- package/dist/commands/buy.js.map +1 -1
- package/dist/commands/confirmation.d.ts.map +1 -1
- package/dist/commands/confirmation.js +279 -70
- package/dist/commands/confirmation.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +2 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/gateway/balance.d.ts.map +1 -1
- package/dist/gateway/balance.js +3 -1
- package/dist/gateway/balance.js.map +1 -1
- package/dist/gateway/catalog.d.ts +8 -2
- package/dist/gateway/catalog.d.ts.map +1 -1
- package/dist/gateway/catalog.js +7 -3
- package/dist/gateway/catalog.js.map +1 -1
- package/dist/gateway/client.d.ts +8 -0
- package/dist/gateway/client.d.ts.map +1 -1
- package/dist/gateway/client.js +20 -0
- package/dist/gateway/client.js.map +1 -1
- package/dist/gateway/lifecycle.d.ts.map +1 -1
- package/dist/gateway/lifecycle.js +2 -2
- package/dist/gateway/lifecycle.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/signers/circleReview.d.ts +43 -15
- package/dist/signers/circleReview.d.ts.map +1 -1
- package/dist/signers/circleReview.js +128 -29
- package/dist/signers/circleReview.js.map +1 -1
- package/dist/signers/circleReviewChild.js +8 -4
- package/dist/signers/circleReviewChild.js.map +1 -1
- package/dist/signers/circleReviewTransport.d.ts +58 -1
- package/dist/signers/circleReviewTransport.d.ts.map +1 -1
- package/dist/signers/circleReviewTransport.js +145 -6
- package/dist/signers/circleReviewTransport.js.map +1 -1
- package/dist/store/orders.d.ts +6 -0
- package/dist/store/orders.d.ts.map +1 -1
- package/dist/store/orders.js.map +1 -1
- 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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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:
|
|
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",
|
|
528
|
-
|
|
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(),
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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:
|
|
560
|
-
txHash:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
727
|
-
remediation:
|
|
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
|
-
|
|
731
|
-
const
|
|
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 ${
|
|
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,
|
|
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 ${
|
|
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 ${
|
|
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:
|
|
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
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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 ||
|