@elizaos/plugin-x402 2.0.0-alpha.5 → 2.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +57 -2
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30914 -1844
- package/dist/index.js.map +114 -21
- package/dist/payment-config.d.ts +256 -0
- package/dist/payment-config.d.ts.map +1 -0
- package/dist/payment-wrapper.d.ts +42 -0
- package/dist/payment-wrapper.d.ts.map +1 -0
- package/dist/startup-validator.d.ts +28 -0
- package/dist/startup-validator.d.ts.map +1 -0
- package/dist/types.d.ts +158 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/x402-facilitator-binding.d.ts +9 -0
- package/dist/x402-facilitator-binding.d.ts.map +1 -0
- package/dist/x402-replay-durable.d.ts +30 -0
- package/dist/x402-replay-durable.d.ts.map +1 -0
- package/dist/x402-replay-guard.d.ts +28 -0
- package/dist/x402-replay-guard.d.ts.map +1 -0
- package/dist/x402-replay-keys.d.ts +21 -0
- package/dist/x402-replay-keys.d.ts.map +1 -0
- package/dist/x402-resolve.d.ts +6 -0
- package/dist/x402-resolve.d.ts.map +1 -0
- package/dist/x402-standard-payment.d.ts +130 -0
- package/dist/x402-standard-payment.d.ts.map +1 -0
- package/dist/x402-types.d.ts +130 -0
- package/dist/x402-types.d.ts.map +1 -0
- package/package.json +43 -94
- package/src/index.ts +113 -0
- package/src/payment-config.ts +737 -0
- package/src/payment-wrapper.ts +1991 -0
- package/src/startup-validator.ts +349 -0
- package/src/types.ts +177 -0
- package/src/x402-facilitator-binding.ts +104 -0
- package/src/x402-replay-durable.ts +320 -0
- package/src/x402-replay-guard.ts +165 -0
- package/src/x402-replay-keys.ts +151 -0
- package/src/x402-resolve.ts +43 -0
- package/src/x402-standard-payment.ts +519 -0
- package/src/x402-types.ts +376 -0
|
@@ -0,0 +1,1991 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Character,
|
|
3
|
+
IAgentRuntime,
|
|
4
|
+
PaymentEnabledRoute,
|
|
5
|
+
Route,
|
|
6
|
+
RouteRequest,
|
|
7
|
+
RouteResponse,
|
|
8
|
+
X402Config,
|
|
9
|
+
} from "@elizaos/core";
|
|
10
|
+
import { logger } from "@elizaos/core";
|
|
11
|
+
|
|
12
|
+
/** Route with resolved `x402` object (not `true`) */
|
|
13
|
+
type X402PaidRoute = PaymentEnabledRoute & { x402: X402Config };
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
type Address,
|
|
17
|
+
type Hex,
|
|
18
|
+
recoverTypedDataAddress,
|
|
19
|
+
type TypedDataDomain,
|
|
20
|
+
} from "viem";
|
|
21
|
+
import { base, bsc, mainnet, polygon } from "viem/chains";
|
|
22
|
+
import {
|
|
23
|
+
atomicAmountForPriceInCents,
|
|
24
|
+
getCAIP19FromConfig,
|
|
25
|
+
getPaymentConfig,
|
|
26
|
+
type Network,
|
|
27
|
+
toResourceUrl,
|
|
28
|
+
toX402Network,
|
|
29
|
+
} from "./payment-config.js";
|
|
30
|
+
import { validateX402Startup } from "./startup-validator.js";
|
|
31
|
+
import type {
|
|
32
|
+
EIP712Authorization,
|
|
33
|
+
EIP712Domain,
|
|
34
|
+
EIP712PaymentProof,
|
|
35
|
+
X402Response as ExpressResponse,
|
|
36
|
+
FacilitatorVerificationResponse,
|
|
37
|
+
FacilitatorVerifyContext,
|
|
38
|
+
PaymentVerificationParams,
|
|
39
|
+
PaymentVerifiedDetails,
|
|
40
|
+
VerifyPaymentResult,
|
|
41
|
+
X402Request,
|
|
42
|
+
X402Runtime,
|
|
43
|
+
} from "./types.js";
|
|
44
|
+
import {
|
|
45
|
+
facilitatorVerifyResponseMatchesRoute,
|
|
46
|
+
isFacilitatorBindingRelaxed,
|
|
47
|
+
} from "./x402-facilitator-binding.js";
|
|
48
|
+
import {
|
|
49
|
+
replayGuardAbortAsync,
|
|
50
|
+
replayGuardCommit,
|
|
51
|
+
replayGuardTryBegin,
|
|
52
|
+
} from "./x402-replay-guard.js";
|
|
53
|
+
import {
|
|
54
|
+
collectReplayKeysToCheck,
|
|
55
|
+
decodePaymentProofForParsing,
|
|
56
|
+
} from "./x402-replay-keys.js";
|
|
57
|
+
import {
|
|
58
|
+
resolveEffectiveX402,
|
|
59
|
+
X402_EVENT_PAYMENT_REQUIRED,
|
|
60
|
+
X402_EVENT_PAYMENT_VERIFIED,
|
|
61
|
+
} from "./x402-resolve.js";
|
|
62
|
+
import {
|
|
63
|
+
buildFacilitatorPaymentRequirements,
|
|
64
|
+
buildStandardPaymentRequired,
|
|
65
|
+
decodeXPaymentHeader,
|
|
66
|
+
findMatchingPaymentConfigForStandardPayload,
|
|
67
|
+
isX402StandardPaymentPayload,
|
|
68
|
+
settlePaymentPayloadViaFacilitatorPost,
|
|
69
|
+
verifyPaymentPayloadViaFacilitatorPost,
|
|
70
|
+
} from "./x402-standard-payment.js";
|
|
71
|
+
import {
|
|
72
|
+
createAccepts,
|
|
73
|
+
createX402Response,
|
|
74
|
+
type OutputSchema,
|
|
75
|
+
type PaymentExtraMetadata,
|
|
76
|
+
type X402Response,
|
|
77
|
+
} from "./x402-types.js";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* x402 **seller** middleware for plugin HTTP routes.
|
|
81
|
+
*
|
|
82
|
+
* **Why one middleware layer:** plugins should not each reimplement 402 bodies,
|
|
83
|
+
* header encodings, facilitator POSTs, replay semantics, or chain RPC checks.
|
|
84
|
+
* This module is the single integration point for “paid route” behavior.
|
|
85
|
+
*
|
|
86
|
+
* **Why multiple verification strategies coexist:** deployments differ—some
|
|
87
|
+
* have on-chain receipts only, some use facilitator payment IDs, some use modern
|
|
88
|
+
* `PAYMENT-SIGNATURE` payloads. Keeping strategies behind one `verifyPayment`
|
|
89
|
+
* function preserves one gate while letting operators choose what their clients send.
|
|
90
|
+
*
|
|
91
|
+
* **Why standard path calls settle:** see `x402-standard-payment.ts`—settlement is
|
|
92
|
+
* the economically meaningful step after verify for facilitator-backed flows.
|
|
93
|
+
*
|
|
94
|
+
* **Why we still emit legacy JSON 402:** backward compatibility for wallets and
|
|
95
|
+
* tools that parse the body; V2 clients additionally read `PAYMENT-REQUIRED`.
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Set on routes returned by {@link applyPaymentProtection} so HTTP dispatch
|
|
100
|
+
* (`tryHandleRuntimePluginRoute`) does not call {@link createPaymentAwareHandler} again.
|
|
101
|
+
*/
|
|
102
|
+
export const X402_ROUTE_PAYMENT_WRAPPED = Symbol.for(
|
|
103
|
+
"elizaos.x402.routePaymentWrapped",
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
export function isRoutePaymentWrapped(route: unknown): boolean {
|
|
107
|
+
return (
|
|
108
|
+
typeof route === "object" &&
|
|
109
|
+
route !== null &&
|
|
110
|
+
Reflect.get(route, X402_ROUTE_PAYMENT_WRAPPED) === true
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Debug logging helper - only logs if DEBUG_X402_PAYMENTS is enabled
|
|
116
|
+
*/
|
|
117
|
+
const DEBUG = process.env.DEBUG_X402_PAYMENTS === "true";
|
|
118
|
+
function formatLogArg(arg: unknown): string {
|
|
119
|
+
if (typeof arg === "string") return arg;
|
|
120
|
+
if (typeof arg === "bigint") return arg.toString();
|
|
121
|
+
try {
|
|
122
|
+
return JSON.stringify(arg);
|
|
123
|
+
} catch {
|
|
124
|
+
return String(arg);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function log(...args: unknown[]) {
|
|
128
|
+
if (DEBUG) logger.debug(args.map(formatLogArg).join(" "));
|
|
129
|
+
}
|
|
130
|
+
function logSection(title: string) {
|
|
131
|
+
if (DEBUG) {
|
|
132
|
+
logger.debug(`[x402] ${title}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function logError(...args: unknown[]) {
|
|
136
|
+
logger.error(args.map(formatLogArg).join(" "));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* EIP-712 TransferWithAuthorization type
|
|
141
|
+
*/
|
|
142
|
+
const TRANSFER_WITH_AUTHORIZATION_TYPES = [
|
|
143
|
+
{ name: "from", type: "address" },
|
|
144
|
+
{ name: "to", type: "address" },
|
|
145
|
+
{ name: "value", type: "uint256" },
|
|
146
|
+
{ name: "validAfter", type: "uint256" },
|
|
147
|
+
{ name: "validBefore", type: "uint256" },
|
|
148
|
+
{ name: "nonce", type: "bytes32" },
|
|
149
|
+
] as const;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* EIP-712 ReceiveWithAuthorization type
|
|
153
|
+
*/
|
|
154
|
+
const RECEIVE_WITH_AUTHORIZATION_TYPES = [
|
|
155
|
+
{ name: "from", type: "address" },
|
|
156
|
+
{ name: "to", type: "address" },
|
|
157
|
+
{ name: "value", type: "uint256" },
|
|
158
|
+
{ name: "validAfter", type: "uint256" },
|
|
159
|
+
{ name: "validBefore", type: "uint256" },
|
|
160
|
+
{ name: "nonce", type: "bytes32" },
|
|
161
|
+
] as const;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get the viem chain object for a network
|
|
165
|
+
*/
|
|
166
|
+
function getViemChain(network: string) {
|
|
167
|
+
switch (network.toUpperCase()) {
|
|
168
|
+
case "BASE":
|
|
169
|
+
return base;
|
|
170
|
+
case "POLYGON":
|
|
171
|
+
return polygon;
|
|
172
|
+
case "BSC":
|
|
173
|
+
return bsc;
|
|
174
|
+
case "ETHEREUM":
|
|
175
|
+
return mainnet;
|
|
176
|
+
default:
|
|
177
|
+
return base;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get RPC URL for a network
|
|
183
|
+
*/
|
|
184
|
+
function getRpcUrl(network: string, runtime: X402Runtime): string {
|
|
185
|
+
const networkUpper = network.toUpperCase();
|
|
186
|
+
const settingKey = `${networkUpper}_RPC_URL`;
|
|
187
|
+
const customRpc = runtime.getSetting(settingKey);
|
|
188
|
+
if (customRpc && typeof customRpc === "string") {
|
|
189
|
+
return customRpc;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
switch (networkUpper) {
|
|
193
|
+
case "BASE":
|
|
194
|
+
return "https://mainnet.base.org";
|
|
195
|
+
case "POLYGON":
|
|
196
|
+
return "https://polygon-rpc.com";
|
|
197
|
+
case "BSC":
|
|
198
|
+
return "https://bsc-dataseed.binance.org";
|
|
199
|
+
case "ETHEREUM":
|
|
200
|
+
return "https://eth.llamarpc.com";
|
|
201
|
+
default:
|
|
202
|
+
return "https://mainnet.base.org";
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Get USDC contract address for a network
|
|
208
|
+
*/
|
|
209
|
+
function getUsdcContractAddress(network: string): Address {
|
|
210
|
+
switch (network.toUpperCase()) {
|
|
211
|
+
case "BASE":
|
|
212
|
+
return "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
|
213
|
+
case "POLYGON":
|
|
214
|
+
return "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359";
|
|
215
|
+
case "BSC":
|
|
216
|
+
return "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d";
|
|
217
|
+
case "ETHEREUM":
|
|
218
|
+
return "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
|
219
|
+
default:
|
|
220
|
+
return "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function chainIdToNetwork(chainId: number): Network | null {
|
|
225
|
+
if (chainId === 8453) return "BASE";
|
|
226
|
+
if (chainId === 137) return "POLYGON";
|
|
227
|
+
if (chainId === 56) return "BSC";
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function sumOwnerMint(
|
|
232
|
+
balances:
|
|
233
|
+
| Array<{ mint: string; owner?: string; uiTokenAmount: { amount: string } }>
|
|
234
|
+
| null
|
|
235
|
+
| undefined,
|
|
236
|
+
owner: string,
|
|
237
|
+
mint: string,
|
|
238
|
+
): bigint {
|
|
239
|
+
if (!balances?.length) return 0n;
|
|
240
|
+
let s = 0n;
|
|
241
|
+
for (const b of balances) {
|
|
242
|
+
if (b.mint === mint && b.owner === owner) {
|
|
243
|
+
s += BigInt(b.uiTokenAmount?.amount ?? "0");
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return s;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Verify payment proof from x402 payment provider
|
|
251
|
+
*/
|
|
252
|
+
async function verifyPayment(
|
|
253
|
+
params: PaymentVerificationParams,
|
|
254
|
+
): Promise<VerifyPaymentResult> {
|
|
255
|
+
const {
|
|
256
|
+
paymentProof,
|
|
257
|
+
paymentId,
|
|
258
|
+
route,
|
|
259
|
+
priceInCents,
|
|
260
|
+
paymentConfigNames,
|
|
261
|
+
agentId,
|
|
262
|
+
runtime,
|
|
263
|
+
req,
|
|
264
|
+
} = params;
|
|
265
|
+
|
|
266
|
+
logSection("PAYMENT VERIFICATION");
|
|
267
|
+
log(
|
|
268
|
+
"Route:",
|
|
269
|
+
route,
|
|
270
|
+
"priceInCents:",
|
|
271
|
+
priceInCents,
|
|
272
|
+
"configs:",
|
|
273
|
+
paymentConfigNames,
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
if (!paymentProof && !paymentId) {
|
|
277
|
+
logError("✗ No payment credentials provided");
|
|
278
|
+
return { ok: false };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const replayKeys = collectReplayKeysToCheck(paymentProof, paymentId);
|
|
282
|
+
if (!(await replayGuardTryBegin(replayKeys, runtime, agentId))) {
|
|
283
|
+
logError(
|
|
284
|
+
"✗ Payment credential in use or already consumed (replay protection)",
|
|
285
|
+
);
|
|
286
|
+
return { ok: false };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let committed = false;
|
|
290
|
+
const finishVerified = async (
|
|
291
|
+
details: PaymentVerifiedDetails,
|
|
292
|
+
): Promise<VerifyPaymentResult> => {
|
|
293
|
+
committed = true;
|
|
294
|
+
await replayGuardCommit(replayKeys, runtime, agentId);
|
|
295
|
+
return { ok: true, details };
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
const configsOrdered = paymentConfigNames.map((n) => ({
|
|
300
|
+
name: n,
|
|
301
|
+
cfg: getPaymentConfig(n, agentId),
|
|
302
|
+
}));
|
|
303
|
+
|
|
304
|
+
if (paymentProof) {
|
|
305
|
+
try {
|
|
306
|
+
// Standard payloads (PAYMENT-SIGNATURE / X-Payment) are tried first so we
|
|
307
|
+
// do not mis-classify them as legacy JSON proofs or raw tx hashes. Why:
|
|
308
|
+
// the same header value can look like opaque base64; decode + shape
|
|
309
|
+
// detection routes buyers to facilitator verify+settle instead of unsafe
|
|
310
|
+
// local EIP-712 paths.
|
|
311
|
+
const standardDecoded = decodeXPaymentHeader(
|
|
312
|
+
typeof paymentProof === "string" ? paymentProof : "",
|
|
313
|
+
);
|
|
314
|
+
if (isX402StandardPaymentPayload(standardDecoded)) {
|
|
315
|
+
const match = findMatchingPaymentConfigForStandardPayload(
|
|
316
|
+
standardDecoded,
|
|
317
|
+
paymentConfigNames,
|
|
318
|
+
priceInCents,
|
|
319
|
+
agentId,
|
|
320
|
+
);
|
|
321
|
+
if (!match) {
|
|
322
|
+
// Standard x402 payload had no matching config (wrong network /
|
|
323
|
+
// asset / amount). Reject outright instead of re-evaluating the
|
|
324
|
+
// same payload through legacy JSON / EIP-712 paths with looser
|
|
325
|
+
// routing rules.
|
|
326
|
+
log(
|
|
327
|
+
"Standard X-Payment payload did not match any allowed payment config",
|
|
328
|
+
);
|
|
329
|
+
return { ok: false };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const paymentRequirements = buildFacilitatorPaymentRequirements({
|
|
333
|
+
routePath: route,
|
|
334
|
+
priceInCents,
|
|
335
|
+
configName: match.name,
|
|
336
|
+
agentId,
|
|
337
|
+
});
|
|
338
|
+
const postResult = await verifyPaymentPayloadViaFacilitatorPost(
|
|
339
|
+
runtime,
|
|
340
|
+
standardDecoded,
|
|
341
|
+
paymentRequirements,
|
|
342
|
+
);
|
|
343
|
+
if (postResult.ok !== true) {
|
|
344
|
+
log(
|
|
345
|
+
"Standard X-Payment facilitator verify failed:",
|
|
346
|
+
postResult.invalidReason,
|
|
347
|
+
);
|
|
348
|
+
// Do not fall through to legacy JSON / local EIP-712 paths with the
|
|
349
|
+
// same header — the facilitator has already rejected this credential.
|
|
350
|
+
return { ok: false };
|
|
351
|
+
}
|
|
352
|
+
const settleResult = await settlePaymentPayloadViaFacilitatorPost(
|
|
353
|
+
runtime,
|
|
354
|
+
standardDecoded,
|
|
355
|
+
paymentRequirements,
|
|
356
|
+
);
|
|
357
|
+
if (settleResult.ok === false) {
|
|
358
|
+
log(
|
|
359
|
+
"Standard X-Payment facilitator settle failed:",
|
|
360
|
+
settleResult.invalidReason,
|
|
361
|
+
);
|
|
362
|
+
return { ok: false };
|
|
363
|
+
}
|
|
364
|
+
log(
|
|
365
|
+
"✓ Standard X-Payment verified and settled via facilitator",
|
|
366
|
+
match.name,
|
|
367
|
+
);
|
|
368
|
+
return await finishVerified({
|
|
369
|
+
paymentConfig: match.name,
|
|
370
|
+
network: match.cfg.network,
|
|
371
|
+
amountAtomic: paymentRequirements.amount,
|
|
372
|
+
symbol: match.cfg.symbol,
|
|
373
|
+
payer:
|
|
374
|
+
settleResult.payer ??
|
|
375
|
+
postResult.payer ??
|
|
376
|
+
standardDecoded.payload.authorization.from,
|
|
377
|
+
proofId: standardDecoded.payload.signature,
|
|
378
|
+
paymentResponse: settleResult.paymentResponse,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const decodedProof = decodePaymentProofForParsing(paymentProof);
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
const jsonProof = JSON.parse(decodedProof) as {
|
|
386
|
+
payload?: {
|
|
387
|
+
signature?: string;
|
|
388
|
+
authorization?: EIP712Authorization;
|
|
389
|
+
domain?: EIP712Domain;
|
|
390
|
+
};
|
|
391
|
+
domain?: EIP712Domain;
|
|
392
|
+
network?: string;
|
|
393
|
+
scheme?: string;
|
|
394
|
+
};
|
|
395
|
+
log("Detected JSON payment proof");
|
|
396
|
+
|
|
397
|
+
const authData = jsonProof.payload
|
|
398
|
+
? {
|
|
399
|
+
signature: jsonProof.payload.signature,
|
|
400
|
+
authorization: jsonProof.payload.authorization,
|
|
401
|
+
network: jsonProof.network,
|
|
402
|
+
scheme: jsonProof.scheme,
|
|
403
|
+
domain: jsonProof.payload.domain ?? jsonProof.domain,
|
|
404
|
+
}
|
|
405
|
+
: { ...jsonProof, domain: jsonProof.domain };
|
|
406
|
+
|
|
407
|
+
const domain =
|
|
408
|
+
(authData as { domain?: EIP712Domain }).domain ?? jsonProof.domain;
|
|
409
|
+
const chainId = domain?.chainId;
|
|
410
|
+
const inferredNet =
|
|
411
|
+
typeof chainId === "number" ? chainIdToNetwork(chainId) : null;
|
|
412
|
+
|
|
413
|
+
const authObj = authData as Record<string, unknown>;
|
|
414
|
+
const hasEip712 =
|
|
415
|
+
typeof authObj.signature === "string" &&
|
|
416
|
+
authObj.authorization &&
|
|
417
|
+
typeof authObj.authorization === "object";
|
|
418
|
+
|
|
419
|
+
if (hasEip712) {
|
|
420
|
+
const evmCandidates = configsOrdered.filter(
|
|
421
|
+
(c) =>
|
|
422
|
+
c.cfg.network === "BASE" ||
|
|
423
|
+
c.cfg.network === "POLYGON" ||
|
|
424
|
+
c.cfg.network === "BSC",
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
for (const { name, cfg } of evmCandidates) {
|
|
428
|
+
if (inferredNet && cfg.network !== inferredNet) continue;
|
|
429
|
+
if (
|
|
430
|
+
domain?.verifyingContract &&
|
|
431
|
+
domain.verifyingContract.toLowerCase() !==
|
|
432
|
+
cfg.assetReference.toLowerCase()
|
|
433
|
+
) {
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const atomic = atomicAmountForPriceInCents(priceInCents, cfg);
|
|
438
|
+
const recipient = cfg.paymentAddress;
|
|
439
|
+
const ok = await verifyEvmPayment(
|
|
440
|
+
JSON.stringify(authData),
|
|
441
|
+
recipient,
|
|
442
|
+
atomic,
|
|
443
|
+
cfg.network,
|
|
444
|
+
runtime,
|
|
445
|
+
req,
|
|
446
|
+
{
|
|
447
|
+
eip712TokenContract: cfg.assetReference as Address,
|
|
448
|
+
erc20Contract: cfg.assetReference as Address,
|
|
449
|
+
},
|
|
450
|
+
);
|
|
451
|
+
if (ok) {
|
|
452
|
+
const auth = authObj.authorization as EIP712Authorization;
|
|
453
|
+
log(
|
|
454
|
+
`✓ ${cfg.network} payment verified (EIP-712) config=${name}`,
|
|
455
|
+
);
|
|
456
|
+
return await finishVerified({
|
|
457
|
+
paymentConfig: name,
|
|
458
|
+
network: cfg.network,
|
|
459
|
+
amountAtomic: atomic,
|
|
460
|
+
symbol: cfg.symbol,
|
|
461
|
+
payer: auth?.from,
|
|
462
|
+
proofId:
|
|
463
|
+
typeof authObj.signature === "string"
|
|
464
|
+
? authObj.signature
|
|
465
|
+
: undefined,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
} catch {
|
|
471
|
+
const parts = decodedProof.split(":");
|
|
472
|
+
|
|
473
|
+
if (parts.length >= 3) {
|
|
474
|
+
const [networkRaw, address, signature] = parts;
|
|
475
|
+
const network = networkRaw.toUpperCase();
|
|
476
|
+
log(`Legacy format: ${network}`);
|
|
477
|
+
|
|
478
|
+
if (network === "SOLANA") {
|
|
479
|
+
for (const { name, cfg } of configsOrdered) {
|
|
480
|
+
if (cfg.network !== "SOLANA") continue;
|
|
481
|
+
if (address.trim() !== cfg.paymentAddress.trim()) {
|
|
482
|
+
logError(
|
|
483
|
+
"Solana legacy proof: recipient field must equal the route pay-to address (expected",
|
|
484
|
+
cfg.paymentAddress,
|
|
485
|
+
"got",
|
|
486
|
+
address,
|
|
487
|
+
);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const atomic = atomicAmountForPriceInCents(priceInCents, cfg);
|
|
491
|
+
if (
|
|
492
|
+
await verifySolanaPayment(
|
|
493
|
+
signature,
|
|
494
|
+
cfg.paymentAddress,
|
|
495
|
+
cfg.assetReference,
|
|
496
|
+
atomic,
|
|
497
|
+
runtime,
|
|
498
|
+
)
|
|
499
|
+
) {
|
|
500
|
+
log("✓ Solana payment verified");
|
|
501
|
+
return await finishVerified({
|
|
502
|
+
paymentConfig: name,
|
|
503
|
+
network: "SOLANA",
|
|
504
|
+
amountAtomic: atomic,
|
|
505
|
+
symbol: cfg.symbol,
|
|
506
|
+
proofId: signature,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
} else if (
|
|
511
|
+
network === "BASE" ||
|
|
512
|
+
network === "POLYGON" ||
|
|
513
|
+
network === "BSC"
|
|
514
|
+
) {
|
|
515
|
+
for (const { name, cfg } of configsOrdered) {
|
|
516
|
+
if (cfg.network !== network) continue;
|
|
517
|
+
if (cfg.assetNamespace !== "erc20") continue;
|
|
518
|
+
const atomic = atomicAmountForPriceInCents(priceInCents, cfg);
|
|
519
|
+
if (
|
|
520
|
+
await verifyEvmPayment(
|
|
521
|
+
signature,
|
|
522
|
+
cfg.paymentAddress,
|
|
523
|
+
atomic,
|
|
524
|
+
network,
|
|
525
|
+
runtime,
|
|
526
|
+
req,
|
|
527
|
+
{
|
|
528
|
+
erc20Contract: cfg.assetReference as Address,
|
|
529
|
+
eip712TokenContract: cfg.assetReference as Address,
|
|
530
|
+
},
|
|
531
|
+
)
|
|
532
|
+
) {
|
|
533
|
+
log(`✓ ${network} payment verified`);
|
|
534
|
+
return await finishVerified({
|
|
535
|
+
paymentConfig: name,
|
|
536
|
+
network: cfg.network,
|
|
537
|
+
amountAtomic: atomic,
|
|
538
|
+
symbol: cfg.symbol,
|
|
539
|
+
proofId: signature,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
} else if (parts.length === 1 && parts[0].length > 50) {
|
|
545
|
+
const sigOnly = parts[0];
|
|
546
|
+
for (const { name, cfg } of configsOrdered) {
|
|
547
|
+
if (cfg.network !== "SOLANA") continue;
|
|
548
|
+
const atomic = atomicAmountForPriceInCents(priceInCents, cfg);
|
|
549
|
+
if (
|
|
550
|
+
await verifySolanaPayment(
|
|
551
|
+
sigOnly,
|
|
552
|
+
cfg.paymentAddress,
|
|
553
|
+
cfg.assetReference,
|
|
554
|
+
atomic,
|
|
555
|
+
runtime,
|
|
556
|
+
)
|
|
557
|
+
) {
|
|
558
|
+
log("✓ Solana payment verified (raw signature)");
|
|
559
|
+
return await finishVerified({
|
|
560
|
+
paymentConfig: name,
|
|
561
|
+
network: "SOLANA",
|
|
562
|
+
amountAtomic: atomic,
|
|
563
|
+
symbol: cfg.symbol,
|
|
564
|
+
proofId: sigOnly,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
} catch (error) {
|
|
571
|
+
logError(
|
|
572
|
+
"Blockchain verification error:",
|
|
573
|
+
error instanceof Error ? error.message : String(error),
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (paymentId) {
|
|
579
|
+
try {
|
|
580
|
+
if (
|
|
581
|
+
await verifyPaymentIdViaFacilitator(paymentId, runtime, {
|
|
582
|
+
resource: toResourceUrl(route),
|
|
583
|
+
routePath: route,
|
|
584
|
+
priceInCents,
|
|
585
|
+
paymentConfigNames,
|
|
586
|
+
})
|
|
587
|
+
) {
|
|
588
|
+
log("✓ Facilitator payment verified");
|
|
589
|
+
return await finishVerified({
|
|
590
|
+
paymentConfig: "facilitator",
|
|
591
|
+
network: "facilitator",
|
|
592
|
+
amountAtomic: "",
|
|
593
|
+
proofId: paymentId,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
} catch (error) {
|
|
597
|
+
logError(
|
|
598
|
+
"Facilitator verification error:",
|
|
599
|
+
error instanceof Error ? error.message : String(error),
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
logError("✗ All payment verification strategies failed");
|
|
605
|
+
return { ok: false };
|
|
606
|
+
} finally {
|
|
607
|
+
if (!committed) await replayGuardAbortAsync(replayKeys, runtime, agentId);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Sanitize and validate payment ID format
|
|
613
|
+
*/
|
|
614
|
+
function sanitizePaymentId(paymentId: string): string {
|
|
615
|
+
// Remove any whitespace
|
|
616
|
+
const cleaned = paymentId.trim();
|
|
617
|
+
|
|
618
|
+
// Validate format (alphanumeric, hyphens, underscores only)
|
|
619
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(cleaned)) {
|
|
620
|
+
throw new Error("Invalid payment ID format");
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Limit length to prevent abuse
|
|
624
|
+
if (cleaned.length > 128) {
|
|
625
|
+
throw new Error("Payment ID too long");
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return cleaned;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Verify payment ID via facilitator API
|
|
633
|
+
*/
|
|
634
|
+
async function verifyPaymentIdViaFacilitator(
|
|
635
|
+
paymentId: string,
|
|
636
|
+
runtime: X402Runtime,
|
|
637
|
+
ctx?: FacilitatorVerifyContext,
|
|
638
|
+
): Promise<boolean> {
|
|
639
|
+
logSection("FACILITATOR VERIFICATION");
|
|
640
|
+
|
|
641
|
+
// Sanitize payment ID
|
|
642
|
+
let cleanPaymentId: string;
|
|
643
|
+
try {
|
|
644
|
+
cleanPaymentId = sanitizePaymentId(paymentId);
|
|
645
|
+
log("Payment ID:", cleanPaymentId);
|
|
646
|
+
} catch (error) {
|
|
647
|
+
logError(
|
|
648
|
+
"Invalid payment ID:",
|
|
649
|
+
error instanceof Error ? error.message : String(error),
|
|
650
|
+
);
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const facilitatorUrlSetting = runtime.getSetting("X402_FACILITATOR_URL");
|
|
655
|
+
const facilitatorUrl =
|
|
656
|
+
typeof facilitatorUrlSetting === "string"
|
|
657
|
+
? facilitatorUrlSetting
|
|
658
|
+
: "https://x402.elizacloud.ai/api/facilitator";
|
|
659
|
+
|
|
660
|
+
if (!facilitatorUrl) {
|
|
661
|
+
logError("⚠️ No facilitator URL configured");
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
try {
|
|
666
|
+
const cleanUrl = facilitatorUrl.replace(/\/$/, "");
|
|
667
|
+
const verifyPath = `${cleanUrl}/verify/${encodeURIComponent(cleanPaymentId)}`;
|
|
668
|
+
const url = new URL(verifyPath);
|
|
669
|
+
if (ctx) {
|
|
670
|
+
url.searchParams.set("resource", ctx.resource);
|
|
671
|
+
url.searchParams.set("routePath", ctx.routePath);
|
|
672
|
+
url.searchParams.set("priceInCents", String(ctx.priceInCents));
|
|
673
|
+
url.searchParams.set("paymentConfigs", ctx.paymentConfigNames.join(","));
|
|
674
|
+
}
|
|
675
|
+
const endpoint = url.toString();
|
|
676
|
+
log("Verifying at:", endpoint);
|
|
677
|
+
|
|
678
|
+
const response = await fetch(endpoint, {
|
|
679
|
+
method: "GET",
|
|
680
|
+
headers: {
|
|
681
|
+
Accept: "application/json",
|
|
682
|
+
"User-Agent": "ElizaOS-X402-Client/1.0",
|
|
683
|
+
},
|
|
684
|
+
signal: AbortSignal.timeout(10000),
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
const responseText = await response.text();
|
|
688
|
+
const responseData: FacilitatorVerificationResponse = responseText
|
|
689
|
+
? JSON.parse(responseText)
|
|
690
|
+
: {};
|
|
691
|
+
|
|
692
|
+
if (response.ok) {
|
|
693
|
+
const isValid =
|
|
694
|
+
responseData?.valid !== false && responseData?.verified !== false;
|
|
695
|
+
if (isValid) {
|
|
696
|
+
if (
|
|
697
|
+
ctx &&
|
|
698
|
+
!facilitatorVerifyResponseMatchesRoute(
|
|
699
|
+
responseData,
|
|
700
|
+
ctx,
|
|
701
|
+
isFacilitatorBindingRelaxed(),
|
|
702
|
+
)
|
|
703
|
+
) {
|
|
704
|
+
logError(
|
|
705
|
+
isFacilitatorBindingRelaxed()
|
|
706
|
+
? "✗ Facilitator response failed route binding checks"
|
|
707
|
+
: "✗ Facilitator strict binding failed (response must include matching resource, routePath or route, priceInCents, paymentConfig). Set X402_FACILITATOR_RELAXED_BINDING=1 if your facilitator cannot echo these fields yet.",
|
|
708
|
+
);
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
log("✓ Facilitator verified payment");
|
|
712
|
+
return true;
|
|
713
|
+
} else {
|
|
714
|
+
logError("✗ Payment invalid per facilitator");
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
} else if (response.status === 404) {
|
|
718
|
+
logError("✗ Payment ID not found (404)");
|
|
719
|
+
return false;
|
|
720
|
+
} else if (response.status === 410) {
|
|
721
|
+
logError("✗ Payment ID already used (410 - replay attack prevented)");
|
|
722
|
+
return false;
|
|
723
|
+
} else {
|
|
724
|
+
logError(
|
|
725
|
+
`✗ Facilitator error: ${response.status} ${response.statusText}`,
|
|
726
|
+
);
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
} catch (error) {
|
|
730
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
731
|
+
logError("✗ Facilitator request timed out (10s)");
|
|
732
|
+
} else {
|
|
733
|
+
logError(
|
|
734
|
+
"✗ Facilitator verification error:",
|
|
735
|
+
error instanceof Error ? error.message : String(error),
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Sanitize Solana signature
|
|
744
|
+
*/
|
|
745
|
+
function sanitizeSolanaSignature(signature: string): string {
|
|
746
|
+
const cleaned = signature.trim();
|
|
747
|
+
|
|
748
|
+
// Solana signatures are base58, typically 87-88 characters
|
|
749
|
+
if (!/^[1-9A-HJ-NP-Za-km-z]{87,88}$/.test(cleaned)) {
|
|
750
|
+
throw new Error("Invalid Solana signature format");
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return cleaned;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Verify a Solana SPL transfer landed on-chain for the expected mint, recipient, and amount.
|
|
758
|
+
*/
|
|
759
|
+
async function verifySolanaPayment(
|
|
760
|
+
signature: string,
|
|
761
|
+
expectedRecipient: string,
|
|
762
|
+
expectedMint: string,
|
|
763
|
+
expectedAmountAtomic: string,
|
|
764
|
+
runtime: X402Runtime,
|
|
765
|
+
): Promise<boolean> {
|
|
766
|
+
let cleanSignature: string;
|
|
767
|
+
try {
|
|
768
|
+
cleanSignature = sanitizeSolanaSignature(signature);
|
|
769
|
+
log(
|
|
770
|
+
"Verifying Solana transaction:",
|
|
771
|
+
`${cleanSignature.substring(0, 20)}...`,
|
|
772
|
+
);
|
|
773
|
+
} catch (error) {
|
|
774
|
+
logError(
|
|
775
|
+
"Invalid signature:",
|
|
776
|
+
error instanceof Error ? error.message : String(error),
|
|
777
|
+
);
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
try {
|
|
782
|
+
const { Connection } = await import("@solana/web3.js");
|
|
783
|
+
const rpcUrlSetting = runtime.getSetting("SOLANA_RPC_URL");
|
|
784
|
+
const rpcUrl =
|
|
785
|
+
typeof rpcUrlSetting === "string"
|
|
786
|
+
? rpcUrlSetting
|
|
787
|
+
: "https://api.mainnet-beta.solana.com";
|
|
788
|
+
const connection = new Connection(rpcUrl);
|
|
789
|
+
|
|
790
|
+
const tx = await connection.getTransaction(cleanSignature, {
|
|
791
|
+
maxSupportedTransactionVersion: 0,
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
if (!tx) {
|
|
795
|
+
logError("Transaction not found on Solana blockchain");
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (tx.meta?.err) {
|
|
800
|
+
logError("Transaction failed on-chain:", tx.meta.err);
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const meta = tx.meta;
|
|
805
|
+
const pre = sumOwnerMint(
|
|
806
|
+
meta?.preTokenBalances as Parameters<typeof sumOwnerMint>[0],
|
|
807
|
+
expectedRecipient,
|
|
808
|
+
expectedMint,
|
|
809
|
+
);
|
|
810
|
+
const post = sumOwnerMint(
|
|
811
|
+
meta?.postTokenBalances as Parameters<typeof sumOwnerMint>[0],
|
|
812
|
+
expectedRecipient,
|
|
813
|
+
expectedMint,
|
|
814
|
+
);
|
|
815
|
+
const delta = post - pre;
|
|
816
|
+
const need = BigInt(expectedAmountAtomic);
|
|
817
|
+
if (delta < need) {
|
|
818
|
+
logError(
|
|
819
|
+
"Solana SPL credit too low:",
|
|
820
|
+
delta.toString(),
|
|
821
|
+
"vs required",
|
|
822
|
+
need.toString(),
|
|
823
|
+
);
|
|
824
|
+
return false;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
log("✓ Solana SPL transfer verified");
|
|
828
|
+
return true;
|
|
829
|
+
} catch (error) {
|
|
830
|
+
logError(
|
|
831
|
+
"Solana verification error:",
|
|
832
|
+
error instanceof Error ? error.message : String(error),
|
|
833
|
+
);
|
|
834
|
+
return false;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Sanitize and parse payment proof data
|
|
840
|
+
*/
|
|
841
|
+
function sanitizePaymentProof(paymentData: string): string {
|
|
842
|
+
const cleaned = paymentData.trim();
|
|
843
|
+
|
|
844
|
+
// Limit size to prevent DoS
|
|
845
|
+
if (cleaned.length > 10000) {
|
|
846
|
+
throw new Error("Payment proof too large");
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
return cleaned;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
type EvmPaymentVerifyOpts = {
|
|
853
|
+
/** On-chain tx: `receipt.to` must be this ERC-20 contract */
|
|
854
|
+
erc20Contract?: Address;
|
|
855
|
+
/** EIP-712: domain `verifyingContract` must match this token */
|
|
856
|
+
eip712TokenContract?: Address;
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Verify an EVM transaction or EIP-712 signature
|
|
861
|
+
*/
|
|
862
|
+
async function verifyEvmPayment(
|
|
863
|
+
paymentData: string,
|
|
864
|
+
expectedRecipient: string,
|
|
865
|
+
expectedAmountAtomic: string,
|
|
866
|
+
network: string,
|
|
867
|
+
runtime: X402Runtime,
|
|
868
|
+
req?: X402Request,
|
|
869
|
+
opts?: EvmPaymentVerifyOpts,
|
|
870
|
+
): Promise<boolean> {
|
|
871
|
+
let cleanPaymentData: string;
|
|
872
|
+
try {
|
|
873
|
+
cleanPaymentData = sanitizePaymentProof(paymentData);
|
|
874
|
+
log(
|
|
875
|
+
`Verifying ${network} payment:`,
|
|
876
|
+
`${cleanPaymentData.substring(0, 20)}...`,
|
|
877
|
+
);
|
|
878
|
+
} catch (error) {
|
|
879
|
+
logError(
|
|
880
|
+
"Invalid payment data:",
|
|
881
|
+
error instanceof Error ? error.message : String(error),
|
|
882
|
+
);
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
try {
|
|
887
|
+
if (cleanPaymentData.match(/^0x[a-fA-F0-9]{64}$/)) {
|
|
888
|
+
log("Detected transaction hash format");
|
|
889
|
+
return await verifyEvmTransaction(
|
|
890
|
+
cleanPaymentData,
|
|
891
|
+
expectedRecipient,
|
|
892
|
+
expectedAmountAtomic,
|
|
893
|
+
network,
|
|
894
|
+
runtime,
|
|
895
|
+
opts?.erc20Contract,
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
try {
|
|
900
|
+
const parsed: unknown = JSON.parse(cleanPaymentData);
|
|
901
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
902
|
+
const proof = parsed as Partial<EIP712PaymentProof>;
|
|
903
|
+
if (proof.signature || (proof.v && proof.r && proof.s)) {
|
|
904
|
+
log("Detected EIP-712 signature format");
|
|
905
|
+
const allowEip712 =
|
|
906
|
+
process.env.X402_ALLOW_EIP712_SIGNATURE_VERIFICATION === "true" ||
|
|
907
|
+
process.env.X402_ALLOW_EIP712_SIGNATURE_VERIFICATION === "1";
|
|
908
|
+
if (!allowEip712) {
|
|
909
|
+
logError(
|
|
910
|
+
"EIP-712 authorization proofs are disabled (they do not prove on-chain settlement). Set X402_ALLOW_EIP712_SIGNATURE_VERIFICATION=1 only if you accept that risk.",
|
|
911
|
+
);
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
const token = opts?.eip712TokenContract;
|
|
915
|
+
if (!token) {
|
|
916
|
+
logError("EIP-712 verification missing expected token contract");
|
|
917
|
+
return false;
|
|
918
|
+
}
|
|
919
|
+
return await verifyEip712Authorization(
|
|
920
|
+
parsed,
|
|
921
|
+
expectedRecipient,
|
|
922
|
+
expectedAmountAtomic,
|
|
923
|
+
token,
|
|
924
|
+
network,
|
|
925
|
+
runtime,
|
|
926
|
+
req,
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
} catch {
|
|
931
|
+
// Not JSON, continue
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (cleanPaymentData.match(/^0x[a-fA-F0-9]{130}$/)) {
|
|
935
|
+
logError("Raw signature detected but authorization parameters missing");
|
|
936
|
+
return false;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
logError("Unrecognized EVM payment format");
|
|
940
|
+
return false;
|
|
941
|
+
} catch (error) {
|
|
942
|
+
logError(
|
|
943
|
+
"EVM verification error:",
|
|
944
|
+
error instanceof Error ? error.message : String(error),
|
|
945
|
+
);
|
|
946
|
+
return false;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Verify a regular EVM transaction (on-chain ERC-20 transfer / transferFrom).
|
|
952
|
+
* `expectedAmountAtomic` is the minimum token amount in smallest units (string integer).
|
|
953
|
+
*/
|
|
954
|
+
async function verifyEvmTransaction(
|
|
955
|
+
txHash: string,
|
|
956
|
+
expectedRecipient: string,
|
|
957
|
+
expectedAmountAtomic: string,
|
|
958
|
+
network: string,
|
|
959
|
+
runtime: X402Runtime,
|
|
960
|
+
tokenContract?: Address,
|
|
961
|
+
): Promise<boolean> {
|
|
962
|
+
log("Verifying on-chain transaction:", txHash);
|
|
963
|
+
|
|
964
|
+
try {
|
|
965
|
+
const rpcUrl = getRpcUrl(network, runtime);
|
|
966
|
+
const chain = getViemChain(network);
|
|
967
|
+
|
|
968
|
+
const { createPublicClient, http, decodeFunctionData, parseAbi } =
|
|
969
|
+
await import("viem");
|
|
970
|
+
const publicClient = createPublicClient({
|
|
971
|
+
chain,
|
|
972
|
+
transport: http(rpcUrl),
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
const receipt = await publicClient.getTransactionReceipt({
|
|
976
|
+
hash: txHash as Hex,
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
if (receipt.status !== "success") {
|
|
980
|
+
logError("Transaction failed on-chain");
|
|
981
|
+
return false;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const tx = await publicClient.getTransaction({ hash: txHash as Hex });
|
|
985
|
+
|
|
986
|
+
const targetContract = tokenContract ?? getUsdcContractAddress(network);
|
|
987
|
+
const expectedUnits = BigInt(expectedAmountAtomic);
|
|
988
|
+
|
|
989
|
+
if (receipt.to?.toLowerCase() !== targetContract.toLowerCase()) {
|
|
990
|
+
logError("Transaction not to expected token contract:", receipt.to);
|
|
991
|
+
return false;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
log("Detected ERC-20 token transfer");
|
|
995
|
+
|
|
996
|
+
if (tx.input === "0x") {
|
|
997
|
+
logError("No input data in transaction");
|
|
998
|
+
return false;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
try {
|
|
1002
|
+
const erc20Abi = parseAbi([
|
|
1003
|
+
"function transfer(address to, uint256 amount) returns (bool)",
|
|
1004
|
+
"function transferFrom(address from, address to, uint256 amount) returns (bool)",
|
|
1005
|
+
]);
|
|
1006
|
+
|
|
1007
|
+
const decoded = decodeFunctionData({
|
|
1008
|
+
abi: erc20Abi,
|
|
1009
|
+
data: tx.input as Hex,
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
const functionName = decoded.functionName;
|
|
1013
|
+
log("Decoded function:", functionName);
|
|
1014
|
+
|
|
1015
|
+
let transferTo: Address;
|
|
1016
|
+
let transferAmount: bigint;
|
|
1017
|
+
|
|
1018
|
+
if (functionName === "transfer") {
|
|
1019
|
+
const [to, amount] = decoded.args as [Address, bigint];
|
|
1020
|
+
transferTo = to;
|
|
1021
|
+
transferAmount = amount;
|
|
1022
|
+
} else if (functionName === "transferFrom") {
|
|
1023
|
+
const [_from, to, amount] = decoded.args as [Address, Address, bigint];
|
|
1024
|
+
transferTo = to;
|
|
1025
|
+
transferAmount = amount;
|
|
1026
|
+
} else {
|
|
1027
|
+
logError("Unknown ERC-20 function:", functionName);
|
|
1028
|
+
return false;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
log("Transfer to:", transferTo, "Amount:", transferAmount.toString());
|
|
1032
|
+
|
|
1033
|
+
if (transferTo.toLowerCase() !== expectedRecipient.toLowerCase()) {
|
|
1034
|
+
logError(
|
|
1035
|
+
"ERC-20 transfer recipient mismatch:",
|
|
1036
|
+
transferTo,
|
|
1037
|
+
"vs",
|
|
1038
|
+
expectedRecipient,
|
|
1039
|
+
);
|
|
1040
|
+
return false;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (transferAmount < expectedUnits) {
|
|
1044
|
+
logError(
|
|
1045
|
+
"ERC-20 transfer amount too low:",
|
|
1046
|
+
transferAmount.toString(),
|
|
1047
|
+
"vs",
|
|
1048
|
+
expectedUnits.toString(),
|
|
1049
|
+
);
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
log("✓ ERC-20 transaction verified");
|
|
1054
|
+
return true;
|
|
1055
|
+
} catch (decodeError) {
|
|
1056
|
+
logError(
|
|
1057
|
+
"Failed to decode ERC-20 transfer:",
|
|
1058
|
+
decodeError instanceof Error
|
|
1059
|
+
? decodeError.message
|
|
1060
|
+
: String(decodeError),
|
|
1061
|
+
);
|
|
1062
|
+
return false;
|
|
1063
|
+
}
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
logError(
|
|
1066
|
+
"Transaction verification error:",
|
|
1067
|
+
error instanceof Error ? error.message : String(error),
|
|
1068
|
+
);
|
|
1069
|
+
return false;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Verify EIP-712 authorization signature (ERC-3009 TransferWithAuthorization)
|
|
1075
|
+
*/
|
|
1076
|
+
async function verifyEip712Authorization(
|
|
1077
|
+
paymentData: unknown,
|
|
1078
|
+
expectedRecipient: string,
|
|
1079
|
+
expectedAmountAtomic: string,
|
|
1080
|
+
expectedVerifyingContract: Address,
|
|
1081
|
+
network: string,
|
|
1082
|
+
runtime: X402Runtime,
|
|
1083
|
+
req?: X402Request,
|
|
1084
|
+
): Promise<boolean> {
|
|
1085
|
+
log("Verifying EIP-712 authorization signature");
|
|
1086
|
+
|
|
1087
|
+
// Type guard for payment data
|
|
1088
|
+
if (typeof paymentData !== "object" || paymentData === null) {
|
|
1089
|
+
logError("Invalid payment data: must be an object");
|
|
1090
|
+
return false;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const proofData = paymentData as EIP712PaymentProof;
|
|
1094
|
+
log("Payment data:", JSON.stringify(proofData, null, 2));
|
|
1095
|
+
|
|
1096
|
+
try {
|
|
1097
|
+
let signature: string;
|
|
1098
|
+
let authorization: EIP712Authorization;
|
|
1099
|
+
|
|
1100
|
+
if (proofData.signature && typeof proofData.signature === "string") {
|
|
1101
|
+
signature = proofData.signature;
|
|
1102
|
+
authorization = proofData.authorization as EIP712Authorization;
|
|
1103
|
+
} else if (proofData.v && proofData.r && proofData.s) {
|
|
1104
|
+
signature = `0x${proofData.r}${proofData.s}${proofData.v.toString(16).padStart(2, "0")}`;
|
|
1105
|
+
authorization = proofData.authorization as EIP712Authorization;
|
|
1106
|
+
} else {
|
|
1107
|
+
logError("No valid signature found in payment data");
|
|
1108
|
+
return false;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (!authorization || typeof authorization !== "object") {
|
|
1112
|
+
logError("No authorization data found in payment data");
|
|
1113
|
+
return false;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Validate authorization fields
|
|
1117
|
+
if (
|
|
1118
|
+
!authorization.from ||
|
|
1119
|
+
!authorization.to ||
|
|
1120
|
+
!authorization.value ||
|
|
1121
|
+
!authorization.nonce
|
|
1122
|
+
) {
|
|
1123
|
+
logError("Authorization missing required fields");
|
|
1124
|
+
return false;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
log("Authorization:", {
|
|
1128
|
+
from: `${authorization.from?.substring(0, 10)}...`,
|
|
1129
|
+
to: `${authorization.to?.substring(0, 10)}...`,
|
|
1130
|
+
value: authorization.value,
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
// Null check before toLowerCase()
|
|
1134
|
+
if (!authorization.to) {
|
|
1135
|
+
logError('Authorization missing "to" field');
|
|
1136
|
+
return false;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
if (authorization.to.toLowerCase() !== expectedRecipient.toLowerCase()) {
|
|
1140
|
+
logError(
|
|
1141
|
+
"Recipient mismatch:",
|
|
1142
|
+
authorization.to,
|
|
1143
|
+
"vs",
|
|
1144
|
+
expectedRecipient,
|
|
1145
|
+
);
|
|
1146
|
+
return false;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const need = BigInt(expectedAmountAtomic);
|
|
1150
|
+
const authValue = BigInt(authorization.value);
|
|
1151
|
+
if (authValue < need) {
|
|
1152
|
+
logError("Amount too low:", authValue.toString(), "vs", need.toString());
|
|
1153
|
+
return false;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1157
|
+
const validAfter = Number.parseInt(authorization.validAfter || "0", 10);
|
|
1158
|
+
const validBefore = Number.parseInt(
|
|
1159
|
+
authorization.validBefore || String(now + 86400),
|
|
1160
|
+
10,
|
|
1161
|
+
);
|
|
1162
|
+
|
|
1163
|
+
if (now < validAfter) {
|
|
1164
|
+
logError("Authorization not yet valid:", now, "<", validAfter);
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
if (now > validBefore) {
|
|
1169
|
+
logError("Authorization expired:", now, ">", validBefore);
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
log("✓ EIP-712 authorization parameters valid");
|
|
1174
|
+
|
|
1175
|
+
logSection("Cryptographic Signature Verification");
|
|
1176
|
+
|
|
1177
|
+
try {
|
|
1178
|
+
let verifyingContract: Address;
|
|
1179
|
+
let chainId: number;
|
|
1180
|
+
let domainName = "USD Coin";
|
|
1181
|
+
let domainVersion = "2";
|
|
1182
|
+
|
|
1183
|
+
const expectedChainId = getViemChain(network).id;
|
|
1184
|
+
|
|
1185
|
+
if (proofData.domain && typeof proofData.domain === "object") {
|
|
1186
|
+
const domain = proofData.domain as EIP712Domain;
|
|
1187
|
+
log("Using domain from payment data:", domain);
|
|
1188
|
+
if (
|
|
1189
|
+
(domain.verifyingContract as string).toLowerCase() !==
|
|
1190
|
+
expectedVerifyingContract.toLowerCase()
|
|
1191
|
+
) {
|
|
1192
|
+
logError(
|
|
1193
|
+
"EIP-712 verifyingContract does not match route token:",
|
|
1194
|
+
domain.verifyingContract,
|
|
1195
|
+
expectedVerifyingContract,
|
|
1196
|
+
);
|
|
1197
|
+
return false;
|
|
1198
|
+
}
|
|
1199
|
+
if (domain.chainId !== expectedChainId) {
|
|
1200
|
+
logError(
|
|
1201
|
+
"EIP-712 chainId mismatch:",
|
|
1202
|
+
domain.chainId,
|
|
1203
|
+
"expected",
|
|
1204
|
+
expectedChainId,
|
|
1205
|
+
);
|
|
1206
|
+
return false;
|
|
1207
|
+
}
|
|
1208
|
+
verifyingContract = domain.verifyingContract as Address;
|
|
1209
|
+
chainId = domain.chainId;
|
|
1210
|
+
if (domain.name) domainName = domain.name;
|
|
1211
|
+
if (domain.version) domainVersion = domain.version;
|
|
1212
|
+
} else {
|
|
1213
|
+
log("No domain in payment data — using expected token + network chain");
|
|
1214
|
+
verifyingContract = expectedVerifyingContract;
|
|
1215
|
+
chainId = expectedChainId;
|
|
1216
|
+
const usdc = getUsdcContractAddress(network);
|
|
1217
|
+
if (expectedVerifyingContract.toLowerCase() === usdc.toLowerCase()) {
|
|
1218
|
+
domainName = "USD Coin";
|
|
1219
|
+
} else {
|
|
1220
|
+
domainName = "Token";
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
log("Verifying contract:", verifyingContract, "chainId:", chainId);
|
|
1225
|
+
|
|
1226
|
+
const domain: TypedDataDomain = {
|
|
1227
|
+
name: domainName,
|
|
1228
|
+
version: domainVersion,
|
|
1229
|
+
chainId,
|
|
1230
|
+
verifyingContract,
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
log("Domain for verification:", domain);
|
|
1234
|
+
|
|
1235
|
+
const types = {
|
|
1236
|
+
TransferWithAuthorization: TRANSFER_WITH_AUTHORIZATION_TYPES,
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
const message = {
|
|
1240
|
+
from: authorization.from as Address,
|
|
1241
|
+
to: authorization.to as Address,
|
|
1242
|
+
value: BigInt(authorization.value),
|
|
1243
|
+
validAfter: BigInt(authorization.validAfter || 0),
|
|
1244
|
+
validBefore: BigInt(
|
|
1245
|
+
authorization.validBefore || Math.floor(Date.now() / 1000) + 86400,
|
|
1246
|
+
),
|
|
1247
|
+
nonce: authorization.nonce as Hex,
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
log("Message:", {
|
|
1251
|
+
from: message.from,
|
|
1252
|
+
to: message.to,
|
|
1253
|
+
value: message.value.toString(),
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
try {
|
|
1257
|
+
const recoveredAddress = await recoverTypedDataAddress({
|
|
1258
|
+
domain,
|
|
1259
|
+
types,
|
|
1260
|
+
primaryType: "TransferWithAuthorization",
|
|
1261
|
+
message,
|
|
1262
|
+
signature: signature as Hex,
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
log(
|
|
1266
|
+
"Recovered signer:",
|
|
1267
|
+
recoveredAddress,
|
|
1268
|
+
"Expected:",
|
|
1269
|
+
authorization.from,
|
|
1270
|
+
);
|
|
1271
|
+
|
|
1272
|
+
const signerMatches =
|
|
1273
|
+
recoveredAddress.toLowerCase() === authorization.from.toLowerCase();
|
|
1274
|
+
|
|
1275
|
+
if (!signerMatches) {
|
|
1276
|
+
try {
|
|
1277
|
+
const wrongTypeRecovered = await recoverTypedDataAddress({
|
|
1278
|
+
domain,
|
|
1279
|
+
types: {
|
|
1280
|
+
ReceiveWithAuthorization: RECEIVE_WITH_AUTHORIZATION_TYPES,
|
|
1281
|
+
},
|
|
1282
|
+
primaryType: "ReceiveWithAuthorization",
|
|
1283
|
+
message,
|
|
1284
|
+
signature: signature as Hex,
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
if (
|
|
1288
|
+
wrongTypeRecovered.toLowerCase() ===
|
|
1289
|
+
authorization.from.toLowerCase()
|
|
1290
|
+
) {
|
|
1291
|
+
logError("❌ CLIENT ERROR: Wrong EIP-712 type used");
|
|
1292
|
+
return false;
|
|
1293
|
+
}
|
|
1294
|
+
} catch (_e) {
|
|
1295
|
+
log("Could not recover with ReceiveWithAuthorization either");
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
log("Signature match:", signerMatches ? "✓ Valid" : "✗ Invalid");
|
|
1300
|
+
|
|
1301
|
+
if (!signerMatches) {
|
|
1302
|
+
const userAgent = req?.headers?.["user-agent"];
|
|
1303
|
+
const isX402Gateway =
|
|
1304
|
+
typeof userAgent === "string" && userAgent.includes("X402-Gateway");
|
|
1305
|
+
|
|
1306
|
+
if (isX402Gateway) {
|
|
1307
|
+
log("🔍 Detected X402 Gateway User-Agent");
|
|
1308
|
+
const trustedSignersSetting = runtime.getSetting(
|
|
1309
|
+
"X402_TRUSTED_GATEWAY_SIGNERS",
|
|
1310
|
+
);
|
|
1311
|
+
const trustedSigners =
|
|
1312
|
+
typeof trustedSignersSetting === "string"
|
|
1313
|
+
? trustedSignersSetting
|
|
1314
|
+
: "0x2EB8323f66eE172315503de7325D04c676089267";
|
|
1315
|
+
const signerWhitelist = trustedSigners
|
|
1316
|
+
.split(",")
|
|
1317
|
+
.map((addr: string) => addr.trim().toLowerCase());
|
|
1318
|
+
|
|
1319
|
+
if (signerWhitelist.includes(recoveredAddress.toLowerCase())) {
|
|
1320
|
+
log("✅ Signature verified: signed by authorized X402 Gateway");
|
|
1321
|
+
return true;
|
|
1322
|
+
} else {
|
|
1323
|
+
logError(
|
|
1324
|
+
`✗ Gateway signer NOT in whitelist: ${recoveredAddress}`,
|
|
1325
|
+
);
|
|
1326
|
+
logError(
|
|
1327
|
+
`Add to X402_TRUSTED_GATEWAY_SIGNERS to allow: ${recoveredAddress}`,
|
|
1328
|
+
);
|
|
1329
|
+
return false;
|
|
1330
|
+
}
|
|
1331
|
+
} else {
|
|
1332
|
+
logError("✗ Signature verification failed: signer mismatch");
|
|
1333
|
+
logError(
|
|
1334
|
+
`Expected: ${authorization.from}, Actual: ${recoveredAddress}`,
|
|
1335
|
+
);
|
|
1336
|
+
return false;
|
|
1337
|
+
}
|
|
1338
|
+
} else {
|
|
1339
|
+
log("✓ Signature cryptographically verified");
|
|
1340
|
+
return true;
|
|
1341
|
+
}
|
|
1342
|
+
} catch (error) {
|
|
1343
|
+
logError(
|
|
1344
|
+
"✗ Signature verification failed:",
|
|
1345
|
+
error instanceof Error ? error.message : String(error),
|
|
1346
|
+
);
|
|
1347
|
+
return false;
|
|
1348
|
+
}
|
|
1349
|
+
} catch (error) {
|
|
1350
|
+
logError(
|
|
1351
|
+
"EIP-712 verification error:",
|
|
1352
|
+
error instanceof Error ? error.message : String(error),
|
|
1353
|
+
);
|
|
1354
|
+
return false;
|
|
1355
|
+
}
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
logError(
|
|
1358
|
+
"EIP-712 verification error:",
|
|
1359
|
+
error instanceof Error ? error.message : String(error),
|
|
1360
|
+
);
|
|
1361
|
+
return false;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* Create a payment-aware route handler
|
|
1367
|
+
*/
|
|
1368
|
+
export function createPaymentAwareHandler(
|
|
1369
|
+
route: PaymentEnabledRoute,
|
|
1370
|
+
): NonNullable<Route["handler"]> {
|
|
1371
|
+
const originalHandler = route.handler;
|
|
1372
|
+
|
|
1373
|
+
return async (
|
|
1374
|
+
req: RouteRequest,
|
|
1375
|
+
res: RouteResponse,
|
|
1376
|
+
runtime: IAgentRuntime,
|
|
1377
|
+
) => {
|
|
1378
|
+
const typedReq = req as X402Request;
|
|
1379
|
+
const typedRes = res as ExpressResponse;
|
|
1380
|
+
const typedRuntime = runtime as X402Runtime;
|
|
1381
|
+
|
|
1382
|
+
if (route.x402 == null) {
|
|
1383
|
+
if (originalHandler) {
|
|
1384
|
+
return originalHandler(req, res, runtime);
|
|
1385
|
+
}
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const testMode =
|
|
1390
|
+
process.env.X402_TEST_MODE === "true" ||
|
|
1391
|
+
process.env.X402_TEST_MODE === "1";
|
|
1392
|
+
if (testMode) {
|
|
1393
|
+
logger.warn(
|
|
1394
|
+
"[@elizaos/agent x402] X402_TEST_MODE is set — skipping payment verification (development only)",
|
|
1395
|
+
);
|
|
1396
|
+
if (originalHandler) {
|
|
1397
|
+
return originalHandler(req, res, runtime);
|
|
1398
|
+
}
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const x402Cfg = resolveEffectiveX402(route, typedRuntime);
|
|
1403
|
+
if (!x402Cfg) {
|
|
1404
|
+
if (!typedRes.headersSent) {
|
|
1405
|
+
typedRes.status(500).json({
|
|
1406
|
+
error: "x402 misconfiguration",
|
|
1407
|
+
message:
|
|
1408
|
+
"Could not resolve x402 price/paymentConfigs. For `x402: true`, set character.settings.x402.defaultPriceInCents and defaultPaymentConfigs. For partial x402 on the route, supply priceInCents and paymentConfigs or the matching character defaults.",
|
|
1409
|
+
path: route.path,
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const payRoute: X402PaidRoute = { ...route, x402: x402Cfg };
|
|
1416
|
+
|
|
1417
|
+
logSection(`X402 Payment Check - ${route.path}`);
|
|
1418
|
+
log("Method:", typedReq.method);
|
|
1419
|
+
|
|
1420
|
+
if (route.validator) {
|
|
1421
|
+
try {
|
|
1422
|
+
const validationResult = await route.validator(typedReq);
|
|
1423
|
+
|
|
1424
|
+
if (!validationResult.valid) {
|
|
1425
|
+
logError("✗ Validation failed:", validationResult.error?.message);
|
|
1426
|
+
|
|
1427
|
+
const x402Response = buildX402Response(payRoute, typedRuntime);
|
|
1428
|
+
void typedRuntime.emitEvent(X402_EVENT_PAYMENT_REQUIRED, {
|
|
1429
|
+
path: route.path,
|
|
1430
|
+
configNames: payRoute.x402.paymentConfigs ?? ["base_usdc"],
|
|
1431
|
+
reason: "validator_failed",
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
const errorMessage = validationResult.error?.details
|
|
1435
|
+
? `${validationResult.error.message}: ${JSON.stringify(validationResult.error.details)}`
|
|
1436
|
+
: validationResult.error?.message || "Invalid request parameters";
|
|
1437
|
+
|
|
1438
|
+
setStandardPaymentRequiredHeaders(
|
|
1439
|
+
typedRes,
|
|
1440
|
+
payRoute,
|
|
1441
|
+
typedRuntime,
|
|
1442
|
+
errorMessage,
|
|
1443
|
+
);
|
|
1444
|
+
return typedRes.status(402).json({
|
|
1445
|
+
...x402Response,
|
|
1446
|
+
error: errorMessage,
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
log("✓ Validation passed");
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
logError(
|
|
1453
|
+
"✗ Validation error:",
|
|
1454
|
+
error instanceof Error ? error.message : String(error),
|
|
1455
|
+
);
|
|
1456
|
+
|
|
1457
|
+
const x402Response = buildX402Response(payRoute, typedRuntime);
|
|
1458
|
+
void typedRuntime.emitEvent(X402_EVENT_PAYMENT_REQUIRED, {
|
|
1459
|
+
path: route.path,
|
|
1460
|
+
configNames: payRoute.x402.paymentConfigs ?? ["base_usdc"],
|
|
1461
|
+
reason: "validator_error",
|
|
1462
|
+
});
|
|
1463
|
+
setStandardPaymentRequiredHeaders(
|
|
1464
|
+
typedRes,
|
|
1465
|
+
payRoute,
|
|
1466
|
+
typedRuntime,
|
|
1467
|
+
"Validation error",
|
|
1468
|
+
);
|
|
1469
|
+
return typedRes.status(402).json({
|
|
1470
|
+
...x402Response,
|
|
1471
|
+
error: `Validation error: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
log("Headers:", JSON.stringify(typedReq.headers, null, 2));
|
|
1477
|
+
log("Query:", JSON.stringify(typedReq.query, null, 2));
|
|
1478
|
+
if (typedReq.method === "POST" && typedReq.body) {
|
|
1479
|
+
log("Body:", JSON.stringify(typedReq.body, null, 2));
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
const paymentProof =
|
|
1483
|
+
typedReq.headers["x-payment-proof"] ||
|
|
1484
|
+
typedReq.headers["x-payment"] ||
|
|
1485
|
+
typedReq.headers["payment-signature"] ||
|
|
1486
|
+
typedReq.query?.paymentProof;
|
|
1487
|
+
const paymentId =
|
|
1488
|
+
typedReq.headers["x-payment-id"] || typedReq.query?.paymentId;
|
|
1489
|
+
|
|
1490
|
+
log("Payment credentials:", {
|
|
1491
|
+
"x-payment-proof": !!typedReq.headers["x-payment-proof"],
|
|
1492
|
+
"x-payment": !!typedReq.headers["x-payment"],
|
|
1493
|
+
"payment-signature": !!typedReq.headers["payment-signature"],
|
|
1494
|
+
"x-payment-id": !!paymentId,
|
|
1495
|
+
found: !!(paymentProof || paymentId),
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
if (paymentProof || paymentId) {
|
|
1499
|
+
log("Payment credentials received:", {
|
|
1500
|
+
proofLength: paymentProof ? String(paymentProof).length : 0,
|
|
1501
|
+
paymentId,
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
try {
|
|
1505
|
+
const cfgNames = payRoute.x402.paymentConfigs ?? ["base_usdc"];
|
|
1506
|
+
const outcome = await verifyPayment({
|
|
1507
|
+
paymentProof:
|
|
1508
|
+
typeof paymentProof === "string" ? paymentProof : undefined,
|
|
1509
|
+
paymentId: typeof paymentId === "string" ? paymentId : undefined,
|
|
1510
|
+
route: route.path,
|
|
1511
|
+
priceInCents: payRoute.x402.priceInCents,
|
|
1512
|
+
paymentConfigNames: cfgNames,
|
|
1513
|
+
agentId: typedRuntime.agentId
|
|
1514
|
+
? String(typedRuntime.agentId)
|
|
1515
|
+
: undefined,
|
|
1516
|
+
runtime: typedRuntime,
|
|
1517
|
+
req: typedReq,
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
if (outcome.ok) {
|
|
1521
|
+
log("✓ PAYMENT VERIFIED - executing handler");
|
|
1522
|
+
void typedRuntime.emitEvent(X402_EVENT_PAYMENT_VERIFIED, {
|
|
1523
|
+
path: route.path,
|
|
1524
|
+
priceInCents: payRoute.x402.priceInCents,
|
|
1525
|
+
paymentConfigs: payRoute.x402.paymentConfigs,
|
|
1526
|
+
payer: outcome.details.payer,
|
|
1527
|
+
amountAtomic: outcome.details.amountAtomic,
|
|
1528
|
+
network: outcome.details.network,
|
|
1529
|
+
proofId: outcome.details.proofId,
|
|
1530
|
+
paymentConfig: outcome.details.paymentConfig,
|
|
1531
|
+
symbol: outcome.details.symbol,
|
|
1532
|
+
});
|
|
1533
|
+
if (outcome.details.paymentResponse && typedRes.setHeader) {
|
|
1534
|
+
typedRes.setHeader(
|
|
1535
|
+
"PAYMENT-RESPONSE",
|
|
1536
|
+
outcome.details.paymentResponse,
|
|
1537
|
+
);
|
|
1538
|
+
typedRes.setHeader(
|
|
1539
|
+
"Access-Control-Expose-Headers",
|
|
1540
|
+
"PAYMENT-REQUIRED, PAYMENT-RESPONSE, Payment-Required, Payment-Response",
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
if (originalHandler) {
|
|
1544
|
+
return originalHandler(req as never, res as never, runtime);
|
|
1545
|
+
}
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
logError("✗ PAYMENT VERIFICATION FAILED");
|
|
1549
|
+
const x402Base = buildX402Response(payRoute, typedRuntime);
|
|
1550
|
+
void typedRuntime.emitEvent(X402_EVENT_PAYMENT_REQUIRED, {
|
|
1551
|
+
path: route.path,
|
|
1552
|
+
configNames: cfgNames,
|
|
1553
|
+
reason: "verification_failed",
|
|
1554
|
+
});
|
|
1555
|
+
setStandardPaymentRequiredHeaders(
|
|
1556
|
+
typedRes,
|
|
1557
|
+
payRoute,
|
|
1558
|
+
typedRuntime,
|
|
1559
|
+
"Payment verification failed",
|
|
1560
|
+
);
|
|
1561
|
+
typedRes.status(402).json({
|
|
1562
|
+
...x402Base,
|
|
1563
|
+
error: "Payment verification failed",
|
|
1564
|
+
message:
|
|
1565
|
+
"The provided payment proof is invalid or has expired, or the amount or token does not match this route.",
|
|
1566
|
+
});
|
|
1567
|
+
return;
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
logError(
|
|
1570
|
+
"✗ PAYMENT VERIFICATION ERROR:",
|
|
1571
|
+
error instanceof Error ? error.message : String(error),
|
|
1572
|
+
);
|
|
1573
|
+
let x402Base: X402Response;
|
|
1574
|
+
try {
|
|
1575
|
+
x402Base = buildX402Response(payRoute, typedRuntime);
|
|
1576
|
+
} catch {
|
|
1577
|
+
x402Base = createX402Response({
|
|
1578
|
+
error: "Payment verification error",
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
void typedRuntime.emitEvent(X402_EVENT_PAYMENT_REQUIRED, {
|
|
1582
|
+
path: route.path,
|
|
1583
|
+
configNames: payRoute.x402.paymentConfigs ?? ["base_usdc"],
|
|
1584
|
+
reason: "verification_error",
|
|
1585
|
+
});
|
|
1586
|
+
setStandardPaymentRequiredHeaders(
|
|
1587
|
+
typedRes,
|
|
1588
|
+
payRoute,
|
|
1589
|
+
typedRuntime,
|
|
1590
|
+
"Payment verification error",
|
|
1591
|
+
);
|
|
1592
|
+
typedRes.status(402).json({
|
|
1593
|
+
...x402Base,
|
|
1594
|
+
error: "Payment verification error",
|
|
1595
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1596
|
+
});
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
log("No payment credentials - returning 402");
|
|
1602
|
+
|
|
1603
|
+
try {
|
|
1604
|
+
const x402Response = buildX402Response(payRoute, typedRuntime);
|
|
1605
|
+
void typedRuntime.emitEvent(X402_EVENT_PAYMENT_REQUIRED, {
|
|
1606
|
+
path: route.path,
|
|
1607
|
+
configNames: payRoute.x402.paymentConfigs ?? ["base_usdc"],
|
|
1608
|
+
reason: "payment_required",
|
|
1609
|
+
});
|
|
1610
|
+
log("Payment options:", {
|
|
1611
|
+
paymentConfigs: payRoute.x402.paymentConfigs || ["base_usdc"],
|
|
1612
|
+
priceInCents: payRoute.x402.priceInCents,
|
|
1613
|
+
count: x402Response.accepts?.length || 0,
|
|
1614
|
+
});
|
|
1615
|
+
log("402 Response:", JSON.stringify(x402Response, null, 2));
|
|
1616
|
+
|
|
1617
|
+
setStandardPaymentRequiredHeaders(
|
|
1618
|
+
typedRes,
|
|
1619
|
+
payRoute,
|
|
1620
|
+
typedRuntime,
|
|
1621
|
+
"Payment Required",
|
|
1622
|
+
);
|
|
1623
|
+
typedRes.status(402).json(x402Response);
|
|
1624
|
+
} catch (error) {
|
|
1625
|
+
logError(
|
|
1626
|
+
"✗ Failed to build x402 response:",
|
|
1627
|
+
error instanceof Error ? error.message : String(error),
|
|
1628
|
+
);
|
|
1629
|
+
typedRes.status(402).json(
|
|
1630
|
+
createX402Response({
|
|
1631
|
+
error: `Payment Required: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
1632
|
+
}),
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/**
|
|
1639
|
+
* Attach x402 **V2** `PAYMENT-REQUIRED` while still returning the **legacy JSON** 402 body.
|
|
1640
|
+
*
|
|
1641
|
+
* **Why both:** many readers still consume `accepts` from JSON (`x402Version: 1`);
|
|
1642
|
+
* V2 buyers expect a base64 `PaymentRequired` object in `PAYMENT-REQUIRED`
|
|
1643
|
+
* (`x402Version: 2`, CAIP-2 `network`, etc.). Shipping both avoids breaking older
|
|
1644
|
+
* integrations while giving spec-aligned clients a deterministic header to parse.
|
|
1645
|
+
*
|
|
1646
|
+
* **Why base64:** per x402 V2 HTTP docs, header values are base64-encoded JSON so
|
|
1647
|
+
* proxies and intermediaries do not mangle structured characters.
|
|
1648
|
+
*/
|
|
1649
|
+
function setStandardPaymentRequiredHeaders(
|
|
1650
|
+
res: ExpressResponse,
|
|
1651
|
+
route: X402PaidRoute,
|
|
1652
|
+
runtime?: X402Runtime,
|
|
1653
|
+
error = "Payment Required",
|
|
1654
|
+
): void {
|
|
1655
|
+
if (!res.setHeader || res.headersSent) return;
|
|
1656
|
+
const paymentConfigNames = route.x402.paymentConfigs || ["base_usdc"];
|
|
1657
|
+
const agentId = runtime?.agentId ? String(runtime.agentId) : undefined;
|
|
1658
|
+
const paymentRequired = buildStandardPaymentRequired({
|
|
1659
|
+
routePath: route.path,
|
|
1660
|
+
description: generateDescription(route),
|
|
1661
|
+
priceInCents: route.x402.priceInCents,
|
|
1662
|
+
paymentConfigNames,
|
|
1663
|
+
agentId,
|
|
1664
|
+
error,
|
|
1665
|
+
});
|
|
1666
|
+
const encoded = Buffer.from(JSON.stringify(paymentRequired), "utf8").toString(
|
|
1667
|
+
"base64",
|
|
1668
|
+
);
|
|
1669
|
+
res.setHeader("PAYMENT-REQUIRED", encoded);
|
|
1670
|
+
res.setHeader(
|
|
1671
|
+
"Access-Control-Expose-Headers",
|
|
1672
|
+
"PAYMENT-REQUIRED, PAYMENT-RESPONSE, Payment-Required, Payment-Response",
|
|
1673
|
+
);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/**
|
|
1677
|
+
* Build x402scan-compliant response for a route
|
|
1678
|
+
*/
|
|
1679
|
+
function buildX402Response(
|
|
1680
|
+
route: X402PaidRoute,
|
|
1681
|
+
runtime?: X402Runtime,
|
|
1682
|
+
): X402Response {
|
|
1683
|
+
if (!route.x402.priceInCents) {
|
|
1684
|
+
throw new Error("Route x402.priceInCents is required for x402 response");
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
const paymentConfigs = route.x402.paymentConfigs || ["base_usdc"];
|
|
1688
|
+
const agentId = runtime?.agentId ? String(runtime.agentId) : undefined;
|
|
1689
|
+
|
|
1690
|
+
const accepts = paymentConfigs.flatMap((configName) => {
|
|
1691
|
+
const config = getPaymentConfig(configName, agentId);
|
|
1692
|
+
const caip19 = getCAIP19FromConfig(config);
|
|
1693
|
+
const maxAmountRequired = atomicAmountForPriceInCents(
|
|
1694
|
+
route.x402.priceInCents,
|
|
1695
|
+
config,
|
|
1696
|
+
);
|
|
1697
|
+
|
|
1698
|
+
const inputSchema = buildInputSchemaFromRoute(route);
|
|
1699
|
+
|
|
1700
|
+
const method = route.type === "POST" ? "POST" : "GET";
|
|
1701
|
+
|
|
1702
|
+
const outputSchema: OutputSchema = {
|
|
1703
|
+
input: {
|
|
1704
|
+
type: "http",
|
|
1705
|
+
method: method,
|
|
1706
|
+
bodyType: method === "POST" ? "json" : undefined,
|
|
1707
|
+
pathParams: inputSchema.pathParams,
|
|
1708
|
+
queryParams: inputSchema.queryParams,
|
|
1709
|
+
bodyFields: inputSchema.bodyFields,
|
|
1710
|
+
headerFields: {
|
|
1711
|
+
"X-Payment": {
|
|
1712
|
+
type: "string",
|
|
1713
|
+
required: false,
|
|
1714
|
+
description:
|
|
1715
|
+
"Standard x402 payment header (base64-encoded JSON or raw JSON with x402Version, accepted, payload) — verified via facilitator POST when configured",
|
|
1716
|
+
},
|
|
1717
|
+
"X-Payment-Proof": {
|
|
1718
|
+
type: "string",
|
|
1719
|
+
required: false,
|
|
1720
|
+
description:
|
|
1721
|
+
"Legacy payment proof (tx hash, colon-delimited, or JSON)",
|
|
1722
|
+
},
|
|
1723
|
+
"X-Payment-Id": {
|
|
1724
|
+
type: "string",
|
|
1725
|
+
required: false,
|
|
1726
|
+
description: "Optional payment ID for tracking",
|
|
1727
|
+
},
|
|
1728
|
+
},
|
|
1729
|
+
},
|
|
1730
|
+
output: {
|
|
1731
|
+
type: "object",
|
|
1732
|
+
description: "API response data (varies by endpoint)",
|
|
1733
|
+
},
|
|
1734
|
+
};
|
|
1735
|
+
|
|
1736
|
+
const extra: PaymentExtraMetadata = {
|
|
1737
|
+
priceInCents: route.x402.priceInCents || 0,
|
|
1738
|
+
priceUSD: `$${((route.x402.priceInCents || 0) / 100).toFixed(2)}`,
|
|
1739
|
+
symbol: config.symbol,
|
|
1740
|
+
paymentConfig: configName,
|
|
1741
|
+
expiresIn: 300, // Payment window in seconds
|
|
1742
|
+
};
|
|
1743
|
+
|
|
1744
|
+
// Add EIP-712 domain for EVM chains (helps client developers)
|
|
1745
|
+
if (
|
|
1746
|
+
config.network === "BASE" ||
|
|
1747
|
+
config.network === "POLYGON" ||
|
|
1748
|
+
config.network === "BSC"
|
|
1749
|
+
) {
|
|
1750
|
+
const isUsdc = config.symbol?.toUpperCase() === "USDC";
|
|
1751
|
+
const tokenName = isUsdc ? "USD Coin" : config.symbol || "Token";
|
|
1752
|
+
extra.name = tokenName;
|
|
1753
|
+
extra.version = "2";
|
|
1754
|
+
extra.eip712Domain = {
|
|
1755
|
+
name: tokenName,
|
|
1756
|
+
version: "2",
|
|
1757
|
+
chainId: Number.parseInt(config.chainId || "1", 10),
|
|
1758
|
+
verifyingContract: config.assetReference,
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
return createAccepts({
|
|
1763
|
+
network: toX402Network(config.network),
|
|
1764
|
+
maxAmountRequired,
|
|
1765
|
+
resource: toResourceUrl(route.path),
|
|
1766
|
+
description: generateDescription(route),
|
|
1767
|
+
payTo: config.paymentAddress,
|
|
1768
|
+
asset: caip19,
|
|
1769
|
+
mimeType: "application/json",
|
|
1770
|
+
maxTimeoutSeconds: 300,
|
|
1771
|
+
outputSchema,
|
|
1772
|
+
extra,
|
|
1773
|
+
});
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
return createX402Response({
|
|
1777
|
+
accepts,
|
|
1778
|
+
error: "Payment Required",
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/**
|
|
1783
|
+
* Extract path parameter names from Express-style route path
|
|
1784
|
+
*/
|
|
1785
|
+
function extractPathParams(path: string): string[] {
|
|
1786
|
+
const matches = path.matchAll(/:([^/]+)/g);
|
|
1787
|
+
return Array.from(matches, (m) => m[1]);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/**
|
|
1791
|
+
* OpenAPI schema types for type safety
|
|
1792
|
+
*/
|
|
1793
|
+
interface OpenAPIPropertySchema {
|
|
1794
|
+
type?: string;
|
|
1795
|
+
description?: string;
|
|
1796
|
+
enum?: string[];
|
|
1797
|
+
pattern?: string;
|
|
1798
|
+
properties?: Record<string, OpenAPIPropertySchema>;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
interface OpenAPIObjectSchema extends OpenAPIPropertySchema {
|
|
1802
|
+
type: "object";
|
|
1803
|
+
required?: string[];
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1807
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function isOpenAPIObjectSchema(schema: unknown): schema is OpenAPIObjectSchema {
|
|
1811
|
+
if (!isRecord(schema) || schema.type !== "object") {
|
|
1812
|
+
return false;
|
|
1813
|
+
}
|
|
1814
|
+
const properties = schema.properties;
|
|
1815
|
+
return (
|
|
1816
|
+
properties === undefined ||
|
|
1817
|
+
(isRecord(properties) &&
|
|
1818
|
+
Object.values(properties).every(
|
|
1819
|
+
(property) => property === undefined || isRecord(property),
|
|
1820
|
+
))
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
/**
|
|
1825
|
+
* Field definition for schema conversion
|
|
1826
|
+
*/
|
|
1827
|
+
interface FieldDefinition {
|
|
1828
|
+
type?: string;
|
|
1829
|
+
required?: boolean;
|
|
1830
|
+
description?: string;
|
|
1831
|
+
enum?: string[];
|
|
1832
|
+
pattern?: string;
|
|
1833
|
+
properties?: Record<string, FieldDefinition>;
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
/**
|
|
1837
|
+
* Convert OpenAPI schema to FieldDef format
|
|
1838
|
+
*/
|
|
1839
|
+
function convertOpenAPISchemaToFieldDef(
|
|
1840
|
+
schema: OpenAPIObjectSchema | OpenAPIPropertySchema,
|
|
1841
|
+
): Record<string, FieldDefinition> {
|
|
1842
|
+
if ("properties" in schema && schema.properties) {
|
|
1843
|
+
const fields: Record<string, FieldDefinition> = {};
|
|
1844
|
+
for (const [key, value] of Object.entries(schema.properties)) {
|
|
1845
|
+
fields[key] = {
|
|
1846
|
+
type: value.type,
|
|
1847
|
+
required:
|
|
1848
|
+
"required" in schema && schema.required
|
|
1849
|
+
? schema.required.includes(key)
|
|
1850
|
+
: false,
|
|
1851
|
+
description: value.description,
|
|
1852
|
+
enum: value.enum,
|
|
1853
|
+
pattern: value.pattern,
|
|
1854
|
+
properties: value.properties
|
|
1855
|
+
? convertOpenAPISchemaToFieldDef(value)
|
|
1856
|
+
: undefined,
|
|
1857
|
+
};
|
|
1858
|
+
}
|
|
1859
|
+
return fields;
|
|
1860
|
+
}
|
|
1861
|
+
return {};
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
/**
|
|
1865
|
+
* Input schema structure
|
|
1866
|
+
*/
|
|
1867
|
+
interface InputSchema {
|
|
1868
|
+
pathParams?: Record<string, FieldDefinition>;
|
|
1869
|
+
queryParams?: Record<string, FieldDefinition>;
|
|
1870
|
+
bodyFields?: Record<string, FieldDefinition>;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
/**
|
|
1874
|
+
* Build input schema from route
|
|
1875
|
+
*/
|
|
1876
|
+
function buildInputSchemaFromRoute(route: PaymentEnabledRoute): InputSchema {
|
|
1877
|
+
const schema: InputSchema = {};
|
|
1878
|
+
|
|
1879
|
+
if (route.openapi?.parameters) {
|
|
1880
|
+
const pathParams: Record<string, FieldDefinition> = {};
|
|
1881
|
+
for (const p of route.openapi.parameters.filter((x) => x.in === "path")) {
|
|
1882
|
+
pathParams[p.name] = {
|
|
1883
|
+
type: p.schema.type,
|
|
1884
|
+
required: p.required ?? true,
|
|
1885
|
+
description: p.description,
|
|
1886
|
+
enum: p.schema.enum,
|
|
1887
|
+
pattern: p.schema.pattern,
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
if (Object.keys(pathParams).length > 0) schema.pathParams = pathParams;
|
|
1891
|
+
} else {
|
|
1892
|
+
const paramNames = extractPathParams(route.path);
|
|
1893
|
+
if (paramNames.length > 0) {
|
|
1894
|
+
const pathParams: Record<string, FieldDefinition> = {};
|
|
1895
|
+
for (const name of paramNames) {
|
|
1896
|
+
pathParams[name] = {
|
|
1897
|
+
type: "string",
|
|
1898
|
+
required: true,
|
|
1899
|
+
description: `Path parameter: ${name}`,
|
|
1900
|
+
};
|
|
1901
|
+
}
|
|
1902
|
+
schema.pathParams = pathParams;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
if (route.openapi?.parameters) {
|
|
1907
|
+
const queryParams: Record<string, FieldDefinition> = {};
|
|
1908
|
+
for (const p of route.openapi.parameters.filter((x) => x.in === "query")) {
|
|
1909
|
+
queryParams[p.name] = {
|
|
1910
|
+
type: p.schema.type,
|
|
1911
|
+
required: p.required ?? false,
|
|
1912
|
+
description: p.description,
|
|
1913
|
+
enum: p.schema.enum,
|
|
1914
|
+
pattern: p.schema.pattern,
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
if (Object.keys(queryParams).length > 0) schema.queryParams = queryParams;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
if (route.openapi?.requestBody?.content?.["application/json"]?.schema) {
|
|
1921
|
+
const requestBodySchema =
|
|
1922
|
+
route.openapi.requestBody.content["application/json"].schema;
|
|
1923
|
+
if (isOpenAPIObjectSchema(requestBodySchema)) {
|
|
1924
|
+
schema.bodyFields = convertOpenAPISchemaToFieldDef(requestBodySchema);
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
return schema;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
/**
|
|
1932
|
+
* Auto-generate description from route path if not provided
|
|
1933
|
+
*/
|
|
1934
|
+
function generateDescription(route: PaymentEnabledRoute): string {
|
|
1935
|
+
if (route.description) return route.description;
|
|
1936
|
+
|
|
1937
|
+
const pathParts = route.path.split("/").filter(Boolean);
|
|
1938
|
+
const action = route.type.toLowerCase() === "get" ? "Get" : "Execute";
|
|
1939
|
+
const resource =
|
|
1940
|
+
pathParts[pathParts.length - 1]?.replace(/^:/, "") || "resource";
|
|
1941
|
+
return `${action} ${resource}`;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// Re-export types from core
|
|
1945
|
+
export type { X402RequestValidator, X402ValidationResult } from "@elizaos/core";
|
|
1946
|
+
|
|
1947
|
+
/**
|
|
1948
|
+
* Apply payment protection to an array of routes
|
|
1949
|
+
* Runs comprehensive startup validation before applying protection. Pass
|
|
1950
|
+
* `character`/`agentId` so routes that use the `x402: true` shorthand (which
|
|
1951
|
+
* resolves price + paymentConfigs from `character.settings.x402`) can validate
|
|
1952
|
+
* without errors.
|
|
1953
|
+
*/
|
|
1954
|
+
export function applyPaymentProtection(
|
|
1955
|
+
routes: Route[],
|
|
1956
|
+
context?: { character?: Character; agentId?: string },
|
|
1957
|
+
): Route[] {
|
|
1958
|
+
if (!Array.isArray(routes)) {
|
|
1959
|
+
throw new Error("routes must be an array");
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
const validation = validateX402Startup(routes, context?.character, {
|
|
1963
|
+
agentId: context?.agentId,
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
if (!validation.valid) {
|
|
1967
|
+
throw new Error(
|
|
1968
|
+
`\nx402 Configuration Invalid (${validation.errors.length} error${validation.errors.length > 1 ? "s" : ""}):\n\n` +
|
|
1969
|
+
validation.errors.map((e) => ` • ${e}`).join("\n") +
|
|
1970
|
+
"\n\nPlease fix these errors and try again.\n",
|
|
1971
|
+
);
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
return routes.map((route) => {
|
|
1975
|
+
const x402Route = route as PaymentEnabledRoute;
|
|
1976
|
+
if (x402Route.x402 != null) {
|
|
1977
|
+
logger.debug(
|
|
1978
|
+
{ path: x402Route.path, x402: x402Route.x402 },
|
|
1979
|
+
"[x402] payment protection enabled",
|
|
1980
|
+
);
|
|
1981
|
+
|
|
1982
|
+
const wrappedRoute: Route & { [X402_ROUTE_PAYMENT_WRAPPED]: true } = {
|
|
1983
|
+
...route,
|
|
1984
|
+
handler: createPaymentAwareHandler(x402Route),
|
|
1985
|
+
[X402_ROUTE_PAYMENT_WRAPPED]: true,
|
|
1986
|
+
};
|
|
1987
|
+
return wrappedRoute;
|
|
1988
|
+
}
|
|
1989
|
+
return route;
|
|
1990
|
+
});
|
|
1991
|
+
}
|