@ch4p/plugin-x402 0.1.4
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/LICENSE +190 -0
- package/dist/index.d.ts +303 -0
- package/dist/index.js +330 -0
- package/package.json +24 -0
- package/src/index.ts +37 -0
- package/src/middleware.test.ts +335 -0
- package/src/middleware.ts +188 -0
- package/src/signer.test.ts +180 -0
- package/src/signer.ts +159 -0
- package/src/types.ts +168 -0
- package/src/x402-pay-tool.test.ts +324 -0
- package/src/x402-pay-tool.ts +228 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { X402PayTool } from './x402-pay-tool.js';
|
|
3
|
+
import type { X402ToolContext } from './x402-pay-tool.js';
|
|
4
|
+
import type { X402Response, X402PaymentAuthorization } from './types.js';
|
|
5
|
+
import type { ToolContext } from '@ch4p/core';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Test helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
const WALLET = '0x1234567890abcdef1234567890abcdef12345678';
|
|
12
|
+
const RECIPIENT = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd';
|
|
13
|
+
|
|
14
|
+
function makeContext(overrides: Partial<X402ToolContext> = {}): ToolContext {
|
|
15
|
+
return {
|
|
16
|
+
sessionId: 'test-session',
|
|
17
|
+
cwd: '/tmp',
|
|
18
|
+
securityPolicy: {} as ToolContext['securityPolicy'],
|
|
19
|
+
abortSignal: new AbortController().signal,
|
|
20
|
+
onProgress: vi.fn(),
|
|
21
|
+
...overrides,
|
|
22
|
+
} as unknown as ToolContext;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeX402Response(overrides: Partial<X402Response> = {}): string {
|
|
26
|
+
const response: X402Response = {
|
|
27
|
+
x402Version: 1,
|
|
28
|
+
error: 'X402',
|
|
29
|
+
accepts: [
|
|
30
|
+
{
|
|
31
|
+
scheme: 'exact',
|
|
32
|
+
network: 'base',
|
|
33
|
+
maxAmountRequired: '1000000',
|
|
34
|
+
resource: '/sessions',
|
|
35
|
+
description: 'Test payment',
|
|
36
|
+
mimeType: 'application/json',
|
|
37
|
+
payTo: RECIPIENT,
|
|
38
|
+
maxTimeoutSeconds: 300,
|
|
39
|
+
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
40
|
+
extra: {},
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
return JSON.stringify(response);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const tool = new X402PayTool();
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// validate
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
describe('X402PayTool.validate', () => {
|
|
55
|
+
it('passes with url + x402_response', () => {
|
|
56
|
+
const r = tool.validate({ url: 'https://example.com/sessions', x402_response: '{}' });
|
|
57
|
+
expect(r.valid).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('passes with all fields', () => {
|
|
61
|
+
const r = tool.validate({
|
|
62
|
+
url: 'https://example.com/sessions',
|
|
63
|
+
x402_response: '{}',
|
|
64
|
+
wallet_address: WALLET,
|
|
65
|
+
});
|
|
66
|
+
expect(r.valid).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('fails when url is missing', () => {
|
|
70
|
+
const r = tool.validate({ x402_response: '{}' });
|
|
71
|
+
expect(r.valid).toBe(false);
|
|
72
|
+
expect(r.errors).toContain('url must be a non-empty string.');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('fails when x402_response is missing', () => {
|
|
76
|
+
const r = tool.validate({ url: 'https://example.com' });
|
|
77
|
+
expect(r.valid).toBe(false);
|
|
78
|
+
expect(r.errors).toContain('x402_response must be a non-empty string.');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('fails on invalid wallet_address format', () => {
|
|
82
|
+
const r = tool.validate({
|
|
83
|
+
url: 'https://example.com',
|
|
84
|
+
x402_response: '{}',
|
|
85
|
+
wallet_address: 'not-an-address',
|
|
86
|
+
});
|
|
87
|
+
expect(r.valid).toBe(false);
|
|
88
|
+
expect(r.errors?.some((e) => e.includes('wallet_address'))).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('fails when args is not an object', () => {
|
|
92
|
+
const r = tool.validate('string');
|
|
93
|
+
expect(r.valid).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// execute — error cases
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
describe('X402PayTool.execute — error cases', () => {
|
|
102
|
+
it('returns error when args are invalid', async () => {
|
|
103
|
+
const result = await tool.execute({}, makeContext());
|
|
104
|
+
expect(result.success).toBe(false);
|
|
105
|
+
expect(result.error).toBeDefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns error when x402_response is not valid JSON', async () => {
|
|
109
|
+
const result = await tool.execute(
|
|
110
|
+
{ url: 'https://example.com/sessions', x402_response: 'not json', wallet_address: WALLET },
|
|
111
|
+
makeContext(),
|
|
112
|
+
);
|
|
113
|
+
expect(result.success).toBe(false);
|
|
114
|
+
expect(result.error).toContain('not valid JSON');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('returns error when accepts array is empty', async () => {
|
|
118
|
+
const result = await tool.execute(
|
|
119
|
+
{
|
|
120
|
+
url: 'https://example.com/sessions',
|
|
121
|
+
x402_response: JSON.stringify({ x402Version: 1, error: 'X402', accepts: [] }),
|
|
122
|
+
wallet_address: WALLET,
|
|
123
|
+
},
|
|
124
|
+
makeContext(),
|
|
125
|
+
);
|
|
126
|
+
expect(result.success).toBe(false);
|
|
127
|
+
expect(result.error).toContain('no payment requirements');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('returns error when no wallet address is available', async () => {
|
|
131
|
+
const result = await tool.execute(
|
|
132
|
+
{
|
|
133
|
+
url: 'https://example.com/sessions',
|
|
134
|
+
x402_response: makeX402Response(),
|
|
135
|
+
// no wallet_address in args or context
|
|
136
|
+
},
|
|
137
|
+
makeContext(), // no agentWalletAddress in context
|
|
138
|
+
);
|
|
139
|
+
expect(result.success).toBe(false);
|
|
140
|
+
expect(result.error).toContain('No wallet address');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('returns error when signer throws', async () => {
|
|
144
|
+
const context = makeContext({
|
|
145
|
+
agentWalletAddress: WALLET,
|
|
146
|
+
x402Signer: async () => {
|
|
147
|
+
throw new Error('key vault unavailable');
|
|
148
|
+
},
|
|
149
|
+
} as Partial<X402ToolContext>);
|
|
150
|
+
const result = await tool.execute(
|
|
151
|
+
{ url: 'https://example.com/sessions', x402_response: makeX402Response() },
|
|
152
|
+
context,
|
|
153
|
+
);
|
|
154
|
+
expect(result.success).toBe(false);
|
|
155
|
+
expect(result.error).toContain('Signing failed');
|
|
156
|
+
expect(result.error).toContain('key vault unavailable');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// execute — success cases
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
describe('X402PayTool.execute — success cases', () => {
|
|
165
|
+
it('builds payment header with explicit wallet_address (placeholder sig)', async () => {
|
|
166
|
+
const result = await tool.execute(
|
|
167
|
+
{
|
|
168
|
+
url: 'https://gateway.example.com/sessions',
|
|
169
|
+
x402_response: makeX402Response(),
|
|
170
|
+
wallet_address: WALLET,
|
|
171
|
+
},
|
|
172
|
+
makeContext(),
|
|
173
|
+
);
|
|
174
|
+
expect(result.success).toBe(true);
|
|
175
|
+
expect(result.output).toContain('X-PAYMENT header value');
|
|
176
|
+
expect(result.metadata?.unsigned).toBe(true);
|
|
177
|
+
expect(result.metadata?.payTo).toBe(RECIPIENT);
|
|
178
|
+
expect(result.metadata?.amount).toBe('1000000');
|
|
179
|
+
expect(result.metadata?.network).toBe('base');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('uses agentWalletAddress from context when no explicit address', async () => {
|
|
183
|
+
const context = makeContext({ agentWalletAddress: WALLET } as Partial<X402ToolContext>);
|
|
184
|
+
const result = await tool.execute(
|
|
185
|
+
{
|
|
186
|
+
url: 'https://gateway.example.com/sessions',
|
|
187
|
+
x402_response: makeX402Response(),
|
|
188
|
+
},
|
|
189
|
+
context,
|
|
190
|
+
);
|
|
191
|
+
expect(result.success).toBe(true);
|
|
192
|
+
expect(result.output).toContain(WALLET);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('uses x402Signer when provided (no placeholder)', async () => {
|
|
196
|
+
const signer = vi.fn<[X402PaymentAuthorization], Promise<string>>()
|
|
197
|
+
.mockResolvedValue('0xdeadbeef');
|
|
198
|
+
const context = makeContext({
|
|
199
|
+
agentWalletAddress: WALLET,
|
|
200
|
+
x402Signer: signer,
|
|
201
|
+
} as Partial<X402ToolContext>);
|
|
202
|
+
const result = await tool.execute(
|
|
203
|
+
{
|
|
204
|
+
url: 'https://gateway.example.com/sessions',
|
|
205
|
+
x402_response: makeX402Response(),
|
|
206
|
+
},
|
|
207
|
+
context,
|
|
208
|
+
);
|
|
209
|
+
expect(result.success).toBe(true);
|
|
210
|
+
expect(result.metadata?.unsigned).toBe(false);
|
|
211
|
+
expect(signer).toHaveBeenCalledOnce();
|
|
212
|
+
// Decode the header and check the signature.
|
|
213
|
+
const headerValue = result.metadata?.headerValue as string;
|
|
214
|
+
const decoded = JSON.parse(Buffer.from(headerValue, 'base64').toString('utf-8'));
|
|
215
|
+
expect(decoded.payload.signature).toBe('0xdeadbeef');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('produces a valid base64-encoded X402PaymentPayload', async () => {
|
|
219
|
+
const result = await tool.execute(
|
|
220
|
+
{
|
|
221
|
+
url: 'https://gateway.example.com/sessions',
|
|
222
|
+
x402_response: makeX402Response(),
|
|
223
|
+
wallet_address: WALLET,
|
|
224
|
+
},
|
|
225
|
+
makeContext(),
|
|
226
|
+
);
|
|
227
|
+
const headerValue = result.metadata?.headerValue as string;
|
|
228
|
+
const decoded = JSON.parse(Buffer.from(headerValue, 'base64').toString('utf-8'));
|
|
229
|
+
expect(decoded.x402Version).toBe(1);
|
|
230
|
+
expect(decoded.scheme).toBe('exact');
|
|
231
|
+
expect(decoded.network).toBe('base');
|
|
232
|
+
expect(decoded.payload.authorization.from).toBe(WALLET);
|
|
233
|
+
expect(decoded.payload.authorization.to).toBe(RECIPIENT);
|
|
234
|
+
expect(decoded.payload.authorization.value).toBe('1000000');
|
|
235
|
+
expect(decoded.payload.authorization.nonce).toMatch(/^0x[0-9a-f]{64}$/);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('prefers Base network requirement over others', async () => {
|
|
239
|
+
const response: X402Response = {
|
|
240
|
+
x402Version: 1,
|
|
241
|
+
error: 'X402',
|
|
242
|
+
accepts: [
|
|
243
|
+
{
|
|
244
|
+
scheme: 'exact',
|
|
245
|
+
network: 'ethereum',
|
|
246
|
+
maxAmountRequired: '2000000000000000',
|
|
247
|
+
resource: '/sessions',
|
|
248
|
+
description: 'ETH payment',
|
|
249
|
+
mimeType: 'application/json',
|
|
250
|
+
payTo: RECIPIENT,
|
|
251
|
+
maxTimeoutSeconds: 60,
|
|
252
|
+
asset: '0x0',
|
|
253
|
+
extra: {},
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
scheme: 'exact',
|
|
257
|
+
network: 'base',
|
|
258
|
+
maxAmountRequired: '1000000',
|
|
259
|
+
resource: '/sessions',
|
|
260
|
+
description: 'USDC on Base',
|
|
261
|
+
mimeType: 'application/json',
|
|
262
|
+
payTo: RECIPIENT,
|
|
263
|
+
maxTimeoutSeconds: 300,
|
|
264
|
+
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
265
|
+
extra: {},
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
};
|
|
269
|
+
const result = await tool.execute(
|
|
270
|
+
{
|
|
271
|
+
url: 'https://gateway.example.com/sessions',
|
|
272
|
+
x402_response: JSON.stringify(response),
|
|
273
|
+
wallet_address: WALLET,
|
|
274
|
+
},
|
|
275
|
+
makeContext(),
|
|
276
|
+
);
|
|
277
|
+
expect(result.success).toBe(true);
|
|
278
|
+
expect(result.metadata?.network).toBe('base');
|
|
279
|
+
expect(result.metadata?.amount).toBe('1000000');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('outputs a warning when signature is unsigned', async () => {
|
|
283
|
+
const result = await tool.execute(
|
|
284
|
+
{
|
|
285
|
+
url: 'https://gateway.example.com/sessions',
|
|
286
|
+
x402_response: makeX402Response(),
|
|
287
|
+
wallet_address: WALLET,
|
|
288
|
+
},
|
|
289
|
+
makeContext(),
|
|
290
|
+
);
|
|
291
|
+
expect(result.output).toContain('WARNING');
|
|
292
|
+
expect(result.output).toContain('Placeholder signature');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('does not output a warning when signed', async () => {
|
|
296
|
+
const context = makeContext({
|
|
297
|
+
agentWalletAddress: WALLET,
|
|
298
|
+
x402Signer: async () => '0xsig',
|
|
299
|
+
} as Partial<X402ToolContext>);
|
|
300
|
+
const result = await tool.execute(
|
|
301
|
+
{ url: 'https://example.com/sessions', x402_response: makeX402Response() },
|
|
302
|
+
context,
|
|
303
|
+
);
|
|
304
|
+
expect(result.output).not.toContain('WARNING');
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Tool metadata
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
describe('X402PayTool metadata', () => {
|
|
313
|
+
it('has correct name and weight', () => {
|
|
314
|
+
expect(tool.name).toBe('x402_pay');
|
|
315
|
+
expect(tool.weight).toBe('lightweight');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('has required fields in parameters schema', () => {
|
|
319
|
+
const schema = tool.parameters;
|
|
320
|
+
expect(schema.required).toContain('url');
|
|
321
|
+
expect(schema.required).toContain('x402_response');
|
|
322
|
+
expect(schema.required).not.toContain('wallet_address');
|
|
323
|
+
});
|
|
324
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X402PayTool — agent tool for paying x402-gated resources.
|
|
3
|
+
*
|
|
4
|
+
* When an HTTP request returns 402 Payment Required with an x402 response
|
|
5
|
+
* body, the agent uses this tool to construct the X-PAYMENT header value
|
|
6
|
+
* required to retry the request with proof of payment.
|
|
7
|
+
*
|
|
8
|
+
* The tool produces a structurally correct EIP-3009 payment payload.
|
|
9
|
+
* Actual signing (EIP-712 transferWithAuthorization) is delegated to an
|
|
10
|
+
* optional `x402Signer` callback in the tool context. Without a signer,
|
|
11
|
+
* the payload includes a placeholder signature clearly marked as unsigned
|
|
12
|
+
* — suitable for development and testing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ITool, ToolContext, ToolResult, ValidationResult, JSONSchema7 } from '@ch4p/core';
|
|
16
|
+
import type { X402Response, X402PaymentAuthorization, X402PaymentPayload } from './types.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extended ToolContext for x402 payment operations.
|
|
20
|
+
*
|
|
21
|
+
* x402Signer and agentWalletAddress are now part of the base ToolContext
|
|
22
|
+
* in @ch4p/core, so this is a plain alias kept for backward compatibility.
|
|
23
|
+
*/
|
|
24
|
+
export type X402ToolContext = ToolContext;
|
|
25
|
+
|
|
26
|
+
interface X402PayArgs {
|
|
27
|
+
url: string;
|
|
28
|
+
x402_response: string;
|
|
29
|
+
wallet_address?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Placeholder signature used when no signer is available. */
|
|
33
|
+
const PLACEHOLDER_SIG =
|
|
34
|
+
'0x0000000000000000000000000000000000000000000000000000000000000000' +
|
|
35
|
+
'0000000000000000000000000000000000000000000000000000000000000000';
|
|
36
|
+
|
|
37
|
+
export class X402PayTool implements ITool {
|
|
38
|
+
readonly name = 'x402_pay';
|
|
39
|
+
readonly description =
|
|
40
|
+
'Generate an X-PAYMENT header for a resource that returned HTTP 402 Payment Required. ' +
|
|
41
|
+
'Provide the 402 response body JSON and a payer wallet address. ' +
|
|
42
|
+
'Returns the base64-encoded X-PAYMENT value to include when retrying the request. ' +
|
|
43
|
+
'Full payment execution requires an IIdentityProvider with wallet signing support.';
|
|
44
|
+
|
|
45
|
+
readonly weight = 'lightweight' as const;
|
|
46
|
+
|
|
47
|
+
readonly parameters: JSONSchema7 = {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
url: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
description: 'The URL of the resource that returned 402 (used to match the requirement).',
|
|
53
|
+
minLength: 1,
|
|
54
|
+
},
|
|
55
|
+
x402_response: {
|
|
56
|
+
type: 'string',
|
|
57
|
+
description:
|
|
58
|
+
'The JSON body of the 402 response. Stringify the x402 error response object.',
|
|
59
|
+
minLength: 2,
|
|
60
|
+
},
|
|
61
|
+
wallet_address: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
description:
|
|
64
|
+
'Payer wallet address (0x + 40 hex chars). ' +
|
|
65
|
+
'If omitted, the identity provider wallet address is used when available.',
|
|
66
|
+
pattern: '^0x[0-9a-fA-F]{40}$',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
required: ['url', 'x402_response'],
|
|
70
|
+
additionalProperties: false,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
validate(args: unknown): ValidationResult {
|
|
74
|
+
if (typeof args !== 'object' || args === null) {
|
|
75
|
+
return { valid: false, errors: ['Arguments must be an object.'] };
|
|
76
|
+
}
|
|
77
|
+
const { url, x402_response, wallet_address } = args as Record<string, unknown>;
|
|
78
|
+
const errors: string[] = [];
|
|
79
|
+
|
|
80
|
+
if (typeof url !== 'string' || url.trim().length === 0) {
|
|
81
|
+
errors.push('url must be a non-empty string.');
|
|
82
|
+
}
|
|
83
|
+
if (typeof x402_response !== 'string' || x402_response.trim().length === 0) {
|
|
84
|
+
errors.push('x402_response must be a non-empty string.');
|
|
85
|
+
}
|
|
86
|
+
if (wallet_address !== undefined) {
|
|
87
|
+
if (
|
|
88
|
+
typeof wallet_address !== 'string' ||
|
|
89
|
+
!/^0x[0-9a-fA-F]{40}$/.test(wallet_address)
|
|
90
|
+
) {
|
|
91
|
+
errors.push('wallet_address must be a valid Ethereum address (0x + 40 hex chars).');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return errors.length > 0 ? { valid: false, errors } : { valid: true };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async execute(args: unknown, context: ToolContext): Promise<ToolResult> {
|
|
98
|
+
const validation = this.validate(args);
|
|
99
|
+
if (!validation.valid) {
|
|
100
|
+
return {
|
|
101
|
+
success: false,
|
|
102
|
+
output: '',
|
|
103
|
+
error: `Invalid arguments: ${validation.errors!.join(' ')}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { url, x402_response, wallet_address } = args as X402PayArgs;
|
|
108
|
+
const x402Context = context as X402ToolContext;
|
|
109
|
+
|
|
110
|
+
// Parse the 402 response body.
|
|
111
|
+
let response: X402Response;
|
|
112
|
+
try {
|
|
113
|
+
response = JSON.parse(x402_response) as X402Response;
|
|
114
|
+
} catch {
|
|
115
|
+
return { success: false, output: '', error: 'x402_response is not valid JSON.' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!Array.isArray(response.accepts) || response.accepts.length === 0) {
|
|
119
|
+
return {
|
|
120
|
+
success: false,
|
|
121
|
+
output: '',
|
|
122
|
+
error: 'x402 response contains no payment requirements in "accepts".',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Prefer Base network, fall back to first exact scheme, then first entry.
|
|
127
|
+
const requirements =
|
|
128
|
+
response.accepts.find((r) => r.scheme === 'exact' && r.network === 'base') ??
|
|
129
|
+
response.accepts.find((r) => r.scheme === 'exact') ??
|
|
130
|
+
response.accepts[0]!;
|
|
131
|
+
|
|
132
|
+
// Determine payer wallet address (explicit arg takes priority).
|
|
133
|
+
const payer = wallet_address ?? x402Context.agentWalletAddress;
|
|
134
|
+
if (!payer) {
|
|
135
|
+
return {
|
|
136
|
+
success: false,
|
|
137
|
+
output: '',
|
|
138
|
+
error:
|
|
139
|
+
'No wallet address available. Provide wallet_address or configure ' +
|
|
140
|
+
'agentWalletAddress via toolContextExtensions in the agent runtime.',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Build EIP-3009 authorization struct.
|
|
145
|
+
const nowSecs = Math.floor(Date.now() / 1000);
|
|
146
|
+
const randomBytes = new Uint8Array(32);
|
|
147
|
+
if (typeof globalThis.crypto !== 'undefined') {
|
|
148
|
+
globalThis.crypto.getRandomValues(randomBytes);
|
|
149
|
+
} else {
|
|
150
|
+
// Fallback for environments without Web Crypto (should not happen in Node 22+).
|
|
151
|
+
// WARNING: Math.random() is not cryptographically secure — nonce may be predictable.
|
|
152
|
+
console.warn('x402: crypto.getRandomValues unavailable; using insecure Math.random fallback for nonce.');
|
|
153
|
+
for (let i = 0; i < 32; i++) {
|
|
154
|
+
randomBytes[i] = Math.floor(Math.random() * 256);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const nonce =
|
|
158
|
+
'0x' + Array.from(randomBytes).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
159
|
+
|
|
160
|
+
const authorization: X402PaymentAuthorization = {
|
|
161
|
+
from: payer,
|
|
162
|
+
to: requirements.payTo,
|
|
163
|
+
value: requirements.maxAmountRequired,
|
|
164
|
+
validAfter: '0',
|
|
165
|
+
validBefore: String(nowSecs + requirements.maxTimeoutSeconds),
|
|
166
|
+
nonce,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Sign if a signer is available, otherwise emit a placeholder.
|
|
170
|
+
let signature: string;
|
|
171
|
+
let unsigned = false;
|
|
172
|
+
if (x402Context.x402Signer) {
|
|
173
|
+
try {
|
|
174
|
+
signature = await x402Context.x402Signer(authorization);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
return {
|
|
177
|
+
success: false,
|
|
178
|
+
output: '',
|
|
179
|
+
error: `Signing failed: ${(err as Error).message}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
signature = PLACEHOLDER_SIG;
|
|
184
|
+
unsigned = true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const paymentPayload: X402PaymentPayload = {
|
|
188
|
+
x402Version: 1,
|
|
189
|
+
scheme: 'exact',
|
|
190
|
+
network: requirements.network,
|
|
191
|
+
payload: { signature, authorization },
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const headerValue = Buffer.from(JSON.stringify(paymentPayload)).toString('base64');
|
|
195
|
+
|
|
196
|
+
const lines: string[] = [
|
|
197
|
+
`Resource: ${url}`,
|
|
198
|
+
`Network: ${requirements.network}`,
|
|
199
|
+
`Amount: ${requirements.maxAmountRequired} (asset ${requirements.asset})`,
|
|
200
|
+
`Pay to: ${requirements.payTo}`,
|
|
201
|
+
`From: ${payer}`,
|
|
202
|
+
'',
|
|
203
|
+
'X-PAYMENT header value (add to your retry request):',
|
|
204
|
+
headerValue,
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
if (unsigned) {
|
|
208
|
+
lines.push(
|
|
209
|
+
'',
|
|
210
|
+
'WARNING: Placeholder signature — cannot be used for real on-chain payments.',
|
|
211
|
+
'Configure an IIdentityProvider with a bound wallet to enable live signing.',
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
success: true,
|
|
217
|
+
output: lines.join('\n'),
|
|
218
|
+
metadata: {
|
|
219
|
+
headerValue,
|
|
220
|
+
network: requirements.network,
|
|
221
|
+
amount: requirements.maxAmountRequired,
|
|
222
|
+
payTo: requirements.payTo,
|
|
223
|
+
asset: requirements.asset,
|
|
224
|
+
unsigned,
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|