@daski/pay 0.3.1 → 0.4.1

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 (101) hide show
  1. package/README.md +18 -11
  2. package/dist/bin/daski.js +26 -11
  3. package/dist/bin/daski.js.map +1 -1
  4. package/dist/chain/reader.d.ts +92 -0
  5. package/dist/chain/reader.d.ts.map +1 -0
  6. package/dist/chain/reader.js +130 -0
  7. package/dist/chain/reader.js.map +1 -0
  8. package/dist/commands/approval.js +2 -2
  9. package/dist/commands/approval.js.map +1 -1
  10. package/dist/commands/buy.d.ts +0 -1
  11. package/dist/commands/buy.d.ts.map +1 -1
  12. package/dist/commands/buy.js +15 -22
  13. package/dist/commands/buy.js.map +1 -1
  14. package/dist/commands/confirmation.d.ts +322 -4
  15. package/dist/commands/confirmation.d.ts.map +1 -1
  16. package/dist/commands/confirmation.js +771 -60
  17. package/dist/commands/confirmation.js.map +1 -1
  18. package/dist/commands/doctor.d.ts +20 -0
  19. package/dist/commands/doctor.d.ts.map +1 -1
  20. package/dist/commands/doctor.js +204 -59
  21. package/dist/commands/doctor.js.map +1 -1
  22. package/dist/commands/order.d.ts +37 -6
  23. package/dist/commands/order.d.ts.map +1 -1
  24. package/dist/commands/order.js +181 -103
  25. package/dist/commands/order.js.map +1 -1
  26. package/dist/commands/signPayment.d.ts.map +1 -1
  27. package/dist/commands/signPayment.js +8 -3
  28. package/dist/commands/signPayment.js.map +1 -1
  29. package/dist/commands/wallet.d.ts +3 -0
  30. package/dist/commands/wallet.d.ts.map +1 -1
  31. package/dist/commands/wallet.js +72 -16
  32. package/dist/commands/wallet.js.map +1 -1
  33. package/dist/config.d.ts +10 -2
  34. package/dist/config.d.ts.map +1 -1
  35. package/dist/config.js +41 -1
  36. package/dist/config.js.map +1 -1
  37. package/dist/context.d.ts +28 -3
  38. package/dist/context.d.ts.map +1 -1
  39. package/dist/context.js +72 -3
  40. package/dist/context.js.map +1 -1
  41. package/dist/gateway/client.d.ts +20 -17
  42. package/dist/gateway/client.d.ts.map +1 -1
  43. package/dist/gateway/client.js +62 -43
  44. package/dist/gateway/client.js.map +1 -1
  45. package/dist/gateway/lifecycle.d.ts +4 -1
  46. package/dist/gateway/lifecycle.d.ts.map +1 -1
  47. package/dist/gateway/lifecycle.js +10 -5
  48. package/dist/gateway/lifecycle.js.map +1 -1
  49. package/dist/gateway/metadata.d.ts +66 -0
  50. package/dist/gateway/metadata.d.ts.map +1 -0
  51. package/dist/gateway/metadata.js +152 -0
  52. package/dist/gateway/metadata.js.map +1 -0
  53. package/dist/gateway/purchase.d.ts +51 -67
  54. package/dist/gateway/purchase.d.ts.map +1 -1
  55. package/dist/gateway/purchase.js +153 -125
  56. package/dist/gateway/purchase.js.map +1 -1
  57. package/dist/host.d.ts +41 -0
  58. package/dist/host.d.ts.map +1 -0
  59. package/dist/host.js +112 -0
  60. package/dist/host.js.map +1 -0
  61. package/dist/index.d.ts +12 -6
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +10 -4
  64. package/dist/index.js.map +1 -1
  65. package/dist/signers/circleAgent.d.ts +37 -0
  66. package/dist/signers/circleAgent.d.ts.map +1 -0
  67. package/dist/signers/circleAgent.js +244 -0
  68. package/dist/signers/circleAgent.js.map +1 -0
  69. package/dist/signers/contract.d.ts +34 -0
  70. package/dist/signers/contract.d.ts.map +1 -0
  71. package/dist/signers/contract.js +47 -0
  72. package/dist/signers/contract.js.map +1 -0
  73. package/dist/signers/index.d.ts +16 -7
  74. package/dist/signers/index.d.ts.map +1 -1
  75. package/dist/signers/index.js +7 -3
  76. package/dist/signers/index.js.map +1 -1
  77. package/dist/signers/local.d.ts +5 -1
  78. package/dist/signers/local.d.ts.map +1 -1
  79. package/dist/signers/local.js +16 -3
  80. package/dist/signers/local.js.map +1 -1
  81. package/dist/signers/selfTest.d.ts +32 -12
  82. package/dist/signers/selfTest.d.ts.map +1 -1
  83. package/dist/signers/selfTest.js +85 -12
  84. package/dist/signers/selfTest.js.map +1 -1
  85. package/dist/signers/signature.d.ts +10 -0
  86. package/dist/signers/signature.d.ts.map +1 -0
  87. package/dist/signers/signature.js +11 -0
  88. package/dist/signers/signature.js.map +1 -0
  89. package/dist/store/keystore.d.ts +48 -6
  90. package/dist/store/keystore.d.ts.map +1 -1
  91. package/dist/store/keystore.js +414 -110
  92. package/dist/store/keystore.js.map +1 -1
  93. package/dist/store/lock.d.ts +59 -0
  94. package/dist/store/lock.d.ts.map +1 -0
  95. package/dist/store/lock.js +269 -0
  96. package/dist/store/lock.js.map +1 -0
  97. package/dist/store/orders.d.ts +49 -9
  98. package/dist/store/orders.d.ts.map +1 -1
  99. package/dist/store/orders.js +173 -37
  100. package/dist/store/orders.js.map +1 -1
  101. package/package.json +2 -2
@@ -1,9 +1,60 @@
1
+ /**
2
+ * `daski order confirm` and `daski order revoke-confirmation` — the payer's
3
+ * delivery confirmation, in one of two modes chosen by the signer.
4
+ *
5
+ * Sponsored (plain wallets): the gateway prepares a delegated EAS request,
6
+ * this CLI rebuilds the narrow review message from deployment pins, chain
7
+ * state and the user's choice, the wallet signs only when both agree, and
8
+ * Daski's relayer submits it. Direct (contract accounts, or an EOA that asks
9
+ * for it): the gateway prepares the closed `attest` or `revoke` call, this
10
+ * CLI validates every field of it against the same chain facts and the
11
+ * profile's pinned EAS address, re-encodes the calldata, and prints the call
12
+ * for the wallet's own tool to submit. The CLI never sends a transaction in
13
+ * either mode, and never constructs anything that could.
14
+ *
15
+ * A direct submission is tracked in `orders.json`: `--tx <hash>` records the
16
+ * hash immediately as `submitted` (recorded, unverified) so a restart never
17
+ * loses the association; `--check` advances it to `observed` only when the
18
+ * receipt succeeded, the pinned EAS emitted the matching event with the payer
19
+ * as attester, `getAttestation` binds the attestation to what was prepared,
20
+ * and the gateway's final read, anchored at or past the receipt's block,
21
+ * shows the result. "Final" is the profile chain's finality tag, `safe` on
22
+ * the sandbox and `finalized` on Base mainnet, the rule the gateway applies
23
+ * through CHAIN_FINALITY_TAG. Evidence is bound to one final view: the
24
+ * profile's RPC must report the receipt's height as final before its
25
+ * canonical block at that height is compared with the receipt's block (a
26
+ * read below the RPC's own final height does not move without an L1
27
+ * reorganization, which the sandbox accepts), attestations are read pinned
28
+ * to that final height, and the gateway's final block must be that RPC's
29
+ * canonical block at its height, so the receipt's block is a final ancestor
30
+ * of the anchor. The RPC is taken as one consistent
31
+ * node. A batched receipt may carry other orders' events first; every
32
+ * candidate event is tried and the one whose attestation binds is taken.
33
+ * `--abandon` clears the local record when no hash is recorded or the receipt
34
+ * is a revert in a final canonical block, and cancels nothing at the
35
+ * wallet. A hash recorded by mistake is corrected (replaced with `--tx`, or
36
+ * cleared with `--abandon`) only once its transaction is final and
37
+ * canonical and no candidate event binds to this call; a missing, pending,
38
+ * not yet final, or matching receipt keeps the record.
39
+ *
40
+ * One order's journal is updated under a per-order lock: the read-check-write
41
+ * of a preparation spans gateway and chain calls, and two concurrent
42
+ * preparations must not both find nothing pending. The signer is resolved
43
+ * before the lock, so no passphrase prompt runs inside it.
44
+ */
1
45
  import { canonicalHash } from "@daski/x402-scheme";
2
- import { createPublicClient, encodeAbiParameters, getAddress, http, parseAbi, parseAbiParameters } from "viem";
46
+ import { decodeEventLog, encodeAbiParameters, encodeFunctionData, getAddress, isAddressEqual, keccak256, parseAbi, parseAbiParameters, } from "viem";
47
+ import { finalityTagFor } from "../chain/reader.js";
3
48
  import { CliError } from "../cli/errors.js";
4
49
  import { callAuthorizedLifecycleTool } from "../gateway/lifecycle.js";
5
- import { updateOrder } from "../store/orders.js";
6
- import { readWithCapability, withOrder } from "./order.js";
50
+ import { confirmationPinsMissing, easAddressMismatch } from "../gateway/metadata.js";
51
+ import { findByIntent, updateOrder, withOrderLock, } from "../store/orders.js";
52
+ import { readWithCapability } from "./order.js";
53
+ export const FINAL_ATTESTATION_WARNING = "this is the last confirmation you can submit; it can still be revoked";
54
+ export const ATTESTATION_CAP = 3;
55
+ const ZERO_UID = `0x${"00".repeat(32)}`;
56
+ const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
57
+ const HEX32 = /^0x[0-9a-fA-F]{64}$/;
7
58
  const attestTypes = { Attest: [
8
59
  { name: "schema", type: "bytes32" }, { name: "recipient", type: "address" },
9
60
  { name: "expirationTime", type: "uint64" }, { name: "revocable", type: "bool" },
@@ -14,21 +65,62 @@ const revokeTypes = { Revoke: [
14
65
  { name: "schema", type: "bytes32" }, { name: "uid", type: "bytes32" },
15
66
  { name: "value", type: "uint256" }, { name: "nonce", type: "uint256" }, { name: "deadline", type: "uint64" },
16
67
  ] };
68
+ /** EAS 1.2.0: the two calls direct mode validates, the read that binds a receipt, and the events. */
69
+ export const EAS_ABI = parseAbi([
70
+ "function attest((bytes32 schema,(address recipient,uint64 expirationTime,bool revocable,bytes32 refUID,bytes data,uint256 value) data) request) payable returns (bytes32)",
71
+ "function revoke((bytes32 schema,(bytes32 uid,uint256 value) data) request) payable",
72
+ "function getAttestation(bytes32 uid) view returns ((bytes32 uid,bytes32 schema,uint64 time,uint64 expirationTime,uint64 revocationTime,bytes32 refUID,address recipient,address attester,bool revocable,bytes data))",
73
+ "function getNonce(address account) view returns (uint256)",
74
+ "event Attested(address indexed recipient,address indexed attester,bytes32 uid,bytes32 indexed schemaUID)",
75
+ "event Revoked(address indexed recipient,address indexed attester,bytes32 uid,bytes32 indexed schemaUID)",
76
+ ]);
77
+ export const REPUTATION_ABI = parseAbi([
78
+ "function getRecord(bytes32 orderKey) view returns ((bytes32 orderKey,bytes32 authorizationKey,uint256 providerAgentId,bytes32 serviceId,address payer,address providerOwner,address providerAgentWallet,address providerPayee,address canonicalToken,uint256 grossAmount,uint64 paidAt,bytes32 providerIdentitySnapshotHash,bytes32 listingManifestHash,bytes32 releaseEvidenceHash,uint8 outcome,uint8 confirmation,uint64 outcomeAttestationDelay,uint64 outcomeTimestamp,uint64 confirmationTimestamp,uint8 confirmationSubmissions,bool outcomeRecorded,bool reputationEligible,bytes32 currentConfirmationUid))",
79
+ ]);
80
+ /** The attestation payload for an order and label. */
81
+ export function confirmationData(orderKey, choice) {
82
+ return encodeAbiParameters(parseAbiParameters("bytes32 orderKey,uint8 confirmation"), [orderKey, choice === "Confirmed" ? 1 : 2]);
83
+ }
84
+ /** Mode by signer: a contract account submits directly; a plain wallet is sponsored unless it asks otherwise. */
85
+ export function selectConfirmationMode(description, requested) {
86
+ const contract = description.accountType === "contract";
87
+ if (requested === undefined)
88
+ return contract ? "direct" : "sponsored";
89
+ if (requested !== "sponsored" && requested !== "direct") {
90
+ throw new CliError({
91
+ code: "DASKI_CONFIRMATION_SUBMISSION_INVALID",
92
+ message: `--submission must be sponsored or direct, not "${requested}".`,
93
+ remediation: "Omit the flag to let the signer decide, or pass --submission direct.",
94
+ });
95
+ }
96
+ if (requested === "sponsored" && contract) {
97
+ throw new CliError({
98
+ code: "DASKI_CONFIRMATION_SPONSORED_REQUIRES_EOA",
99
+ message: `The ${description.provider} signer is a contract account; Daski sponsors attestations ` +
100
+ "for plain wallets only.",
101
+ remediation: "Omit --submission (or pass --submission direct): the CLI prints a validated call for " +
102
+ "the wallet's own tool to submit.",
103
+ });
104
+ }
105
+ return requested;
106
+ }
17
107
  /** Rebuild the narrow EAS review message from chain facts and the user's choice. */
18
108
  export function validateConfirmationPreparation(prepared, facts, choice, acknowledged, now = Math.floor(Date.now() / 1000)) {
19
109
  const proposed = prepared.signableTypedData;
20
110
  const deadline = Number(proposed?.message?.deadline);
111
+ const final = choice !== "revoke" && facts.submissionsUsed === ATTESTATION_CAP - 1;
21
112
  if (!proposed || prepared.orderKey !== facts.orderKey || prepared.currentRefUid !== facts.currentUid ||
22
- prepared.transitionsUsed !== facts.transitionsUsed || facts.transitionsUsed >= 3 ||
23
- (facts.transitionsUsed === 2 && !acknowledged) || !Number.isSafeInteger(deadline) || deadline <= now || deadline > now + 330) {
113
+ prepared.submissionsUsed !== facts.submissionsUsed || Boolean(prepared.finalAttestation) !== final ||
114
+ (choice !== "revoke" && facts.submissionsUsed >= ATTESTATION_CAP) ||
115
+ (choice === "revoke" && facts.currentUid === ZERO_UID) || (final && !acknowledged) ||
116
+ !Number.isSafeInteger(deadline) || deadline <= now || deadline > now + 330) {
24
117
  throw invalidPreparation();
25
118
  }
26
119
  const common = { schema: facts.schemaUid, value: "0", nonce: facts.nonce, deadline: String(deadline) };
27
120
  const expected = { domain: { name: "EAS", version: "1.2.0", chainId: facts.chainId, verifyingContract: facts.eas },
28
121
  types: choice === "revoke" ? revokeTypes : attestTypes, primaryType: choice === "revoke" ? "Revoke" : "Attest",
29
122
  message: choice === "revoke" ? { ...common, uid: facts.currentUid } : { ...common, recipient: facts.recipient,
30
- expirationTime: "0", revocable: true, refUID: facts.currentUid,
31
- data: encodeAbiParameters(parseAbiParameters("bytes32 orderKey,uint8 confirmation"), [facts.orderKey, choice === "Confirmed" ? 1 : 2]) } };
123
+ expirationTime: "0", revocable: true, refUID: facts.currentUid, data: confirmationData(facts.orderKey, choice) } };
32
124
  if (canonicalHash(proposed) !== canonicalHash(expected))
33
125
  throw invalidPreparation();
34
126
  return expected;
@@ -37,87 +129,706 @@ function invalidPreparation() {
37
129
  return new CliError({ code: "DASKI_CONFIRMATION_MISMATCH", message: "The review preparation does not match this order and choice.",
38
130
  remediation: "Check the order and request a fresh review preparation." });
39
131
  }
132
+ function invalidCall(reason) {
133
+ return new CliError({
134
+ code: "DASKI_CONFIRMATION_PREPARATION_INVALID",
135
+ message: `The prepared call does not match this order's chain facts: ${reason}.`,
136
+ remediation: "Nothing was shown or submitted. Run daski doctor --json to check the gateway's " +
137
+ "confirmation pins, then request a fresh preparation.",
138
+ });
139
+ }
140
+ function sameHex(a, b) {
141
+ return typeof a === "string" && a.toLowerCase() === b.toLowerCase();
142
+ }
143
+ function exactKeys(value, keys) {
144
+ if (!value || typeof value !== "object" || Array.isArray(value))
145
+ return false;
146
+ const actual = Object.keys(value).sort();
147
+ return actual.join(",") === [...keys].sort().join(",");
148
+ }
149
+ /**
150
+ * Validates a direct-mode call against chain facts exactly as the sponsored
151
+ * path validates its typed data: chain id, the pinned EAS as target, the
152
+ * pinned schema, the recipient, refUID, data and zero value derived from
153
+ * `getRecord` and the label, and calldata that re-encodes identically. Any
154
+ * mismatch aborts before the call is shown.
155
+ */
156
+ export function validateDirectCall(value, facts, choice, easAddress) {
157
+ if (!exactKeys(value, ["chainId", "to", "function", "request", "calldata", "value"]))
158
+ throw invalidCall("unexpected call shape");
159
+ const call = value;
160
+ if (call.value !== "0")
161
+ throw invalidCall(`value ${String(call.value)} is not zero`);
162
+ if (call.chainId !== facts.chainId)
163
+ throw invalidCall(`chain ${String(call.chainId)} is not ${facts.chainId}`);
164
+ if (typeof call.to !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(call.to) ||
165
+ !isAddressEqual(getAddress(call.to), easAddress) || !isAddressEqual(easAddress, facts.eas)) {
166
+ throw invalidCall(`target ${String(call.to)} is not the pinned EAS ${easAddress}`);
167
+ }
168
+ const action = choice === "revoke" ? "revoke" : "attest";
169
+ if (call.function !== action)
170
+ throw invalidCall(`function ${String(call.function)} is not ${action}`);
171
+ if (!exactKeys(call.request, ["schema", "data"]) || !sameHex(call.request.schema, facts.schemaUid)) {
172
+ throw invalidCall("the request does not name the pinned confirmation schema");
173
+ }
174
+ const fields = call.request.data;
175
+ let calldata;
176
+ let data;
177
+ if (action === "attest") {
178
+ if (!exactKeys(fields, ["recipient", "expirationTime", "revocable", "refUID", "data", "value"])) {
179
+ throw invalidCall("unexpected attest request shape");
180
+ }
181
+ data = confirmationData(facts.orderKey, choice);
182
+ if (typeof fields.recipient !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(fields.recipient) ||
183
+ !isAddressEqual(getAddress(fields.recipient), facts.recipient))
184
+ throw invalidCall("recipient differs from the provider on record");
185
+ if (fields.expirationTime !== "0" || fields.revocable !== true || fields.value !== "0")
186
+ throw invalidCall("expirationTime, revocable or value differ from the confirmation profile");
187
+ if (!sameHex(fields.refUID, facts.currentUid))
188
+ throw invalidCall("refUID differs from the current confirmation");
189
+ if (!sameHex(fields.data, data))
190
+ throw invalidCall("data differs from the order key and label");
191
+ if (facts.submissionsUsed >= ATTESTATION_CAP)
192
+ throw invalidCall("all attestations for this order are used");
193
+ calldata = encodeFunctionData({ abi: EAS_ABI, functionName: "attest", args: [{
194
+ schema: facts.schemaUid, data: { recipient: facts.recipient, expirationTime: 0n, revocable: true,
195
+ refUID: facts.currentUid, data, value: 0n }
196
+ }] });
197
+ }
198
+ else {
199
+ if (!exactKeys(fields, ["uid", "value"]))
200
+ throw invalidCall("unexpected revoke request shape");
201
+ if (facts.currentUid === ZERO_UID)
202
+ throw invalidCall("no confirmation is active to revoke");
203
+ if (!sameHex(fields.uid, facts.currentUid))
204
+ throw invalidCall("uid differs from the current confirmation");
205
+ if (fields.value !== "0")
206
+ throw invalidCall("value is not zero");
207
+ calldata = encodeFunctionData({ abi: EAS_ABI, functionName: "revoke", args: [{
208
+ schema: facts.schemaUid, data: { uid: facts.currentUid, value: 0n }
209
+ }] });
210
+ }
211
+ if (!sameHex(call.calldata, calldata))
212
+ throw invalidCall("calldata does not re-encode from the request");
213
+ return {
214
+ action,
215
+ call: {
216
+ chainId: facts.chainId, to: easAddress, function: action,
217
+ request: call.request, calldata: call.calldata.toLowerCase(), value: "0",
218
+ },
219
+ data,
220
+ };
221
+ }
40
222
  export async function runConfirmation(options) {
223
+ const { withOrder } = await import("./order.js");
41
224
  return withOrder(options, (context, record) => confirmOrder(context, record, options));
42
225
  }
226
+ /**
227
+ * Gateway refusals the submit phase raises before any sponsorship is reserved
228
+ * (request shape, mode, signature, exhausted budget): the signed request was
229
+ * never admitted, so the retained submission is cleared and another mode can
230
+ * be prepared. Anything else may have been admitted and keeps the record for
231
+ * --resume: an unavailable chain read, a transport failure, and
232
+ * CONFIRMATION_PREPARATION_STALE, which the gateway also answers once its
233
+ * preparation TTL has passed for a submission it admitted earlier, so a stale
234
+ * answer on --resume says nothing about whether the operation is running.
235
+ */
236
+ const REFUSED_BEFORE_ADMISSION = new Set([
237
+ "CONFIRMATION_REQUEST_INVALID",
238
+ "CONFIRMATION_SPONSORED_REQUIRES_EOA",
239
+ "CONFIRMATION_SIGNATURE_INVALID",
240
+ "CONFIRMATION_SPONSORSHIP_LIMIT",
241
+ ]);
242
+ /** The record as the store holds it now; the caller's copy may predate another process's write. */
243
+ function latest(record) {
244
+ return findByIntent(record.intentId) ?? record;
245
+ }
43
246
  export async function confirmOrder(context, record, options, factsReader = readConfirmationFacts) {
44
- if (!options.resume && !options.revoke && !["Confirmed", "NotConfirmed"].includes(options.confirmation ?? "")) {
247
+ const handle = record.handle ?? options.handle;
248
+ if (options.tx !== undefined || options.check || options.abandon) {
249
+ // Only --check signs (the gateway's check authorization); the signer is
250
+ // resolved before the lock so a passphrase prompt never runs inside it.
251
+ const signer = options.check ? await context.resolveSigner() : undefined;
252
+ return withOrderLock(record.intentId, () => {
253
+ const current = latest(record);
254
+ if (options.check && !options.tx && !options.abandon) {
255
+ const target = checkTarget(current.confirmationTx, options.submission, signer.describe());
256
+ if (target.kind === "gateway")
257
+ return checkGatewayReview(context, current, handle, target.submission);
258
+ }
259
+ return manageDirectRecord(context, current, options);
260
+ });
261
+ }
262
+ const choice = options.revoke ? "revoke"
263
+ : options.confirmation === "Confirmed" || options.confirmation === "NotConfirmed" ? options.confirmation : undefined;
264
+ if (!options.resume && choice === undefined) {
45
265
  throw new CliError({ code: "DASKI_CONFIRMATION_CHOICE_REQUIRED", message: "Choose the delivery confirmation for this order.",
46
266
  remediation: "After the user's choice, pass --choice Confirmed or --choice NotConfirmed. Leaving it Pending requires no action." });
47
267
  }
48
- if (record.confirmationSubmission && !options.resume)
49
- throw new CliError({ code: "DASKI_CONFIRMATION_PENDING",
50
- message: "A review submission for this order is awaiting reconciliation.", remediation: `Run daski order confirm ${options.handle} --resume.` });
51
268
  const call = (action, request) => callAuthorizedLifecycleTool({
52
269
  client: context.client, signer: context.signer, toolName: action === "confirmation" ? "daski_confirm_delivery" : "daski_revoke_delivery_confirmation",
53
- action, orderHandle: record.handle ?? options.handle, request, chainId: context.profile.chainId, gatewayUrl: context.profile.gatewayUrl
270
+ action, orderHandle: handle, request, chainId: context.profile.chainId, gatewayUrl: context.profile.gatewayUrl
54
271
  });
55
- let submission = record.confirmationSubmission;
56
- if (!submission) {
57
- if (options.resume)
58
- throw new CliError({ code: "DASKI_CONFIRMATION_NOT_PENDING", message: "There is no pending review submission.",
59
- remediation: "Check order status, then supply the user's review choice if a new review is wanted." });
60
- const action = options.revoke ? "revoke-confirmation" : "confirmation";
61
- const facts = await factsReader(context, record);
62
- const prepared = await call(action, { phase: "prepare", acknowledgeFinalTransition: options.acknowledgeFinalTransition === true,
63
- ...(options.revoke ? {} : { confirmation: options.confirmation }) });
64
- if (!prepared.signableTypedData) {
65
- if (prepared.warning?.code !== "FINAL_CONFIRMATION_TRANSITION")
272
+ // Every path below signs something; building the signer may prompt a
273
+ // person, which must not happen while the order's lock is held.
274
+ const signer = await context.resolveSigner();
275
+ return withOrderLock(record.intentId, async () => {
276
+ record = latest(record);
277
+ if (record.confirmationSubmission && !options.resume)
278
+ throw new CliError({ code: "DASKI_CONFIRMATION_PENDING",
279
+ message: "A review submission for this order is awaiting reconciliation.", remediation: `Run daski order confirm ${options.handle} --resume.` });
280
+ let submission = record.confirmationSubmission;
281
+ if (!submission) {
282
+ if (options.resume)
283
+ throw new CliError({ code: "DASKI_CONFIRMATION_NOT_PENDING", message: "There is no pending review submission.",
284
+ remediation: "Check order status, then supply the user's review choice if a new review is wanted." });
285
+ const action = options.revoke ? "revoke-confirmation" : "confirmation";
286
+ // One review journal per order: a direct submission still prepared or
287
+ // submitted blocks any new preparation, sponsored included, so a second
288
+ // review cannot be signed while the first is unresolved on chain.
289
+ if (isPendingDirect(record.confirmationTx))
290
+ throw directPending(handle, record.confirmationTx);
291
+ const mode = selectConfirmationMode(signer.describe(), options.submission);
292
+ const facts = await factsReader(context, record);
293
+ assertCapacity(facts, choice, handle);
294
+ const acknowledged = options.acknowledgeFinalTransition === true;
295
+ // Revocation preparation carries no acknowledgement: only an attestation
296
+ // can be final, and the gateway's closed request shape rejects the key.
297
+ const prepared = await call(action, { phase: "prepare", submission: mode,
298
+ ...(options.revoke ? {} : { confirmation: options.confirmation, acknowledgeFinalTransition: acknowledged }) });
299
+ // Whether this attestation is the final one is a chain fact, decided here
300
+ // for both modes; the gateway's count and flag must agree with it, and
301
+ // the signable or call is withheld only for the unacknowledged final one.
302
+ const final = choice !== "revoke" && facts.submissionsUsed === ATTESTATION_CAP - 1;
303
+ if (prepared.submissionsUsed !== facts.submissionsUsed || Boolean(prepared.finalAttestation) !== final) {
304
+ throw invalidPreparation();
305
+ }
306
+ const summary = { submissionsUsed: facts.submissionsUsed, revocationAvailable: facts.currentUid !== ZERO_UID, finalAttestation: final };
307
+ const withheld = mode === "sponsored" ? !prepared.signableTypedData : !prepared.call;
308
+ if (withheld) {
309
+ if (!final || acknowledged)
310
+ throw invalidPreparation();
311
+ return { orderHandle: record.handle, mode, ...summary,
312
+ warning: { code: "FINAL_ATTESTATION", message: FINAL_ATTESTATION_WARNING },
313
+ next: "Show the warning to the user. After explicit acceptance, repeat with --acknowledge-final-transition." };
314
+ }
315
+ if (final && !acknowledged)
66
316
  throw invalidPreparation();
67
- return { orderHandle: record.handle, ...prepared,
68
- next: "Show the final-transition warning to the user. After explicit acceptance, repeat with --acknowledge-final-transition." };
317
+ const warning = final ? { code: "FINAL_ATTESTATION", message: FINAL_ATTESTATION_WARNING } : undefined;
318
+ if (mode === "direct") {
319
+ const validated = validateDirectCall(prepared.call, facts, choice, context.profile.easAddress);
320
+ const expected = await expectedBinding(context, facts, validated, signer);
321
+ const tracked = {
322
+ action: validated.action,
323
+ callHash: canonicalHash(validated.call),
324
+ expected,
325
+ state: "prepared",
326
+ preparedAt: new Date().toISOString(),
327
+ };
328
+ updateOrder(record.intentId, { confirmationTx: tracked });
329
+ return { orderHandle: record.handle, mode, action: validated.action, ...summary,
330
+ ...(warning ? { warning } : {}),
331
+ call: validated.call,
332
+ callHash: tracked.callHash,
333
+ note: "This CLI validated the call against chain facts and sends no transaction.",
334
+ next: "Submit the call with the wallet's own tool (for the Circle agent wallet, the circle CLI). " +
335
+ `Then record the hash: daski order confirm ${handle} --tx <hash>, and verify it: ` +
336
+ `daski order confirm ${handle} --check.` };
337
+ }
338
+ const typedData = validateConfirmationPreparation(prepared, facts, choice, acknowledged);
339
+ if (typeof prepared.preparationId !== "string")
340
+ throw invalidPreparation();
341
+ const signature = await signer.signTypedData(typedData);
342
+ submission = { action, request: { phase: "submit", submission: "sponsored", preparationId: prepared.preparationId, signature } };
343
+ updateOrder(record.intentId, { confirmationSubmission: submission, readCapability: undefined });
344
+ }
345
+ try {
346
+ const result = await call(submission.action, submission.request);
347
+ updateOrder(record.intentId, { confirmationSubmission: undefined, readCapability: undefined });
348
+ return { orderHandle: record.handle, mode: "sponsored", ...result };
349
+ }
350
+ catch (error) {
351
+ if (error instanceof CliError && error.code === "CONFIRMATION_SUBMISSION_PENDING")
352
+ return {
353
+ orderHandle: record.handle, mode: "sponsored", status: "pending", preparationId: submission.request.preparationId,
354
+ next: `Run daski order confirm ${options.handle} --resume to check the same submission.`
355
+ };
356
+ if (error instanceof CliError && REFUSED_BEFORE_ADMISSION.has(error.code)) {
357
+ updateOrder(record.intentId, { confirmationSubmission: undefined });
358
+ }
359
+ if (error instanceof CliError && error.code === "CONFIRMATION_PREPARATION_STALE") {
360
+ throw new CliError({
361
+ code: error.code,
362
+ message: error.message,
363
+ remediation: "The gateway may already have admitted this submission before its preparation expired, " +
364
+ `so the signed submission is kept. Keep running daski order confirm ${options.handle} --resume ` +
365
+ `(or --check) until the gateway reports the operation's state; a new preparation is refused ` +
366
+ "while this one is pending.",
367
+ details: error.details,
368
+ });
369
+ }
370
+ throw error;
371
+ }
372
+ });
373
+ }
374
+ function assertCapacity(facts, choice, handle) {
375
+ if (choice === "revoke") {
376
+ if (facts.currentUid === ZERO_UID)
377
+ throw new CliError({
378
+ code: "DASKI_CONFIRMATION_NOT_ACTIVE",
379
+ message: "No confirmation is active for this order, so there is nothing to revoke.",
380
+ remediation: `Submit one with daski order confirm ${handle} --choice Confirmed|NotConfirmed.`,
381
+ });
382
+ return;
383
+ }
384
+ if (facts.submissionsUsed >= ATTESTATION_CAP)
385
+ throw new CliError({
386
+ code: "DASKI_CONFIRMATION_CAP_REACHED",
387
+ message: `All ${ATTESTATION_CAP} confirmations for this order have been submitted; none can be added.`,
388
+ remediation: facts.currentUid === ZERO_UID
389
+ ? "No confirmation is active and no further one can be submitted for this order."
390
+ : `The current confirmation can still be revoked: daski order revoke-confirmation ${handle}.`,
391
+ });
392
+ }
393
+ function isPendingDirect(tracked) {
394
+ return tracked !== undefined && (tracked.state === "prepared" || tracked.state === "submitted");
395
+ }
396
+ /** A direct record that --tx, --check, or --abandon can act on: anything but none or abandoned. */
397
+ function isDirectTracked(tracked) {
398
+ return tracked !== undefined && tracked.state !== "abandoned";
399
+ }
400
+ /**
401
+ * What --check consults. An explicit --submission decides: `direct` verifies
402
+ * the recorded transaction (refused when none is tracked), `sponsored` asks
403
+ * the gateway. Otherwise a direct record still prepared or submitted is
404
+ * verified, and anything else (no record, an abandoned one, or one already
405
+ * observed) asks the gateway for the order's current state in the signer's
406
+ * mode, so a review submitted after an observed direct one is never hidden
407
+ * behind the retained receipt.
408
+ */
409
+ function checkTarget(tracked, requested, description) {
410
+ if (requested === "direct")
411
+ return { kind: "record" };
412
+ if (requested === "sponsored")
413
+ return { kind: "gateway", submission: "sponsored" };
414
+ if (requested !== undefined) {
415
+ throw new CliError({
416
+ code: "DASKI_CONFIRMATION_SUBMISSION_INVALID",
417
+ message: `--submission must be sponsored or direct, not "${requested}".`,
418
+ remediation: "Omit the flag to check the pending direct record or the gateway's state, or pass --submission direct|sponsored.",
419
+ });
420
+ }
421
+ if (isPendingDirect(tracked))
422
+ return { kind: "record" };
423
+ return { kind: "gateway", submission: selectConfirmationMode(description, undefined) };
424
+ }
425
+ /**
426
+ * `--check` against the gateway: its final read of the order's confirmation
427
+ * state, which its relayer reconciles after a sponsored submission mined and
428
+ * which a direct submission moves once its receipt is covered. Nothing is
429
+ * stored locally; an observed direct record is reported next to it as
430
+ * history.
431
+ */
432
+ async function checkGatewayReview(context, record, handle, submission) {
433
+ const check = await callAuthorizedLifecycleTool({
434
+ client: context.client, signer: context.signer, toolName: "daski_confirm_delivery", action: "confirmation",
435
+ orderHandle: handle, request: { phase: "check", submission },
436
+ chainId: context.profile.chainId, gatewayUrl: context.profile.gatewayUrl,
437
+ });
438
+ const anchor = finalizedAnchor(check);
439
+ const tracked = record.confirmationTx;
440
+ return { orderHandle: record.handle, mode: submission, state: "checked",
441
+ confirmedCurrent: check.confirmedCurrent ?? null, lastObserved: check.lastObserved ?? null,
442
+ submissionsUsed: check.submissionsUsed ?? null,
443
+ finalizedBlock: anchor ? anchor.number.toString() : null,
444
+ ...(record.confirmationSubmission ? { pendingSubmission: record.confirmationSubmission.request.preparationId } : {}),
445
+ ...(tracked && isDirectTracked(tracked)
446
+ ? { directRecord: { action: tracked.action, state: tracked.state, callHash: tracked.callHash,
447
+ ...(tracked.txHash ? { txHash: tracked.txHash } : {}), ...(tracked.uid ? { uid: tracked.uid } : {}) } }
448
+ : {}),
449
+ note: `confirmedCurrent is the gateway's final state; lastObserved is its latest read. ${finalityNote(context.profile.chainId)}` +
450
+ (tracked?.state === "observed" ? " The observed direct record is kept as history; --submission direct returns its recorded evidence." : ""),
451
+ next: record.confirmationSubmission
452
+ ? `A sponsored submission is still pending here: daski order confirm ${handle} --resume.`
453
+ : `Run daski order confirm ${handle} --check again once the chain's final view has caught up.` };
454
+ }
455
+ function directPending(handle, tracked) {
456
+ return new CliError({
457
+ code: "DASKI_CONFIRMATION_TX_PENDING",
458
+ message: `A direct ${tracked.action} for this order is ${tracked.state}` +
459
+ (tracked.txHash ? ` (transaction ${tracked.txHash})` : "") + "; a new preparation is refused until it is resolved.",
460
+ remediation: tracked.txHash
461
+ ? `Verify it with daski order confirm ${handle} --check. If the transaction reverted, clear it with --abandon.`
462
+ : `Submit the prepared call and record it with daski order confirm ${handle} --tx <hash>, or clear it with --abandon.`,
463
+ });
464
+ }
465
+ /** What a receipt must later bind to: the values the prepared call commits to. */
466
+ async function expectedBinding(context, facts, validated, signer) {
467
+ if (validated.action === "attest") {
468
+ return { schema: facts.schemaUid, recipient: facts.recipient, refUID: facts.currentUid, dataHash: keccak256(validated.data) };
469
+ }
470
+ // A revocation binds to the attestation it revokes, read now so the receipt
471
+ // can be matched later without trusting the event alone.
472
+ const current = await readAttestation(context.chain, facts.eas, facts.currentUid);
473
+ const payer = getAddress(await signer.getAddress());
474
+ if (!sameHex(current.uid, facts.currentUid) || !sameHex(current.schema, facts.schemaUid) ||
475
+ !isAddressEqual(current.attester, payer))
476
+ throw invalidCall("the current confirmation is not this payer's");
477
+ return { schema: facts.schemaUid, recipient: getAddress(current.recipient), refUID: current.refUID,
478
+ dataHash: keccak256(current.data), uid: facts.currentUid };
479
+ }
480
+ async function readAttestation(chain, eas, uid, blockNumber) {
481
+ return chain.readContract({ address: eas, abi: EAS_ABI, functionName: "getAttestation", args: [uid],
482
+ ...(blockNumber === undefined ? {} : { blockNumber }) });
483
+ }
484
+ // -- direct record management: --tx, --check, --abandon -------------------------
485
+ async function manageDirectRecord(context, record, options) {
486
+ const handle = record.handle ?? options.handle;
487
+ const tracked = record.confirmationTx;
488
+ const flags = [options.tx !== undefined, options.check === true, options.abandon === true].filter(Boolean).length;
489
+ if (flags > 1 || options.resume || options.confirmation !== undefined) {
490
+ throw new CliError({ code: "DASKI_CONFIRMATION_FLAGS_CONFLICT",
491
+ message: "--tx, --check and --abandon each stand alone.",
492
+ remediation: "Pass exactly one of them, without --choice or --resume." });
493
+ }
494
+ if (!tracked || tracked.state === "abandoned") {
495
+ throw new CliError({ code: "DASKI_CONFIRMATION_TX_NOT_PREPARED",
496
+ message: "No direct confirmation is being tracked for this order.",
497
+ remediation: `Prepare one first: daski order confirm ${handle} --choice Confirmed|NotConfirmed (or daski order revoke-confirmation ${handle}).` });
498
+ }
499
+ const base = { orderHandle: record.handle, mode: "direct", action: tracked.action, callHash: tracked.callHash };
500
+ const cancelsNothing = "This cancels nothing at the wallet: a transaction already sent is unaffected.";
501
+ if (options.tx !== undefined) {
502
+ if (!HEX32.test(options.tx))
503
+ throw new CliError({ code: "DASKI_CONFIRMATION_TX_MALFORMED",
504
+ message: `--tx expects a 32-byte transaction hash, got "${options.tx}".`,
505
+ remediation: "Pass the hash the wallet's tool reported, e.g. --tx 0x<64 hex characters>." });
506
+ const txHash = options.tx.toLowerCase();
507
+ if (tracked.state === "observed")
508
+ throw new CliError({ code: "DASKI_CONFIRMATION_TX_ALREADY_RECORDED",
509
+ message: `This confirmation was already observed as ${tracked.txHash}.`,
510
+ remediation: "Nothing to record. Prepare a new confirmation if another submission is wanted." });
511
+ let corrected;
512
+ if (tracked.state === "submitted" && tracked.txHash !== txHash) {
513
+ // Replacing a recorded hash is a journal correction, allowed only once the
514
+ // recorded transaction is canonical, final, and provably not the
515
+ // prepared call.
516
+ const unrelated = await provenUnrelated(context, tracked, handle);
517
+ if (!unrelated)
518
+ throw new CliError({ code: "DASKI_CONFIRMATION_TX_ALREADY_RECORDED",
519
+ message: `Transaction ${tracked.txHash} is already recorded for this confirmation.`,
520
+ remediation: `Verify it with daski order confirm ${handle} --check. A recorded hash is replaced only once its ` +
521
+ "transaction is final and carries no event that binds to the prepared call; if it reverted, --abandon clears it first." });
522
+ corrected = { previousTxHash: tracked.txHash, reason: unrelated };
69
523
  }
70
- const typedData = validateConfirmationPreparation(prepared, facts, options.revoke ? "revoke" : options.confirmation, options.acknowledgeFinalTransition === true);
71
- if (typeof prepared.preparationId !== "string")
72
- throw invalidPreparation();
73
- const signature = await context.signer.signTypedData(typedData);
74
- submission = { action, request: { phase: "submit", preparationId: prepared.preparationId, signature } };
75
- updateOrder(record.intentId, { confirmationSubmission: submission, readCapability: undefined });
524
+ updateOrder(record.intentId, { confirmationTx: { ...tracked, txHash, state: "submitted" } });
525
+ return { ...base, txHash, state: "submitted", verification: "recorded, not yet verified",
526
+ ...(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}` } : {}),
527
+ next: `Run daski order confirm ${handle} --check once the transaction is mined. ${finalityNote(context.profile.chainId)}` };
76
528
  }
529
+ if (options.check)
530
+ return checkDirectRecord(context, record, tracked, handle, base);
531
+ // --abandon: only when nothing recorded can still execute: no hash, a
532
+ // revert in a final canonical block, or a final canonical
533
+ // transaction provably not this call. A revert in a block that is not yet final
534
+ // settles nothing: a reorganization can re-include the transaction against
535
+ // different state.
536
+ let unrelatedReceipt;
537
+ if (tracked.txHash) {
538
+ const receipt = await context.chain.getTransactionReceipt(tracked.txHash);
539
+ if (!receipt)
540
+ throw new CliError({ code: "DASKI_CONFIRMATION_TX_MAY_EXECUTE",
541
+ message: `Transaction ${tracked.txHash} has no receipt yet and may still execute.`,
542
+ 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.` });
543
+ if (receipt.status === "success") {
544
+ const unrelated = await provenUnrelated(context, tracked, handle, receipt);
545
+ if (!unrelated)
546
+ throw new CliError({ code: "DASKI_CONFIRMATION_TX_MAY_EXECUTE",
547
+ message: `Transaction ${tracked.txHash} executed; the record cannot be abandoned.`,
548
+ remediation: `Verify it with daski order confirm ${handle} --check.` });
549
+ unrelatedReceipt = unrelated;
550
+ }
551
+ else {
552
+ const view = await receiptView(context, receipt);
553
+ if (!view.final || !view.canonical)
554
+ throw new CliError({ code: "DASKI_CONFIRMATION_TX_MAY_EXECUTE",
555
+ message: `Transaction ${tracked.txHash} reverted in ${view.final
556
+ ? "a block that is not the chain's canonical block at its height"
557
+ : `block ${receipt.blockNumber}, which is not final yet (final block ${view.finalBlock})`}; it may still be re-included.`,
558
+ remediation: `Wait for the block to become final. ${finalityNote(context.profile.chainId)} Then run daski order confirm ${handle} --check and --abandon.` });
559
+ }
560
+ }
561
+ updateOrder(record.intentId, { confirmationTx: { ...tracked, state: "abandoned" } });
562
+ return { ...base, ...(tracked.txHash ? { txHash: tracked.txHash } : {}), state: "abandoned",
563
+ ...(unrelatedReceipt ? { unrelatedReceipt } : {}),
564
+ note: `Local tracking was cleared. ${cancelsNothing}` +
565
+ (unrelatedReceipt ? " If the prepared call was sent under another hash, prepare nothing new: record that hash instead." : ""),
566
+ next: `Prepare again when ready: daski order confirm ${handle} --choice Confirmed|NotConfirmed.` };
567
+ }
568
+ /** How long "final" takes on the profile's chain, for the buyer's next step. */
569
+ function finalityNote(chainId) {
570
+ return finalityTagFor(chainId) === "finalized"
571
+ ? "Finality on Base mainnet (the finalized tag) takes minutes to tens of minutes."
572
+ : "The sandbox treats Base Sepolia's safe tag as final; it lags the head by minutes.";
573
+ }
574
+ /**
575
+ * The one final view every canonical read uses (R04): the RPC's final height
576
+ * is read first, and the canonical block at the receipt's height is compared
577
+ * only when that height is already final there, so the comparison cannot be
578
+ * overtaken by a reorganization. The RPC is one consistent node; a balancer
579
+ * over unsynchronized nodes is outside this guarantee.
580
+ */
581
+ async function receiptView(context, receipt) {
582
+ const finalBlock = await context.chain.getFinalBlockNumber();
583
+ if (receipt.blockNumber > finalBlock)
584
+ return { finalBlock, final: false, canonical: false };
585
+ const canonical = sameHex(await context.chain.getBlockHash(receipt.blockNumber), receipt.blockHash);
586
+ return { finalBlock, final: true, canonical };
587
+ }
588
+ /**
589
+ * The reason a recorded, successful transaction is provably not the prepared
590
+ * call, or null when it is (or may be) this call. The finalized view is
591
+ * established first; only a finalized canonical receipt none of whose
592
+ * candidate events binds is unrelated. A related receipt keeps the record;
593
+ * an unrelated one that is not yet finalized and canonical is refused until
594
+ * it is, so a reorganization cannot turn a correction into a lost call.
595
+ */
596
+ async function provenUnrelated(context, tracked, handle, known) {
597
+ if (!tracked.txHash)
598
+ return null;
599
+ const receipt = known ?? await context.chain.getTransactionReceipt(tracked.txHash);
600
+ if (!receipt || receipt.status !== "success")
601
+ return null;
602
+ const view = await receiptView(context, receipt);
603
+ if (!view.final)
604
+ throw new CliError({ code: "DASKI_CONFIRMATION_TX_UNFINALIZED",
605
+ message: `Transaction ${tracked.txHash} is not final yet (block ${receipt.blockNumber}, final block ${view.finalBlock}); whether it carried this confirmation cannot be settled.`,
606
+ remediation: `Wait for the block to become final. ${finalityNote(context.profile.chainId)} Then repeat the same daski order confirm ${handle} command.` });
607
+ if (!view.canonical)
608
+ throw new CliError({ code: "DASKI_CONFIRMATION_TX_UNFINALIZED",
609
+ message: `Transaction ${tracked.txHash} was carried by a block that is not the chain's canonical block at height ${receipt.blockNumber}.`,
610
+ remediation: `The transaction may be re-included or dropped. Wait, then repeat the same daski order confirm ${handle} command.` });
77
611
  try {
78
- const result = await call(submission.action, submission.request);
79
- updateOrder(record.intentId, { confirmationSubmission: undefined, readCapability: undefined });
80
- return { orderHandle: record.handle, ...result };
612
+ await boundConfirmationUid(context, receipt, tracked, view.finalBlock);
613
+ return null;
81
614
  }
82
615
  catch (error) {
83
- if (error instanceof CliError && error.code === "CONFIRMATION_SUBMISSION_PENDING")
84
- return {
85
- orderHandle: record.handle, status: "pending", preparationId: submission.request.preparationId,
86
- next: `Run daski order confirm ${options.handle} --resume to check the same submission.`
87
- };
88
- if (error instanceof CliError && error.code === "CONFIRMATION_PREPARATION_STALE") {
89
- updateOrder(record.intentId, { confirmationSubmission: undefined });
616
+ if (!(error instanceof CliError) || error.code !== "DASKI_CONFIRMATION_RECEIPT_UNRELATED")
617
+ throw error;
618
+ return error.message;
619
+ }
620
+ }
621
+ async function checkDirectRecord(context, record, tracked, handle, base) {
622
+ if (tracked.state === "observed") {
623
+ return { ...base, txHash: tracked.txHash, uid: tracked.uid, state: "observed", note: "Already observed; nothing further to verify." };
624
+ }
625
+ if (!tracked.txHash)
626
+ throw new CliError({ code: "DASKI_CONFIRMATION_TX_NOT_RECORDED",
627
+ message: "The prepared call has no transaction hash recorded yet.",
628
+ remediation: `Submit it with the wallet's own tool, then record the hash: daski order confirm ${handle} --tx <hash>.` });
629
+ const receipt = await context.chain.getTransactionReceipt(tracked.txHash);
630
+ if (!receipt) {
631
+ return { ...base, txHash: tracked.txHash, state: "submitted", receipt: "pending",
632
+ next: `The transaction is not mined yet. Run daski order confirm ${handle} --check again shortly.` };
633
+ }
634
+ const view = await receiptView(context, receipt);
635
+ const later = `${finalityNote(context.profile.chainId)} Run daski order confirm ${handle} --check again later.`;
636
+ if (receipt.status !== "success") {
637
+ const settled = view.final && view.canonical;
638
+ return { ...base, txHash: tracked.txHash, state: "submitted", receipt: "reverted", revertFinal: settled,
639
+ next: settled
640
+ ? `The transaction reverted in a final block. Clear the record with daski order confirm ${handle} --abandon, then prepare again.`
641
+ : `The transaction reverted, but ${view.final ? "the block that carried it is not the chain's canonical block" : "its block is not final yet"}; it may still be re-included. Run daski order confirm ${handle} --check again later, then --abandon.` };
642
+ }
643
+ if (!view.final) {
644
+ return { ...base, txHash: tracked.txHash, state: "submitted", receipt: "success",
645
+ verification: `the receipt's block ${receipt.blockNumber} is not final on the profile's RPC yet (final block ${view.finalBlock})`, next: later };
646
+ }
647
+ if (!view.canonical) {
648
+ return { ...base, txHash: tracked.txHash, state: "submitted", receipt: "reorganized",
649
+ verification: `the block that carried the transaction is not the chain's canonical block at height ${receipt.blockNumber}`,
650
+ next: `The transaction may be re-included or dropped. Run daski order confirm ${handle} --check again later.` };
651
+ }
652
+ const uid = await boundConfirmationUid(context, receipt, tracked, view.finalBlock);
653
+ const check = await callAuthorizedLifecycleTool({
654
+ client: context.client, signer: context.signer,
655
+ toolName: tracked.action === "attest" ? "daski_confirm_delivery" : "daski_revoke_delivery_confirmation",
656
+ action: tracked.action === "attest" ? "confirmation" : "revoke-confirmation",
657
+ orderHandle: handle, request: { phase: "check", submission: "direct" },
658
+ chainId: context.profile.chainId, gatewayUrl: context.profile.gatewayUrl,
659
+ });
660
+ const notYet = (verification) => ({ ...base, txHash: tracked.txHash, uid, state: "submitted", receipt: "success",
661
+ verification, check, next: later });
662
+ const anchor = finalizedAnchor(check);
663
+ if (!anchor || anchor.number < receipt.blockNumber) {
664
+ return notYet("the receipt, the EAS event and the attestation match the prepared call; the gateway's final read is not past the receipt's block yet");
665
+ }
666
+ // The gateway's final block must be this RPC's canonical block at its
667
+ // height; the receipt's block, final on this RPC at a lower or equal
668
+ // height, is then its ancestor, so the receipt's effect is what the anchor
669
+ // shows. The receipt's block is compared once more against the same view
670
+ // so an RPC that contradicted itself between the reads cannot pass.
671
+ if (!sameHex(await context.chain.getBlockHash(anchor.number), anchor.hash)) {
672
+ return notYet(`the gateway's final block ${anchor.number} is not the chain's canonical block at that height as the profile's RPC reports it`);
673
+ }
674
+ if (!sameHex(await context.chain.getBlockHash(receipt.blockNumber), receipt.blockHash)) {
675
+ return { ...base, txHash: tracked.txHash, uid, state: "submitted", receipt: "reorganized", check,
676
+ verification: `the profile's RPC changed its canonical block at height ${receipt.blockNumber} between reads`,
677
+ next: `Run daski order confirm ${handle} --check again later.` };
678
+ }
679
+ // Execution is proven: the final canonical receipt and the bound EAS
680
+ // record close the transaction. Whether the review still stands is a
681
+ // separate fact: the wallet may have revoked or replaced it since, and
682
+ // that must never leave the journal pending.
683
+ const review = reviewEffect(check, tracked, uid);
684
+ updateOrder(record.intentId, { confirmationTx: { ...tracked, uid, state: "observed" }, readCapability: undefined });
685
+ return { ...base, txHash: tracked.txHash, uid, state: "observed", receipt: "success", review,
686
+ observedBlock: receipt.blockNumber.toString(), finalizedBlock: anchor.number.toString(), check,
687
+ ...(review === "current" ? {} : {
688
+ note: tracked.action === "attest"
689
+ ? "The attestation executed but is no longer the order's current review: it was revoked or replaced afterwards."
690
+ : "The revocation executed; a later review is current.",
691
+ }) };
692
+ }
693
+ function unrelatedReceipt(reason) {
694
+ return new CliError({
695
+ code: "DASKI_CONFIRMATION_RECEIPT_UNRELATED",
696
+ message: `The recorded transaction does not carry this confirmation: ${reason}.`,
697
+ remediation: "The record stays submitted. If the hash was recorded by mistake, record the right one with " +
698
+ "--tx <hash> or clear the record with --abandon; a reverted transaction can be abandoned once its " +
699
+ "block is final. Otherwise check the wallet's tool for the transaction that carried the prepared call.",
700
+ });
701
+ }
702
+ /**
703
+ * Every Attested or Revoked event the pinned EAS emitted in the receipt for
704
+ * this payer and the confirmation schema (for a revocation, of the revoked
705
+ * uid). A smart-wallet batch can carry several orders' events, so all are
706
+ * candidates.
707
+ */
708
+ function candidateEventUids(receipt, easAddress, tracked, payer) {
709
+ const wanted = tracked.action === "attest" ? "Attested" : "Revoked";
710
+ const uids = [];
711
+ let sawEas = false;
712
+ for (const log of receipt.logs) {
713
+ if (!isAddressEqual(log.address, easAddress))
714
+ continue;
715
+ sawEas = true;
716
+ let decoded;
717
+ try {
718
+ decoded = decodeEventLog({ abi: EAS_ABI, data: log.data, topics: log.topics });
90
719
  }
91
- throw error;
720
+ catch {
721
+ continue;
722
+ }
723
+ if (decoded.eventName !== wanted)
724
+ continue;
725
+ const args = decoded.args;
726
+ if (!sameHex(args.schemaUID, tracked.expected.schema) || !isAddressEqual(args.attester, payer))
727
+ continue;
728
+ if (tracked.action === "revoke" && !sameHex(args.uid, tracked.expected.uid ?? ""))
729
+ continue;
730
+ uids.push(args.uid);
92
731
  }
732
+ return { uids, sawEas };
733
+ }
734
+ /**
735
+ * The uid among the receipt's candidate events whose attestation binds to
736
+ * the prepared call, or DASKI_CONFIRMATION_RECEIPT_UNRELATED when none does.
737
+ * Attestations are read pinned to the RPC's final height, the same view
738
+ * the receipt was placed in; a uid commits to every field the binding checks,
739
+ * and a revocation time only ever moves from zero, so that view is complete.
740
+ */
741
+ async function boundConfirmationUid(context, receipt, tracked, finalizedBlock) {
742
+ const easAddress = context.profile.easAddress;
743
+ const { uids, sawEas } = candidateEventUids(receipt, easAddress, tracked, context.payerAddress);
744
+ const wanted = tracked.action === "attest" ? "Attested" : "Revoked";
745
+ if (uids.length === 0) {
746
+ throw unrelatedReceipt(sawEas
747
+ ? `no ${wanted} event for the confirmation schema with the payer as attester`
748
+ : `no log was emitted by the pinned EAS ${easAddress}`);
749
+ }
750
+ const mismatches = [];
751
+ for (const uid of uids) {
752
+ const mismatch = bindingMismatch(await readAttestation(context.chain, easAddress, uid, finalizedBlock), tracked, uid);
753
+ if (!mismatch)
754
+ return uid;
755
+ mismatches.push(mismatch);
756
+ }
757
+ throw unrelatedReceipt(mismatches.length === 1
758
+ ? mismatches[0]
759
+ : `none of the ${uids.length} candidate events binds to the prepared call (${mismatches.join("; ")})`);
760
+ }
761
+ /** Events do not carry refUID, recipient or data; the attestation itself must. Returns the first mismatch, or null. */
762
+ function bindingMismatch(attestation, tracked, uid) {
763
+ const expected = tracked.expected;
764
+ if (!sameHex(attestation.uid, uid))
765
+ return "the EAS holds no attestation for the event's uid";
766
+ if (!sameHex(attestation.schema, expected.schema))
767
+ return "the attestation's schema differs";
768
+ if (!sameHex(attestation.refUID, expected.refUID))
769
+ return "the attestation's refUID differs from the prepared call";
770
+ if (!isAddressEqual(attestation.recipient, expected.recipient))
771
+ return "the attestation's recipient differs from the prepared call";
772
+ if (!sameHex(keccak256(attestation.data), expected.dataHash))
773
+ return "the attestation's data differs from the prepared call";
774
+ if (tracked.action === "revoke" && attestation.revocationTime === 0n)
775
+ return "the attestation is not revoked";
776
+ return null;
777
+ }
778
+ /** The gateway's final block view (wire field `finalizedBlock`: `{ number, hash }`, number a decimal string), or null. */
779
+ function finalizedAnchor(check) {
780
+ const anchor = check.finalizedBlock;
781
+ if (!anchor || typeof anchor !== "object" || Array.isArray(anchor))
782
+ return null;
783
+ const { number, hash } = anchor;
784
+ if (typeof number !== "string" || !/^\d+$/.test(number) || typeof hash !== "string" || !HEX32.test(hash))
785
+ return null;
786
+ return { number: BigInt(number), hash: hash.toLowerCase() };
787
+ }
788
+ /**
789
+ * What the gateway's final read says about the executed review now: an
790
+ * attestation is `current` while it is the order's current uid and
791
+ * `superseded` once revoked or replaced; a revocation is `current` while the
792
+ * revoked uid is no longer current. The caller has established that the
793
+ * anchor is at or past the receipt's block and canonical on the profile's RPC.
794
+ */
795
+ function reviewEffect(check, tracked, uid) {
796
+ const finalized = check.confirmedCurrent;
797
+ if (!finalized || typeof finalized !== "object" || Array.isArray(finalized))
798
+ return "superseded";
799
+ const current = finalized.currentUid;
800
+ if (typeof current !== "string")
801
+ return "superseded";
802
+ const stands = tracked.action === "attest" ? sameHex(current, uid) : !sameHex(current, tracked.expected.uid ?? "");
803
+ return stands ? "current" : "superseded";
93
804
  }
94
805
  /** Read the deployment pins separately, then verify the selected order and EAS nonce on chain. */
95
- async function readConfirmationFacts(context, record) {
806
+ export async function readConfirmationFacts(context, record) {
96
807
  const status = await readWithCapability(context, record, { toolName: "daski_get_order_status", action: "status", request: {} });
97
- if (typeof status.orderKey !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(status.orderKey))
98
- throw invalidPreparation();
99
- const response = await fetch(`${context.profile.gatewayUrl.replace(/\/$/, "")}/.well-known/mcp.json`, { signal: AbortSignal.timeout(15000) });
100
- if (!response.ok)
808
+ if (typeof status.orderKey !== "string" || !HEX32.test(status.orderKey))
101
809
  throw invalidPreparation();
102
- const metadata = await response.json();
103
- const pins = metadata.confirmationSigning;
104
- if (!pins || pins.chainId !== context.profile.chainId || !/^0x[0-9a-fA-F]{64}$/.test(pins.schemaUid))
810
+ const pins = (await context.metadata()).confirmationSigning;
811
+ if (!pins)
812
+ throw confirmationPinsMissing(context.profile.gatewayUrl);
813
+ if (pins.chainId !== context.profile.chainId)
105
814
  throw invalidPreparation();
106
- const client = createPublicClient({ transport: http(context.profile.rpcUrl) });
107
- const abi = parseAbi([
108
- "function getNonce(address account) view returns (uint256)",
109
- "function getRecord(bytes32 orderKey) view returns ((bytes32 orderKey,bytes32 authorizationKey,uint256 providerAgentId,bytes32 serviceId,address payer,address providerOwner,address providerAgentWallet,address providerPayee,address canonicalToken,uint256 grossAmount,uint64 paidAt,bytes32 providerIdentitySnapshotHash,bytes32 listingManifestHash,bytes32 releaseEvidenceHash,uint8 outcome,uint8 confirmation,uint64 outcomeAttestationDelay,uint64 outcomeTimestamp,uint64 confirmationTimestamp,uint8 confirmationTransitions,bool outcomeRecorded,bool reputationEligible,bytes32 currentConfirmationUid))",
110
- ]);
815
+ if (!isAddressEqual(pins.eas, context.profile.easAddress))
816
+ throw easAddressMismatch(pins.eas, context.profile.easAddress, context.profile.chainId);
111
817
  const orderKey = status.orderKey;
112
818
  const [current, nonce] = await Promise.all([
113
- client.readContract({ address: getAddress(pins.reputationStorage), abi, functionName: "getRecord", args: [orderKey] }),
114
- client.readContract({ address: getAddress(pins.eas), abi, functionName: "getNonce", args: [context.payerAddress] }),
819
+ context.chain.readContract({ address: pins.reputationStorage, abi: REPUTATION_ABI, functionName: "getRecord", args: [orderKey] }),
820
+ context.chain.readContract({ address: pins.eas, abi: EAS_ABI, functionName: "getNonce", args: [context.payerAddress] }),
115
821
  ]);
116
822
  if (current.orderKey !== orderKey || getAddress(current.payer) !== context.payerAddress ||
117
823
  String(current.providerAgentId) !== record.providerAgentId || !current.outcomeRecorded || !current.reputationEligible)
118
824
  throw invalidPreparation();
119
- return { chainId: pins.chainId, eas: getAddress(pins.eas), schemaUid: pins.schemaUid, orderKey,
120
- recipient: getAddress(current.providerAgentWallet === "0x0000000000000000000000000000000000000000" ? current.providerOwner : current.providerAgentWallet),
121
- currentUid: current.currentConfirmationUid, nonce: nonce.toString(), transitionsUsed: Number(current.confirmationTransitions) };
825
+ // The record's provider agent wallet is the attestation recipient; the
826
+ // contract refuses to register a zero wallet, so a zero here is a record
827
+ // this CLI does not understand.
828
+ if (current.providerAgentWallet === ZERO_ADDRESS)
829
+ throw invalidPreparation();
830
+ return { chainId: pins.chainId, eas: pins.eas, schemaUid: pins.schemaUid, reputationStorage: pins.reputationStorage, orderKey,
831
+ recipient: getAddress(current.providerAgentWallet),
832
+ currentUid: current.currentConfirmationUid, nonce: nonce.toString(), submissionsUsed: Number(current.confirmationSubmissions) };
122
833
  }
123
834
  //# sourceMappingURL=confirmation.js.map