@profullstack/x402-client 0.1.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/LICENSE +21 -0
- package/README.md +111 -0
- package/bin/x402.js +189 -0
- package/index.d.ts +197 -0
- package/package.json +41 -0
- package/src/client.js +230 -0
- package/src/eip712.js +256 -0
- package/src/index.js +34 -0
- package/src/passes.js +117 -0
- package/src/types.js +18 -0
- package/src/wallet.js +59 -0
- package/src/x402.js +278 -0
package/src/wallet.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A wallet is a private key and the address it controls.
|
|
3
|
+
*
|
|
4
|
+
* That is all an x402 payer needs: the `exact` scheme moves USDC with an
|
|
5
|
+
* EIP-3009 authorization that the facilitator broadcasts and pays the gas
|
|
6
|
+
* for, so the key never sends a transaction and never holds ETH. It signs
|
|
7
|
+
* typed data, and it does so offline.
|
|
8
|
+
*
|
|
9
|
+
* The key is held as bytes for the life of the wallet and never logged,
|
|
10
|
+
* serialized or returned. Anything that needs to identify the wallet uses
|
|
11
|
+
* the address.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
|
15
|
+
|
|
16
|
+
import { addressOfPublicKey, hexToBytes, signTypedData } from './eip712.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {{
|
|
20
|
+
* address: string,
|
|
21
|
+
* signTypedData: (domain: object, types: object, primaryType: string, message: object) => string,
|
|
22
|
+
* }} Wallet
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A wallet from a 32-byte secp256k1 private key, hex with or without `0x`.
|
|
27
|
+
*
|
|
28
|
+
* @param {string|Uint8Array} privateKey
|
|
29
|
+
* @returns {Wallet}
|
|
30
|
+
*/
|
|
31
|
+
export function walletFromKey(privateKey) {
|
|
32
|
+
const key = typeof privateKey === 'string' ? hexToBytes(privateKey.trim()) : privateKey;
|
|
33
|
+
if (!(key instanceof Uint8Array) || key.length !== 32) {
|
|
34
|
+
throw new Error('A private key is 32 bytes (64 hex characters)');
|
|
35
|
+
}
|
|
36
|
+
if (!secp256k1.utils.isValidSecretKey(key)) throw new Error('Not a valid secp256k1 private key');
|
|
37
|
+
|
|
38
|
+
const address = addressOfPublicKey(secp256k1.getPublicKey(key, false));
|
|
39
|
+
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
address,
|
|
42
|
+
signTypedData: (domain, types, primaryType, message) => signTypedData(domain, types, primaryType, message, key),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A fresh random wallet, for tests and for a first run with nothing funded.
|
|
48
|
+
*
|
|
49
|
+
* Returns the key as well, since a wallet that cannot be saved cannot be
|
|
50
|
+
* funded: the caller stores it and never sees it here again.
|
|
51
|
+
*
|
|
52
|
+
* @returns {{ wallet: Wallet, privateKey: string }}
|
|
53
|
+
*/
|
|
54
|
+
export function randomWallet() {
|
|
55
|
+
const key = secp256k1.utils.randomSecretKey();
|
|
56
|
+
let hex = '0x';
|
|
57
|
+
for (const b of key) hex += b.toString(16).padStart(2, '0');
|
|
58
|
+
return { wallet: walletFromKey(key), privateKey: hex };
|
|
59
|
+
}
|
package/src/x402.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402 v2 on the wire, from the payer's side.
|
|
3
|
+
*
|
|
4
|
+
* Field shapes follow what servers actually emit, checked against
|
|
5
|
+
* @profullstack/x402-gateway and CoinPay's `x402-v2` rather than remembered
|
|
6
|
+
* from the spec: the 402 carries `{ x402Version: 2, accepts: [...] }` as its
|
|
7
|
+
* JSON body (the gateway) or base64 in a `PAYMENT-REQUIRED` header (the
|
|
8
|
+
* Coinbase-style facilitators), each `accepts` entry names a CAIP-2 network,
|
|
9
|
+
* an `amount` in the token's smallest unit, the `asset` contract, `payTo`,
|
|
10
|
+
* and the token's own EIP-712 domain in `extra`. The proof goes back as base64
|
|
11
|
+
* JSON in `X-PAYMENT` (CoinPay's dialect, the v1 header name) and
|
|
12
|
+
* `PAYMENT-SIGNATURE` (v2); both are sent, since a server reads one and
|
|
13
|
+
* ignores the other.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { TRANSFER_WITH_AUTHORIZATION_TYPES } from './types.js';
|
|
17
|
+
|
|
18
|
+
export { TRANSFER_WITH_AUTHORIZATION_TYPES };
|
|
19
|
+
|
|
20
|
+
/** The protocol version the ecosystem is on. */
|
|
21
|
+
export const X402_VERSION = 2;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* EIP-712 domains for tokens a server might quote without saying so.
|
|
25
|
+
*
|
|
26
|
+
* A well-formed v2 entry carries `extra: { name, version }` and that is what
|
|
27
|
+
* is used. This table is the fallback for an entry that omits it, because the
|
|
28
|
+
* domain is not derivable: `version()` is not part of ERC-20. Every value was
|
|
29
|
+
* read from the deployed contract over JSON-RPC by CoinPay; all three USDC
|
|
30
|
+
* deployments answer "USD Coin" / "2".
|
|
31
|
+
*/
|
|
32
|
+
export const KNOWN_TOKEN_DOMAINS = {
|
|
33
|
+
'eip155:1:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { name: 'USD Coin', version: '2', decimals: 6 },
|
|
34
|
+
'eip155:137:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': { name: 'USD Coin', version: '2', decimals: 6 },
|
|
35
|
+
'eip155:8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': { name: 'USD Coin', version: '2', decimals: 6 },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Numeric EVM chain id from a CAIP-2 id, or null if it is not an eip155 chain. */
|
|
39
|
+
export function evmChainId(network) {
|
|
40
|
+
const match = /^eip155:(\d+)$/i.exec(String(network ?? ''));
|
|
41
|
+
return match ? Number(match[1]) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** base64 (standard or url-safe) -> utf8. */
|
|
45
|
+
function fromBase64(s) {
|
|
46
|
+
const clean = String(s).trim().replace(/-/g, '+').replace(/_/g, '/');
|
|
47
|
+
return Buffer.from(clean, 'base64').toString('utf8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The offer a 402 response carries, or null if it is a 402 without one.
|
|
52
|
+
*
|
|
53
|
+
* Reads the `PAYMENT-REQUIRED` header first and the JSON body second. The
|
|
54
|
+
* body is returned too, whatever it held: a gateway puts the price, the pass
|
|
55
|
+
* header name and the place to pay in there, which is worth showing a person.
|
|
56
|
+
*
|
|
57
|
+
* @param {Response} response
|
|
58
|
+
* @returns {Promise<{ offer: object|null, body: any }>}
|
|
59
|
+
*/
|
|
60
|
+
export async function readOffer(response) {
|
|
61
|
+
let body = null;
|
|
62
|
+
const text = await response.text();
|
|
63
|
+
try {
|
|
64
|
+
body = JSON.parse(text);
|
|
65
|
+
} catch {
|
|
66
|
+
body = text;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const header = response.headers.get('payment-required');
|
|
70
|
+
if (header) {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(fromBase64(header));
|
|
73
|
+
if (isOffer(parsed)) return { offer: parsed, body };
|
|
74
|
+
} catch {
|
|
75
|
+
/* fall through to the body */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { offer: isOffer(body) ? body : null, body };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whether a value has the shape of an x402 offer. */
|
|
83
|
+
export function isOffer(value) {
|
|
84
|
+
return Boolean(value) && typeof value === 'object' && Array.isArray(value.accepts);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The first entry the payer can sign.
|
|
89
|
+
*
|
|
90
|
+
* The order of `accepts` is the server's preference and is kept: the first
|
|
91
|
+
* `exact`-scheme entry on an EVM chain wins, restricted to `networks` when
|
|
92
|
+
* the caller has funds on some chains and not others.
|
|
93
|
+
*
|
|
94
|
+
* @param {object[]} accepts
|
|
95
|
+
* @param {{ networks?: string[] }} [options]
|
|
96
|
+
* @returns {object|null}
|
|
97
|
+
*/
|
|
98
|
+
export function selectAccept(accepts, { networks } = {}) {
|
|
99
|
+
if (!Array.isArray(accepts)) return null;
|
|
100
|
+
const allowed = networks ? new Set(networks.map((n) => String(n).toLowerCase())) : null;
|
|
101
|
+
return (
|
|
102
|
+
accepts.find((entry) => {
|
|
103
|
+
if (!entry || (entry.scheme ?? 'exact') !== 'exact') return false;
|
|
104
|
+
if (evmChainId(entry.network) === null) return false;
|
|
105
|
+
return !allowed || allowed.has(String(entry.network).toLowerCase());
|
|
106
|
+
}) ?? null
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The EIP-712 domain for an entry: the token's, never x402's.
|
|
112
|
+
*
|
|
113
|
+
* `verifyingContract` is the token's address and `name`/`version` are what
|
|
114
|
+
* that token returns from its own domain. Getting this wrong is not a soft
|
|
115
|
+
* failure: the recovered signer is simply some other address.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} entry
|
|
118
|
+
* @returns {{ name: string, version: string, chainId: number, verifyingContract: string }}
|
|
119
|
+
*/
|
|
120
|
+
export function domainFor(entry) {
|
|
121
|
+
const chainId = evmChainId(entry?.network);
|
|
122
|
+
if (chainId === null) throw new Error(`Not an EVM network, cannot sign an EIP-3009 authorization: ${entry?.network}`);
|
|
123
|
+
if (!entry.asset) throw new Error('The offer names no token contract (`asset`)');
|
|
124
|
+
|
|
125
|
+
const known = KNOWN_TOKEN_DOMAINS[`${String(entry.network).toLowerCase()}:${String(entry.asset).toLowerCase()}`];
|
|
126
|
+
const name = entry.extra?.name ?? known?.name;
|
|
127
|
+
const version = entry.extra?.version ?? known?.version;
|
|
128
|
+
if (!name || !version) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`The offer carries no EIP-712 domain for ${entry.asset} on ${entry.network} and it is not a token this client knows`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return { name, version, chainId, verifyingContract: entry.asset };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The amount an entry asks for, in the token's smallest unit, as a string.
|
|
138
|
+
* Reads v2's `amount` and the older `maxAmountRequired`.
|
|
139
|
+
*/
|
|
140
|
+
export function requiredAmount(entry) {
|
|
141
|
+
const raw = entry?.amount ?? entry?.maxAmountRequired;
|
|
142
|
+
if (raw === undefined || raw === null || raw === '') throw new Error('The offer names no amount');
|
|
143
|
+
const value = BigInt(raw);
|
|
144
|
+
if (value < 0n) throw new Error(`The offer names a negative amount: ${raw}`);
|
|
145
|
+
return String(value);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The entry's price in dollars, when the token is a dollar stablecoin this
|
|
150
|
+
* client knows the decimals of. Null for anything else, so a price cap can
|
|
151
|
+
* refuse what it cannot price rather than let it through.
|
|
152
|
+
*
|
|
153
|
+
* @param {object} entry
|
|
154
|
+
* @returns {number|null}
|
|
155
|
+
*/
|
|
156
|
+
export function amountUsd(entry) {
|
|
157
|
+
const known = KNOWN_TOKEN_DOMAINS[`${String(entry?.network).toLowerCase()}:${String(entry?.asset).toLowerCase()}`];
|
|
158
|
+
if (!known) return null;
|
|
159
|
+
return Number(requiredAmount(entry)) / 10 ** known.decimals;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A random 32-byte nonce, hex. EIP-3009 nonces are arbitrary bytes32. */
|
|
163
|
+
export function randomNonce() {
|
|
164
|
+
const bytes = new Uint8Array(32);
|
|
165
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
166
|
+
return `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The authorization to sign.
|
|
171
|
+
*
|
|
172
|
+
* `validAfter` is 0 rather than "now": clocks differ between the payer and the
|
|
173
|
+
* chain, and a validAfter in the payer's near future makes the authorization
|
|
174
|
+
* briefly unusable for no benefit. `validBefore` is bounded by the entry's
|
|
175
|
+
* own `maxTimeoutSeconds`, which is how long the server promises to settle
|
|
176
|
+
* within; signing for longer would leave a spendable authorization lying
|
|
177
|
+
* around after the server has given up on it.
|
|
178
|
+
*
|
|
179
|
+
* @param {object} args
|
|
180
|
+
* @param {string} args.from
|
|
181
|
+
* @param {string} args.to
|
|
182
|
+
* @param {string} args.value
|
|
183
|
+
* @param {number} [args.validForSeconds=600]
|
|
184
|
+
* @param {string} [args.nonce]
|
|
185
|
+
* @param {number} [args.now] unix seconds, for tests
|
|
186
|
+
*/
|
|
187
|
+
export function buildAuthorization({ from, to, value, validForSeconds = 600, nonce, now = Math.floor(Date.now() / 1000) }) {
|
|
188
|
+
if (!from) throw new Error('an authorization needs `from`');
|
|
189
|
+
if (!to) throw new Error('an authorization needs `to`');
|
|
190
|
+
return {
|
|
191
|
+
from,
|
|
192
|
+
to,
|
|
193
|
+
value: String(value),
|
|
194
|
+
validAfter: '0',
|
|
195
|
+
validBefore: String(now + Math.max(1, Math.floor(validForSeconds))),
|
|
196
|
+
nonce: nonce ?? randomNonce(),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Sign one `accepts` entry with a wallet.
|
|
202
|
+
*
|
|
203
|
+
* @param {object} entry
|
|
204
|
+
* @param {import('./wallet.js').Wallet} wallet
|
|
205
|
+
* @param {{ validForSeconds?: number, nonce?: string, now?: number }} [options]
|
|
206
|
+
* @returns {{ payment: object, header: string, authorization: object, domain: object }}
|
|
207
|
+
*/
|
|
208
|
+
export function signPayment(entry, wallet, options = {}) {
|
|
209
|
+
const domain = domainFor(entry);
|
|
210
|
+
if (!entry.payTo) throw new Error('The offer names nobody to pay (`payTo`)');
|
|
211
|
+
|
|
212
|
+
const authorization = buildAuthorization({
|
|
213
|
+
from: wallet.address,
|
|
214
|
+
to: entry.payTo,
|
|
215
|
+
value: requiredAmount(entry),
|
|
216
|
+
validForSeconds: options.validForSeconds ?? entry.maxTimeoutSeconds ?? 600,
|
|
217
|
+
nonce: options.nonce,
|
|
218
|
+
now: options.now,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const signature = wallet.signTypedData(domain, TRANSFER_WITH_AUTHORIZATION_TYPES, 'TransferWithAuthorization', authorization);
|
|
222
|
+
|
|
223
|
+
const payment = {
|
|
224
|
+
x402Version: X402_VERSION,
|
|
225
|
+
scheme: entry.scheme ?? 'exact',
|
|
226
|
+
network: entry.network,
|
|
227
|
+
payload: { signature, authorization },
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
return { payment, header: encodePaymentHeader(payment), authorization, domain };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** base64 of a payment, as the proof headers carry it. */
|
|
234
|
+
export function encodePaymentHeader(payment) {
|
|
235
|
+
return Buffer.from(JSON.stringify(payment), 'utf8').toString('base64');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Inverse of {@link encodePaymentHeader}. Null when the header is not base64 JSON. */
|
|
239
|
+
export function decodePaymentHeader(header) {
|
|
240
|
+
if (!header) return null;
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(fromBase64(header));
|
|
243
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
244
|
+
} catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* What a paid response gives back that is worth keeping.
|
|
251
|
+
*
|
|
252
|
+
* A gateway answers a proof with a receipt naming a pass — a bearer token
|
|
253
|
+
* good for a while — and the header to present it in. A plain x402 resource
|
|
254
|
+
* answers with the resource itself and a `PAYMENT-RESPONSE` header holding the
|
|
255
|
+
* settlement. Both are read; only the pass is something to store.
|
|
256
|
+
*
|
|
257
|
+
* @param {Response} response
|
|
258
|
+
* @param {any} body the parsed JSON body, if there was one
|
|
259
|
+
* @returns {{ pass: string|null, header: string, expires: string|null, settlement: object|null }}
|
|
260
|
+
*/
|
|
261
|
+
export function readReceipt(response, body) {
|
|
262
|
+
const headerName = (typeof body?.header === 'string' && body.header) || 'x-crawl-pass';
|
|
263
|
+
const fromBody = typeof body?.pass === 'string' ? body.pass : (body?.pass?.token ?? body?.token ?? null);
|
|
264
|
+
const pass = fromBody ?? response.headers.get(headerName) ?? null;
|
|
265
|
+
const expires = body?.expires ?? body?.pass?.expires ?? response.headers.get(`${headerName}-expires`) ?? null;
|
|
266
|
+
|
|
267
|
+
let settlement = null;
|
|
268
|
+
const settled = response.headers.get('payment-response') ?? response.headers.get('x-payment-response');
|
|
269
|
+
if (settled) {
|
|
270
|
+
try {
|
|
271
|
+
settlement = JSON.parse(fromBase64(settled));
|
|
272
|
+
} catch {
|
|
273
|
+
settlement = null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { pass: typeof pass === 'string' ? pass : null, header: headerName, expires: expires ? String(expires) : null, settlement };
|
|
278
|
+
}
|