@daski/pay 0.3.0 → 0.4.0
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 +18 -11
- package/dist/bin/daski.js +26 -11
- package/dist/bin/daski.js.map +1 -1
- package/dist/chain/reader.d.ts +92 -0
- package/dist/chain/reader.d.ts.map +1 -0
- package/dist/chain/reader.js +130 -0
- package/dist/chain/reader.js.map +1 -0
- package/dist/commands/approval.js +2 -2
- package/dist/commands/approval.js.map +1 -1
- package/dist/commands/buy.d.ts +0 -1
- package/dist/commands/buy.d.ts.map +1 -1
- package/dist/commands/buy.js +15 -22
- package/dist/commands/buy.js.map +1 -1
- package/dist/commands/confirmation.d.ts +322 -4
- package/dist/commands/confirmation.d.ts.map +1 -1
- package/dist/commands/confirmation.js +771 -60
- package/dist/commands/confirmation.js.map +1 -1
- package/dist/commands/doctor.d.ts +20 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +204 -59
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/order.d.ts +37 -6
- package/dist/commands/order.d.ts.map +1 -1
- package/dist/commands/order.js +181 -103
- package/dist/commands/order.js.map +1 -1
- package/dist/commands/signPayment.d.ts +2 -2
- package/dist/commands/signPayment.d.ts.map +1 -1
- package/dist/commands/signPayment.js +10 -5
- package/dist/commands/signPayment.js.map +1 -1
- package/dist/commands/wallet.d.ts +3 -0
- package/dist/commands/wallet.d.ts.map +1 -1
- package/dist/commands/wallet.js +72 -16
- package/dist/commands/wallet.js.map +1 -1
- package/dist/config.d.ts +10 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +41 -1
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +28 -3
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +72 -3
- package/dist/context.js.map +1 -1
- package/dist/gateway/client.d.ts +20 -17
- package/dist/gateway/client.d.ts.map +1 -1
- package/dist/gateway/client.js +62 -43
- package/dist/gateway/client.js.map +1 -1
- package/dist/gateway/lifecycle.d.ts +4 -1
- package/dist/gateway/lifecycle.d.ts.map +1 -1
- package/dist/gateway/lifecycle.js +10 -5
- package/dist/gateway/lifecycle.js.map +1 -1
- package/dist/gateway/metadata.d.ts +66 -0
- package/dist/gateway/metadata.d.ts.map +1 -0
- package/dist/gateway/metadata.js +152 -0
- package/dist/gateway/metadata.js.map +1 -0
- package/dist/gateway/purchase.d.ts +51 -67
- package/dist/gateway/purchase.d.ts.map +1 -1
- package/dist/gateway/purchase.js +153 -125
- package/dist/gateway/purchase.js.map +1 -1
- package/dist/host.d.ts +41 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +112 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/signers/circleAgent.d.ts +37 -0
- package/dist/signers/circleAgent.d.ts.map +1 -0
- package/dist/signers/circleAgent.js +244 -0
- package/dist/signers/circleAgent.js.map +1 -0
- package/dist/signers/contract.d.ts +34 -0
- package/dist/signers/contract.d.ts.map +1 -0
- package/dist/signers/contract.js +47 -0
- package/dist/signers/contract.js.map +1 -0
- package/dist/signers/index.d.ts +16 -7
- package/dist/signers/index.d.ts.map +1 -1
- package/dist/signers/index.js +7 -3
- package/dist/signers/index.js.map +1 -1
- package/dist/signers/local.d.ts +5 -1
- package/dist/signers/local.d.ts.map +1 -1
- package/dist/signers/local.js +16 -3
- package/dist/signers/local.js.map +1 -1
- package/dist/signers/selfTest.d.ts +32 -12
- package/dist/signers/selfTest.d.ts.map +1 -1
- package/dist/signers/selfTest.js +85 -12
- package/dist/signers/selfTest.js.map +1 -1
- package/dist/signers/signature.d.ts +10 -0
- package/dist/signers/signature.d.ts.map +1 -0
- package/dist/signers/signature.js +11 -0
- package/dist/signers/signature.js.map +1 -0
- package/dist/store/keystore.d.ts +48 -6
- package/dist/store/keystore.d.ts.map +1 -1
- package/dist/store/keystore.js +414 -110
- package/dist/store/keystore.js.map +1 -1
- package/dist/store/lock.d.ts +59 -0
- package/dist/store/lock.d.ts.map +1 -0
- package/dist/store/lock.js +269 -0
- package/dist/store/lock.js.map +1 -0
- package/dist/store/orders.d.ts +49 -9
- package/dist/store/orders.d.ts.map +1 -1
- package/dist/store/orders.js +173 -37
- package/dist/store/orders.js.map +1 -1
- 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 {
|
|
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 {
|
|
6
|
-
import {
|
|
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.
|
|
23
|
-
(
|
|
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
|
-
|
|
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:
|
|
270
|
+
action, orderHandle: handle, request, chainId: context.profile.chainId, gatewayUrl: context.profile.gatewayUrl
|
|
54
271
|
});
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (!
|
|
65
|
-
if (
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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" ||
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|