@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.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Tests for the x402 EIP-712 signer.
3
+ *
4
+ * Uses a well-known test private key to verify:
5
+ * - walletAddress() derives the correct checksummed address.
6
+ * - createEIP712Signer() produces a 65-byte EIP-712 signature.
7
+ * - The signature verifies against the expected typed data.
8
+ * - KNOWN_TOKENS contains correct network entries.
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+ import { ethers } from 'ethers';
13
+ import { createEIP712Signer, walletAddress, KNOWN_TOKENS } from './signer.js';
14
+ import type { X402PaymentAuthorization } from './types.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Test fixtures
18
+ //
19
+ // Using the Ethereum test private key from the hardhat / foundry well-known
20
+ // set. This key has no real funds and is safe for testing.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const TEST_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
24
+ const EXPECTED_ADDRESS = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266';
25
+
26
+ const SAMPLE_AUTH: X402PaymentAuthorization = {
27
+ from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
28
+ to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
29
+ value: '1000000', // 1 USDC
30
+ validAfter: '0',
31
+ validBefore: '9999999999',
32
+ nonce: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
33
+ };
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // walletAddress
37
+ // ---------------------------------------------------------------------------
38
+
39
+ describe('walletAddress', () => {
40
+ it('returns the checksummed Ethereum address for the hardhat test key', () => {
41
+ const addr = walletAddress(TEST_PRIVATE_KEY);
42
+ expect(addr).toBe(EXPECTED_ADDRESS);
43
+ });
44
+
45
+ it('returns a checksummed EIP-55 address (mixed-case)', () => {
46
+ const addr = walletAddress(TEST_PRIVATE_KEY);
47
+ // EIP-55 checksummed addresses are not all-lowercase.
48
+ expect(addr).not.toBe(addr.toLowerCase());
49
+ });
50
+ });
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // KNOWN_TOKENS
54
+ // ---------------------------------------------------------------------------
55
+
56
+ describe('KNOWN_TOKENS', () => {
57
+ it('contains a base entry with correct chainId', () => {
58
+ expect(KNOWN_TOKENS.base.chainId).toBe(8453);
59
+ expect(KNOWN_TOKENS.base.address).toMatch(/^0x/);
60
+ });
61
+
62
+ it('contains a base-sepolia entry with correct chainId', () => {
63
+ expect(KNOWN_TOKENS['base-sepolia'].chainId).toBe(84532);
64
+ expect(KNOWN_TOKENS['base-sepolia'].address).toMatch(/^0x/);
65
+ });
66
+
67
+ it('base and base-sepolia have different contract addresses', () => {
68
+ expect(KNOWN_TOKENS.base.address).not.toBe(KNOWN_TOKENS['base-sepolia'].address);
69
+ });
70
+ });
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // createEIP712Signer
74
+ // ---------------------------------------------------------------------------
75
+
76
+ describe('createEIP712Signer', () => {
77
+ it('returns a function (the signer callback)', () => {
78
+ const signer = createEIP712Signer(TEST_PRIVATE_KEY);
79
+ expect(typeof signer).toBe('function');
80
+ });
81
+
82
+ it('produces a 65-byte hex signature (0x + 130 hex chars)', async () => {
83
+ const signer = createEIP712Signer(TEST_PRIVATE_KEY);
84
+ const sig = await signer(SAMPLE_AUTH);
85
+
86
+ // EIP-712 signatures are 65 bytes: 32 (r) + 32 (s) + 1 (v).
87
+ // Represented as "0x" + 130 lowercase hex characters.
88
+ expect(sig).toMatch(/^0x[0-9a-f]{130}$/i);
89
+ });
90
+
91
+ it('signature verifies with ethers.verifyTypedData', async () => {
92
+ const signer = createEIP712Signer(TEST_PRIVATE_KEY);
93
+ const sig = await signer(SAMPLE_AUTH);
94
+
95
+ const domain = {
96
+ name: KNOWN_TOKENS.base.name,
97
+ version: KNOWN_TOKENS.base.version,
98
+ chainId: KNOWN_TOKENS.base.chainId,
99
+ verifyingContract: KNOWN_TOKENS.base.address,
100
+ };
101
+ const types = {
102
+ TransferWithAuthorization: [
103
+ { name: 'from', type: 'address' },
104
+ { name: 'to', type: 'address' },
105
+ { name: 'value', type: 'uint256' },
106
+ { name: 'validAfter', type: 'uint256' },
107
+ { name: 'validBefore', type: 'uint256' },
108
+ { name: 'nonce', type: 'bytes32' },
109
+ ],
110
+ };
111
+ const message = {
112
+ from: SAMPLE_AUTH.from,
113
+ to: SAMPLE_AUTH.to,
114
+ value: BigInt(SAMPLE_AUTH.value),
115
+ validAfter: BigInt(SAMPLE_AUTH.validAfter),
116
+ validBefore: BigInt(SAMPLE_AUTH.validBefore),
117
+ nonce: SAMPLE_AUTH.nonce,
118
+ };
119
+
120
+ const recovered = ethers.verifyTypedData(domain, types, message, sig);
121
+ expect(recovered).toBe(EXPECTED_ADDRESS);
122
+ });
123
+
124
+ it('produces different signatures for different authorizations', async () => {
125
+ const signer = createEIP712Signer(TEST_PRIVATE_KEY);
126
+
127
+ const auth2: X402PaymentAuthorization = {
128
+ ...SAMPLE_AUTH,
129
+ nonce: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
130
+ };
131
+
132
+ const sig1 = await signer(SAMPLE_AUTH);
133
+ const sig2 = await signer(auth2);
134
+
135
+ expect(sig1).not.toBe(sig2);
136
+ });
137
+
138
+ it('accepts custom chainId and tokenAddress opts', async () => {
139
+ const signer = createEIP712Signer(TEST_PRIVATE_KEY, {
140
+ chainId: KNOWN_TOKENS['base-sepolia'].chainId,
141
+ tokenAddress: KNOWN_TOKENS['base-sepolia'].address,
142
+ tokenName: KNOWN_TOKENS['base-sepolia'].name,
143
+ tokenVersion: KNOWN_TOKENS['base-sepolia'].version,
144
+ });
145
+
146
+ const sig = await signer(SAMPLE_AUTH);
147
+ // Should be a valid 65-byte signature.
148
+ expect(sig).toMatch(/^0x[0-9a-f]{130}$/i);
149
+
150
+ // Signature over base-sepolia domain should NOT verify against base domain.
151
+ const baseDomain = {
152
+ name: KNOWN_TOKENS.base.name,
153
+ version: KNOWN_TOKENS.base.version,
154
+ chainId: KNOWN_TOKENS.base.chainId,
155
+ verifyingContract: KNOWN_TOKENS.base.address,
156
+ };
157
+ const types = {
158
+ TransferWithAuthorization: [
159
+ { name: 'from', type: 'address' },
160
+ { name: 'to', type: 'address' },
161
+ { name: 'value', type: 'uint256' },
162
+ { name: 'validAfter', type: 'uint256' },
163
+ { name: 'validBefore', type: 'uint256' },
164
+ { name: 'nonce', type: 'bytes32' },
165
+ ],
166
+ };
167
+ const message = {
168
+ from: SAMPLE_AUTH.from,
169
+ to: SAMPLE_AUTH.to,
170
+ value: BigInt(SAMPLE_AUTH.value),
171
+ validAfter: BigInt(SAMPLE_AUTH.validAfter),
172
+ validBefore: BigInt(SAMPLE_AUTH.validBefore),
173
+ nonce: SAMPLE_AUTH.nonce,
174
+ };
175
+
176
+ const recovered = ethers.verifyTypedData(baseDomain, types, message, sig);
177
+ // Different domain — recovered address should differ from expected.
178
+ expect(recovered).not.toBe(EXPECTED_ADDRESS);
179
+ });
180
+ });
package/src/signer.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * EIP-712 signer for x402 EIP-3009 transferWithAuthorization.
3
+ *
4
+ * Provides a ready-to-use signer factory for the `x402Signer` callback in
5
+ * X402ToolContext. Uses ethers.js v6 `Wallet.signTypedData()` to produce a
6
+ * standards-compliant EIP-712 signature over the `TransferWithAuthorization`
7
+ * struct defined in USDC (ERC-20 + EIP-3009).
8
+ *
9
+ * Quick start:
10
+ *
11
+ * ```ts
12
+ * import { createEIP712Signer, walletAddress } from '@ch4p/plugin-x402';
13
+ *
14
+ * const signer = createEIP712Signer(process.env.X402_PRIVATE_KEY!);
15
+ * const agentWallet = walletAddress(process.env.X402_PRIVATE_KEY!);
16
+ * ```
17
+ */
18
+
19
+ import { ethers } from 'ethers';
20
+ import type { X402PaymentAuthorization } from './types.js';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Well-known token addresses
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * USDC contract addresses and EIP-712 domain parameters for supported networks.
28
+ * Import `KNOWN_TOKENS` when you need to look up token details by network name.
29
+ */
30
+ export const KNOWN_TOKENS = {
31
+ base: {
32
+ address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
33
+ chainId: 8453,
34
+ name: 'USD Coin',
35
+ version: '2',
36
+ },
37
+ 'base-sepolia': {
38
+ address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
39
+ chainId: 84532,
40
+ name: 'USD Coin',
41
+ version: '2',
42
+ },
43
+ } as const;
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Public types
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export interface EIP712SignerOpts {
50
+ /**
51
+ * EIP-712 domain chain ID.
52
+ * Default: 8453 (Base mainnet).
53
+ */
54
+ chainId?: number;
55
+ /**
56
+ * ERC-20 contract address whose `TransferWithAuthorization` is signed.
57
+ * Default: USDC on Base mainnet.
58
+ */
59
+ tokenAddress?: string;
60
+ /**
61
+ * Token name for the EIP-712 domain separator.
62
+ * Default: "USD Coin".
63
+ */
64
+ tokenName?: string;
65
+ /**
66
+ * Token version for the EIP-712 domain separator.
67
+ * Default: "2".
68
+ */
69
+ tokenVersion?: string;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // EIP-712 type definitions (fixed for EIP-3009 transferWithAuthorization)
74
+ // ---------------------------------------------------------------------------
75
+
76
+ const TRANSFER_WITH_AUTHORIZATION_TYPES: Record<string, Array<{ name: string; type: string }>> = {
77
+ TransferWithAuthorization: [
78
+ { name: 'from', type: 'address' },
79
+ { name: 'to', type: 'address' },
80
+ { name: 'value', type: 'uint256' },
81
+ { name: 'validAfter', type: 'uint256' },
82
+ { name: 'validBefore', type: 'uint256' },
83
+ { name: 'nonce', type: 'bytes32' },
84
+ ],
85
+ };
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // createEIP712Signer
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * Create an EIP-712 signer for x402 EIP-3009 `transferWithAuthorization`.
93
+ *
94
+ * Returns the `x402Signer` callback expected by `X402ToolContext`.
95
+ *
96
+ * @param privateKey 0x-prefixed hex private key. Typically sourced from an
97
+ * environment variable (e.g. `process.env.X402_PRIVATE_KEY`).
98
+ * @param opts Optional overrides for chain ID, token address, name,
99
+ * and version used in the EIP-712 domain separator.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * const signer = createEIP712Signer(process.env.X402_PRIVATE_KEY!, {
104
+ * chainId: 84532, // base-sepolia
105
+ * tokenAddress: KNOWN_TOKENS['base-sepolia'].address,
106
+ * });
107
+ * ```
108
+ */
109
+ /** Validate a private key is a 0x-prefixed 32-byte hex string. */
110
+ function assertValidPrivateKey(key: string): void {
111
+ if (!/^0x[a-fA-F0-9]{64}$/.test(key)) {
112
+ throw new Error(
113
+ 'Invalid private key: expected a 0x-prefixed 64-character hex string (32 bytes).',
114
+ );
115
+ }
116
+ }
117
+
118
+ export function createEIP712Signer(
119
+ privateKey: string,
120
+ opts: EIP712SignerOpts = {},
121
+ ): (authorization: X402PaymentAuthorization) => Promise<string> {
122
+ assertValidPrivateKey(privateKey);
123
+ const wallet = new ethers.Wallet(privateKey);
124
+
125
+ const domain = {
126
+ name: opts.tokenName ?? KNOWN_TOKENS.base.name,
127
+ version: opts.tokenVersion ?? KNOWN_TOKENS.base.version,
128
+ chainId: opts.chainId ?? KNOWN_TOKENS.base.chainId,
129
+ verifyingContract: opts.tokenAddress ?? KNOWN_TOKENS.base.address,
130
+ };
131
+
132
+ return async (auth: X402PaymentAuthorization): Promise<string> => {
133
+ const message = {
134
+ from: auth.from,
135
+ to: auth.to,
136
+ value: BigInt(auth.value),
137
+ validAfter: BigInt(auth.validAfter),
138
+ validBefore: BigInt(auth.validBefore),
139
+ nonce: auth.nonce,
140
+ };
141
+
142
+ return wallet.signTypedData(domain, TRANSFER_WITH_AUTHORIZATION_TYPES, message);
143
+ };
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // walletAddress
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /**
151
+ * Derive the checksummed Ethereum address from a private key.
152
+ * Use this to populate `agentWalletAddress` in `toolContextExtensions`.
153
+ *
154
+ * @param privateKey 0x-prefixed hex private key.
155
+ */
156
+ export function walletAddress(privateKey: string): string {
157
+ assertValidPrivateKey(privateKey);
158
+ return new ethers.Wallet(privateKey).address;
159
+ }
package/src/types.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * x402 Payment Required protocol types.
3
+ *
4
+ * Based on the x402 open standard for HTTP micropayments.
5
+ * Reference: https://www.x402.org
6
+ */
7
+
8
+ /**
9
+ * Payment requirement describing what is needed to access a resource.
10
+ * Returned as part of the 402 Payment Required response body.
11
+ */
12
+ export interface X402PaymentRequirements {
13
+ /** Always "exact" — pay exactly this amount. */
14
+ scheme: 'exact';
15
+ /** Network identifier (e.g. "base", "base-sepolia", "ethereum"). */
16
+ network: string;
17
+ /** Amount in the asset's smallest unit (e.g. "1000000" = 1 USDC at 6 decimals). */
18
+ maxAmountRequired: string;
19
+ /** The URL path being protected (e.g. "/api/data"). */
20
+ resource: string;
21
+ /** Human-readable payment description shown to the payer. */
22
+ description: string;
23
+ /** MIME type of the gated resource (e.g. "application/json"). */
24
+ mimeType: string;
25
+ /** Wallet address that receives the payment. */
26
+ payTo: string;
27
+ /** Seconds before the payment authorization expires. */
28
+ maxTimeoutSeconds: number;
29
+ /** ERC-20 token contract address (zero address "0x0" for native ETH). */
30
+ asset: string;
31
+ /** Scheme-specific extra data. */
32
+ extra: Record<string, unknown>;
33
+ }
34
+
35
+ /**
36
+ * Body of an HTTP 402 Payment Required response.
37
+ */
38
+ export interface X402Response {
39
+ x402Version: 1;
40
+ error: 'X402';
41
+ accepts: X402PaymentRequirements[];
42
+ }
43
+
44
+ /**
45
+ * EIP-3009 transferWithAuthorization parameters.
46
+ * Embedded inside X402PaymentPayload.payload.
47
+ */
48
+ export interface X402PaymentAuthorization {
49
+ /** Payer wallet address. */
50
+ from: string;
51
+ /** Recipient wallet address (must match payTo). */
52
+ to: string;
53
+ /** Transfer amount in the asset's smallest unit. */
54
+ value: string;
55
+ /** Unix timestamp (seconds) after which the auth is valid. Use "0" for immediate. */
56
+ validAfter: string;
57
+ /** Unix timestamp (seconds) before which the auth must be submitted. */
58
+ validBefore: string;
59
+ /** Random 32-byte nonce (0x hex) to prevent replay attacks. */
60
+ nonce: string;
61
+ }
62
+
63
+ /**
64
+ * Payment proof sent in the X-PAYMENT HTTP header (base64-encoded JSON).
65
+ */
66
+ export interface X402PaymentPayload {
67
+ x402Version: 1;
68
+ scheme: 'exact';
69
+ network: string;
70
+ payload: {
71
+ /** EIP-712 signature over the EIP-3009 authorization struct. */
72
+ signature: string;
73
+ authorization: X402PaymentAuthorization;
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Server-side x402 protection configuration.
79
+ */
80
+ export interface X402ServerConfig {
81
+ /** Wallet address that receives payments. */
82
+ payTo: string;
83
+ /**
84
+ * Payment amount in the asset's smallest unit.
85
+ * Example: "1000000" = 1 USDC (6 decimals).
86
+ */
87
+ amount: string;
88
+ /**
89
+ * ERC-20 token contract address.
90
+ * Defaults to USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913).
91
+ */
92
+ asset?: string;
93
+ /**
94
+ * Network identifier.
95
+ * Defaults to "base".
96
+ */
97
+ network?: string;
98
+ /** Human-readable description shown in the 402 response. */
99
+ description?: string;
100
+ /**
101
+ * URL paths to gate. Supports trailing "/*" wildcard suffix.
102
+ * Examples: "/sessions", "/webhooks/*", "/*" (all paths).
103
+ * Default: all paths except /health, /.well-known/agent.json, and /pair.
104
+ */
105
+ protectedPaths?: string[];
106
+ /** Seconds before a payment authorization expires. Default: 300. */
107
+ maxTimeoutSeconds?: number;
108
+ /**
109
+ * Optional payment verifier. Called with the decoded payment and the
110
+ * active requirements after the X-PAYMENT header passes structural
111
+ * validation. Return true to grant access, false to re-issue 402.
112
+ *
113
+ * If omitted, structural validity of the X-PAYMENT header is sufficient.
114
+ * For production deployments, provide an on-chain verifier that calls
115
+ * transferWithAuthorization on the ERC-20 asset contract.
116
+ */
117
+ verifyPayment?: (
118
+ payment: X402PaymentPayload,
119
+ requirements: X402PaymentRequirements,
120
+ ) => Promise<boolean>;
121
+ }
122
+
123
+ /**
124
+ * Client-side wallet configuration for signing x402 payment authorizations.
125
+ * Set `x402.client.privateKey` (or `${X402_PRIVATE_KEY}` env substitution)
126
+ * in ~/.ch4p/config.json to enable live on-chain payments.
127
+ */
128
+ export interface X402ClientConfig {
129
+ /**
130
+ * 0x-prefixed hex private key used to sign EIP-712 payment authorizations.
131
+ * Supports env-var substitution: `"${X402_PRIVATE_KEY}"`.
132
+ *
133
+ * ⚠️ Never commit a real private key to source control.
134
+ */
135
+ privateKey?: string;
136
+ /**
137
+ * ERC-20 token contract address.
138
+ * Default: USDC on Base mainnet (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913).
139
+ */
140
+ tokenAddress?: string;
141
+ /**
142
+ * EIP-712 domain chain ID.
143
+ * Default: 8453 (Base mainnet).
144
+ */
145
+ chainId?: number;
146
+ /**
147
+ * EIP-712 domain token name.
148
+ * Default: "USD Coin".
149
+ */
150
+ tokenName?: string;
151
+ /**
152
+ * EIP-712 domain token version.
153
+ * Default: "2".
154
+ */
155
+ tokenVersion?: string;
156
+ }
157
+
158
+ /**
159
+ * Top-level x402 plugin configuration (added to ~/.ch4p/config.json).
160
+ */
161
+ export interface X402Config {
162
+ /** Whether x402 payment enforcement is active. Default: false. */
163
+ enabled?: boolean;
164
+ /** Server-side: protect gateway endpoints with payment requirements. */
165
+ server?: X402ServerConfig;
166
+ /** Client-side: wallet config for signing payment authorizations. */
167
+ client?: X402ClientConfig;
168
+ }