@mingderwang/x402 0.1.0 → 0.1.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/package.json +6 -2
- package/src/index.ts +307 -0
package/package.json
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mingderwang/x402",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "x402 (HTTP 402 Payment Required) client + server helpers: payment requirements, EIP-3009 signing, X-PAYMENT, verification and settlement.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
9
|
"exports": {
|
|
10
|
-
".":
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
11
14
|
},
|
|
12
15
|
"files": [
|
|
13
16
|
"dist",
|
|
17
|
+
"src",
|
|
14
18
|
"README.md"
|
|
15
19
|
],
|
|
16
20
|
"engines": {
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { randomBytesHex, NETWORKS } from '@mingderwang/wallet';
|
|
2
|
+
import { verifyTypedData, Contract, JsonRpcProvider, Wallet } from 'ethers';
|
|
3
|
+
|
|
4
|
+
// ---------- types ----------
|
|
5
|
+
|
|
6
|
+
export interface PaymentRequirements {
|
|
7
|
+
scheme: string;
|
|
8
|
+
network: string;
|
|
9
|
+
maxAmountRequired: string;
|
|
10
|
+
asset: string;
|
|
11
|
+
payTo: string;
|
|
12
|
+
resource: string;
|
|
13
|
+
description: string;
|
|
14
|
+
mimeType?: string;
|
|
15
|
+
outputSchema?: unknown;
|
|
16
|
+
maxTimeoutSeconds: number;
|
|
17
|
+
extra?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PaymentRequirementsResponse {
|
|
21
|
+
x402Version: 1;
|
|
22
|
+
error: string;
|
|
23
|
+
accepts: PaymentRequirements[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Authorization {
|
|
27
|
+
from: string;
|
|
28
|
+
to: string;
|
|
29
|
+
value: string;
|
|
30
|
+
validAfter: string;
|
|
31
|
+
validBefore: string;
|
|
32
|
+
nonce: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PaymentPayload {
|
|
36
|
+
x402Version: 1;
|
|
37
|
+
scheme: 'exact';
|
|
38
|
+
network: string;
|
|
39
|
+
payload: {
|
|
40
|
+
signature: string;
|
|
41
|
+
authorization: Authorization;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SettlementResponse {
|
|
46
|
+
success: boolean;
|
|
47
|
+
errorReason?: string;
|
|
48
|
+
transaction: string;
|
|
49
|
+
network: string;
|
|
50
|
+
payer: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const PAYMENT_REQUIRED_HEADER = 'payment-required';
|
|
54
|
+
export const PAYMENT_SIGNATURE_HEADER = 'x-payment';
|
|
55
|
+
export const PAYMENT_RESPONSE_HEADER = 'payment-response';
|
|
56
|
+
|
|
57
|
+
export const ERROR = {
|
|
58
|
+
insufficientFunds: 'insufficient_funds',
|
|
59
|
+
invalidValidAfter: 'invalid_exact_evm_payload_authorization_valid_after',
|
|
60
|
+
invalidValidBefore: 'invalid_exact_evm_payload_authorization_valid_before',
|
|
61
|
+
invalidValue: 'invalid_exact_evm_payload_authorization_value',
|
|
62
|
+
invalidSignature: 'invalid_exact_evm_payload_signature',
|
|
63
|
+
recipientMismatch: 'invalid_exact_evm_payload_recipient_mismatch',
|
|
64
|
+
invalidNetwork: 'invalid_network',
|
|
65
|
+
invalidPayload: 'invalid_payload',
|
|
66
|
+
invalidPaymentRequirements: 'invalid_payment_requirements',
|
|
67
|
+
invalidScheme: 'invalid_scheme',
|
|
68
|
+
invalidVersion: 'invalid_x402_version',
|
|
69
|
+
invalidTxState: 'invalid_transaction_state',
|
|
70
|
+
} as const;
|
|
71
|
+
|
|
72
|
+
// ---------- encoding ----------
|
|
73
|
+
|
|
74
|
+
export function encodeJson(obj: unknown): string {
|
|
75
|
+
return Buffer.from(JSON.stringify(obj)).toString('base64');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function decodeJson<T>(b64: string): T {
|
|
79
|
+
return JSON.parse(Buffer.from(b64, 'base64').toString('utf8')) as T;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------- server: payment requirements ----------
|
|
83
|
+
|
|
84
|
+
export function makePaymentRequired(
|
|
85
|
+
opts: {
|
|
86
|
+
resource: string;
|
|
87
|
+
description: string;
|
|
88
|
+
network: string;
|
|
89
|
+
amount: string; // atomic units, e.g. "500000" for 0.5 USDC
|
|
90
|
+
asset: string;
|
|
91
|
+
payTo: string;
|
|
92
|
+
maxTimeoutSeconds?: number;
|
|
93
|
+
mimeType?: string;
|
|
94
|
+
extra?: Record<string, unknown>;
|
|
95
|
+
}
|
|
96
|
+
): PaymentRequirementsResponse {
|
|
97
|
+
return {
|
|
98
|
+
x402Version: 1,
|
|
99
|
+
error: `${PAYMENT_SIGNATURE_HEADER.toUpperCase()} header is required`,
|
|
100
|
+
accepts: [
|
|
101
|
+
{
|
|
102
|
+
scheme: 'exact',
|
|
103
|
+
network: opts.network,
|
|
104
|
+
maxAmountRequired: opts.amount,
|
|
105
|
+
asset: opts.asset.toLowerCase(),
|
|
106
|
+
payTo: opts.payTo.toLowerCase(),
|
|
107
|
+
resource: opts.resource,
|
|
108
|
+
description: opts.description,
|
|
109
|
+
mimeType: opts.mimeType ?? 'application/json',
|
|
110
|
+
outputSchema: null,
|
|
111
|
+
maxTimeoutSeconds: opts.maxTimeoutSeconds ?? 300,
|
|
112
|
+
extra: opts.extra ?? { name: 'USDC', version: '2' },
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------- client: 402 detection + payment construction ----------
|
|
119
|
+
|
|
120
|
+
export function detectPaymentRequired(response: { status: number; headers?: Headers; text?: string }): PaymentRequirementsResponse | null {
|
|
121
|
+
if (response.status !== 402) return null;
|
|
122
|
+
const header = response.headers?.get?.(PAYMENT_REQUIRED_HEADER);
|
|
123
|
+
if (header) {
|
|
124
|
+
try {
|
|
125
|
+
return decodeJson<PaymentRequirementsResponse>(header);
|
|
126
|
+
} catch {
|
|
127
|
+
/* fall through to body */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (response.text) {
|
|
131
|
+
try {
|
|
132
|
+
const parsed = JSON.parse(response.text);
|
|
133
|
+
if (parsed && Array.isArray(parsed.accepts)) return parsed as PaymentRequirementsResponse;
|
|
134
|
+
} catch {
|
|
135
|
+
/* not json */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface PaymentOptions {
|
|
142
|
+
wallet: Wallet;
|
|
143
|
+
requirement: PaymentRequirements;
|
|
144
|
+
nowSeconds?: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function makePaymentPayload({ wallet, requirement, nowSeconds }: PaymentOptions): Promise<PaymentPayload> {
|
|
148
|
+
const { NETWORKS: _net } = { NETWORKS };
|
|
149
|
+
const chainId = chainIdForNetwork(requirement.network);
|
|
150
|
+
const now = nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
151
|
+
const value = BigInt(requirement.maxAmountRequired);
|
|
152
|
+
|
|
153
|
+
const domain = {
|
|
154
|
+
name: 'USD Coin',
|
|
155
|
+
version: '2',
|
|
156
|
+
chainId,
|
|
157
|
+
verifyingContract: requirement.asset,
|
|
158
|
+
};
|
|
159
|
+
const types = {
|
|
160
|
+
TransferWithAuthorization: [
|
|
161
|
+
{ name: 'from', type: 'address' },
|
|
162
|
+
{ name: 'to', type: 'address' },
|
|
163
|
+
{ name: 'value', type: 'uint256' },
|
|
164
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
165
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
166
|
+
{ name: 'nonce', type: 'bytes32' },
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
const authorization: Authorization = {
|
|
170
|
+
from: wallet.address,
|
|
171
|
+
to: requirement.payTo,
|
|
172
|
+
value: value.toString(),
|
|
173
|
+
validAfter: (now - 60).toString(),
|
|
174
|
+
validBefore: (now + requirement.maxTimeoutSeconds).toString(),
|
|
175
|
+
nonce: randomBytesHex(32),
|
|
176
|
+
};
|
|
177
|
+
const signature = await wallet.signTypedData(
|
|
178
|
+
domain,
|
|
179
|
+
types,
|
|
180
|
+
{ from: authorization.from, to: authorization.to, value: authorization.value, validAfter: authorization.validAfter, validBefore: authorization.validBefore, nonce: authorization.nonce }
|
|
181
|
+
);
|
|
182
|
+
return {
|
|
183
|
+
x402Version: 1,
|
|
184
|
+
scheme: 'exact',
|
|
185
|
+
network: requirement.network,
|
|
186
|
+
payload: { signature, authorization },
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function chainIdForNetwork(network: string): number {
|
|
191
|
+
for (const n of Object.values(NETWORKS)) {
|
|
192
|
+
if (n.networkId === network) return n.chainId;
|
|
193
|
+
}
|
|
194
|
+
const m = /-(\d+)$/.exec(network);
|
|
195
|
+
if (m) return Number(m[1]);
|
|
196
|
+
throw new Error(`Unknown network ${network}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function xPaymentHeader(payload: PaymentPayload): string {
|
|
200
|
+
return encodeJson(payload);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------- server: verification + settlement ----------
|
|
204
|
+
|
|
205
|
+
const TRANSFER_WITH_AUTH_ABI = [
|
|
206
|
+
'function transferWithAuthorization(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce)',
|
|
207
|
+
'function balanceOf(address) view returns (uint256)',
|
|
208
|
+
] as const;
|
|
209
|
+
|
|
210
|
+
export function verifyPaymentPayload(
|
|
211
|
+
payload: unknown,
|
|
212
|
+
requirement: PaymentRequirements
|
|
213
|
+
): { isValid: boolean; payer?: string; invalidReason?: string } {
|
|
214
|
+
if (!payload || typeof payload !== 'object') {
|
|
215
|
+
return { isValid: false, invalidReason: ERROR.invalidPayload };
|
|
216
|
+
}
|
|
217
|
+
const p = payload as PaymentPayload;
|
|
218
|
+
if (p.x402Version !== 1) return { isValid: false, invalidReason: ERROR.invalidVersion };
|
|
219
|
+
if (p.scheme !== 'exact') return { isValid: false, invalidReason: ERROR.invalidScheme };
|
|
220
|
+
if (p.network !== requirement.network) return { isValid: false, invalidReason: ERROR.invalidNetwork };
|
|
221
|
+
const a = p.payload?.authorization;
|
|
222
|
+
const sig = p.payload?.signature;
|
|
223
|
+
if (!a || !sig) return { isValid: false, invalidReason: ERROR.invalidPayload };
|
|
224
|
+
|
|
225
|
+
const now = Math.floor(Date.now() / 1000);
|
|
226
|
+
const validAfter = Number(a.validAfter);
|
|
227
|
+
const validBefore = Number(a.validBefore);
|
|
228
|
+
if (now < validAfter) return { isValid: false, invalidReason: ERROR.invalidValidAfter };
|
|
229
|
+
if (now > validBefore) return { isValid: false, invalidReason: ERROR.invalidValidBefore };
|
|
230
|
+
if (BigInt(a.value) < BigInt(requirement.maxAmountRequired)) {
|
|
231
|
+
return { isValid: false, invalidReason: ERROR.invalidValue };
|
|
232
|
+
}
|
|
233
|
+
if (a.to.toLowerCase() !== requirement.payTo.toLowerCase()) {
|
|
234
|
+
return { isValid: false, invalidReason: ERROR.recipientMismatch };
|
|
235
|
+
}
|
|
236
|
+
if (!/^0x[a-fA-F0-9]{40}$/.test(a.from) || !/^0x[a-fA-F0-9]{64}$/.test(a.nonce)) {
|
|
237
|
+
return { isValid: false, invalidReason: ERROR.invalidPayload };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const chainId = chainIdForNetwork(requirement.network);
|
|
241
|
+
const domain = { name: 'USD Coin', version: '2', chainId, verifyingContract: requirement.asset };
|
|
242
|
+
const types = {
|
|
243
|
+
TransferWithAuthorization: [
|
|
244
|
+
{ name: 'from', type: 'address' },
|
|
245
|
+
{ name: 'to', type: 'address' },
|
|
246
|
+
{ name: 'value', type: 'uint256' },
|
|
247
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
248
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
249
|
+
{ name: 'nonce', type: 'bytes32' },
|
|
250
|
+
],
|
|
251
|
+
};
|
|
252
|
+
const message = { from: a.from, to: a.to, value: a.value, validAfter: a.validAfter, validBefore: a.validBefore, nonce: a.nonce };
|
|
253
|
+
try {
|
|
254
|
+
const signer = verifyTypedData(domain, types, message, sig);
|
|
255
|
+
if (signer.toLowerCase() !== a.from.toLowerCase()) {
|
|
256
|
+
return { isValid: false, invalidReason: ERROR.invalidSignature };
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
return { isValid: false, invalidReason: ERROR.invalidSignature };
|
|
260
|
+
}
|
|
261
|
+
return { isValid: true, payer: a.from };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function settlePayment(
|
|
265
|
+
payload: PaymentPayload,
|
|
266
|
+
requirement: PaymentRequirements,
|
|
267
|
+
opts: { signer?: Wallet; rpcUrl?: string; confirmations?: number }
|
|
268
|
+
): Promise<SettlementResponse> {
|
|
269
|
+
const a = payload.payload.authorization;
|
|
270
|
+
const network = requirement.network;
|
|
271
|
+
const rpc = opts.rpcUrl ?? process.env.X402_RPC_URL;
|
|
272
|
+
const provider = new JsonRpcProvider(rpc);
|
|
273
|
+
const signer =
|
|
274
|
+
opts.signer ??
|
|
275
|
+
(process.env.X402_GAS_PRIVATE_KEY
|
|
276
|
+
? new Wallet(process.env.X402_GAS_PRIVATE_KEY, provider)
|
|
277
|
+
: undefined);
|
|
278
|
+
if (!signer) {
|
|
279
|
+
return { success: false, errorReason: 'server has no gas signer configured', transaction: '', network, payer: a.from };
|
|
280
|
+
}
|
|
281
|
+
const contract = new Contract(requirement.asset, TRANSFER_WITH_AUTH_ABI, signer);
|
|
282
|
+
const erc20 = contract as unknown as {
|
|
283
|
+
balanceOf: (address: string) => Promise<bigint>;
|
|
284
|
+
transferWithAuthorization: (
|
|
285
|
+
from: string, to: string, value: string, validAfter: string, validBefore: string, nonce: string
|
|
286
|
+
) => Promise<{ hash: string; wait: (confirmations?: number) => Promise<unknown> }>;
|
|
287
|
+
};
|
|
288
|
+
try {
|
|
289
|
+
const balance = await erc20.balanceOf(a.from);
|
|
290
|
+
if (balance < BigInt(a.value)) {
|
|
291
|
+
return { success: false, errorReason: ERROR.insufficientFunds, transaction: '', network, payer: a.from };
|
|
292
|
+
}
|
|
293
|
+
const tx = await erc20.transferWithAuthorization(a.from, a.to, a.value, a.validAfter, a.validBefore, a.nonce);
|
|
294
|
+
await tx.wait(opts.confirmations ?? 1);
|
|
295
|
+
return { success: true, transaction: tx.hash, network, payer: a.from };
|
|
296
|
+
} catch (e) {
|
|
297
|
+
return {
|
|
298
|
+
success: false,
|
|
299
|
+
errorReason: ERROR.invalidTxState,
|
|
300
|
+
transaction: '',
|
|
301
|
+
network,
|
|
302
|
+
payer: a.from,
|
|
303
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
304
|
+
...((e as any)?.shortMessage ? { errorReason: (e as { shortMessage: string }).shortMessage } : {}),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|