warpmetal 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 +56 -7
- package/package.json +2 -2
- package/skills/warpmetal/SKILL.md +44 -22
- package/skills/warpmetal/agents/openai.yaml +2 -2
- package/skills/warpmetal/references/cli-reference.md +28 -4
- package/skills/warpmetal/references/payments.md +110 -0
- package/skills/warpmetal/references/safety.md +20 -7
- package/src/cli.js +134 -24
- package/src/payment.js +769 -0
- package/src/state.js +55 -1
package/src/payment.js
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, mkdir, open, readFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { CliError } from "./errors.js";
|
|
6
|
+
|
|
7
|
+
const BASE64 =
|
|
8
|
+
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
9
|
+
const SHA256 = /^sha256:[0-9a-f]{64}$/;
|
|
10
|
+
const PAYMENT_IDENTIFIER = /^[A-Za-z0-9_-]{16,128}$/;
|
|
11
|
+
const CAIP2_NETWORK = /^[-a-z0-9]{3,8}:[-_a-zA-Z0-9]{1,32}$/;
|
|
12
|
+
const PRINTABLE_ASCII = /^[\x20-\x7e]+$/;
|
|
13
|
+
const MAX_CHALLENGE_BYTES = 64 * 1024;
|
|
14
|
+
const MAX_ARTIFACT_BYTES = 1024 * 1024;
|
|
15
|
+
const MAX_SIGNATURE_BYTES = 512 * 1024;
|
|
16
|
+
|
|
17
|
+
export const AGENT_WALLET_PACKAGE = "@x402api/agent-wallet-cli";
|
|
18
|
+
export const AGENT_WALLET_VERSION = "0.2.1";
|
|
19
|
+
const AGENT_WALLET_SPEC = `${AGENT_WALLET_PACKAGE}@${AGENT_WALLET_VERSION}`;
|
|
20
|
+
const GAS_SPONSORSHIP_EXTENSION = "com.x402api.gas-sponsorship";
|
|
21
|
+
const BASE_NETWORK = "eip155:8453";
|
|
22
|
+
const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
|
23
|
+
const BASE_SPONSORED_PROFILE =
|
|
24
|
+
"com.x402api.x402.base-usdc-eip3009-sponsored.v1";
|
|
25
|
+
const SOLANA_NETWORK = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
|
|
26
|
+
const SOLANA_USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
|
27
|
+
const SOLANA_USDT = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB";
|
|
28
|
+
const SOLANA_SPONSORED_PROFILE = "com.x402api.x402.solana-sponsored.v1";
|
|
29
|
+
const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/;
|
|
30
|
+
const SOLANA_ADDRESS = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
|
31
|
+
const SOLANA_MEMO =
|
|
32
|
+
/^k1h:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
33
|
+
|
|
34
|
+
function invalid(message) {
|
|
35
|
+
return new CliError(message, { exitCode: 2 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isObject(value) {
|
|
39
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function exactObject(value, keys, label) {
|
|
43
|
+
if (!isObject(value)) throw invalid(`${label} must be a JSON object.`);
|
|
44
|
+
if (Object.keys(value).some((key) => !keys.includes(key))) {
|
|
45
|
+
throw invalid(`${label} contains an unsupported field.`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function assertJsonValue(value) {
|
|
51
|
+
if (
|
|
52
|
+
value === null ||
|
|
53
|
+
typeof value === "string" ||
|
|
54
|
+
typeof value === "boolean"
|
|
55
|
+
) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (typeof value === "number") {
|
|
59
|
+
if (!Number.isSafeInteger(value)) {
|
|
60
|
+
throw invalid("The x402 challenge contains a non-canonical JSON number.");
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (Array.isArray(value)) {
|
|
65
|
+
value.forEach(assertJsonValue);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (isObject(value)) {
|
|
69
|
+
Object.values(value).forEach(assertJsonValue);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
throw invalid("The x402 challenge contains a non-JSON value.");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function canonicalJson(value) {
|
|
76
|
+
assertJsonValue(value);
|
|
77
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
|
|
80
|
+
}
|
|
81
|
+
return `{${Object.keys(value)
|
|
82
|
+
.sort()
|
|
83
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
84
|
+
.join(",")}}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function digestBytes(value) {
|
|
88
|
+
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function digestJson(value) {
|
|
92
|
+
return digestBytes(canonicalJson(value));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function decodeBase64Json(value, label, maximumBytes) {
|
|
96
|
+
if (
|
|
97
|
+
typeof value !== "string" ||
|
|
98
|
+
value.length === 0 ||
|
|
99
|
+
value.length > maximumBytes ||
|
|
100
|
+
!BASE64.test(value)
|
|
101
|
+
) {
|
|
102
|
+
throw invalid(`${label} is not canonical base64 JSON.`);
|
|
103
|
+
}
|
|
104
|
+
let bytes;
|
|
105
|
+
try {
|
|
106
|
+
bytes = Buffer.from(value, "base64");
|
|
107
|
+
if (bytes.toString("base64") !== value) throw new Error("not canonical");
|
|
108
|
+
} catch {
|
|
109
|
+
throw invalid(`${label} is not valid base64.`);
|
|
110
|
+
}
|
|
111
|
+
if (bytes.length > maximumBytes) throw invalid(`${label} is too large.`);
|
|
112
|
+
let text;
|
|
113
|
+
try {
|
|
114
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
115
|
+
} catch {
|
|
116
|
+
throw invalid(`${label} is not UTF-8.`);
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(text);
|
|
120
|
+
assertJsonValue(parsed);
|
|
121
|
+
return parsed;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (error instanceof CliError) throw error;
|
|
124
|
+
throw invalid(`${label} is not JSON.`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizedHttpsUrl(value, label) {
|
|
129
|
+
let url;
|
|
130
|
+
try {
|
|
131
|
+
url = new URL(value);
|
|
132
|
+
} catch {
|
|
133
|
+
throw invalid(`${label} is not a valid URL.`);
|
|
134
|
+
}
|
|
135
|
+
if (
|
|
136
|
+
url.protocol !== "https:" ||
|
|
137
|
+
url.username ||
|
|
138
|
+
url.password ||
|
|
139
|
+
url.hash ||
|
|
140
|
+
url.toString() !== value
|
|
141
|
+
) {
|
|
142
|
+
throw invalid(`${label} must be a normalized credential-free HTTPS URL.`);
|
|
143
|
+
}
|
|
144
|
+
return url.toString();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function optionalString(value) {
|
|
148
|
+
return value === undefined || typeof value === "string";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function paymentResource(value, label) {
|
|
152
|
+
const resource = exactObject(
|
|
153
|
+
value,
|
|
154
|
+
["url", "description", "mimeType", "serviceName", "tags", "iconUrl"],
|
|
155
|
+
label,
|
|
156
|
+
);
|
|
157
|
+
if (
|
|
158
|
+
typeof resource.url !== "string" ||
|
|
159
|
+
!optionalString(resource.description) ||
|
|
160
|
+
!optionalString(resource.mimeType) ||
|
|
161
|
+
!optionalString(resource.serviceName) ||
|
|
162
|
+
(resource.serviceName !== undefined &&
|
|
163
|
+
(resource.serviceName.length < 1 ||
|
|
164
|
+
resource.serviceName.length > 32 ||
|
|
165
|
+
!PRINTABLE_ASCII.test(resource.serviceName))) ||
|
|
166
|
+
(resource.tags !== undefined &&
|
|
167
|
+
(!Array.isArray(resource.tags) ||
|
|
168
|
+
resource.tags.length > 5 ||
|
|
169
|
+
resource.tags.some(
|
|
170
|
+
(tag) =>
|
|
171
|
+
typeof tag !== "string" ||
|
|
172
|
+
tag.length < 1 ||
|
|
173
|
+
tag.length > 32 ||
|
|
174
|
+
!PRINTABLE_ASCII.test(tag),
|
|
175
|
+
))) ||
|
|
176
|
+
!optionalString(resource.iconUrl)
|
|
177
|
+
) {
|
|
178
|
+
throw invalid(`${label} is malformed.`);
|
|
179
|
+
}
|
|
180
|
+
return resource;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function validateGasSponsorship(declaration) {
|
|
184
|
+
const info = declaration.info;
|
|
185
|
+
const schema = declaration.schema;
|
|
186
|
+
const infoKeys = [
|
|
187
|
+
"billingParty",
|
|
188
|
+
"buyerNativeFeeRequired",
|
|
189
|
+
"expiresAt",
|
|
190
|
+
"finalChargePolicy",
|
|
191
|
+
"maximumReservationEvidenceDigest",
|
|
192
|
+
"mode",
|
|
193
|
+
"requirements",
|
|
194
|
+
"version",
|
|
195
|
+
];
|
|
196
|
+
if (
|
|
197
|
+
!isObject(info) ||
|
|
198
|
+
Object.keys(info).sort().join(",") !== infoKeys.slice().sort().join(",") ||
|
|
199
|
+
info.version !== 1 ||
|
|
200
|
+
info.mode !== "facilitator_pays" ||
|
|
201
|
+
info.buyerNativeFeeRequired !== false ||
|
|
202
|
+
info.billingParty !== "tenant_service_credit" ||
|
|
203
|
+
info.finalChargePolicy !== "canonical_actual_gas_capped_by_reservation" ||
|
|
204
|
+
typeof info.maximumReservationEvidenceDigest !== "string" ||
|
|
205
|
+
!SHA256.test(info.maximumReservationEvidenceDigest) ||
|
|
206
|
+
typeof info.expiresAt !== "string" ||
|
|
207
|
+
!Number.isFinite(Date.parse(info.expiresAt)) ||
|
|
208
|
+
!Array.isArray(info.requirements) ||
|
|
209
|
+
info.requirements.length < 1 ||
|
|
210
|
+
info.requirements.length > 3
|
|
211
|
+
) {
|
|
212
|
+
throw invalid("The x402 gas-sponsorship extension is malformed.");
|
|
213
|
+
}
|
|
214
|
+
const identities = new Set();
|
|
215
|
+
for (const requirement of info.requirements) {
|
|
216
|
+
if (
|
|
217
|
+
!isObject(requirement) ||
|
|
218
|
+
Object.keys(requirement).sort().join(",") !==
|
|
219
|
+
"asset,network,payloadProfile" ||
|
|
220
|
+
typeof requirement.network !== "string" ||
|
|
221
|
+
!CAIP2_NETWORK.test(requirement.network) ||
|
|
222
|
+
typeof requirement.asset !== "string" ||
|
|
223
|
+
requirement.asset.length === 0 ||
|
|
224
|
+
![BASE_SPONSORED_PROFILE, SOLANA_SPONSORED_PROFILE].includes(
|
|
225
|
+
requirement.payloadProfile,
|
|
226
|
+
)
|
|
227
|
+
) {
|
|
228
|
+
throw invalid("An x402 sponsored payment binding is malformed.");
|
|
229
|
+
}
|
|
230
|
+
const identity = `${requirement.network}|${requirement.asset.toLowerCase()}|${requirement.payloadProfile}`;
|
|
231
|
+
if (identities.has(identity)) {
|
|
232
|
+
throw invalid("The x402 gas-sponsorship extension contains a duplicate.");
|
|
233
|
+
}
|
|
234
|
+
identities.add(identity);
|
|
235
|
+
}
|
|
236
|
+
if (
|
|
237
|
+
!isObject(schema) ||
|
|
238
|
+
schema.$id !== "urn:com:x402api:gas-sponsorship:v1" ||
|
|
239
|
+
schema.type !== "object" ||
|
|
240
|
+
schema.additionalProperties !== false ||
|
|
241
|
+
!Array.isArray(schema.required) ||
|
|
242
|
+
schema.required.length !== infoKeys.length ||
|
|
243
|
+
[...schema.required].sort().join(",") !== infoKeys.slice().sort().join(",")
|
|
244
|
+
) {
|
|
245
|
+
throw invalid("The x402 gas-sponsorship schema is malformed.");
|
|
246
|
+
}
|
|
247
|
+
return info;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function paymentExtensions(value, label) {
|
|
251
|
+
if (value === undefined) return {};
|
|
252
|
+
if (!isObject(value)) throw invalid(`${label} must be a JSON object.`);
|
|
253
|
+
for (const [name, candidate] of Object.entries(value)) {
|
|
254
|
+
const declaration = exactObject(
|
|
255
|
+
candidate,
|
|
256
|
+
["info", "schema"],
|
|
257
|
+
`${label} ${name}`,
|
|
258
|
+
);
|
|
259
|
+
if (
|
|
260
|
+
!isObject(declaration.info) ||
|
|
261
|
+
(declaration.schema !== undefined && !isObject(declaration.schema))
|
|
262
|
+
) {
|
|
263
|
+
throw invalid(`${label} ${name} is malformed.`);
|
|
264
|
+
}
|
|
265
|
+
assertJsonValue(declaration.info);
|
|
266
|
+
if (declaration.schema !== undefined) assertJsonValue(declaration.schema);
|
|
267
|
+
if (name === GAS_SPONSORSHIP_EXTENSION) {
|
|
268
|
+
validateGasSponsorship(declaration);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return value;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function paymentRequirement(value) {
|
|
275
|
+
const requirement = exactObject(
|
|
276
|
+
value,
|
|
277
|
+
[
|
|
278
|
+
"scheme",
|
|
279
|
+
"network",
|
|
280
|
+
"amount",
|
|
281
|
+
"asset",
|
|
282
|
+
"payTo",
|
|
283
|
+
"maxTimeoutSeconds",
|
|
284
|
+
"extra",
|
|
285
|
+
],
|
|
286
|
+
"An x402 payment requirement",
|
|
287
|
+
);
|
|
288
|
+
if (
|
|
289
|
+
typeof requirement.scheme !== "string" ||
|
|
290
|
+
typeof requirement.network !== "string" ||
|
|
291
|
+
!CAIP2_NETWORK.test(requirement.network) ||
|
|
292
|
+
typeof requirement.amount !== "string" ||
|
|
293
|
+
!/^(?:0|[1-9][0-9]{0,77})$/.test(requirement.amount) ||
|
|
294
|
+
requirement.amount === "0" ||
|
|
295
|
+
typeof requirement.asset !== "string" ||
|
|
296
|
+
typeof requirement.payTo !== "string" ||
|
|
297
|
+
!Number.isSafeInteger(requirement.maxTimeoutSeconds) ||
|
|
298
|
+
requirement.maxTimeoutSeconds < 1 ||
|
|
299
|
+
!isObject(requirement.extra)
|
|
300
|
+
) {
|
|
301
|
+
throw invalid("An x402 payment requirement is malformed.");
|
|
302
|
+
}
|
|
303
|
+
assertJsonValue(requirement.extra);
|
|
304
|
+
return requirement;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function externalRecipientBinds(requirement, extensions) {
|
|
308
|
+
if (extensions["com.k1hub.wallet-manifest"] !== undefined) return false;
|
|
309
|
+
const info = extensions["com.k1hub.external-recipient"]?.info;
|
|
310
|
+
if (info?.version !== 1 || !Array.isArray(info.recipients)) return false;
|
|
311
|
+
const matches = info.recipients.filter((candidate) => {
|
|
312
|
+
if (
|
|
313
|
+
!isObject(candidate) ||
|
|
314
|
+
candidate.network !== requirement.network ||
|
|
315
|
+
candidate.asset !== requirement.asset ||
|
|
316
|
+
candidate.payTo !== requirement.payTo ||
|
|
317
|
+
typeof candidate.recipientDescriptorDigest !== "string" ||
|
|
318
|
+
!SHA256.test(candidate.recipientDescriptorDigest) ||
|
|
319
|
+
!isObject(candidate.recipientDescriptor)
|
|
320
|
+
) {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
return (
|
|
324
|
+
digestJson(candidate.recipientDescriptor) ===
|
|
325
|
+
candidate.recipientDescriptorDigest
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
return matches.length === 1;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function agentWalletSupports(requirement, sponsorship, extensions) {
|
|
332
|
+
const profile = requirement.extra.payloadProfile;
|
|
333
|
+
const baseExtra = Object.keys(requirement.extra).sort().join(",");
|
|
334
|
+
const supportedRail =
|
|
335
|
+
requirement.scheme === "exact" &&
|
|
336
|
+
((requirement.network === BASE_NETWORK &&
|
|
337
|
+
requirement.asset.toLowerCase() === BASE_USDC.toLowerCase() &&
|
|
338
|
+
EVM_ADDRESS.test(requirement.asset) &&
|
|
339
|
+
EVM_ADDRESS.test(requirement.payTo) &&
|
|
340
|
+
baseExtra === "assetTransferMethod,name,payloadProfile,version" &&
|
|
341
|
+
requirement.extra.assetTransferMethod === "eip3009" &&
|
|
342
|
+
requirement.extra.name === "USD Coin" &&
|
|
343
|
+
requirement.extra.version === "2" &&
|
|
344
|
+
profile === BASE_SPONSORED_PROFILE) ||
|
|
345
|
+
(requirement.network === SOLANA_NETWORK &&
|
|
346
|
+
[SOLANA_USDC, SOLANA_USDT].includes(requirement.asset) &&
|
|
347
|
+
SOLANA_ADDRESS.test(requirement.payTo) &&
|
|
348
|
+
SOLANA_ADDRESS.test(requirement.extra.feePayer) &&
|
|
349
|
+
SOLANA_MEMO.test(requirement.extra.memo) &&
|
|
350
|
+
profile === SOLANA_SPONSORED_PROFILE));
|
|
351
|
+
return Boolean(
|
|
352
|
+
supportedRail &&
|
|
353
|
+
extensions["payment-identifier"] !== undefined &&
|
|
354
|
+
externalRecipientBinds(requirement, extensions) &&
|
|
355
|
+
sponsorship?.requirements.some(
|
|
356
|
+
(binding) =>
|
|
357
|
+
binding.network === requirement.network &&
|
|
358
|
+
binding.asset === requirement.asset &&
|
|
359
|
+
binding.payloadProfile === profile,
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function parsePaymentRequired(value, expectedUrl) {
|
|
365
|
+
const parsed = exactObject(
|
|
366
|
+
decodeBase64Json(value, "PAYMENT-REQUIRED", MAX_CHALLENGE_BYTES),
|
|
367
|
+
["x402Version", "error", "resource", "accepts", "extensions"],
|
|
368
|
+
"PAYMENT-REQUIRED",
|
|
369
|
+
);
|
|
370
|
+
if (
|
|
371
|
+
parsed.x402Version !== 2 ||
|
|
372
|
+
(parsed.error !== undefined && typeof parsed.error !== "string") ||
|
|
373
|
+
!Array.isArray(parsed.accepts) ||
|
|
374
|
+
parsed.accepts.length === 0 ||
|
|
375
|
+
(parsed.extensions !== undefined && !isObject(parsed.extensions))
|
|
376
|
+
) {
|
|
377
|
+
throw invalid("PAYMENT-REQUIRED is malformed.");
|
|
378
|
+
}
|
|
379
|
+
const resource = paymentResource(parsed.resource, "The x402 resource");
|
|
380
|
+
const resourceUrl = normalizedHttpsUrl(resource.url, "The x402 resource URL");
|
|
381
|
+
if (resourceUrl !== normalizedHttpsUrl(expectedUrl, "The checkout URL")) {
|
|
382
|
+
throw invalid("PAYMENT-REQUIRED is bound to a different checkout URL.");
|
|
383
|
+
}
|
|
384
|
+
const accepts = parsed.accepts.map(paymentRequirement);
|
|
385
|
+
const extensions = paymentExtensions(
|
|
386
|
+
parsed.extensions,
|
|
387
|
+
"The x402 challenge extension",
|
|
388
|
+
);
|
|
389
|
+
const sponsorship = extensions[GAS_SPONSORSHIP_EXTENSION]?.info;
|
|
390
|
+
return {
|
|
391
|
+
challenge: parsed,
|
|
392
|
+
challengeDigest: digestJson(parsed),
|
|
393
|
+
accepts,
|
|
394
|
+
sponsorshipExpiresAt: sponsorship?.expiresAt,
|
|
395
|
+
terms: accepts.map((requirement) => {
|
|
396
|
+
const supported = agentWalletSupports(
|
|
397
|
+
requirement,
|
|
398
|
+
sponsorship,
|
|
399
|
+
extensions,
|
|
400
|
+
);
|
|
401
|
+
return {
|
|
402
|
+
scheme: requirement.scheme,
|
|
403
|
+
network: requirement.network,
|
|
404
|
+
amountAtomic: requirement.amount,
|
|
405
|
+
asset: requirement.asset,
|
|
406
|
+
payTo: requirement.payTo,
|
|
407
|
+
maxTimeoutSeconds: requirement.maxTimeoutSeconds,
|
|
408
|
+
payloadProfile:
|
|
409
|
+
typeof requirement.extra.payloadProfile === "string"
|
|
410
|
+
? requirement.extra.payloadProfile
|
|
411
|
+
: undefined,
|
|
412
|
+
agentWalletSupported: supported,
|
|
413
|
+
sponsoredNetworkFee: supported,
|
|
414
|
+
buyerNativeFeeRequired: supported ? false : undefined,
|
|
415
|
+
sponsorshipExpiresAt: supported ? sponsorship.expiresAt : undefined,
|
|
416
|
+
requirementDigest: digestJson(requirement),
|
|
417
|
+
};
|
|
418
|
+
}),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function createPaymentRequestEnvelope({
|
|
423
|
+
baseUrl,
|
|
424
|
+
checkoutPath,
|
|
425
|
+
checkoutBody,
|
|
426
|
+
paymentRequired,
|
|
427
|
+
merchantReference,
|
|
428
|
+
}) {
|
|
429
|
+
const url = new URL(
|
|
430
|
+
checkoutPath,
|
|
431
|
+
`${baseUrl.replace(/\/$/, "")}/`,
|
|
432
|
+
).toString();
|
|
433
|
+
const parsed = parsePaymentRequired(paymentRequired, url);
|
|
434
|
+
const supportedTerms = parsed.terms.filter(
|
|
435
|
+
(term) => term.agentWalletSupported,
|
|
436
|
+
);
|
|
437
|
+
if (supportedTerms.length === 0) {
|
|
438
|
+
throw invalid(
|
|
439
|
+
`PAYMENT-REQUIRED has no option supported by ${AGENT_WALLET_SPEC}.`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
if (Date.parse(parsed.sponsorshipExpiresAt) <= Date.now()) {
|
|
443
|
+
throw invalid("The x402 gas-sponsorship reservation has expired.");
|
|
444
|
+
}
|
|
445
|
+
if (
|
|
446
|
+
typeof merchantReference !== "string" ||
|
|
447
|
+
merchantReference.length === 0 ||
|
|
448
|
+
merchantReference.length > 256
|
|
449
|
+
) {
|
|
450
|
+
throw invalid("The payment merchant reference is invalid.");
|
|
451
|
+
}
|
|
452
|
+
const envelope = {
|
|
453
|
+
version: 1,
|
|
454
|
+
method: "POST",
|
|
455
|
+
url,
|
|
456
|
+
contentType: "application/json",
|
|
457
|
+
bodyBase64: Buffer.from(checkoutBody, "utf8").toString("base64"),
|
|
458
|
+
paymentRequired,
|
|
459
|
+
challengeDigest: parsed.challengeDigest,
|
|
460
|
+
merchantReference,
|
|
461
|
+
};
|
|
462
|
+
return {
|
|
463
|
+
envelope,
|
|
464
|
+
requestDigest: digestJson(envelope),
|
|
465
|
+
challengeDigest: parsed.challengeDigest,
|
|
466
|
+
challenge: parsed.challenge,
|
|
467
|
+
sponsorshipExpiresAt: parsed.sponsorshipExpiresAt,
|
|
468
|
+
requirementDigests: new Set(
|
|
469
|
+
supportedTerms.map((term) => term.requirementDigest),
|
|
470
|
+
),
|
|
471
|
+
terms: parsed.terms,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function readSafeFile(path, label, maximumBytes, requirePrivate = false) {
|
|
476
|
+
const absolute = resolve(path);
|
|
477
|
+
let info;
|
|
478
|
+
try {
|
|
479
|
+
info = await lstat(absolute);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
if (error?.code === "ENOENT")
|
|
482
|
+
throw invalid(`${label} was not found: ${absolute}`);
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
if (
|
|
486
|
+
!info.isFile() ||
|
|
487
|
+
info.isSymbolicLink() ||
|
|
488
|
+
info.size < 2 ||
|
|
489
|
+
info.size > maximumBytes
|
|
490
|
+
) {
|
|
491
|
+
throw invalid(`${label} is unsafe or too large.`);
|
|
492
|
+
}
|
|
493
|
+
if (
|
|
494
|
+
requirePrivate &&
|
|
495
|
+
process.platform !== "win32" &&
|
|
496
|
+
(info.mode & 0o077) !== 0
|
|
497
|
+
) {
|
|
498
|
+
throw invalid(`${label} must have owner-only permissions.`);
|
|
499
|
+
}
|
|
500
|
+
return { absolute, bytes: await readFile(absolute) };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export async function writePaymentRequestEnvelope(path, envelope) {
|
|
504
|
+
const absolute = resolve(path);
|
|
505
|
+
const text = `${JSON.stringify(envelope, null, 2)}\n`;
|
|
506
|
+
await mkdir(dirname(absolute), { recursive: true, mode: 0o700 });
|
|
507
|
+
let handle;
|
|
508
|
+
try {
|
|
509
|
+
handle = await open(absolute, "wx", 0o600);
|
|
510
|
+
await handle.writeFile(text, "utf8");
|
|
511
|
+
await handle.sync();
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (error?.code !== "EEXIST") throw error;
|
|
514
|
+
const existing = await readSafeFile(
|
|
515
|
+
absolute,
|
|
516
|
+
"The payment request envelope",
|
|
517
|
+
MAX_ARTIFACT_BYTES,
|
|
518
|
+
true,
|
|
519
|
+
);
|
|
520
|
+
if (existing.bytes.toString("utf8") !== text) {
|
|
521
|
+
throw invalid(
|
|
522
|
+
`The payment request envelope already exists with different content: ${absolute}`,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
} finally {
|
|
526
|
+
await handle?.close();
|
|
527
|
+
}
|
|
528
|
+
if (process.platform !== "win32") await chmod(absolute, 0o600);
|
|
529
|
+
return absolute;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export function defaultPaymentPaths(stateDirectory, taskId, challengeDigest) {
|
|
533
|
+
const taskDigest = createHash("sha256")
|
|
534
|
+
.update(taskId)
|
|
535
|
+
.digest("hex")
|
|
536
|
+
.slice(0, 16);
|
|
537
|
+
const challenge = challengeDigest.slice(
|
|
538
|
+
"sha256:".length,
|
|
539
|
+
"sha256:".length + 16,
|
|
540
|
+
);
|
|
541
|
+
const stem = `${taskDigest}-${challenge}`;
|
|
542
|
+
const directory = join(stateDirectory, "payments");
|
|
543
|
+
return {
|
|
544
|
+
requestEnvelopePath: join(directory, `${stem}.request.json`),
|
|
545
|
+
paymentArtifactPath: join(directory, `${stem}.payment.json`),
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function paymentSignatureRequirement(value, expected, buyerPaymentIdentifier) {
|
|
550
|
+
const signature = exactObject(
|
|
551
|
+
decodeBase64Json(
|
|
552
|
+
value,
|
|
553
|
+
"The payment artifact signature",
|
|
554
|
+
MAX_SIGNATURE_BYTES,
|
|
555
|
+
),
|
|
556
|
+
["x402Version", "accepted", "payload", "extensions", "resource"],
|
|
557
|
+
"The payment artifact signature",
|
|
558
|
+
);
|
|
559
|
+
if (
|
|
560
|
+
signature.x402Version !== 2 ||
|
|
561
|
+
!isObject(signature.accepted) ||
|
|
562
|
+
!isObject(signature.payload) ||
|
|
563
|
+
!isObject(signature.extensions)
|
|
564
|
+
) {
|
|
565
|
+
throw invalid("The payment artifact signature is malformed.");
|
|
566
|
+
}
|
|
567
|
+
const resource = paymentResource(
|
|
568
|
+
signature.resource,
|
|
569
|
+
"The payment artifact signature resource",
|
|
570
|
+
);
|
|
571
|
+
if (
|
|
572
|
+
normalizedHttpsUrl(
|
|
573
|
+
resource.url,
|
|
574
|
+
"The payment artifact signature resource URL",
|
|
575
|
+
) !== expected.envelope.url ||
|
|
576
|
+
canonicalJson(resource) !== canonicalJson(expected.challenge.resource)
|
|
577
|
+
) {
|
|
578
|
+
throw invalid(
|
|
579
|
+
"The payment artifact signature is bound to a different checkout resource.",
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
const signatureExtensions = paymentExtensions(
|
|
583
|
+
signature.extensions,
|
|
584
|
+
"The payment artifact signature extension",
|
|
585
|
+
);
|
|
586
|
+
const expectedExtensions = JSON.parse(
|
|
587
|
+
canonicalJson(expected.challenge.extensions ?? {}),
|
|
588
|
+
);
|
|
589
|
+
if (expectedExtensions["payment-identifier"] !== undefined) {
|
|
590
|
+
expectedExtensions["payment-identifier"].info.id = buyerPaymentIdentifier;
|
|
591
|
+
}
|
|
592
|
+
if (
|
|
593
|
+
canonicalJson(signatureExtensions) !== canonicalJson(expectedExtensions)
|
|
594
|
+
) {
|
|
595
|
+
throw invalid(
|
|
596
|
+
"The payment artifact signature extensions do not match the saved challenge.",
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
return paymentRequirement(signature.accepted);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export async function readPaymentArtifact(path, expected) {
|
|
603
|
+
const { absolute, bytes } = await readSafeFile(
|
|
604
|
+
path,
|
|
605
|
+
"The payment artifact",
|
|
606
|
+
MAX_ARTIFACT_BYTES,
|
|
607
|
+
true,
|
|
608
|
+
);
|
|
609
|
+
let value;
|
|
610
|
+
try {
|
|
611
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
612
|
+
} catch {
|
|
613
|
+
throw invalid("The payment artifact is not UTF-8 JSON.");
|
|
614
|
+
}
|
|
615
|
+
const artifact = exactObject(
|
|
616
|
+
value,
|
|
617
|
+
[
|
|
618
|
+
"version",
|
|
619
|
+
"attemptId",
|
|
620
|
+
"requestDigest",
|
|
621
|
+
"buyerPaymentIdentifier",
|
|
622
|
+
"wallet",
|
|
623
|
+
"payerAddress",
|
|
624
|
+
"selectedRequirementDigest",
|
|
625
|
+
"paymentSignature",
|
|
626
|
+
"createdAt",
|
|
627
|
+
"expiresAt",
|
|
628
|
+
],
|
|
629
|
+
"The payment artifact",
|
|
630
|
+
);
|
|
631
|
+
const createdAt = Date.parse(artifact.createdAt);
|
|
632
|
+
const expiresAt = Date.parse(artifact.expiresAt);
|
|
633
|
+
if (
|
|
634
|
+
artifact.version !== 1 ||
|
|
635
|
+
typeof artifact.attemptId !== "string" ||
|
|
636
|
+
artifact.attemptId.length === 0 ||
|
|
637
|
+
typeof artifact.requestDigest !== "string" ||
|
|
638
|
+
!SHA256.test(artifact.requestDigest) ||
|
|
639
|
+
typeof artifact.buyerPaymentIdentifier !== "string" ||
|
|
640
|
+
!PAYMENT_IDENTIFIER.test(artifact.buyerPaymentIdentifier) ||
|
|
641
|
+
typeof artifact.wallet !== "string" ||
|
|
642
|
+
artifact.wallet.length === 0 ||
|
|
643
|
+
typeof artifact.payerAddress !== "string" ||
|
|
644
|
+
artifact.payerAddress.length === 0 ||
|
|
645
|
+
typeof artifact.selectedRequirementDigest !== "string" ||
|
|
646
|
+
!SHA256.test(artifact.selectedRequirementDigest) ||
|
|
647
|
+
typeof artifact.paymentSignature !== "string" ||
|
|
648
|
+
!Number.isFinite(createdAt) ||
|
|
649
|
+
!Number.isFinite(expiresAt) ||
|
|
650
|
+
createdAt > expiresAt
|
|
651
|
+
) {
|
|
652
|
+
throw invalid("The payment artifact is malformed.");
|
|
653
|
+
}
|
|
654
|
+
if (artifact.requestDigest !== expected.requestDigest) {
|
|
655
|
+
throw invalid(
|
|
656
|
+
"The payment artifact is bound to a different checkout request.",
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
if (!expected.requirementDigests.has(artifact.selectedRequirementDigest)) {
|
|
660
|
+
throw invalid(
|
|
661
|
+
"The payment artifact selected an unsupported payment requirement.",
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
if (expiresAt <= Date.now())
|
|
665
|
+
throw invalid("The payment artifact has expired.");
|
|
666
|
+
if (expiresAt > Date.parse(expected.sponsorshipExpiresAt)) {
|
|
667
|
+
throw invalid(
|
|
668
|
+
"The payment artifact outlives the saved gas-sponsorship reservation.",
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
const accepted = paymentSignatureRequirement(
|
|
672
|
+
artifact.paymentSignature,
|
|
673
|
+
expected,
|
|
674
|
+
artifact.buyerPaymentIdentifier,
|
|
675
|
+
);
|
|
676
|
+
if (digestJson(accepted) !== artifact.selectedRequirementDigest) {
|
|
677
|
+
throw invalid(
|
|
678
|
+
"The payment artifact signature does not match its selected requirement.",
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
return {
|
|
682
|
+
path: absolute,
|
|
683
|
+
attemptId: artifact.attemptId,
|
|
684
|
+
requestDigest: artifact.requestDigest,
|
|
685
|
+
buyerPaymentIdentifier: artifact.buyerPaymentIdentifier,
|
|
686
|
+
wallet: artifact.wallet,
|
|
687
|
+
payerAddress: artifact.payerAddress,
|
|
688
|
+
selectedRequirementDigest: artifact.selectedRequirementDigest,
|
|
689
|
+
paymentSignature: artifact.paymentSignature,
|
|
690
|
+
createdAt: artifact.createdAt,
|
|
691
|
+
expiresAt: artifact.expiresAt,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
export function paymentWorkflow({
|
|
696
|
+
taskId,
|
|
697
|
+
requestEnvelopePath,
|
|
698
|
+
paymentArtifactPath,
|
|
699
|
+
}) {
|
|
700
|
+
return {
|
|
701
|
+
signer: "x402api Agent Wallet",
|
|
702
|
+
signerExecutable: "x402api",
|
|
703
|
+
signerNodeRequirement: ">=22",
|
|
704
|
+
signerPackage: {
|
|
705
|
+
name: AGENT_WALLET_PACKAGE,
|
|
706
|
+
version: AGENT_WALLET_VERSION,
|
|
707
|
+
spec: AGENT_WALLET_SPEC,
|
|
708
|
+
registryUrl: `https://www.npmjs.com/package/${AGENT_WALLET_PACKAGE}`,
|
|
709
|
+
install: {
|
|
710
|
+
argv: ["npm", "install", "--global", AGENT_WALLET_SPEC],
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
signerContract: {
|
|
714
|
+
version: 1,
|
|
715
|
+
probe: { argv: ["x402api", "help", "--json"] },
|
|
716
|
+
},
|
|
717
|
+
signerReleaseStatus:
|
|
718
|
+
"published launch payer; sponsored Base USDC and Solana USDC/USDT only",
|
|
719
|
+
walletSkill: {
|
|
720
|
+
name: "x402api-pay",
|
|
721
|
+
install: {
|
|
722
|
+
argv: [
|
|
723
|
+
"x402api",
|
|
724
|
+
"skill",
|
|
725
|
+
"install",
|
|
726
|
+
"--output",
|
|
727
|
+
"<agent-skill-directory>/x402api-pay",
|
|
728
|
+
"--json",
|
|
729
|
+
],
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
requestEnvelopePath,
|
|
733
|
+
paymentArtifactPath,
|
|
734
|
+
authorize: {
|
|
735
|
+
argv: [
|
|
736
|
+
"x402api",
|
|
737
|
+
"payment",
|
|
738
|
+
"authorize",
|
|
739
|
+
"--wallet",
|
|
740
|
+
"<wallet-name>",
|
|
741
|
+
"--request-envelope",
|
|
742
|
+
requestEnvelopePath,
|
|
743
|
+
"--artifact-out",
|
|
744
|
+
paymentArtifactPath,
|
|
745
|
+
"--json",
|
|
746
|
+
],
|
|
747
|
+
},
|
|
748
|
+
submit: {
|
|
749
|
+
argv: [
|
|
750
|
+
"warpmetal",
|
|
751
|
+
"checkout",
|
|
752
|
+
"submit",
|
|
753
|
+
"--task",
|
|
754
|
+
taskId,
|
|
755
|
+
"--payment-artifact",
|
|
756
|
+
paymentArtifactPath,
|
|
757
|
+
"--wait",
|
|
758
|
+
"--json",
|
|
759
|
+
],
|
|
760
|
+
},
|
|
761
|
+
compatibility: {
|
|
762
|
+
alternativeInput: "--payment-signature-file <path>",
|
|
763
|
+
walletKeysHandledByWarpMetal: false,
|
|
764
|
+
walletDirectSubmissionUsed: false,
|
|
765
|
+
reason:
|
|
766
|
+
"WarpMetal checkout requires the private owner token, so x402api authorizes and WarpMetal submits.",
|
|
767
|
+
},
|
|
768
|
+
};
|
|
769
|
+
}
|