nullpath-mcp 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yml +43 -0
- package/.releaserc.json +9 -0
- package/README.md +63 -70
- package/SWARM_SPEC.md +75 -0
- package/dist/__tests__/payment.test.d.ts +2 -0
- package/dist/__tests__/payment.test.d.ts.map +1 -0
- package/dist/__tests__/payment.test.js +106 -0
- package/dist/__tests__/payment.test.js.map +1 -0
- package/dist/index.js +144 -23
- package/dist/index.js.map +1 -1
- package/dist/lib/eip3009.d.ts +128 -0
- package/dist/lib/eip3009.d.ts.map +1 -0
- package/dist/lib/eip3009.js +151 -0
- package/dist/lib/eip3009.js.map +1 -0
- package/dist/lib/payment.d.ts +99 -0
- package/dist/lib/payment.d.ts.map +1 -0
- package/dist/lib/payment.js +254 -0
- package/dist/lib/payment.js.map +1 -0
- package/dist/lib/wallet.d.ts +81 -0
- package/dist/lib/wallet.d.ts.map +1 -0
- package/dist/lib/wallet.js +131 -0
- package/dist/lib/wallet.js.map +1 -0
- package/docs/API_DESIGN.md +698 -0
- package/docs/CODE_REVIEW.md +322 -0
- package/package.json +4 -1
- package/src/__tests__/payment.test.ts +126 -0
- package/src/index.ts +160 -27
- package/src/lib/eip3009.ts +201 -0
- package/src/lib/payment.ts +334 -0
- package/src/lib/wallet.ts +164 -0
- package/dist/__tests__/x402.test.d.ts +0 -2
- package/dist/__tests__/x402.test.d.ts.map +0 -1
- package/dist/__tests__/x402.test.js +0 -187
- package/dist/__tests__/x402.test.js.map +0 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EIP-3009: Transfer With Authorization for USDC
|
|
3
|
+
*
|
|
4
|
+
* Implements typed data structures and signing for USDC's
|
|
5
|
+
* TransferWithAuthorization function, enabling gasless transfers
|
|
6
|
+
* where a third party can submit the transaction.
|
|
7
|
+
*
|
|
8
|
+
* @see https://eips.ethereum.org/EIPS/eip-3009
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { WalletClient } from 'viem';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* USDC contract address on Base mainnet
|
|
15
|
+
*/
|
|
16
|
+
export const USDC_ADDRESS_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as const;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* USDC decimals (standard across all networks)
|
|
20
|
+
*/
|
|
21
|
+
export const USDC_DECIMALS = 6;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* EIP-712 Domain for USDC on Base
|
|
25
|
+
*/
|
|
26
|
+
export const USDC_DOMAIN = {
|
|
27
|
+
name: 'USD Coin',
|
|
28
|
+
version: '2',
|
|
29
|
+
chainId: 8453,
|
|
30
|
+
verifyingContract: USDC_ADDRESS_BASE,
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* EIP-712 types for TransferWithAuthorization
|
|
35
|
+
*/
|
|
36
|
+
export const TRANSFER_WITH_AUTHORIZATION_TYPES = {
|
|
37
|
+
TransferWithAuthorization: [
|
|
38
|
+
{ name: 'from', type: 'address' },
|
|
39
|
+
{ name: 'to', type: 'address' },
|
|
40
|
+
{ name: 'value', type: 'uint256' },
|
|
41
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
42
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
43
|
+
{ name: 'nonce', type: 'bytes32' },
|
|
44
|
+
],
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parameters for TransferWithAuthorization
|
|
49
|
+
*/
|
|
50
|
+
export interface TransferAuthorizationParams {
|
|
51
|
+
/** Sender address (must match wallet) */
|
|
52
|
+
from: `0x${string}`;
|
|
53
|
+
/** Recipient address */
|
|
54
|
+
to: `0x${string}`;
|
|
55
|
+
/** Amount in atomic units (6 decimals for USDC) */
|
|
56
|
+
value: bigint;
|
|
57
|
+
/** Unix timestamp after which the authorization is valid */
|
|
58
|
+
validAfter: bigint;
|
|
59
|
+
/** Unix timestamp before which the authorization is valid */
|
|
60
|
+
validBefore: bigint;
|
|
61
|
+
/** Unique nonce (32 bytes) to prevent replay */
|
|
62
|
+
nonce: `0x${string}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Signed authorization ready to submit on-chain
|
|
67
|
+
*/
|
|
68
|
+
export interface SignedTransferAuthorization extends TransferAuthorizationParams {
|
|
69
|
+
/** EIP-712 signature */
|
|
70
|
+
signature: `0x${string}`;
|
|
71
|
+
/** Signature v component */
|
|
72
|
+
v: number;
|
|
73
|
+
/** Signature r component */
|
|
74
|
+
r: `0x${string}`;
|
|
75
|
+
/** Signature s component */
|
|
76
|
+
s: `0x${string}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Generate a cryptographically random nonce (32 bytes)
|
|
81
|
+
*/
|
|
82
|
+
export function generateNonce(): `0x${string}` {
|
|
83
|
+
const bytes = new Uint8Array(32);
|
|
84
|
+
crypto.getRandomValues(bytes);
|
|
85
|
+
return `0x${Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')}` as `0x${string}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Convert USD amount to USDC atomic units
|
|
90
|
+
*/
|
|
91
|
+
export function usdToAtomicUsdc(usd: number): bigint {
|
|
92
|
+
return BigInt(Math.round(usd * 10 ** USDC_DECIMALS));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Convert USDC atomic units to USD
|
|
97
|
+
*/
|
|
98
|
+
export function atomicUsdcToUsd(atomic: bigint): number {
|
|
99
|
+
return Number(atomic) / 10 ** USDC_DECIMALS;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Sign a TransferWithAuthorization message
|
|
104
|
+
*
|
|
105
|
+
* Creates an EIP-712 signature that authorizes a transfer of USDC
|
|
106
|
+
* from the signer's wallet to a recipient. The signature can be
|
|
107
|
+
* submitted by anyone to execute the transfer.
|
|
108
|
+
*
|
|
109
|
+
* @param walletClient - viem wallet client with signing capability
|
|
110
|
+
* @param params - Transfer authorization parameters
|
|
111
|
+
* @returns Signed authorization with signature components
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```ts
|
|
115
|
+
* const signed = await signTransferAuthorization(walletClient, {
|
|
116
|
+
* from: '0x...',
|
|
117
|
+
* to: '0x...',
|
|
118
|
+
* value: usdToAtomicUsdc(0.10),
|
|
119
|
+
* validAfter: 0n,
|
|
120
|
+
* validBefore: BigInt(Math.floor(Date.now() / 1000) + 3600),
|
|
121
|
+
* nonce: generateNonce(),
|
|
122
|
+
* });
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export async function signTransferAuthorization(
|
|
126
|
+
walletClient: WalletClient,
|
|
127
|
+
params: TransferAuthorizationParams
|
|
128
|
+
): Promise<SignedTransferAuthorization> {
|
|
129
|
+
const account = walletClient.account;
|
|
130
|
+
if (!account) {
|
|
131
|
+
throw new Error('Wallet client must have an account');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Verify from address matches wallet
|
|
135
|
+
if (params.from.toLowerCase() !== account.address.toLowerCase()) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`From address ${params.from} does not match wallet address ${account.address}`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Sign the typed data
|
|
142
|
+
const signature = await walletClient.signTypedData({
|
|
143
|
+
account,
|
|
144
|
+
domain: USDC_DOMAIN,
|
|
145
|
+
types: TRANSFER_WITH_AUTHORIZATION_TYPES,
|
|
146
|
+
primaryType: 'TransferWithAuthorization',
|
|
147
|
+
message: {
|
|
148
|
+
from: params.from,
|
|
149
|
+
to: params.to,
|
|
150
|
+
value: params.value,
|
|
151
|
+
validAfter: params.validAfter,
|
|
152
|
+
validBefore: params.validBefore,
|
|
153
|
+
nonce: params.nonce,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Validate signature length (65 bytes = 130 hex chars + 0x prefix)
|
|
158
|
+
if (signature.length !== 132) {
|
|
159
|
+
throw new Error(`Unexpected signature length: ${signature.length}, expected 132`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Extract v, r, s components from signature
|
|
163
|
+
const r = `0x${signature.slice(2, 66)}` as `0x${string}`;
|
|
164
|
+
const s = `0x${signature.slice(66, 130)}` as `0x${string}`;
|
|
165
|
+
const v = parseInt(signature.slice(130, 132), 16);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
...params,
|
|
169
|
+
signature,
|
|
170
|
+
v,
|
|
171
|
+
r,
|
|
172
|
+
s,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Create transfer authorization params with sensible defaults
|
|
178
|
+
*
|
|
179
|
+
* @param from - Sender address
|
|
180
|
+
* @param to - Recipient address
|
|
181
|
+
* @param amountUsd - Amount in USD
|
|
182
|
+
* @param validitySeconds - How long the authorization is valid (default 5 minutes)
|
|
183
|
+
* @returns TransferAuthorizationParams ready for signing
|
|
184
|
+
*/
|
|
185
|
+
export function createTransferAuthorizationParams(
|
|
186
|
+
from: `0x${string}`,
|
|
187
|
+
to: `0x${string}`,
|
|
188
|
+
amountUsd: number,
|
|
189
|
+
validitySeconds: number = 300
|
|
190
|
+
): TransferAuthorizationParams {
|
|
191
|
+
const now = Math.floor(Date.now() / 1000);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
from,
|
|
195
|
+
to,
|
|
196
|
+
value: usdToAtomicUsdc(amountUsd),
|
|
197
|
+
validAfter: 0n, // Valid immediately
|
|
198
|
+
validBefore: BigInt(now + validitySeconds),
|
|
199
|
+
nonce: generateNonce(),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payment Integration for nullpath MCP Client
|
|
3
|
+
*
|
|
4
|
+
* Handles x402 payment flow:
|
|
5
|
+
* 1. Parse 402 Payment Required responses
|
|
6
|
+
* 2. Sign EIP-3009 TransferWithAuthorization
|
|
7
|
+
* 3. Encode payment header for retry
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
signTransferAuthorization,
|
|
12
|
+
generateNonce,
|
|
13
|
+
USDC_ADDRESS_BASE,
|
|
14
|
+
type TransferAuthorizationParams,
|
|
15
|
+
type SignedTransferAuthorization,
|
|
16
|
+
} from './eip3009.js';
|
|
17
|
+
import {
|
|
18
|
+
createWallet,
|
|
19
|
+
isWalletConfigured,
|
|
20
|
+
WalletNotConfiguredError,
|
|
21
|
+
InvalidPrivateKeyError,
|
|
22
|
+
type NullpathWallet,
|
|
23
|
+
} from './wallet.js';
|
|
24
|
+
|
|
25
|
+
/** Expected network for payments (Base mainnet) */
|
|
26
|
+
const EXPECTED_NETWORK = 8453;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Payment requirements from 402 response
|
|
30
|
+
*/
|
|
31
|
+
export interface PaymentRequirements {
|
|
32
|
+
/** Recipient wallet address */
|
|
33
|
+
recipient: `0x${string}`;
|
|
34
|
+
/** Amount in atomic USDC units */
|
|
35
|
+
amount: bigint;
|
|
36
|
+
/** USDC contract address */
|
|
37
|
+
asset: `0x${string}`;
|
|
38
|
+
/** Chain ID (8453 for Base) */
|
|
39
|
+
network: number;
|
|
40
|
+
/** Unix timestamp - authorization valid after */
|
|
41
|
+
validAfter: bigint;
|
|
42
|
+
/** Unix timestamp - authorization valid before */
|
|
43
|
+
validBefore: bigint;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Payment header payload
|
|
48
|
+
*/
|
|
49
|
+
export interface PaymentPayload {
|
|
50
|
+
signature: string;
|
|
51
|
+
from: string;
|
|
52
|
+
to: string;
|
|
53
|
+
value: string;
|
|
54
|
+
validAfter: string;
|
|
55
|
+
validBefore: string;
|
|
56
|
+
nonce: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Error thrown when payment is required but cannot be made
|
|
61
|
+
*/
|
|
62
|
+
export class PaymentRequiredError extends Error {
|
|
63
|
+
constructor(
|
|
64
|
+
message: string,
|
|
65
|
+
public readonly requirements?: PaymentRequirements
|
|
66
|
+
) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = 'PaymentRequiredError';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Error thrown when payment signing fails
|
|
74
|
+
*/
|
|
75
|
+
export class PaymentSigningError extends Error {
|
|
76
|
+
constructor(message: string, public readonly cause?: Error) {
|
|
77
|
+
super(message);
|
|
78
|
+
this.name = 'PaymentSigningError';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse 402 Payment Required response headers
|
|
84
|
+
*
|
|
85
|
+
* Extracts payment requirements from X-PAYMENT-REQUIRED header.
|
|
86
|
+
* The header contains base64-encoded JSON with payment details.
|
|
87
|
+
*
|
|
88
|
+
* @param response - Fetch Response object
|
|
89
|
+
* @returns PaymentRequirements or null if not a 402 response
|
|
90
|
+
*/
|
|
91
|
+
export function parsePaymentRequired(response: Response): PaymentRequirements | null {
|
|
92
|
+
if (response.status !== 402) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const header = response.headers.get('X-PAYMENT-REQUIRED');
|
|
97
|
+
if (!header) {
|
|
98
|
+
// Try legacy header name
|
|
99
|
+
const legacyHeader = response.headers.get('X-Payment-Required');
|
|
100
|
+
if (!legacyHeader) {
|
|
101
|
+
throw new PaymentRequiredError(
|
|
102
|
+
'Payment required but X-PAYMENT-REQUIRED header missing'
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return parsePaymentHeader(legacyHeader);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return parsePaymentHeader(header);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Parse the payment header value
|
|
113
|
+
*/
|
|
114
|
+
function parsePaymentHeader(header: string): PaymentRequirements {
|
|
115
|
+
try {
|
|
116
|
+
// Decode base64
|
|
117
|
+
const decoded = Buffer.from(header, 'base64').toString('utf-8');
|
|
118
|
+
const data = JSON.parse(decoded);
|
|
119
|
+
|
|
120
|
+
// Extract and validate required fields
|
|
121
|
+
const recipient = data.recipient || data.payee;
|
|
122
|
+
const amount = data.amount || data.maxAmountRequired;
|
|
123
|
+
const asset = data.asset || data.usdcAddress;
|
|
124
|
+
const network = data.network || data.chainId || 8453;
|
|
125
|
+
|
|
126
|
+
// Default validity window: now to 5 minutes from now
|
|
127
|
+
const now = Math.floor(Date.now() / 1000);
|
|
128
|
+
const validAfter = BigInt(data.validAfter || 0);
|
|
129
|
+
const validBefore = BigInt(data.validBefore || now + 300);
|
|
130
|
+
|
|
131
|
+
// Ensure authorization window is still valid
|
|
132
|
+
if (validBefore <= BigInt(now)) {
|
|
133
|
+
throw new Error(`Payment authorization expired: validBefore ${validBefore} is in the past`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!recipient || !amount) {
|
|
137
|
+
throw new Error('Missing recipient or amount');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
recipient: recipient as `0x${string}`,
|
|
142
|
+
amount: BigInt(amount),
|
|
143
|
+
asset: (asset || USDC_ADDRESS_BASE) as `0x${string}`,
|
|
144
|
+
network: Number(network),
|
|
145
|
+
validAfter,
|
|
146
|
+
validBefore,
|
|
147
|
+
};
|
|
148
|
+
} catch (error) {
|
|
149
|
+
throw new PaymentRequiredError(
|
|
150
|
+
`Failed to parse payment requirements: ${error instanceof Error ? error.message : 'unknown error'}`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Sign a payment using EIP-3009 TransferWithAuthorization
|
|
157
|
+
*
|
|
158
|
+
* @param wallet - NullpathWallet instance
|
|
159
|
+
* @param requirements - Payment requirements from 402 response
|
|
160
|
+
* @returns Signed authorization
|
|
161
|
+
*/
|
|
162
|
+
export async function signPayment(
|
|
163
|
+
wallet: NullpathWallet,
|
|
164
|
+
requirements: PaymentRequirements
|
|
165
|
+
): Promise<SignedTransferAuthorization> {
|
|
166
|
+
// Validate that the requested payment matches our signing configuration
|
|
167
|
+
const requestedNetwork = requirements.network;
|
|
168
|
+
const requestedAsset = requirements.asset?.toLowerCase();
|
|
169
|
+
const expectedAsset = USDC_ADDRESS_BASE.toLowerCase();
|
|
170
|
+
|
|
171
|
+
if (requestedNetwork !== EXPECTED_NETWORK || requestedAsset !== expectedAsset) {
|
|
172
|
+
throw new PaymentRequiredError(
|
|
173
|
+
`Payment requirements mismatch: requested network ${requestedNetwork} and asset ${requirements.asset} ` +
|
|
174
|
+
`do not match supported Base mainnet USDC (network ${EXPECTED_NETWORK}, asset ${USDC_ADDRESS_BASE}).`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const params: TransferAuthorizationParams = {
|
|
180
|
+
from: wallet.address,
|
|
181
|
+
to: requirements.recipient,
|
|
182
|
+
value: requirements.amount,
|
|
183
|
+
validAfter: requirements.validAfter,
|
|
184
|
+
validBefore: requirements.validBefore,
|
|
185
|
+
nonce: generateNonce(),
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
return await signTransferAuthorization(wallet.client, params);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
throw new PaymentSigningError(
|
|
191
|
+
`Failed to sign payment: ${error instanceof Error ? error.message : 'unknown error'}`,
|
|
192
|
+
error instanceof Error ? error : undefined
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Encode signed authorization as X-PAYMENT header value
|
|
199
|
+
*
|
|
200
|
+
* @param signed - Signed transfer authorization
|
|
201
|
+
* @returns Base64-encoded JSON string for X-PAYMENT header
|
|
202
|
+
*/
|
|
203
|
+
export function encodePaymentHeader(signed: SignedTransferAuthorization): string {
|
|
204
|
+
const payload: PaymentPayload = {
|
|
205
|
+
signature: signed.signature,
|
|
206
|
+
from: signed.from,
|
|
207
|
+
to: signed.to,
|
|
208
|
+
value: signed.value.toString(),
|
|
209
|
+
validAfter: signed.validAfter.toString(),
|
|
210
|
+
validBefore: signed.validBefore.toString(),
|
|
211
|
+
nonce: signed.nonce,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
return Buffer.from(JSON.stringify(payload)).toString('base64');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Build headers for fetch, properly handling Headers instances
|
|
219
|
+
*/
|
|
220
|
+
function buildHeaders(base?: RequestInit['headers'], extra?: Record<string, string>): Headers {
|
|
221
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
222
|
+
const headers = new Headers(base as any);
|
|
223
|
+
if (extra) {
|
|
224
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
225
|
+
headers.set(key, value);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return headers;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Execute a fetch request with automatic x402 payment handling
|
|
233
|
+
*
|
|
234
|
+
* If the server returns 402 Payment Required:
|
|
235
|
+
* 1. Parse payment requirements
|
|
236
|
+
* 2. Sign EIP-3009 authorization
|
|
237
|
+
* 3. Retry with X-PAYMENT header
|
|
238
|
+
*
|
|
239
|
+
* @param url - Request URL
|
|
240
|
+
* @param options - Fetch options
|
|
241
|
+
* @returns Fetch Response
|
|
242
|
+
* @throws WalletNotConfiguredError if 402 and no wallet
|
|
243
|
+
* @throws PaymentSigningError if signing fails
|
|
244
|
+
*/
|
|
245
|
+
export async function fetchWithPayment(
|
|
246
|
+
url: string,
|
|
247
|
+
options: RequestInit = {}
|
|
248
|
+
): Promise<Response> {
|
|
249
|
+
// Build headers properly (handles both plain objects and Headers instances)
|
|
250
|
+
const initialHeaders = buildHeaders(options.headers, {
|
|
251
|
+
'Content-Type': 'application/json',
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Make initial request
|
|
255
|
+
const response = await fetch(url, {
|
|
256
|
+
...options,
|
|
257
|
+
headers: initialHeaders,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Check for 402 Payment Required
|
|
261
|
+
if (response.status !== 402) {
|
|
262
|
+
return response;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Payment required - check if wallet is configured
|
|
266
|
+
if (!isWalletConfigured()) {
|
|
267
|
+
throw new WalletNotConfiguredError();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Parse payment requirements
|
|
271
|
+
const requirements = parsePaymentRequired(response);
|
|
272
|
+
if (!requirements) {
|
|
273
|
+
throw new PaymentRequiredError('Payment required but could not parse requirements');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Create wallet and sign payment
|
|
277
|
+
let wallet: NullpathWallet;
|
|
278
|
+
try {
|
|
279
|
+
wallet = createWallet();
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (error instanceof InvalidPrivateKeyError) {
|
|
282
|
+
throw new PaymentSigningError(
|
|
283
|
+
`Invalid wallet configuration: ${error.message}`,
|
|
284
|
+
error
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const signed = await signPayment(wallet, requirements);
|
|
291
|
+
const paymentHeader = encodePaymentHeader(signed);
|
|
292
|
+
|
|
293
|
+
// Build retry headers with payment
|
|
294
|
+
const retryHeaders = buildHeaders(options.headers, {
|
|
295
|
+
'Content-Type': 'application/json',
|
|
296
|
+
'X-PAYMENT': paymentHeader,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Retry with payment header
|
|
300
|
+
const retryResponse = await fetch(url, {
|
|
301
|
+
...options,
|
|
302
|
+
headers: retryHeaders,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// If still 402, payment was rejected
|
|
306
|
+
if (retryResponse.status === 402) {
|
|
307
|
+
const errorBody = await retryResponse.text().catch(() => '');
|
|
308
|
+
throw new PaymentRequiredError(
|
|
309
|
+
`Payment was rejected by the server: ${errorBody || 'no details'}`,
|
|
310
|
+
requirements
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Handle other errors on retry
|
|
315
|
+
if (!retryResponse.ok) {
|
|
316
|
+
const errorBody = await retryResponse.text().catch(() => '');
|
|
317
|
+
throw new Error(`Payment submitted but request failed (${retryResponse.status}): ${errorBody}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return retryResponse;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Format amount in human-readable USDC (using bigint arithmetic to avoid precision loss)
|
|
325
|
+
*/
|
|
326
|
+
export function formatUsdcAmount(atomic: bigint): string {
|
|
327
|
+
const whole = atomic / 1_000_000n;
|
|
328
|
+
const fraction = atomic % 1_000_000n;
|
|
329
|
+
const fractionStr = fraction.toString().padStart(6, '0');
|
|
330
|
+
return `$${whole.toString()}.${fractionStr} USDC`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Re-export wallet utilities for convenience
|
|
334
|
+
export { isWalletConfigured, WalletNotConfiguredError, InvalidPrivateKeyError } from './wallet.js';
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wallet Client Setup for nullpath MCP Client
|
|
3
|
+
*
|
|
4
|
+
* Creates a viem wallet client from the NULLPATH_WALLET_KEY
|
|
5
|
+
* environment variable for signing EIP-3009 payments.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createWalletClient, http, type WalletClient, type Account } from 'viem';
|
|
9
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
10
|
+
import { base } from 'viem/chains';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Environment variable name for the wallet private key
|
|
14
|
+
*/
|
|
15
|
+
export const WALLET_KEY_ENV = 'NULLPATH_WALLET_KEY';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Wallet configuration
|
|
19
|
+
*/
|
|
20
|
+
export interface WalletConfig {
|
|
21
|
+
/** Private key (with or without 0x prefix) */
|
|
22
|
+
privateKey?: string;
|
|
23
|
+
/** RPC URL for Base (optional, uses default public RPC) */
|
|
24
|
+
rpcUrl?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Wallet client with account information
|
|
29
|
+
*/
|
|
30
|
+
export interface NullpathWallet {
|
|
31
|
+
/** viem wallet client for signing */
|
|
32
|
+
client: WalletClient;
|
|
33
|
+
/** Account derived from private key */
|
|
34
|
+
account: Account;
|
|
35
|
+
/** Wallet address */
|
|
36
|
+
address: `0x${string}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Error thrown when wallet is not configured
|
|
41
|
+
*/
|
|
42
|
+
export class WalletNotConfiguredError extends Error {
|
|
43
|
+
constructor() {
|
|
44
|
+
super(
|
|
45
|
+
`Wallet not configured. Set ${WALLET_KEY_ENV} environment variable with your private key.`
|
|
46
|
+
);
|
|
47
|
+
this.name = 'WalletNotConfiguredError';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Error thrown when private key is invalid
|
|
53
|
+
*/
|
|
54
|
+
export class InvalidPrivateKeyError extends Error {
|
|
55
|
+
constructor(reason: string) {
|
|
56
|
+
super(`Invalid private key: ${reason}`);
|
|
57
|
+
this.name = 'InvalidPrivateKeyError';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Normalize private key to proper format
|
|
63
|
+
*
|
|
64
|
+
* Accepts keys with or without 0x prefix
|
|
65
|
+
*/
|
|
66
|
+
function normalizePrivateKey(key: string): `0x${string}` {
|
|
67
|
+
const trimmed = key.trim();
|
|
68
|
+
|
|
69
|
+
// Add 0x prefix if missing
|
|
70
|
+
const prefixed = trimmed.startsWith('0x') ? trimmed : `0x${trimmed}`;
|
|
71
|
+
|
|
72
|
+
// Validate length (0x + 64 hex chars = 66 chars)
|
|
73
|
+
if (prefixed.length !== 66) {
|
|
74
|
+
throw new InvalidPrivateKeyError(
|
|
75
|
+
`Expected 64 hex characters, got ${prefixed.length - 2}`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Validate hex format
|
|
80
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(prefixed)) {
|
|
81
|
+
throw new InvalidPrivateKeyError('Must contain only hexadecimal characters');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return prefixed as `0x${string}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create a wallet client from configuration or environment
|
|
89
|
+
*
|
|
90
|
+
* @param config - Optional wallet configuration. If not provided,
|
|
91
|
+
* reads from NULLPATH_WALLET_KEY environment variable.
|
|
92
|
+
* @returns NullpathWallet with client, account, and address
|
|
93
|
+
* @throws WalletNotConfiguredError if no private key available
|
|
94
|
+
* @throws InvalidPrivateKeyError if private key format is invalid
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```ts
|
|
98
|
+
* // From environment variable
|
|
99
|
+
* const wallet = createWallet();
|
|
100
|
+
*
|
|
101
|
+
* // From explicit config
|
|
102
|
+
* const wallet = createWallet({ privateKey: '0x...' });
|
|
103
|
+
*
|
|
104
|
+
* // Use for signing
|
|
105
|
+
* const signed = await signTransferAuthorization(wallet.client, params);
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export function createWallet(config?: WalletConfig): NullpathWallet {
|
|
109
|
+
// Get private key from config or environment
|
|
110
|
+
const rawKey = config?.privateKey ?? process.env[WALLET_KEY_ENV];
|
|
111
|
+
|
|
112
|
+
if (!rawKey) {
|
|
113
|
+
throw new WalletNotConfiguredError();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Normalize and validate
|
|
117
|
+
const privateKey = normalizePrivateKey(rawKey);
|
|
118
|
+
|
|
119
|
+
// Create account from private key
|
|
120
|
+
const account = privateKeyToAccount(privateKey);
|
|
121
|
+
|
|
122
|
+
// Create wallet client for Base mainnet
|
|
123
|
+
const client = createWalletClient({
|
|
124
|
+
account,
|
|
125
|
+
chain: base,
|
|
126
|
+
transport: http(config?.rpcUrl),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
client,
|
|
131
|
+
account,
|
|
132
|
+
address: account.address,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check if wallet is configured (without creating it)
|
|
138
|
+
*
|
|
139
|
+
* @returns true if NULLPATH_WALLET_KEY is set
|
|
140
|
+
*/
|
|
141
|
+
export function isWalletConfigured(): boolean {
|
|
142
|
+
return !!process.env[WALLET_KEY_ENV];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get wallet address without full client setup
|
|
147
|
+
*
|
|
148
|
+
* Useful for checking the configured address without
|
|
149
|
+
* creating a full wallet client.
|
|
150
|
+
*
|
|
151
|
+
* @returns Wallet address or null if not configured
|
|
152
|
+
*/
|
|
153
|
+
export function getWalletAddress(): `0x${string}` | null {
|
|
154
|
+
const rawKey = process.env[WALLET_KEY_ENV];
|
|
155
|
+
if (!rawKey) return null;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const privateKey = normalizePrivateKey(rawKey);
|
|
159
|
+
const account = privateKeyToAccount(privateKey);
|
|
160
|
+
return account.address;
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"x402.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/x402.test.ts"],"names":[],"mappings":""}
|