@envarai/envarpay 0.2.0-alpha.2 → 0.2.0-alpha.20
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 -56
- package/dist/commerce/agent-setup.d.ts +19 -0
- package/dist/commerce/agent-setup.js +210 -0
- package/dist/commerce/alerts.d.ts +27 -0
- package/dist/commerce/alerts.js +71 -0
- package/dist/commerce/card.js +3 -3
- package/dist/commerce/cli.js +91 -119
- package/dist/commerce/config.d.ts +3 -2
- package/dist/commerce/config.js +19 -77
- package/dist/commerce/configure.d.ts +2 -0
- package/dist/commerce/configure.js +78 -0
- package/dist/commerce/discovery.d.ts +20 -0
- package/dist/commerce/discovery.js +162 -0
- package/dist/commerce/envar-balance.d.ts +25 -0
- package/dist/commerce/envar-balance.js +65 -0
- package/dist/commerce/envar-runtime.d.ts +3 -1
- package/dist/commerce/envar-runtime.js +28 -1
- package/dist/commerce/envar.d.ts +13 -31
- package/dist/commerce/envar.js +35 -83
- package/dist/commerce/external-agent.d.ts +26 -0
- package/dist/commerce/external-agent.js +129 -0
- package/dist/commerce/external-events.d.ts +76 -0
- package/dist/commerce/external-events.js +273 -0
- package/dist/commerce/external-mcp.d.ts +10 -0
- package/dist/commerce/external-mcp.js +199 -0
- package/dist/commerce/external-oauth.d.ts +12 -0
- package/dist/commerce/external-oauth.js +230 -0
- package/dist/commerce/external-runtime.d.ts +25 -0
- package/dist/commerce/external-runtime.js +244 -0
- package/dist/commerce/index.d.ts +1 -0
- package/dist/commerce/index.js +1 -0
- package/dist/commerce/managed.d.ts +3 -0
- package/dist/commerce/managed.js +143 -0
- package/dist/commerce/native-inventory.d.ts +9 -0
- package/dist/commerce/native-inventory.js +75 -0
- package/dist/commerce/native-skills.d.ts +32 -0
- package/dist/commerce/native-skills.js +174 -0
- package/dist/commerce/openclaw-setup.d.ts +28 -0
- package/dist/commerce/openclaw-setup.js +43 -0
- package/dist/commerce/operations.d.ts +8 -3
- package/dist/commerce/operations.js +21 -18
- package/dist/commerce/pricing.d.ts +0 -1
- package/dist/commerce/pricing.js +3 -5
- package/dist/commerce/runtime.d.ts +4 -2
- package/dist/commerce/runtime.js +4 -2
- package/dist/commerce/server.d.ts +3 -2
- package/dist/commerce/server.js +26 -10
- package/dist/commerce/setup.d.ts +25 -0
- package/dist/commerce/setup.js +379 -0
- package/dist/commerce/skill-gate.d.ts +54 -0
- package/dist/commerce/skill-gate.js +153 -0
- package/dist/commerce/store.d.ts +11 -1
- package/dist/commerce/store.js +36 -2
- package/dist/commerce/types.d.ts +6 -57
- package/examples/envar-integration-policy.json +1 -9
- package/examples/hermes-setup-probe.py +109 -0
- package/examples/hermes-skill-inventory.py +108 -0
- package/examples/native-skill-runner.py +197 -0
- package/examples/seller.json +143 -26
- package/package.json +5 -16
- package/schemas/envarpay.config.schema.json +28 -87
- package/dist/commerce/buyer-store.d.ts +0 -101
- package/dist/commerce/buyer-store.js +0 -168
- package/dist/commerce/buyer.d.ts +0 -132
- package/dist/commerce/buyer.js +0 -755
- package/dist/commerce/chain.d.ts +0 -24
- package/dist/commerce/chain.js +0 -146
- package/dist/commerce/client-runtime.d.ts +0 -8
- package/dist/commerce/client-runtime.js +0 -7
- package/dist/commerce/management.d.ts +0 -14
- package/dist/commerce/management.js +0 -138
- package/dist/commerce/mpp-client.d.ts +0 -59
- package/dist/commerce/mpp-client.js +0 -24
- package/dist/commerce/mpp-runtime.d.ts +0 -3
- package/dist/commerce/mpp-runtime.js +0 -3
- package/dist/commerce/mpp.d.ts +0 -53
- package/dist/commerce/mpp.js +0 -163
- package/dist/commerce/peer-proxy.d.ts +0 -44
- package/dist/commerce/peer-proxy.js +0 -673
- package/dist/commerce/stripe-provider.d.ts +0 -9
- package/dist/commerce/stripe-provider.js +0 -97
- package/dist/commerce/x402.d.ts +0 -37
- package/dist/commerce/x402.js +0 -174
- package/examples/buyer-policy.json +0 -18
- package/schemas/buyer-policy.schema.json +0 -253
package/dist/commerce/buyer.js
DELETED
|
@@ -1,755 +0,0 @@
|
|
|
1
|
-
import { findDefaultAsset } from '@x402/evm';
|
|
2
|
-
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
3
|
-
import { AgentCard, SendMessageRequest, GetTaskRequest, Task, TaskState, Message, Role } from '@a2a-js/sdk';
|
|
4
|
-
import { ClientFactory, JsonRpcTransportFactory } from '@a2a-js/sdk/client';
|
|
5
|
-
import { x402Client } from '@x402/core/client';
|
|
6
|
-
import { decodePaymentRequiredHeader, decodePaymentResponseHeader, encodePaymentSignatureHeader } from '@x402/core/http';
|
|
7
|
-
import { ExactEvmScheme } from '@x402/evm/exact/client';
|
|
8
|
-
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
9
|
-
import addFormatsImport from 'ajv-formats';
|
|
10
|
-
import { atomic, digest, loadBuyerPolicy, validateUrl } from './config.js';
|
|
11
|
-
import { CommerceError } from './types.js';
|
|
12
|
-
import { Challenge, Receipt, PaymentRequest } from 'mppx';
|
|
13
|
-
import { assertMppDigest, prepareMppCredential, publicMppReceipt, validateMppCredential } from './mpp-client.js';
|
|
14
|
-
const COMMERCE = 'urn:envarpay:commerce:1', QUOTE = 'urn:envarpay:quote:1';
|
|
15
|
-
const addFormats = addFormatsImport;
|
|
16
|
-
async function bounded(response, limit = 4 * 1024 * 1024) {
|
|
17
|
-
if (Number(response.headers.get('Content-Length') ?? 0) > limit) {
|
|
18
|
-
await response.body?.cancel();
|
|
19
|
-
throw new CommerceError('response_too_large', 'Peer response exceeds the configured limit');
|
|
20
|
-
}
|
|
21
|
-
const chunks = [];
|
|
22
|
-
let length = 0;
|
|
23
|
-
if (response.body) {
|
|
24
|
-
const reader = response.body.getReader();
|
|
25
|
-
for (;;) {
|
|
26
|
-
const next = await reader.read();
|
|
27
|
-
if (next.done)
|
|
28
|
-
break;
|
|
29
|
-
length += next.value.byteLength;
|
|
30
|
-
if (length > limit) {
|
|
31
|
-
await reader.cancel();
|
|
32
|
-
throw new CommerceError('response_too_large', 'Peer response exceeds the configured limit');
|
|
33
|
-
}
|
|
34
|
-
chunks.push(next.value);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return new Response(chunks.length ? Buffer.concat(chunks) : null, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
38
|
-
}
|
|
39
|
-
function inputAmount(offer, input) {
|
|
40
|
-
const ajv = new Ajv2020({ strict: true });
|
|
41
|
-
addFormats(ajv);
|
|
42
|
-
const validate = ajv.compile(offer.contract.inputSchema);
|
|
43
|
-
if (!validate(input))
|
|
44
|
-
throw new CommerceError('invalid_input', 'Input does not satisfy the advertised service schema');
|
|
45
|
-
if (offer.pricing.kind === 'fixed')
|
|
46
|
-
return atomic(offer.pricing.amount).toString();
|
|
47
|
-
if (offer.pricing.kind !== 'quantity')
|
|
48
|
-
throw new CommerceError('free_preview_unsupported', 'This payment preview supports explicitly priced upfront offers only');
|
|
49
|
-
const q = offer.pricing.quantity;
|
|
50
|
-
let value = input;
|
|
51
|
-
if (q.measure !== 'count' || !q.pointer.startsWith('/') || !Number.isSafeInteger(q.min) || !Number.isSafeInteger(q.max) || q.min < 1 || q.max < q.min)
|
|
52
|
-
throw new CommerceError('invalid_offer', 'Invalid quantity pricing');
|
|
53
|
-
for (const part of q.pointer.slice(1).split('/')) {
|
|
54
|
-
const key = part.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
55
|
-
value = value && typeof value === 'object' && Object.hasOwn(value, key) ? value[key] : undefined;
|
|
56
|
-
}
|
|
57
|
-
if (!Array.isArray(value) || value.length < q.min || value.length > q.max)
|
|
58
|
-
throw new CommerceError('invalid_quantity', 'Input quantity is outside the advertised bounds');
|
|
59
|
-
return atomic((atomic(offer.pricing.unitAmount) * BigInt(value.length)).toString()).toString();
|
|
60
|
-
}
|
|
61
|
-
function noExternalRefs(value) {
|
|
62
|
-
if (!value || typeof value !== 'object')
|
|
63
|
-
return;
|
|
64
|
-
for (const [key, child] of Object.entries(value)) {
|
|
65
|
-
if (['$ref', '$dynamicRef'].includes(key) && (typeof child !== 'string' || !child.startsWith('#')))
|
|
66
|
-
throw new CommerceError('external_schema_ref', 'External service schema references are unsupported');
|
|
67
|
-
noExternalRefs(child);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
function standardPolicyDigest(peer) {
|
|
71
|
-
return digest({ mode: peer.mode, cardUrl: peer.cardUrl, endpoint: peer.endpoint, authentication: peer.authentication,
|
|
72
|
-
protocol: peer.protocol, currency: peer.currency, recipient: peer.recipient, localContract: peer.localContract });
|
|
73
|
-
}
|
|
74
|
-
function standardRequirements(peer, required) {
|
|
75
|
-
let resource;
|
|
76
|
-
try {
|
|
77
|
-
resource = new URL(required.resource?.url ?? '');
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
throw new CommerceError('quote_mismatch', 'Standard payment resource must name the approved A2A endpoint');
|
|
81
|
-
}
|
|
82
|
-
const candidates = Array.isArray(required.accepts) ? required.accepts.filter(r => {
|
|
83
|
-
if (!r || r.scheme !== 'exact' || typeof r.asset !== 'string' || typeof r.payTo !== 'string' || `${r.network}/erc20:${r.asset.toLowerCase()}` !== peer.currency || r.payTo.toLowerCase() !== peer.recipient || r.amount !== peer.localContract.amount || !Number.isInteger(r.maxTimeoutSeconds) || r.maxTimeoutSeconds < 1 || r.maxTimeoutSeconds > 300)
|
|
84
|
-
return false;
|
|
85
|
-
const asset = findDefaultAsset(r.asset, r.network), extra = r.extra;
|
|
86
|
-
return !!asset && !!extra && typeof extra === 'object' && !Array.isArray(extra)
|
|
87
|
-
&& (extra.assetTransferMethod === undefined || extra.assetTransferMethod === 'eip3009')
|
|
88
|
-
&& (extra.paymentFlow === undefined || extra.paymentFlow === 'upfront')
|
|
89
|
-
&& extra.name === asset.name && extra.version === asset.version;
|
|
90
|
-
}) : [];
|
|
91
|
-
if (required.x402Version !== 2 || resource.href !== peer.endpoint || resource.username || resource.password || resource.hash || candidates.length !== 1)
|
|
92
|
-
throw new CommerceError('quote_mismatch', 'Native challenge differs from the exact locally approved resource, price, asset, payee or timeout');
|
|
93
|
-
return candidates[0];
|
|
94
|
-
}
|
|
95
|
-
/** Independent x402 wallet; payment and execution state are deliberately separate. */
|
|
96
|
-
export class CommerceBuyer {
|
|
97
|
-
options;
|
|
98
|
-
policy;
|
|
99
|
-
fetchImpl;
|
|
100
|
-
active = new Map();
|
|
101
|
-
timer;
|
|
102
|
-
previews = new Map();
|
|
103
|
-
constructor(options) {
|
|
104
|
-
this.options = options;
|
|
105
|
-
this.policy = loadBuyerPolicy(options.policy);
|
|
106
|
-
if (this.policy.peers.some(peer => peer.protocol !== 'free') && !options.vault)
|
|
107
|
-
throw new CommerceError('vault_required', 'Paid purchases need a private authorization vault');
|
|
108
|
-
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
109
|
-
if (this.policy.peers.some(p => p.protocol === 'x402') && (!options.signer || !/^0x[0-9a-fA-F]{40}$/.test(options.signer.address) || BigInt(options.signer.address) === 0n || !options.verifyReceipt))
|
|
110
|
-
throw new CommerceError('invalid_signer', 'x402 peers require an EVM wallet and independent receipt verifier');
|
|
111
|
-
if (this.policy.peers.some(p => p.protocol === 'mpp') && (!options.mpp || !options.mpp.payer || !/^pm_[A-Za-z0-9]+$/.test(options.mpp.paymentMethod) || !['test', 'live'].includes(options.mpp.mode) || typeof options.mpp.createToken !== 'function' || typeof options.mpp.verifyReceipt !== 'function'))
|
|
112
|
-
throw new CommerceError('mpp_provider_missing', 'MPP peers require explicit token creation and independent provider verification');
|
|
113
|
-
for (const peer of this.policy.peers)
|
|
114
|
-
if (!(peer.mode === 'standard-a2a' && peer.authentication === 'none') && (!options.peerTokens[peer.id] || options.peerTokens[peer.id].length < 16))
|
|
115
|
-
throw new CommerceError('peer_credentials', 'Each authenticated peer needs its own authentication token');
|
|
116
|
-
}
|
|
117
|
-
/** Background observation only; never signs, replays credentials or changes reservations. */
|
|
118
|
-
start(intervalMs = 2000) {
|
|
119
|
-
if (!Number.isSafeInteger(intervalMs) || intervalMs < 100)
|
|
120
|
-
throw new CommerceError('poll_interval', 'Observation interval must be at least 100ms');
|
|
121
|
-
if (this.timer)
|
|
122
|
-
return;
|
|
123
|
-
this.timer = setInterval(() => {
|
|
124
|
-
for (const record of this.options.store.pendingTasks()) {
|
|
125
|
-
if (!this.active.has(record.id))
|
|
126
|
-
void this.exclusive(record.id, () => this.observe(record)).catch(() => undefined);
|
|
127
|
-
}
|
|
128
|
-
}, intervalMs);
|
|
129
|
-
this.timer.unref();
|
|
130
|
-
}
|
|
131
|
-
async stop() { if (this.timer)
|
|
132
|
-
clearInterval(this.timer); this.timer = undefined; await Promise.allSettled([...this.active.values()]); }
|
|
133
|
-
policySnapshot() {
|
|
134
|
-
return { ...structuredClone(this.policy), capabilities: [...new Set(this.policy.peers.map(peer => peer.protocol))], state: this.policy.paymentsEnabled || this.policy.peers.some(peer => peer.protocol === 'free') ? 'ready' : 'disabled', payer: this.options.signer?.address ?? this.options.mpp?.payer, budgets: this.policy.budgets.map(b => ({ ...b, ...this.options.store.usage(b.currency) })) };
|
|
135
|
-
}
|
|
136
|
-
peer(cardUrl) {
|
|
137
|
-
const parsed = validateUrl(cardUrl);
|
|
138
|
-
const matches = this.policy.peers.filter(p => p.cardUrl === parsed.href && p.cardUrl === cardUrl);
|
|
139
|
-
if (matches.length !== 1)
|
|
140
|
-
throw new CommerceError('peer_not_allowed', 'An exact, unambiguous Card URL must be approved locally');
|
|
141
|
-
return matches[0];
|
|
142
|
-
}
|
|
143
|
-
endpoint(peer) {
|
|
144
|
-
const explicit = this.options.peerEndpoints?.[peer.id];
|
|
145
|
-
if (peer.mode === 'standard-a2a') {
|
|
146
|
-
if (explicit && explicit !== peer.endpoint)
|
|
147
|
-
throw new CommerceError('endpoint_policy', 'A standard peer endpoint must match its locally reviewed policy');
|
|
148
|
-
return peer.endpoint;
|
|
149
|
-
}
|
|
150
|
-
const card = new URL(peer.cardUrl);
|
|
151
|
-
if (!explicit && (!card.pathname.endsWith('/agent-card.json') || card.search))
|
|
152
|
-
throw new CommerceError('endpoint_policy', 'Configure an explicit peer endpoint for this Agent Card URL');
|
|
153
|
-
const endpoint = validateUrl(explicit ?? new URL('./a2a', card).href);
|
|
154
|
-
if (endpoint.origin !== card.origin || endpoint.search)
|
|
155
|
-
throw new CommerceError('endpoint_policy', 'Allowed peer endpoint must stay on its Card origin and have no query');
|
|
156
|
-
return endpoint.href;
|
|
157
|
-
}
|
|
158
|
-
scopedFetch(peer, endpoint) {
|
|
159
|
-
return async (input, init) => {
|
|
160
|
-
const request = new Request(input, init);
|
|
161
|
-
if (![peer.cardUrl, endpoint].includes(request.url))
|
|
162
|
-
throw new CommerceError('peer_scope', 'Peer request escaped the locally approved endpoint');
|
|
163
|
-
if ((request.url === peer.cardUrl && request.method !== 'GET') || (request.url === endpoint && request.method !== 'POST'))
|
|
164
|
-
throw new CommerceError('peer_scope', 'Unsupported peer operation');
|
|
165
|
-
const headers = new Headers(request.headers);
|
|
166
|
-
if (peer.mode === 'standard-a2a' && peer.authentication === 'none')
|
|
167
|
-
headers.delete('Authorization');
|
|
168
|
-
else
|
|
169
|
-
headers.set('Authorization', `Bearer ${this.options.peerTokens[peer.id]}`);
|
|
170
|
-
headers.set('A2A-Version', '1.0');
|
|
171
|
-
const response = await this.fetchImpl(new Request(request, { headers, redirect: 'error', signal: AbortSignal.any([request.signal, AbortSignal.timeout(30000)]) }));
|
|
172
|
-
if (response.status >= 300 && response.status < 400)
|
|
173
|
-
throw new CommerceError('peer_redirect', 'Peer redirects are not allowed');
|
|
174
|
-
return bounded(response, request.method === 'GET' ? 512 * 1024 : 4 * 1024 * 1024);
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
record(caller, id) {
|
|
178
|
-
const record = this.options.store.get(id, caller);
|
|
179
|
-
if (!record)
|
|
180
|
-
throw new CommerceError('purchase_not_found', 'Purchase not found for this caller');
|
|
181
|
-
return record;
|
|
182
|
-
}
|
|
183
|
-
snapshot(record) {
|
|
184
|
-
return structuredClone({ id: record.id, quoteToken: record.quoteToken, messageId: record.messageId, payer: record.quote.payer, state: record.state, quote: record.quote, paymentState: record.paymentState, executionState: record.executionState, ...(record.task ? { task: record.task } : {}), ...(record.result !== undefined ? { result: record.result } : {}), ...(record.errorCode ? { errorCode: record.errorCode } : {}), ...(record.receipt ? { receipt: record.protocol === 'mpp' ? publicMppReceipt(record.receipt) : { success: record.receipt.success, transaction: record.receipt.transaction, network: record.receipt.network, ...(record.receipt.payer ? { payer: record.receipt.payer } : {}) } } : {}), ...(record.nonce ? { nonce: record.nonce } : {}), ...(record.continuations?.length ? { continuation: { messageId: record.continuations.at(-1).messageId, state: record.continuations.at(-1).state, input: record.continuations.at(-1).input } } : {}), createdAt: record.createdAt, updatedAt: record.updatedAt });
|
|
185
|
-
}
|
|
186
|
-
get(caller, id) { return this.snapshot(this.record(caller, id)); }
|
|
187
|
-
async preview(caller, input) {
|
|
188
|
-
if (!caller || caller.length > 256 || typeof input.messageId !== 'string' || !input.messageId || input.messageId.length > 128 || typeof input.offerId !== 'string' || !input.offerId || !input.input || typeof input.input !== 'object' || Array.isArray(input.input))
|
|
189
|
-
throw new CommerceError('invalid_purchase', 'Caller, stable message ID, offer and object input are required');
|
|
190
|
-
const fingerprint = digest(input);
|
|
191
|
-
const key = input.messageId;
|
|
192
|
-
this.options.store.assertMessageOwner(caller, input.messageId);
|
|
193
|
-
const existing = this.options.store.find(caller, input.messageId);
|
|
194
|
-
if (existing) {
|
|
195
|
-
if (existing.fingerprint !== fingerprint)
|
|
196
|
-
throw new CommerceError('purchase_conflict', 'Message ID already identifies another purchase');
|
|
197
|
-
return this.snapshot(existing);
|
|
198
|
-
}
|
|
199
|
-
const active = this.previews.get(key);
|
|
200
|
-
if (active) {
|
|
201
|
-
await active;
|
|
202
|
-
return this.preview(caller, input);
|
|
203
|
-
}
|
|
204
|
-
const pending = this.prepare(caller, input, fingerprint);
|
|
205
|
-
this.previews.set(key, pending);
|
|
206
|
-
try {
|
|
207
|
-
return await pending;
|
|
208
|
-
}
|
|
209
|
-
finally {
|
|
210
|
-
this.previews.delete(key);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
async prepare(caller, input, fingerprint) {
|
|
214
|
-
const peer = this.peer(input.cardUrl), endpoint = this.endpoint(peer), scoped = this.scopedFetch(peer, endpoint);
|
|
215
|
-
const response = await scoped(peer.cardUrl);
|
|
216
|
-
if (!response.ok)
|
|
217
|
-
throw new CommerceError('peer_card', 'Unable to read allowed Agent Card');
|
|
218
|
-
const card = AgentCard.fromJSON(await response.json());
|
|
219
|
-
if (!card.supportedInterfaces.some(i => i.protocolVersion === '1.0' && i.protocolBinding === 'JSONRPC' && i.url === endpoint))
|
|
220
|
-
throw new CommerceError('peer_interface', 'Agent Card must expose the approved A2A 1.0 JSONRPC endpoint');
|
|
221
|
-
if (peer.mode === 'standard-a2a')
|
|
222
|
-
return this.prepareStandard(caller, input, fingerprint, peer, card);
|
|
223
|
-
if (card.supportedInterfaces.some(i => i.url !== endpoint))
|
|
224
|
-
throw new CommerceError('peer_interface', 'Agent Card must expose only the approved A2A 1.0 JSONRPC endpoint');
|
|
225
|
-
const advertised = card.capabilities?.extensions.find(e => e.uri === COMMERCE)?.params;
|
|
226
|
-
if (!advertised || advertised.offerId !== input.offerId || !advertised.serviceId || !Number.isSafeInteger(advertised.serviceRevision) || advertised.serviceRevision < 1 || !/^[0-9a-f]{64}$/.test(advertised.termsDigest ?? '') || !advertised.contract?.inputSchema || (peer.protocol !== 'free' && (advertised.collection?.kind !== 'upfront' || advertised.payment?.adapter !== peer.protocol)))
|
|
227
|
-
throw new CommerceError('offer_metadata_required', 'Informed preview requires explicit paid offer terms in the Agent Card');
|
|
228
|
-
if (peer.protocol === 'free')
|
|
229
|
-
return this.prepareFree(caller, input, fingerprint, peer, endpoint, advertised);
|
|
230
|
-
const payment = advertised.payment;
|
|
231
|
-
if (peer.protocol === 'x402' && (`${payment.network}/erc20:${payment.asset?.toLowerCase()}` !== peer.currency || payment.payTo?.toLowerCase() !== peer.recipient))
|
|
232
|
-
throw new CommerceError('payment_policy', 'Advertised payment differs from local wallet policy');
|
|
233
|
-
if (peer.protocol === 'mpp' && (payment.method !== 'stripe' || payment.intent !== 'charge' || payment.currency !== 'usd'))
|
|
234
|
-
throw new CommerceError('payment_policy', 'Advertised MPP method differs from local wallet policy');
|
|
235
|
-
noExternalRefs(advertised.contract.inputSchema);
|
|
236
|
-
const amount = inputAmount(advertised, input.input);
|
|
237
|
-
if (atomic(amount) > atomic(peer.maxPerPurchase))
|
|
238
|
-
throw new CommerceError('purchase_limit', 'Advertised price exceeds the per-purchase limit');
|
|
239
|
-
let originalBody = '', challenge, mppChallenge;
|
|
240
|
-
const capture = async (request, init) => {
|
|
241
|
-
const req = new Request(request, init);
|
|
242
|
-
originalBody = await req.clone().text();
|
|
243
|
-
if (Buffer.byteLength(originalBody) > 1024 * 1024)
|
|
244
|
-
throw new CommerceError('request_too_large', 'Purchase input exceeds 1 MiB');
|
|
245
|
-
const result = await scoped(req);
|
|
246
|
-
if (result.status !== 402)
|
|
247
|
-
throw new CommerceError('paid_quote_required', 'Preview requires a payment challenge before execution');
|
|
248
|
-
if (peer.protocol === 'mpp') {
|
|
249
|
-
const header = result.headers.get('WWW-Authenticate');
|
|
250
|
-
if (!header || header.length > 65536)
|
|
251
|
-
throw new CommerceError('invalid_challenge', 'Missing or oversized MPP challenge');
|
|
252
|
-
mppChallenge = Challenge.fromResponse(result);
|
|
253
|
-
throw new CommerceError('preview_captured', 'MPP challenge captured');
|
|
254
|
-
}
|
|
255
|
-
const header = result.headers.get('PAYMENT-REQUIRED');
|
|
256
|
-
if (!header || header.length > 65536)
|
|
257
|
-
throw new CommerceError('invalid_challenge', 'Missing or oversized payment challenge');
|
|
258
|
-
challenge = decodePaymentRequiredHeader(header);
|
|
259
|
-
throw new CommerceError('preview_captured', 'Payment challenge captured');
|
|
260
|
-
};
|
|
261
|
-
const client = await new ClientFactory({ transports: [new JsonRpcTransportFactory({ fetchImpl: capture })] }).createFromAgentCard(card);
|
|
262
|
-
try {
|
|
263
|
-
await client.sendMessage(SendMessageRequest.fromJSON({ message: { messageId: input.messageId, role: 'ROLE_USER', parts: [{ data: input.input }] }, configuration: { returnImmediately: true } }));
|
|
264
|
-
}
|
|
265
|
-
catch (error) {
|
|
266
|
-
if (!challenge && !mppChallenge)
|
|
267
|
-
throw error;
|
|
268
|
-
}
|
|
269
|
-
if (peer.protocol === 'mpp' && mppChallenge && originalBody)
|
|
270
|
-
return this.prepareMpp(caller, input, fingerprint, peer, endpoint, advertised, amount, originalBody, mppChallenge);
|
|
271
|
-
if (!challenge || !originalBody)
|
|
272
|
-
throw new CommerceError('invalid_challenge', 'Peer did not return a bound payment quote');
|
|
273
|
-
const required = challenge, candidates = required.accepts?.filter(r => r.scheme === 'exact' && `${r.network}/erc20:${r.asset.toLowerCase()}` === peer.currency && r.payTo.toLowerCase() === peer.recipient && r.amount === amount && Number.isInteger(r.maxTimeoutSeconds) && r.maxTimeoutSeconds > 0 && r.maxTimeoutSeconds <= 300 && r.extra?.paymentFlow === 'upfront' && r.extra?.assetTransferMethod === 'eip3009' && r.extra?.name === findDefaultAsset(r.asset, r.network)?.name && r.extra?.version === findDefaultAsset(r.asset, r.network)?.version);
|
|
274
|
-
const quote = required.extensions?.[QUOTE]?.info;
|
|
275
|
-
const resource = new URL(required.resource?.url ?? 'https://invalid.invalid');
|
|
276
|
-
const expires = Date.parse(quote?.expiresAt ?? '');
|
|
277
|
-
if (required.x402Version !== 2 || candidates?.length !== 1 || resource.origin + resource.pathname !== endpoint || resource.searchParams.get('quote') !== quote?.quoteId || [...resource.searchParams.keys()].some(k => k !== 'quote') || !quote || quote.messageId !== input.messageId || quote.inputDigest !== digest(input.input) || quote.termsDigest !== advertised.termsDigest || quote.serviceId !== advertised.serviceId || quote.serviceRevision !== advertised.serviceRevision || quote.offerId !== input.offerId || quote.amount !== amount || quote.currency !== peer.currency || quote.recipient?.toLowerCase() !== peer.recipient || !Number.isFinite(expires) || expires <= Date.now() || expires > Date.now() + 3600000)
|
|
278
|
-
throw new CommerceError('quote_mismatch', 'Native payment quote differs from the advertised purchase or local wallet policy');
|
|
279
|
-
const frozenQuote = {
|
|
280
|
-
quoteId: quote.quoteId, messageId: input.messageId, payer: this.options.signer.address.toLowerCase(),
|
|
281
|
-
amount, currency: peer.currency, recipient: peer.recipient, expiresAt: quote.expiresAt,
|
|
282
|
-
serviceId: quote.serviceId, serviceRevision: quote.serviceRevision, offerId: quote.offerId,
|
|
283
|
-
inputDigest: quote.inputDigest, termsDigest: quote.termsDigest,
|
|
284
|
-
};
|
|
285
|
-
return this.snapshot(this.options.store.insert({ protocol: 'x402', caller, peerId: peer.id, cardUrl: peer.cardUrl, endpoint, messageId: input.messageId, fingerprint, input: structuredClone(input.input), inputSchema: structuredClone(advertised.contract.inputSchema), body: originalBody, quoteToken: randomBytes(32).toString('base64url'), quote: frozenQuote, required, requirements: candidates[0] }));
|
|
286
|
-
}
|
|
287
|
-
async prepareStandard(caller, input, fingerprint, peer, card) {
|
|
288
|
-
const contract = peer.localContract;
|
|
289
|
-
if (card.securityRequirements.length && !card.securityRequirements.some(requirement => {
|
|
290
|
-
const names = Object.keys(requirement.schemes);
|
|
291
|
-
if (!names.length)
|
|
292
|
-
return true;
|
|
293
|
-
if (peer.authentication !== 'bearer' || names.length !== 1)
|
|
294
|
-
return false;
|
|
295
|
-
const name = names[0], scheme = card.securitySchemes[name]?.scheme;
|
|
296
|
-
return scheme?.$case === 'httpAuthSecurityScheme' && scheme.value.scheme.toLowerCase() === 'bearer' && !requirement.schemes[name].list.length;
|
|
297
|
-
}))
|
|
298
|
-
throw new CommerceError('peer_authentication', 'Agent Card authentication is not satisfied by the locally approved none/bearer mode');
|
|
299
|
-
if (input.offerId !== contract.offerId)
|
|
300
|
-
throw new CommerceError('offer_not_allowed', 'Select the locally reviewed standard-peer offer');
|
|
301
|
-
const ajv = new Ajv2020({ strict: true });
|
|
302
|
-
addFormats(ajv);
|
|
303
|
-
if (!ajv.compile(contract.inputSchema)(input.input))
|
|
304
|
-
throw new CommerceError('invalid_input', 'Input does not satisfy the locally reviewed schema');
|
|
305
|
-
const amount = atomic(contract.amount).toString();
|
|
306
|
-
if (atomic(amount) > atomic(peer.maxPerPurchase))
|
|
307
|
-
throw new CommerceError('purchase_limit', 'Local price exceeds the per-purchase limit');
|
|
308
|
-
let body = '', required;
|
|
309
|
-
const capture = async (value, init) => {
|
|
310
|
-
const request = new Request(value, init);
|
|
311
|
-
body = await request.clone().text();
|
|
312
|
-
if (Buffer.byteLength(body) > 1024 * 1024)
|
|
313
|
-
throw new CommerceError('request_too_large', 'Purchase input exceeds 1 MiB');
|
|
314
|
-
const response = await this.scopedFetch(peer, peer.endpoint)(request);
|
|
315
|
-
if (response.status !== 402)
|
|
316
|
-
throw new CommerceError('paid_quote_required', 'The operator-approved paid-only endpoint must return 402 before any authorization');
|
|
317
|
-
const header = response.headers.get('PAYMENT-REQUIRED');
|
|
318
|
-
if (!header || header.length > 65536)
|
|
319
|
-
throw new CommerceError('invalid_challenge', 'Missing or oversized native x402 challenge');
|
|
320
|
-
required = decodePaymentRequiredHeader(header);
|
|
321
|
-
throw new CommerceError('preview_captured', 'Native standard payment challenge captured');
|
|
322
|
-
};
|
|
323
|
-
// Ignore every advertised alternative; only the operator-selected interface may receive requests.
|
|
324
|
-
const selected = AgentCard.fromJSON({ ...AgentCard.toJSON(card), supportedInterfaces: [{ url: peer.endpoint, protocolBinding: 'JSONRPC', protocolVersion: '1.0' }] });
|
|
325
|
-
const client = await new ClientFactory({ transports: [new JsonRpcTransportFactory({ fetchImpl: capture })] }).createFromAgentCard(selected);
|
|
326
|
-
try {
|
|
327
|
-
await client.sendMessage(SendMessageRequest.fromJSON({ message: { messageId: input.messageId, role: 'ROLE_USER', parts: [{ data: input.input }] }, configuration: { returnImmediately: true } }));
|
|
328
|
-
}
|
|
329
|
-
catch (error) {
|
|
330
|
-
if (!required)
|
|
331
|
-
throw error;
|
|
332
|
-
}
|
|
333
|
-
if (!required || !body)
|
|
334
|
-
throw new CommerceError('invalid_challenge', 'The approved endpoint did not return a native payment challenge');
|
|
335
|
-
const requirements = standardRequirements(peer, required), policyDigest = standardPolicyDigest(peer);
|
|
336
|
-
const quote = { quoteId: randomUUID(), messageId: input.messageId, payer: this.options.signer.address.toLowerCase(), amount,
|
|
337
|
-
currency: peer.currency, recipient: peer.recipient, expiresAt: new Date(Date.now() + requirements.maxTimeoutSeconds * 1000).toISOString(),
|
|
338
|
-
serviceId: peer.id, serviceRevision: contract.revision, offerId: contract.offerId, inputDigest: digest(input.input), termsDigest: policyDigest, termsSource: 'local-policy' };
|
|
339
|
-
return this.snapshot(this.options.store.insert({ protocol: 'x402', peerMode: 'standard-a2a', caller, peerId: peer.id, cardUrl: peer.cardUrl, endpoint: peer.endpoint,
|
|
340
|
-
messageId: input.messageId, fingerprint, input: structuredClone(input.input), inputSchema: structuredClone(contract.inputSchema), body,
|
|
341
|
-
quoteToken: randomBytes(32).toString('base64url'), quote, required, requirements,
|
|
342
|
-
standardBinding: { policyDigest, bodyDigest: digest(body), challengeDigest: digest(required) } }));
|
|
343
|
-
}
|
|
344
|
-
prepareFree(caller, input, fingerprint, peer, endpoint, advertised) {
|
|
345
|
-
if (advertised.pricing?.kind !== 'free' || advertised.collection?.kind !== 'none' || advertised.payment !== null)
|
|
346
|
-
throw new CommerceError('free_offer_required', 'This peer permits explicit free offers only');
|
|
347
|
-
noExternalRefs(advertised.contract.inputSchema);
|
|
348
|
-
const ajv = new Ajv2020({ strict: true });
|
|
349
|
-
addFormats(ajv);
|
|
350
|
-
if (!ajv.compile(advertised.contract.inputSchema)(input.input))
|
|
351
|
-
throw new CommerceError('invalid_input', 'Input does not satisfy the advertised service schema');
|
|
352
|
-
const quote = { quoteId: randomUUID(), messageId: input.messageId, payer: caller, amount: '0', currency: null, recipient: null, expiresAt: new Date(Date.now() + 600000).toISOString(), serviceId: advertised.serviceId, serviceRevision: advertised.serviceRevision, offerId: input.offerId, inputDigest: digest(input.input), termsDigest: advertised.termsDigest };
|
|
353
|
-
const params = SendMessageRequest.fromJSON({ message: { messageId: input.messageId, role: 'ROLE_USER', parts: [{ data: input.input }] }, configuration: { returnImmediately: true } });
|
|
354
|
-
const body = JSON.stringify({ jsonrpc: '2.0', id: randomUUID(), method: 'SendMessage', params: SendMessageRequest.toJSON(params) });
|
|
355
|
-
if (Buffer.byteLength(body) > 1024 * 1024)
|
|
356
|
-
throw new CommerceError('request_too_large', 'Purchase input exceeds 1 MiB');
|
|
357
|
-
return this.snapshot(this.options.store.insert({ protocol: 'free', caller, peerId: peer.id, cardUrl: peer.cardUrl, endpoint, messageId: input.messageId, fingerprint, input: structuredClone(input.input), inputSchema: structuredClone(advertised.contract.inputSchema), body, quoteToken: randomBytes(32).toString('base64url'), quote }));
|
|
358
|
-
}
|
|
359
|
-
async replayFree(record) {
|
|
360
|
-
try {
|
|
361
|
-
const peer = this.assertCurrentPolicy(record, false);
|
|
362
|
-
const response = await this.scopedFetch(peer, record.endpoint)(record.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: record.body });
|
|
363
|
-
const raw = await response.json();
|
|
364
|
-
if (response.ok && raw.result?.task)
|
|
365
|
-
return this.snapshot(this.task(record, raw.result.task));
|
|
366
|
-
return this.snapshot(this.options.store.update(record.id, { state: 'unknown', executionState: 'unknown', errorCode: response.status === 402 ? 'free_offer_payment_rejected' : 'original_purchase_pending' }));
|
|
367
|
-
}
|
|
368
|
-
catch {
|
|
369
|
-
return this.snapshot(this.options.store.update(record.id, { state: 'unknown', executionState: 'unknown', errorCode: 'free_purchase_outcome_unknown' }));
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
prepareMpp(caller, input, fingerprint, peer, endpoint, advertised, amount, body, challenge) {
|
|
373
|
-
const request = challenge.request, meta = challenge.meta ?? (challenge.opaque ? PaymentRequest.deserialize(challenge.opaque) : undefined);
|
|
374
|
-
const method = request.methodDetails;
|
|
375
|
-
const expiry = Date.parse(challenge.expires ?? '');
|
|
376
|
-
assertMppDigest(challenge, body);
|
|
377
|
-
if (challenge.method !== 'stripe' || challenge.intent !== 'charge' || challenge.header !== 'Payment-Authorization' || challenge.realm !== new URL(endpoint).host || atomic(amount) < 50n || atomic(amount) > 99999999n || request.amount !== amount || request.currency !== 'usd' || request.recipient !== peer.recipient || method?.networkId !== peer.recipient || method.paymentMethodTypes?.length !== 1 || method.paymentMethodTypes[0] !== 'card' || !meta || request.externalId !== meta.quoteId || meta.messageId !== input.messageId || meta.serviceId !== advertised.serviceId || meta.serviceRevision !== String(advertised.serviceRevision) || meta.offerId !== input.offerId || meta.inputDigest !== digest(input.input) || meta.termsDigest !== advertised.termsDigest || meta.amount !== amount || meta.currency !== 'usd' || meta.recipient !== peer.recipient || meta.expiresAt !== challenge.expires || !Number.isFinite(expiry) || expiry <= Date.now() || expiry > Date.now() + 3600000)
|
|
378
|
-
throw new CommerceError('quote_mismatch', 'MPP challenge differs from the advertised offer or local wallet policy');
|
|
379
|
-
const quote = { messageId: input.messageId, payer: this.options.mpp.payer, amount, currency: 'usd', recipient: peer.recipient, expiresAt: challenge.expires, serviceId: advertised.serviceId, serviceRevision: advertised.serviceRevision, offerId: input.offerId, inputDigest: digest(input.input), termsDigest: advertised.termsDigest, quoteId: meta.quoteId };
|
|
380
|
-
return this.snapshot(this.options.store.insert({ protocol: 'mpp', mppMode: this.options.mpp.mode, mppPaymentMethod: this.options.mpp.paymentMethod, mppChallenge: challenge, caller, peerId: peer.id, cardUrl: peer.cardUrl, endpoint, messageId: input.messageId, fingerprint, input: structuredClone(input.input), inputSchema: structuredClone(advertised.contract.inputSchema), body, quoteToken: randomBytes(32).toString('base64url'), quote }));
|
|
381
|
-
}
|
|
382
|
-
async mppCredential(record, recover, beforeCreate) {
|
|
383
|
-
const options = this.options.mpp;
|
|
384
|
-
if (!record.mppChallenge || record.mppMode !== options.mode)
|
|
385
|
-
throw new CommerceError('mpp_recovery_binding', 'Original MPP provider binding is missing or changed');
|
|
386
|
-
const operationId = record.tokenOperationId ?? `mpp-token-${record.id}`;
|
|
387
|
-
if (!recover)
|
|
388
|
-
record = this.options.store.update(record.id, { tokenOperationId: operationId });
|
|
389
|
-
const credential = await prepareMppCredential(record.mppChallenge, record.mppPaymentMethod ?? options.paymentMethod, async (parameters) => {
|
|
390
|
-
if (parameters.amount !== record.quote.amount || parameters.currency !== record.quote.currency || parameters.networkId !== record.quote.recipient || parameters.expiresAt !== Math.floor(Date.parse(record.quote.expiresAt) / 1000))
|
|
391
|
-
throw new CommerceError('mpp_token_binding', 'SPT request differs from frozen payment conditions');
|
|
392
|
-
if (record.tokenRef) {
|
|
393
|
-
const saved = this.options.vault.get(record.tokenRef);
|
|
394
|
-
if (saved.protocol !== 'mpp-token' || saved.operationId !== operationId || saved.fingerprint !== record.fingerprint)
|
|
395
|
-
throw new CommerceError('mpp_recovery_binding', 'Saved SPT differs from original operation');
|
|
396
|
-
return saved.token;
|
|
397
|
-
}
|
|
398
|
-
const operation = { operationId, idempotencyKey: operationId, purchaseId: record.id, quote: record.quote, mode: record.mppMode, amount: parameters.amount, currency: parameters.currency, networkId: parameters.networkId, expiresAt: parameters.expiresAt, paymentMethod: parameters.paymentMethod, ...(parameters.metadata ? { metadata: parameters.metadata } : {}), challenge: record.mppChallenge };
|
|
399
|
-
let token;
|
|
400
|
-
if (recover) {
|
|
401
|
-
if (!record.tokenOperationId || !options.recoverToken)
|
|
402
|
-
throw new CommerceError('mpp_token_recovery_required', 'Read the original SPT creation operation before retrying');
|
|
403
|
-
token = await options.recoverToken(operation);
|
|
404
|
-
}
|
|
405
|
-
else {
|
|
406
|
-
if (Date.now() >= Date.parse(record.quote.expiresAt))
|
|
407
|
-
throw new CommerceError('quote_expired', 'MPP quote expired before token creation');
|
|
408
|
-
beforeCreate?.();
|
|
409
|
-
token = await options.createToken(operation);
|
|
410
|
-
}
|
|
411
|
-
if (typeof token !== 'string' || !/^spt_[A-Za-z0-9_]+$/.test(token))
|
|
412
|
-
throw new CommerceError('mpp_token_unknown', 'Original SPT creation is unresolved');
|
|
413
|
-
const tokenRef = this.options.vault.put({ protocol: 'mpp-token', token, fingerprint: record.fingerprint, operationId });
|
|
414
|
-
record = this.options.store.update(record.id, { tokenRef });
|
|
415
|
-
return token;
|
|
416
|
-
});
|
|
417
|
-
validateMppCredential(credential, record.mppChallenge);
|
|
418
|
-
const credentialRef = this.options.vault.put({ protocol: 'mpp', credential, challenge: record.mppChallenge, fingerprint: record.fingerprint, operationId });
|
|
419
|
-
return this.options.store.update(record.id, { credentialRef, state: 'submitted', paymentState: 'unknown' });
|
|
420
|
-
}
|
|
421
|
-
async purchaseMpp(record) {
|
|
422
|
-
let creationStarted = false;
|
|
423
|
-
try {
|
|
424
|
-
record = await this.mppCredential(record, false, () => { creationStarted = true; });
|
|
425
|
-
return await this.replayMpp(record);
|
|
426
|
-
}
|
|
427
|
-
catch (error) {
|
|
428
|
-
if (!creationStarted)
|
|
429
|
-
return this.snapshot(this.options.store.rejectBeforeSigning(record.id, record.caller, error instanceof CommerceError ? error.code : 'pre_token_failure'));
|
|
430
|
-
const current = this.record(record.caller, record.id);
|
|
431
|
-
return this.snapshot(this.options.store.update(record.id, { state: 'unknown', paymentState: current.paymentState === 'confirmed' ? 'confirmed' : 'unknown', errorCode: error instanceof CommerceError ? error.code : 'mpp_outcome_unknown' }));
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
savedMpp(record) {
|
|
435
|
-
if (!record.credentialRef)
|
|
436
|
-
throw new CommerceError('mpp_token_recovery_required', 'Original MPP authorization must be recovered');
|
|
437
|
-
const saved = this.options.vault.get(record.credentialRef);
|
|
438
|
-
if (saved.protocol !== 'mpp' || saved.operationId !== record.tokenOperationId || saved.fingerprint !== record.fingerprint || Challenge.serialize(saved.challenge) !== Challenge.serialize(record.mppChallenge))
|
|
439
|
-
throw new CommerceError('mpp_recovery_binding', 'Saved native credential differs from original purchase');
|
|
440
|
-
validateMppCredential(saved.credential, saved.challenge);
|
|
441
|
-
return saved;
|
|
442
|
-
}
|
|
443
|
-
async verifyMpp(record, receipt) {
|
|
444
|
-
if (record.paymentState === 'confirmed' || !receipt)
|
|
445
|
-
return record;
|
|
446
|
-
if (receipt.method !== 'stripe' || receipt.status !== 'success' || !/^pi_[A-Za-z0-9]+$/.test(receipt.reference) || receipt.externalId !== record.quote.quoteId || receipt.amount !== record.quote.amount || receipt.currency !== record.quote.currency || receipt.merchant !== record.quote.recipient || receipt.livemode !== (record.mppMode === 'live'))
|
|
447
|
-
throw new CommerceError('mpp_receipt_binding', 'MPP receipt does not match frozen terms and provider mode');
|
|
448
|
-
const sanitized = publicMppReceipt(receipt);
|
|
449
|
-
record = this.options.store.update(record.id, { receipt: sanitized });
|
|
450
|
-
if (await this.options.mpp.verifyReceipt(sanitized, { purchaseId: record.id, quote: record.quote, mode: record.mppMode, operationId: record.tokenOperationId }))
|
|
451
|
-
return this.options.store.confirmed(record.id, sanitized);
|
|
452
|
-
return record;
|
|
453
|
-
}
|
|
454
|
-
async replayMpp(record) {
|
|
455
|
-
const peer = this.assertCurrentPolicy(record, false), saved = this.savedMpp(record);
|
|
456
|
-
const response = await this.scopedFetch(peer, record.endpoint)(record.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Payment-Authorization': saved.credential }, body: record.body });
|
|
457
|
-
const header = response.headers.get('Payment-Receipt');
|
|
458
|
-
if (header && header.length <= 65536) {
|
|
459
|
-
try {
|
|
460
|
-
record = await this.verifyMpp(record, Receipt.deserialize(header));
|
|
461
|
-
}
|
|
462
|
-
catch { /* receipt evidence remains unconfirmed */ }
|
|
463
|
-
}
|
|
464
|
-
const raw = await response.json();
|
|
465
|
-
if (response.ok && raw.result?.task)
|
|
466
|
-
record = this.task(record, raw.result.task);
|
|
467
|
-
else
|
|
468
|
-
record = this.options.store.update(record.id, { state: 'unknown', errorCode: 'original_purchase_pending' });
|
|
469
|
-
return this.snapshot(record);
|
|
470
|
-
}
|
|
471
|
-
async recoverMpp(record) {
|
|
472
|
-
try {
|
|
473
|
-
if (!record.credentialRef)
|
|
474
|
-
record = await this.mppCredential(record, true);
|
|
475
|
-
this.savedMpp(record);
|
|
476
|
-
try {
|
|
477
|
-
record = await this.verifyMpp(record, record.receipt);
|
|
478
|
-
}
|
|
479
|
-
catch { /* explicit recovery can retry provider reads later */ }
|
|
480
|
-
if (record.task?.id)
|
|
481
|
-
return this.observe(record);
|
|
482
|
-
return await this.replayMpp(record);
|
|
483
|
-
}
|
|
484
|
-
catch (error) {
|
|
485
|
-
return this.snapshot(this.options.store.update(record.id, { state: 'unknown', errorCode: error instanceof CommerceError ? error.code : 'mpp_reconciliation_pending' }));
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
assertCurrentPolicy(record, signing) {
|
|
489
|
-
const peer = this.peer(record.cardUrl);
|
|
490
|
-
if (peer.id !== record.peerId || this.endpoint(peer) !== record.endpoint || peer.currency !== record.quote.currency || peer.recipient !== record.quote.recipient || (peer.protocol !== 'free' && atomic(record.quote.amount) > atomic(peer.maxPerPurchase)) || record.quote.payer !== (peer.protocol === 'free' ? record.caller : peer.protocol === 'mpp' ? this.options.mpp?.payer : this.options.signer?.address.toLowerCase()) || (record.protocol === 'mpp' && record.mppMode !== this.options.mpp?.mode))
|
|
491
|
-
throw new CommerceError('policy_changed', 'Original purchase no longer matches local wallet policy');
|
|
492
|
-
if ((record.peerMode === 'standard-a2a') !== (peer.mode === 'standard-a2a'))
|
|
493
|
-
throw new CommerceError('policy_changed', 'Original peer protocol profile changed');
|
|
494
|
-
if (record.peerMode === 'standard-a2a') {
|
|
495
|
-
if (peer.mode !== 'standard-a2a' || !record.standardBinding || record.standardBinding.policyDigest !== standardPolicyDigest(peer))
|
|
496
|
-
throw new CommerceError('policy_changed', 'Original local standard-peer contract or authentication changed');
|
|
497
|
-
const quote = record.quote, contract = peer.localContract;
|
|
498
|
-
if (quote.termsSource !== 'local-policy' || quote.termsDigest !== record.standardBinding.policyDigest || quote.inputDigest !== digest(record.input) || quote.messageId !== record.messageId || quote.amount !== contract.amount || quote.serviceId !== peer.id || quote.serviceRevision !== contract.revision || quote.offerId !== contract.offerId || digest(record.inputSchema) !== digest(contract.inputSchema))
|
|
499
|
-
throw new CommerceError('recovery_conflict', 'Local review quote no longer matches its frozen standard-peer contract and input');
|
|
500
|
-
if (record.standardBinding.bodyDigest !== digest(record.body) || record.standardBinding.challengeDigest !== digest(record.required) || digest(standardRequirements(peer, record.required)) !== digest(record.requirements))
|
|
501
|
-
throw new CommerceError('recovery_conflict', 'Original standard request or native challenge changed');
|
|
502
|
-
}
|
|
503
|
-
if (signing && peer.protocol !== 'free' && !this.policy.paymentsEnabled)
|
|
504
|
-
throw new CommerceError('payments_disabled', 'Enable payments in local wallet policy before confirming');
|
|
505
|
-
return peer;
|
|
506
|
-
}
|
|
507
|
-
async confirm(caller, input) {
|
|
508
|
-
const record = this.record(caller, input.previewId);
|
|
509
|
-
const token = Buffer.from(input.quoteToken ?? ''), expected = Buffer.from(record.quoteToken);
|
|
510
|
-
if (input.messageId !== record.messageId || token.length !== expected.length || !timingSafeEqual(token, expected))
|
|
511
|
-
throw new CommerceError('approval_mismatch', 'Confirm the original quote token and message ID');
|
|
512
|
-
if (record.state !== 'previewed')
|
|
513
|
-
return this.get(caller, record.id);
|
|
514
|
-
this.assertCurrentPolicy(record, true);
|
|
515
|
-
if (Date.now() >= Date.parse(record.quote.expiresAt))
|
|
516
|
-
throw new CommerceError('quote_expired', 'The reviewed quote has expired');
|
|
517
|
-
return this.exclusive(record.id, () => this.purchase(caller, record.id));
|
|
518
|
-
}
|
|
519
|
-
async exclusive(id, action) {
|
|
520
|
-
const prior = this.active.get(id);
|
|
521
|
-
if (prior)
|
|
522
|
-
return prior;
|
|
523
|
-
const running = action();
|
|
524
|
-
this.active.set(id, running);
|
|
525
|
-
try {
|
|
526
|
-
return await running;
|
|
527
|
-
}
|
|
528
|
-
finally {
|
|
529
|
-
this.active.delete(id);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
async purchase(caller, id) {
|
|
533
|
-
let record = this.record(caller, id);
|
|
534
|
-
this.assertCurrentPolicy(record, true);
|
|
535
|
-
if (record.protocol === 'free') {
|
|
536
|
-
const started = this.options.store.beginFree(id, caller);
|
|
537
|
-
return started.fresh ? this.replayFree(started.record) : this.snapshot(started.record);
|
|
538
|
-
}
|
|
539
|
-
const reserved = this.options.store.reserve(id, caller, this.policy);
|
|
540
|
-
if (!reserved.fresh)
|
|
541
|
-
return this.snapshot(reserved.record);
|
|
542
|
-
record = reserved.record;
|
|
543
|
-
if (record.protocol === 'mpp')
|
|
544
|
-
return this.purchaseMpp(record);
|
|
545
|
-
let signingStarted = false;
|
|
546
|
-
try {
|
|
547
|
-
const fromBlock = await this.options.checkpoint?.(record.requirements);
|
|
548
|
-
if (fromBlock !== undefined)
|
|
549
|
-
record = this.options.store.update(id, { fromBlock });
|
|
550
|
-
if (Date.now() >= Date.parse(record.quote.expiresAt))
|
|
551
|
-
throw new CommerceError('quote_expired', 'The reviewed quote expired before authorization');
|
|
552
|
-
const client = new x402Client().setSpendControls(false).register(record.requirements.network, new ExactEvmScheme({ address: this.options.signer.address, signTypedData: async (message) => { signingStarted = true; return this.options.signer.signTypedData(message); } }));
|
|
553
|
-
const payload = await client.createPaymentPayload({ ...record.required, accepts: [record.requirements] });
|
|
554
|
-
const auth = payload.payload.authorization;
|
|
555
|
-
if (!auth || auth.from.toLowerCase() !== record.quote.payer || auth.to.toLowerCase() !== record.quote.recipient || auth.value !== record.quote.amount || !/^0x[0-9a-fA-F]{64}$/.test(auth.nonce) || digest(payload.accepted) !== digest(record.requirements))
|
|
556
|
-
throw new CommerceError('signature_binding', 'Signer returned an authorization for different payment terms');
|
|
557
|
-
const credentialRef = this.options.vault.put({ payload, requirements: record.requirements, fingerprint: record.fingerprint });
|
|
558
|
-
record = this.options.store.update(id, { credentialRef, nonce: auth.nonce, state: 'submitted', paymentState: 'unknown' });
|
|
559
|
-
return await this.replay(record);
|
|
560
|
-
}
|
|
561
|
-
catch (error) {
|
|
562
|
-
if (!signingStarted)
|
|
563
|
-
return this.snapshot(this.options.store.rejectBeforeSigning(id, caller, error instanceof CommerceError ? error.code : 'pre_sign_failure'));
|
|
564
|
-
return this.snapshot(this.options.store.update(id, { state: 'unknown', paymentState: this.record(caller, id).paymentState === 'confirmed' ? 'confirmed' : 'unknown', errorCode: error instanceof CommerceError ? error.code : 'purchase_outcome_unknown' }));
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
saved(record) {
|
|
568
|
-
if (!record.credentialRef)
|
|
569
|
-
throw new CommerceError('authorization_recovery_required', 'Interrupted signing has no durable authorization; do not create a replacement purchase');
|
|
570
|
-
const saved = this.options.vault.get(record.credentialRef);
|
|
571
|
-
if (saved.fingerprint !== record.fingerprint || digest(saved.requirements) !== digest(record.requirements))
|
|
572
|
-
throw new CommerceError('recovery_conflict', 'Original authorization does not match the frozen purchase');
|
|
573
|
-
return saved;
|
|
574
|
-
}
|
|
575
|
-
async verify(record, saved, receipt) {
|
|
576
|
-
if (record.paymentState === 'confirmed')
|
|
577
|
-
return record;
|
|
578
|
-
if (record.peerMode === 'standard-a2a' && receipt && (receipt.network !== saved.requirements.network || (receipt.payer && receipt.payer.toLowerCase() !== record.quote.payer)))
|
|
579
|
-
throw new CommerceError('receipt_binding', 'Receipt differs from the original standard-peer chain or payer');
|
|
580
|
-
if (receipt)
|
|
581
|
-
record = this.options.store.update(record.id, { receipt });
|
|
582
|
-
if (!receipt?.transaction && record.fromBlock && this.options.findOriginalReceipt)
|
|
583
|
-
receipt = await this.options.findOriginalReceipt(saved.payload, saved.requirements, record.fromBlock);
|
|
584
|
-
if (!receipt)
|
|
585
|
-
return record;
|
|
586
|
-
record = this.options.store.update(record.id, { receipt });
|
|
587
|
-
if (receipt.success && await this.options.verifyReceipt(receipt, saved.payload, saved.requirements))
|
|
588
|
-
return this.options.store.confirmed(record.id, receipt);
|
|
589
|
-
return record;
|
|
590
|
-
}
|
|
591
|
-
task(record, value) {
|
|
592
|
-
const task = Task.fromJSON(value);
|
|
593
|
-
if (!task.id || !task.status || (record.task?.id && record.task.id !== task.id))
|
|
594
|
-
throw new CommerceError('task_binding', 'Peer returned a different or invalid durable Task');
|
|
595
|
-
const done = task.status.state === TaskState.TASK_STATE_COMPLETED;
|
|
596
|
-
const failed = [TaskState.TASK_STATE_FAILED, TaskState.TASK_STATE_REJECTED, TaskState.TASK_STATE_CANCELED].includes(task.status.state);
|
|
597
|
-
const executionState = done ? 'completed' : failed ? 'failed' : task.metadata?.recoveryRequired || task.status.state === TaskState.TASK_STATE_UNSPECIFIED ? 'unknown' : task.status.state === TaskState.TASK_STATE_INPUT_REQUIRED ? 'input_required' : task.status.state === TaskState.TASK_STATE_AUTH_REQUIRED ? 'auth_required' : 'working';
|
|
598
|
-
const serialized = Task.toJSON(task);
|
|
599
|
-
const continuations = record.continuations?.map(entry => entry.state !== 'resolved' && task.history.some(message => message.messageId === entry.messageId) ? { ...entry, state: 'resolved' } : entry);
|
|
600
|
-
const unresolved = continuations?.some(entry => entry.state !== 'resolved');
|
|
601
|
-
return this.options.store.update(record.id, { task: serialized, result: { artifacts: serialized.artifacts }, ...(continuations ? { continuations } : {}), executionState: unresolved ? 'unknown' : executionState, state: ['confirmed', 'not_required'].includes(record.paymentState) && !unresolved ? done ? 'completed' : failed ? 'failed' : 'submitted' : 'unknown', errorCode: unresolved ? 'continuation_outcome_unknown' : ['confirmed', 'not_required'].includes(record.paymentState) ? undefined : record.errorCode });
|
|
602
|
-
}
|
|
603
|
-
async replay(record) {
|
|
604
|
-
const peer = this.assertCurrentPolicy(record, false), saved = this.saved(record);
|
|
605
|
-
const response = await this.scopedFetch(peer, record.endpoint)(record.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'PAYMENT-SIGNATURE': encodePaymentSignatureHeader(saved.payload) }, body: record.body });
|
|
606
|
-
const receiptHeader = response.headers.get('PAYMENT-RESPONSE');
|
|
607
|
-
let receipt;
|
|
608
|
-
if (receiptHeader && receiptHeader.length <= 65536)
|
|
609
|
-
receipt = decodePaymentResponseHeader(receiptHeader);
|
|
610
|
-
try {
|
|
611
|
-
record = await this.verify(record, saved, receipt);
|
|
612
|
-
}
|
|
613
|
-
catch { /* retain the original receipt and reservation for reconciliation */ }
|
|
614
|
-
const raw = await response.json();
|
|
615
|
-
if (record.peerMode === 'standard-a2a' && (raw.jsonrpc !== '2.0' || raw.id !== JSON.parse(record.body).id))
|
|
616
|
-
throw new CommerceError('rpc_response_binding', 'Peer response does not match the original standard JSON-RPC request');
|
|
617
|
-
if (response.ok && raw.result?.task)
|
|
618
|
-
record = this.task(record, raw.result.task);
|
|
619
|
-
else if (record.peerMode === 'standard-a2a' && response.ok && raw.result?.message) {
|
|
620
|
-
const message = Message.fromJSON(raw.result.message);
|
|
621
|
-
if (!message.messageId || message.role !== Role.ROLE_AGENT || !message.parts.length)
|
|
622
|
-
throw new CommerceError('message_binding', 'Peer returned an invalid A2A response message');
|
|
623
|
-
record = this.options.store.update(record.id, { result: { message: Message.toJSON(message) }, executionState: 'completed', state: record.paymentState === 'confirmed' ? 'completed' : 'unknown' });
|
|
624
|
-
}
|
|
625
|
-
else
|
|
626
|
-
record = this.options.store.update(record.id, { state: 'unknown', errorCode: 'original_purchase_pending' });
|
|
627
|
-
return this.snapshot(record);
|
|
628
|
-
}
|
|
629
|
-
client(record, fetchImpl) {
|
|
630
|
-
const peer = this.assertCurrentPolicy(record, false);
|
|
631
|
-
const card = AgentCard.fromJSON({ supportedInterfaces: [{ url: record.endpoint, protocolBinding: 'JSONRPC', protocolVersion: '1.0' }], capabilities: {}, skills: [] });
|
|
632
|
-
return new ClientFactory({ transports: [new JsonRpcTransportFactory({ fetchImpl: fetchImpl ?? this.scopedFetch(peer, record.endpoint) })] }).createFromAgentCard(card);
|
|
633
|
-
}
|
|
634
|
-
async observe(record) {
|
|
635
|
-
if (!record.task?.id)
|
|
636
|
-
return this.snapshot(record);
|
|
637
|
-
try {
|
|
638
|
-
const task = await (await this.client(record)).getTask(GetTaskRequest.fromJSON({ id: String(record.task.id) }));
|
|
639
|
-
return this.snapshot(this.task(record, Task.toJSON(task)));
|
|
640
|
-
}
|
|
641
|
-
catch {
|
|
642
|
-
return this.snapshot(record);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
async continue(caller, id, input) {
|
|
646
|
-
const record = this.record(caller, id);
|
|
647
|
-
if (record.peerMode === 'standard-a2a')
|
|
648
|
-
throw new CommerceError('standard_continuation_unavailable', 'A standard peer has not promised included clarification; inspect its original Task without a new submission');
|
|
649
|
-
if (typeof input.messageId !== 'string' || !input.messageId || input.messageId.length > 128 || input.messageId === record.messageId || !input.input || typeof input.input !== 'object' || Array.isArray(input.input))
|
|
650
|
-
throw new CommerceError('invalid_continuation', 'A distinct stable message ID and full object input are required');
|
|
651
|
-
const running = this.active.get(id);
|
|
652
|
-
if (running) {
|
|
653
|
-
await running;
|
|
654
|
-
return this.continue(caller, id, input);
|
|
655
|
-
}
|
|
656
|
-
return this.exclusive(id, async () => {
|
|
657
|
-
let original = this.record(caller, id);
|
|
658
|
-
this.options.store.assertMessageOwner(caller, input.messageId);
|
|
659
|
-
if (this.options.store.find(caller, input.messageId))
|
|
660
|
-
throw new CommerceError('continuation_conflict', 'Clarification message ID is already used by a purchase');
|
|
661
|
-
const previous = original.continuations ?? [];
|
|
662
|
-
const duplicate = previous.find(entry => entry.messageId === input.messageId);
|
|
663
|
-
if (duplicate) {
|
|
664
|
-
if (digest(duplicate.input) !== digest(input.input))
|
|
665
|
-
throw new CommerceError('continuation_conflict', 'Continuation message ID already identifies different input');
|
|
666
|
-
return this.snapshot(original);
|
|
667
|
-
}
|
|
668
|
-
if (previous.some(entry => entry.state !== 'resolved'))
|
|
669
|
-
throw new CommerceError('continuation_unknown', 'Observe the original continuation before sending another round');
|
|
670
|
-
if (!['confirmed', 'not_required'].includes(original.paymentState) || !original.task?.id || !['input_required', 'auth_required'].includes(original.executionState))
|
|
671
|
-
throw new CommerceError('continuation_unavailable', 'Only the original paid waiting Task accepts clarification');
|
|
672
|
-
if (previous.length >= 10)
|
|
673
|
-
throw new CommerceError('continuation_limit', 'This purchase has reached the clarification limit');
|
|
674
|
-
const currentInput = previous.at(-1)?.input ?? original.input;
|
|
675
|
-
const preserved = (before, after) => {
|
|
676
|
-
if (before && typeof before === 'object' && !Array.isArray(before))
|
|
677
|
-
return Boolean(after && typeof after === 'object' && !Array.isArray(after)) && Object.entries(before).every(([key, value]) => Object.hasOwn(after, key) && preserved(value, after[key]));
|
|
678
|
-
return digest(before) === digest(after);
|
|
679
|
-
};
|
|
680
|
-
if (!preserved(currentInput, input.input))
|
|
681
|
-
throw new CommerceError('continuation_scope', 'Clarification cannot replace existing purchased input');
|
|
682
|
-
const ajv = new Ajv2020({ strict: true });
|
|
683
|
-
addFormats(ajv);
|
|
684
|
-
if (!ajv.compile(original.inputSchema)(input.input))
|
|
685
|
-
throw new CommerceError('invalid_input', 'Clarification must satisfy the original full input schema');
|
|
686
|
-
const peer = this.assertCurrentPolicy(original, false);
|
|
687
|
-
const entry = { messageId: input.messageId, input: structuredClone(input.input), state: 'pending', beforeDigest: digest(original.task) };
|
|
688
|
-
const scoped = this.scopedFetch(peer, original.endpoint);
|
|
689
|
-
const capture = async (request, init) => {
|
|
690
|
-
const req = new Request(request, init);
|
|
691
|
-
const body = await req.clone().text();
|
|
692
|
-
if (Buffer.byteLength(body) > 1024 * 1024)
|
|
693
|
-
throw new CommerceError('request_too_large', 'Clarification exceeds 1 MiB');
|
|
694
|
-
original = this.options.store.update(id, { continuations: [...previous, { ...entry, body }], executionState: 'unknown' });
|
|
695
|
-
return scoped(req);
|
|
696
|
-
};
|
|
697
|
-
try {
|
|
698
|
-
const client = await this.client(original, capture);
|
|
699
|
-
const task = await client.sendMessage(SendMessageRequest.fromJSON({ message: { messageId: input.messageId, taskId: String(original.task.id), role: 'ROLE_USER', parts: [{ data: input.input }] }, configuration: { returnImmediately: true } }));
|
|
700
|
-
if (!('status' in task))
|
|
701
|
-
throw new CommerceError('task_binding', 'Clarification returned no original Task');
|
|
702
|
-
original = this.task(original, Task.toJSON(task));
|
|
703
|
-
original = this.options.store.update(id, { continuations: (original.continuations ?? []).map(value => value.messageId === input.messageId ? { ...value, state: 'resolved' } : value) });
|
|
704
|
-
return this.snapshot(this.task(original, Task.toJSON(task)));
|
|
705
|
-
}
|
|
706
|
-
catch {
|
|
707
|
-
original = this.record(caller, id);
|
|
708
|
-
return this.snapshot(this.options.store.update(id, { state: 'unknown', executionState: 'unknown', continuations: (original.continuations ?? []).map(value => value.messageId === input.messageId ? { ...value, state: 'unknown' } : value), errorCode: 'continuation_outcome_unknown' }));
|
|
709
|
-
}
|
|
710
|
-
});
|
|
711
|
-
}
|
|
712
|
-
async recover(caller, id) {
|
|
713
|
-
const record = this.record(caller, id);
|
|
714
|
-
if (record.state === 'previewed' || record.paymentState === 'rejected' || (['confirmed', 'not_required'].includes(record.paymentState) && ['completed', 'failed'].includes(record.executionState)))
|
|
715
|
-
return this.snapshot(record);
|
|
716
|
-
return this.exclusive(id, async () => {
|
|
717
|
-
let original = this.record(caller, id);
|
|
718
|
-
this.assertCurrentPolicy(original, false);
|
|
719
|
-
if (original.protocol === 'mpp')
|
|
720
|
-
return this.recoverMpp(original);
|
|
721
|
-
if (original.protocol === 'free')
|
|
722
|
-
return original.task?.id ? this.observe(original) : this.replayFree(original);
|
|
723
|
-
try {
|
|
724
|
-
const saved = this.saved(original);
|
|
725
|
-
try {
|
|
726
|
-
original = await this.verify(original, saved, original.receipt);
|
|
727
|
-
}
|
|
728
|
-
catch { /* chain may remain unavailable */ }
|
|
729
|
-
if (original.paymentState !== 'confirmed' && this.options.proveExpiredUnused) {
|
|
730
|
-
let unused = false;
|
|
731
|
-
try {
|
|
732
|
-
unused = await this.options.proveExpiredUnused(saved.payload, saved.requirements);
|
|
733
|
-
}
|
|
734
|
-
catch { /* incomplete finalized chain evidence remains unknown */ }
|
|
735
|
-
if (unused)
|
|
736
|
-
return this.snapshot(this.options.store.rejectExpiredAuthorization(id, caller));
|
|
737
|
-
}
|
|
738
|
-
if (original.task?.id) {
|
|
739
|
-
return this.observe(original);
|
|
740
|
-
}
|
|
741
|
-
if (original.peerMode === 'standard-a2a') {
|
|
742
|
-
// A2A message IDs do not guarantee idempotent execution at an unrelated seller.
|
|
743
|
-
// Keep the exact original authorization, but never POST it again after an ambiguous outcome.
|
|
744
|
-
if (original.paymentState === 'confirmed' && original.executionState === 'completed' && original.result && typeof original.result === 'object' && 'message' in original.result)
|
|
745
|
-
return this.snapshot(this.options.store.update(id, { state: 'completed', errorCode: undefined }));
|
|
746
|
-
return this.snapshot(this.options.store.update(id, { state: 'unknown', executionState: 'unknown', errorCode: 'standard_task_recovery_required' }));
|
|
747
|
-
}
|
|
748
|
-
return await this.replay(original);
|
|
749
|
-
}
|
|
750
|
-
catch (error) {
|
|
751
|
-
return this.snapshot(this.options.store.update(id, { state: 'unknown', errorCode: error instanceof CommerceError ? error.code : 'reconciliation_pending' }));
|
|
752
|
-
}
|
|
753
|
-
});
|
|
754
|
-
}
|
|
755
|
-
}
|