@elizaos/plugin-x402 2.0.0-alpha.6 → 2.0.3-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +151 -0
  3. package/dist/index.d.ts +57 -2
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +2542 -1915
  6. package/dist/index.js.map +14 -21
  7. package/dist/payment-config.d.ts +256 -0
  8. package/dist/payment-config.d.ts.map +1 -0
  9. package/dist/payment-wrapper.d.ts +42 -0
  10. package/dist/payment-wrapper.d.ts.map +1 -0
  11. package/dist/startup-validator.d.ts +28 -0
  12. package/dist/startup-validator.d.ts.map +1 -0
  13. package/dist/types.d.ts +158 -0
  14. package/dist/types.d.ts.map +1 -0
  15. package/dist/x402-facilitator-binding.d.ts +9 -0
  16. package/dist/x402-facilitator-binding.d.ts.map +1 -0
  17. package/dist/x402-replay-durable.d.ts +30 -0
  18. package/dist/x402-replay-durable.d.ts.map +1 -0
  19. package/dist/x402-replay-guard.d.ts +28 -0
  20. package/dist/x402-replay-guard.d.ts.map +1 -0
  21. package/dist/x402-replay-keys.d.ts +21 -0
  22. package/dist/x402-replay-keys.d.ts.map +1 -0
  23. package/dist/x402-resolve.d.ts +6 -0
  24. package/dist/x402-resolve.d.ts.map +1 -0
  25. package/dist/x402-standard-payment.d.ts +130 -0
  26. package/dist/x402-standard-payment.d.ts.map +1 -0
  27. package/dist/x402-types.d.ts +130 -0
  28. package/dist/x402-types.d.ts.map +1 -0
  29. package/package.json +63 -94
  30. package/src/__tests__/core-test-mock.ts +10 -0
  31. package/src/index.ts +115 -0
  32. package/src/payment-config.ts +737 -0
  33. package/src/payment-wrapper.test.ts +234 -0
  34. package/src/payment-wrapper.ts +1997 -0
  35. package/src/startup-validator.test.ts +86 -0
  36. package/src/startup-validator.ts +351 -0
  37. package/src/types.ts +177 -0
  38. package/src/x402-facilitator-binding.ts +104 -0
  39. package/src/x402-replay-durable.ts +320 -0
  40. package/src/x402-replay-guard.ts +165 -0
  41. package/src/x402-replay-keys.ts +151 -0
  42. package/src/x402-resolve.ts +43 -0
  43. package/src/x402-standard-payment.ts +519 -0
  44. package/src/x402-types.ts +376 -0
@@ -0,0 +1,519 @@
1
+ /**
2
+ * Standard x402 **buyer → seller** payloads carried in `PAYMENT-SIGNATURE`, `X-Payment`,
3
+ * or legacy `X-Payment` headers (often base64-wrapped JSON).
4
+ *
5
+ * **Why this file exists:** the agent historically verified “proof strings” (tx
6
+ * hashes, legacy formats, facilitator payment IDs). Modern clients instead send
7
+ * a structured **payment payload** plus expect the seller to validate it against
8
+ * **payment requirements** through a facilitator (`POST /verify`, `POST /settle`).
9
+ * Centralizing decode, requirement construction, and HTTP calls here keeps
10
+ * `payment-wrapper.ts` readable and avoids duplicating facilitator contracts.
11
+ *
12
+ * **Why verify *and* settle:** authorization-like payloads are not settlement.
13
+ * Unlocking paid HTTP work only after settle succeeds matches facilitator-centric
14
+ * flows and closes the “valid signature, no transfer” gap.
15
+ *
16
+ * **Why URL helpers are flexible:** facilitator vendors mount `/verify` and
17
+ * `/settle` under different prefixes; Eliza Cloud uses `/api/v1/x402/*` while
18
+ * other stacks use a single base URL with trailing paths. Explicit override envs
19
+ * exist so production does not depend on one hardcoded layout.
20
+ */
21
+
22
+ import {
23
+ atomicAmountForPriceInCents,
24
+ getPaymentConfig,
25
+ type PaymentConfigDefinition,
26
+ toResourceUrl,
27
+ toX402Network,
28
+ } from "./payment-config.js";
29
+ import type { X402Runtime } from "./types.js";
30
+
31
+ /** Decoded X-Payment body (x402-fetch / CDP-style clients). */
32
+ export type X402StandardPaymentPayload = {
33
+ x402Version: number;
34
+ accepted: {
35
+ scheme: string;
36
+ network: string;
37
+ asset: string;
38
+ amount?: string;
39
+ maxAmountRequired?: string;
40
+ payTo: string;
41
+ };
42
+ payload: {
43
+ signature: string;
44
+ authorization: {
45
+ from: string;
46
+ to: string;
47
+ value: string;
48
+ validAfter?: string;
49
+ validBefore: string;
50
+ nonce: string;
51
+ };
52
+ };
53
+ };
54
+
55
+ export type FacilitatorPaymentRequirements = {
56
+ scheme: string;
57
+ network: string;
58
+ asset: string;
59
+ amount: string;
60
+ payTo: string;
61
+ maxTimeoutSeconds?: number;
62
+ extra?: Record<string, unknown>;
63
+ };
64
+
65
+ export type StandardPaymentRequiredAccept = {
66
+ scheme: "exact";
67
+ network: string;
68
+ maxAmountRequired: string;
69
+ resource: string;
70
+ description: string;
71
+ mimeType: string;
72
+ payTo: string;
73
+ maxTimeoutSeconds: number;
74
+ asset: string;
75
+ extra?: Record<string, unknown>;
76
+ };
77
+
78
+ export type StandardPaymentRequired = {
79
+ x402Version: 2;
80
+ accepts: StandardPaymentRequiredAccept[];
81
+ error?: string;
82
+ };
83
+
84
+ function looksMostlyPrintableAscii(s: string): boolean {
85
+ if (!s || s.length > 100_000) return false;
86
+ let ok = 0;
87
+ for (let i = 0; i < s.length; i++) {
88
+ const code = s.charCodeAt(i);
89
+ if (
90
+ code === 9 ||
91
+ code === 10 ||
92
+ code === 13 ||
93
+ (code >= 32 && code < 127)
94
+ ) {
95
+ ok++;
96
+ }
97
+ }
98
+ return ok / s.length > 0.85;
99
+ }
100
+
101
+ function tryBase64Utf8Json(raw: string): unknown | null {
102
+ const t = raw.trim();
103
+ if (t.length < 8 || !/^[A-Za-z0-9+/=_-]+$/.test(t.replace(/\s/g, ""))) {
104
+ return null;
105
+ }
106
+ const buf = Buffer.from(t, "base64");
107
+ if (buf.length === 0) return null;
108
+ const decoded = buf.toString("utf8");
109
+ if (!decoded || decoded.includes("\0")) return null;
110
+ if (!looksMostlyPrintableAscii(decoded)) return null;
111
+ try {
112
+ return JSON.parse(decoded) as unknown;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Decode `X-Payment` / `X-PAYMENT` value: base64(JSON) first, then raw JSON.
120
+ */
121
+ export function decodeXPaymentHeader(raw: string): unknown | null {
122
+ const t = raw.trim();
123
+ if (!t) return null;
124
+ if (t.startsWith("{") || t.startsWith("[")) {
125
+ try {
126
+ return JSON.parse(t) as unknown;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+ return tryBase64Utf8Json(t);
132
+ }
133
+
134
+ export function isX402StandardPaymentPayload(
135
+ v: unknown,
136
+ ): v is X402StandardPaymentPayload {
137
+ if (typeof v !== "object" || v === null) return false;
138
+ const o = v as Record<string, unknown>;
139
+ if (typeof o.x402Version !== "number") return false;
140
+ const acc = o.accepted;
141
+ if (typeof acc !== "object" || acc === null) return false;
142
+ const a = acc as Record<string, unknown>;
143
+ if (typeof a.scheme !== "string") return false;
144
+ if (typeof a.network !== "string") return false;
145
+ if (typeof a.asset !== "string") return false;
146
+ if (typeof a.amount !== "string" && typeof a.maxAmountRequired !== "string") {
147
+ return false;
148
+ }
149
+ if (typeof a.payTo !== "string") return false;
150
+ const pl = o.payload;
151
+ if (typeof pl !== "object" || pl === null) return false;
152
+ const p = pl as Record<string, unknown>;
153
+ if (typeof p.signature !== "string") return false;
154
+ const auth = p.authorization;
155
+ if (typeof auth !== "object" || auth === null) return false;
156
+ const u = auth as Record<string, unknown>;
157
+ return (
158
+ typeof u.from === "string" &&
159
+ typeof u.to === "string" &&
160
+ typeof u.value === "string" &&
161
+ typeof u.nonce === "string" &&
162
+ typeof u.validBefore === "string"
163
+ );
164
+ }
165
+
166
+ export function toStandardNetwork(
167
+ network: PaymentConfigDefinition["network"],
168
+ ): string {
169
+ if (network === "BASE") return "eip155:8453";
170
+ if (network === "POLYGON") return "eip155:137";
171
+ if (network === "BSC") return "eip155:56";
172
+ return "solana:mainnet";
173
+ }
174
+
175
+ function acceptedNetworkMatches(
176
+ acceptedNetwork: string,
177
+ cfg: PaymentConfigDefinition,
178
+ ): boolean {
179
+ const n = acceptedNetwork.trim();
180
+ if (cfg.network === "SOLANA") {
181
+ return (
182
+ n === "solana" ||
183
+ n === "solana:mainnet" ||
184
+ n === "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" ||
185
+ n.toLowerCase().includes("solana")
186
+ );
187
+ }
188
+ const caip = toStandardNetwork(cfg.network);
189
+ const short = toX402Network(cfg.network);
190
+ return n === caip || n.toLowerCase() === short || n === `caip2:${caip}`;
191
+ }
192
+
193
+ function assetMatchesAccepted(
194
+ acceptedAsset: string,
195
+ cfg: PaymentConfigDefinition,
196
+ ): boolean {
197
+ const a = acceptedAsset.trim().toLowerCase();
198
+ const ref = cfg.assetReference.trim().toLowerCase();
199
+ if (a === ref) return true;
200
+ if (a.includes(ref)) return true;
201
+ if (ref.includes(a) && a.startsWith("0x")) return true;
202
+ return false;
203
+ }
204
+
205
+ export function standardAssetForConfig(cfg: PaymentConfigDefinition): string {
206
+ if (cfg.assetNamespace === "erc20") {
207
+ return cfg.assetReference;
208
+ }
209
+ return cfg.assetReference;
210
+ }
211
+
212
+ export function buildStandardPaymentRequiredAccept(params: {
213
+ routePath: string;
214
+ description: string;
215
+ priceInCents: number;
216
+ configName: string;
217
+ agentId?: string;
218
+ }): StandardPaymentRequiredAccept {
219
+ const cfg = getPaymentConfig(params.configName, params.agentId);
220
+ const maxAmountRequired = atomicAmountForPriceInCents(
221
+ params.priceInCents,
222
+ cfg,
223
+ );
224
+ const extra: Record<string, unknown> = {
225
+ name:
226
+ cfg.symbol?.toUpperCase() === "USDC" ? "USD Coin" : cfg.symbol || "Token",
227
+ version: "2",
228
+ paymentConfig: params.configName,
229
+ };
230
+ return {
231
+ scheme: "exact",
232
+ network: toStandardNetwork(cfg.network),
233
+ maxAmountRequired,
234
+ resource: toResourceUrl(params.routePath),
235
+ description: params.description,
236
+ mimeType: "application/json",
237
+ payTo: cfg.paymentAddress,
238
+ maxTimeoutSeconds: 300,
239
+ asset: standardAssetForConfig(cfg),
240
+ extra,
241
+ };
242
+ }
243
+
244
+ export function buildStandardPaymentRequired(params: {
245
+ routePath: string;
246
+ description: string;
247
+ priceInCents: number;
248
+ paymentConfigNames: string[];
249
+ agentId?: string;
250
+ error?: string;
251
+ }): StandardPaymentRequired {
252
+ return {
253
+ x402Version: 2,
254
+ error: params.error,
255
+ accepts: params.paymentConfigNames.map((configName) =>
256
+ buildStandardPaymentRequiredAccept({
257
+ routePath: params.routePath,
258
+ description: params.description,
259
+ priceInCents: params.priceInCents,
260
+ configName,
261
+ agentId: params.agentId,
262
+ }),
263
+ ),
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Build facilitator `paymentRequirements` for this route/config (must match what we advertise in `accepts`).
269
+ */
270
+ export function buildFacilitatorPaymentRequirements(params: {
271
+ routePath: string;
272
+ priceInCents: number;
273
+ configName: string;
274
+ agentId?: string;
275
+ }): FacilitatorPaymentRequirements {
276
+ const cfg = getPaymentConfig(params.configName, params.agentId);
277
+ const amount = atomicAmountForPriceInCents(params.priceInCents, cfg);
278
+ const network = toStandardNetwork(cfg.network);
279
+ return {
280
+ scheme: "exact",
281
+ network,
282
+ asset: standardAssetForConfig(cfg),
283
+ amount,
284
+ payTo: cfg.paymentAddress,
285
+ maxTimeoutSeconds: 300,
286
+ extra: {
287
+ name:
288
+ cfg.symbol?.toUpperCase() === "USDC"
289
+ ? "USD Coin"
290
+ : cfg.symbol || "Token",
291
+ version: "2",
292
+ resource: toResourceUrl(params.routePath),
293
+ },
294
+ };
295
+ }
296
+
297
+ export function findMatchingPaymentConfigForStandardPayload(
298
+ payload: X402StandardPaymentPayload,
299
+ paymentConfigNames: string[],
300
+ priceInCents: number,
301
+ agentId?: string,
302
+ ): { name: string; cfg: PaymentConfigDefinition } | null {
303
+ const { accepted } = payload;
304
+ if (accepted.scheme !== "exact" && accepted.scheme !== "upto") {
305
+ return null;
306
+ }
307
+ const acceptedAmount = accepted.amount ?? accepted.maxAmountRequired;
308
+ if (!acceptedAmount) return null;
309
+ let payAmount: bigint;
310
+ try {
311
+ payAmount = BigInt(acceptedAmount);
312
+ } catch {
313
+ return null;
314
+ }
315
+
316
+ for (const name of paymentConfigNames) {
317
+ const cfg = getPaymentConfig(name, agentId);
318
+ if (!acceptedNetworkMatches(accepted.network, cfg)) continue;
319
+ if (!assetMatchesAccepted(accepted.asset, cfg)) continue;
320
+ if (
321
+ accepted.payTo.trim().toLowerCase() !==
322
+ cfg.paymentAddress.trim().toLowerCase()
323
+ ) {
324
+ continue;
325
+ }
326
+ const required = BigInt(atomicAmountForPriceInCents(priceInCents, cfg));
327
+ if (payAmount < required) continue;
328
+ return { name, cfg };
329
+ }
330
+ return null;
331
+ }
332
+
333
+ /**
334
+ * Resolve facilitator HTTP endpoints without assuming one vendor’s URL layout.
335
+ *
336
+ * **Why the branching:** Eliza Cloud historically exposed `/api/facilitator` while
337
+ * verify/settle live under `/api/v1/x402/*`; other deployments use a single base
338
+ * with `/verify` and `/settle`. The logic below preserves backwards compatibility
339
+ * and still supports plain base URLs.
340
+ */
341
+ function getFacilitatorEndpoint(
342
+ runtime: X402Runtime,
343
+ endpoint: "verify" | "settle",
344
+ ): string | null {
345
+ const explicit = runtime.getSetting(
346
+ endpoint === "verify"
347
+ ? "X402_FACILITATOR_VERIFY_URL"
348
+ : "X402_FACILITATOR_SETTLE_URL",
349
+ );
350
+ if (typeof explicit === "string" && explicit.trim()) {
351
+ return explicit.trim().replace(/\/$/, "");
352
+ }
353
+ const fuSetting = runtime.getSetting("X402_FACILITATOR_URL");
354
+ const fu =
355
+ typeof fuSetting === "string" && fuSetting.trim()
356
+ ? fuSetting.trim()
357
+ : "https://x402.elizacloud.ai/api/v1/x402";
358
+ try {
359
+ const clean = fu.replace(/\/$/, "");
360
+ const u = new URL(clean);
361
+ if (u.pathname.endsWith("/api/facilitator")) {
362
+ return `${u.origin}/api/v1/x402/${endpoint}`;
363
+ }
364
+ if (u.pathname.endsWith(`/${endpoint}`)) return clean;
365
+ return `${clean}/${endpoint}`;
366
+ } catch {
367
+ return null;
368
+ }
369
+ }
370
+
371
+ export function getFacilitatorVerifyPostUrl(
372
+ runtime: X402Runtime,
373
+ ): string | null {
374
+ return getFacilitatorEndpoint(runtime, "verify");
375
+ }
376
+
377
+ export function getFacilitatorSettlePostUrl(
378
+ runtime: X402Runtime,
379
+ ): string | null {
380
+ return getFacilitatorEndpoint(runtime, "settle");
381
+ }
382
+
383
+ export type FacilitatorVerifyPostResult =
384
+ | { ok: true; payer?: string }
385
+ | { ok: false; invalidReason?: string };
386
+
387
+ export type FacilitatorSettlePostResult =
388
+ | { ok: true; paymentResponse: string; transaction?: string; payer?: string }
389
+ | { ok: false; invalidReason?: string };
390
+
391
+ /**
392
+ * POST `{ paymentPayload, paymentRequirements }` to facilitator verify (Eliza Cloud–compatible).
393
+ */
394
+ export async function verifyPaymentPayloadViaFacilitatorPost(
395
+ runtime: X402Runtime,
396
+ paymentPayload: X402StandardPaymentPayload,
397
+ paymentRequirements: FacilitatorPaymentRequirements,
398
+ ): Promise<FacilitatorVerifyPostResult> {
399
+ const url = getFacilitatorVerifyPostUrl(runtime);
400
+ if (!url) {
401
+ return { ok: false, invalidReason: "no_facilitator_verify_url" };
402
+ }
403
+
404
+ try {
405
+ const res = await fetch(url, {
406
+ method: "POST",
407
+ headers: {
408
+ Accept: "application/json",
409
+ "Content-Type": "application/json",
410
+ "User-Agent": "ElizaOS-X402-Agent/1.0",
411
+ },
412
+ body: JSON.stringify({ paymentPayload, paymentRequirements }),
413
+ signal: AbortSignal.timeout(15_000),
414
+ });
415
+ const text = await res.text();
416
+ let body: { isValid?: boolean; payer?: string; invalidReason?: string } =
417
+ {};
418
+ if (text) {
419
+ try {
420
+ body = JSON.parse(text) as typeof body;
421
+ } catch {
422
+ return { ok: false, invalidReason: "invalid_verify_response_json" };
423
+ }
424
+ }
425
+ if (!res.ok && res.status !== 400) {
426
+ return {
427
+ ok: false,
428
+ invalidReason: `verify_http_${res.status}`,
429
+ };
430
+ }
431
+ if (body.isValid === true) {
432
+ return { ok: true, payer: body.payer };
433
+ }
434
+ return {
435
+ ok: false,
436
+ invalidReason: body.invalidReason ?? "verify_rejected",
437
+ };
438
+ } catch (e) {
439
+ const msg = e instanceof Error ? e.message : String(e);
440
+ return { ok: false, invalidReason: `verify_fetch_error:${msg}` };
441
+ }
442
+ }
443
+
444
+ export async function settlePaymentPayloadViaFacilitatorPost(
445
+ runtime: X402Runtime,
446
+ paymentPayload: X402StandardPaymentPayload,
447
+ paymentRequirements: FacilitatorPaymentRequirements,
448
+ ): Promise<FacilitatorSettlePostResult> {
449
+ const url = getFacilitatorSettlePostUrl(runtime);
450
+ if (!url) {
451
+ return { ok: false, invalidReason: "no_facilitator_settle_url" };
452
+ }
453
+
454
+ try {
455
+ const res = await fetch(url, {
456
+ method: "POST",
457
+ headers: {
458
+ Accept: "application/json",
459
+ "Content-Type": "application/json",
460
+ "User-Agent": "ElizaOS-X402-Agent/1.0",
461
+ },
462
+ body: JSON.stringify({ paymentPayload, paymentRequirements }),
463
+ signal: AbortSignal.timeout(30_000),
464
+ });
465
+ const text = await res.text();
466
+ let body: Record<string, unknown> = {};
467
+ if (text) {
468
+ try {
469
+ body = JSON.parse(text) as Record<string, unknown>;
470
+ } catch {
471
+ return { ok: false, invalidReason: "invalid_settle_response_json" };
472
+ }
473
+ }
474
+
475
+ if (!res.ok) {
476
+ return {
477
+ ok: false,
478
+ invalidReason:
479
+ typeof body.errorReason === "string"
480
+ ? body.errorReason
481
+ : typeof body.invalidReason === "string"
482
+ ? body.invalidReason
483
+ : `settle_http_${res.status}`,
484
+ };
485
+ }
486
+
487
+ // Must match verify semantics: do not treat bare HTTP 200 or `{}` as
488
+ // settlement. Require explicit `success: true` or `isValid: true` (some
489
+ // facilitators return `{ success: false }` on 200 for business errors).
490
+ const explicitFailure = body.success === false || body.isValid === false;
491
+ const explicitSuccess = body.success === true || body.isValid === true;
492
+ const success = !explicitFailure && explicitSuccess;
493
+ if (!success) {
494
+ return {
495
+ ok: false,
496
+ invalidReason:
497
+ typeof body.errorReason === "string"
498
+ ? body.errorReason
499
+ : typeof body.invalidReason === "string"
500
+ ? body.invalidReason
501
+ : `settle_http_${res.status}`,
502
+ };
503
+ }
504
+
505
+ const paymentResponse = Buffer.from(JSON.stringify(body), "utf8").toString(
506
+ "base64",
507
+ );
508
+ return {
509
+ ok: true,
510
+ paymentResponse,
511
+ transaction:
512
+ typeof body.transaction === "string" ? body.transaction : undefined,
513
+ payer: typeof body.payer === "string" ? body.payer : undefined,
514
+ };
515
+ } catch (e) {
516
+ const msg = e instanceof Error ? e.message : String(e);
517
+ return { ok: false, invalidReason: `settle_fetch_error:${msg}` };
518
+ }
519
+ }