@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/client.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client: a `fetch` that pays.
|
|
3
|
+
*
|
|
4
|
+
* The shape of a paid request, as every x402 server does it:
|
|
5
|
+
*
|
|
6
|
+
* 1. ask for the resource; get 402 with an offer
|
|
7
|
+
* 2. pick an option the wallet can sign, sign an EIP-3009 authorization
|
|
8
|
+
* for exactly that amount to exactly that address, and ask again with
|
|
9
|
+
* the proof in a header
|
|
10
|
+
* 3. get the resource — or, from a gateway selling time rather than pages,
|
|
11
|
+
* a receipt naming a pass, which then opens every page for a day
|
|
12
|
+
*
|
|
13
|
+
* Step 3 is the reason this is a client and not a function: a pass is worth
|
|
14
|
+
* remembering, and a crawler that forgets it pays for the same day on every
|
|
15
|
+
* page. Passes are filed by origin and presented automatically.
|
|
16
|
+
*
|
|
17
|
+
* Two rules keep a key-holding client from being a footgun. It never pays
|
|
18
|
+
* twice for one request: a second 402 after a proof means the payment was
|
|
19
|
+
* refused, and signing another authorization for a server that just refused
|
|
20
|
+
* one would be spending on a guess. And it never pays more than `maxUsd`,
|
|
21
|
+
* which defaults low enough that a misconfigured server or a hostile one
|
|
22
|
+
* cannot drain a wallet by asking.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { fileStore, memoryStore, originOf } from './passes.js';
|
|
26
|
+
import { walletFromKey } from './wallet.js';
|
|
27
|
+
import { amountUsd, readOffer, readReceipt, selectAccept, signPayment } from './x402.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A failure with a name a program can switch on.
|
|
31
|
+
*
|
|
32
|
+
* `code` is one of `no-key` (nothing to sign with), `no-offer` (402 without
|
|
33
|
+
* x402 in it), `no-option` (nothing signable in the offer), `too-expensive`
|
|
34
|
+
* (over `maxUsd`, or unpriceable), `rejected` (the server refused the proof).
|
|
35
|
+
*/
|
|
36
|
+
export class X402Error extends Error {
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} code
|
|
39
|
+
* @param {string} message
|
|
40
|
+
* @param {{ status?: number, body?: any, offer?: object|null, url?: string }} [detail]
|
|
41
|
+
*/
|
|
42
|
+
constructor(code, message, detail = {}) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = 'X402Error';
|
|
45
|
+
this.code = code;
|
|
46
|
+
this.status = detail.status ?? null;
|
|
47
|
+
this.body = detail.body ?? null;
|
|
48
|
+
this.offer = detail.offer ?? null;
|
|
49
|
+
this.url = detail.url ?? null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The default ceiling on one payment, in dollars. A day of crawling costs one. */
|
|
54
|
+
export const DEFAULT_MAX_USD = 5;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {import('../index.d.ts').ClientOptions} [options]
|
|
58
|
+
* @returns {import('../index.d.ts').X402Client}
|
|
59
|
+
*/
|
|
60
|
+
export function createClient(options = {}) {
|
|
61
|
+
const {
|
|
62
|
+
key,
|
|
63
|
+
networks,
|
|
64
|
+
maxUsd = DEFAULT_MAX_USD,
|
|
65
|
+
validForSeconds,
|
|
66
|
+
userAgent,
|
|
67
|
+
fetch: f = globalThis.fetch,
|
|
68
|
+
} = options;
|
|
69
|
+
|
|
70
|
+
const wallet = options.wallet ?? (key ? walletFromKey(key) : null);
|
|
71
|
+
const store = options.store === 'file' ? fileStore() : (options.store ?? memoryStore());
|
|
72
|
+
|
|
73
|
+
const baseHeaders = (init) => {
|
|
74
|
+
const headers = new Headers(init?.headers ?? {});
|
|
75
|
+
if (userAgent && !headers.has('user-agent')) headers.set('user-agent', userAgent);
|
|
76
|
+
return headers;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** The live pass for a URL's origin, if one is filed. */
|
|
80
|
+
const passFor = (url) => store.get(originOf(url));
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Pay for a URL and file whatever pass comes back.
|
|
84
|
+
*
|
|
85
|
+
* With no `offer` given, the URL is asked first, as JSON, to get one. The
|
|
86
|
+
* proof is sent on the same request the offer came from: a gateway wants it
|
|
87
|
+
* on the page or on `/crawl`, a plain resource wants it on itself.
|
|
88
|
+
*/
|
|
89
|
+
const pay = async (url, { offer: given, init = {} } = {}) => {
|
|
90
|
+
const target = String(url instanceof Request ? url.url : url);
|
|
91
|
+
if (!wallet) throw new X402Error('no-key', 'Nothing to pay with: create the client with a `key` or a `wallet`', { url: target });
|
|
92
|
+
|
|
93
|
+
let offer = given ?? null;
|
|
94
|
+
let body = null;
|
|
95
|
+
if (!offer) {
|
|
96
|
+
const headers = baseHeaders(init);
|
|
97
|
+
if (!headers.has('accept')) headers.set('accept', 'application/json');
|
|
98
|
+
const first = await f(target, { ...init, headers });
|
|
99
|
+
if (first.status !== 402) {
|
|
100
|
+
throw new X402Error('no-offer', `${target} answered ${first.status}, not 402; there is nothing to pay for`, {
|
|
101
|
+
status: first.status,
|
|
102
|
+
url: target,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
({ offer, body } = await readOffer(first));
|
|
106
|
+
if (!offer) {
|
|
107
|
+
throw new X402Error('no-offer', `${target} answered 402 without an x402 offer`, { status: 402, body, url: target });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const entry = selectAccept(offer.accepts, { networks });
|
|
112
|
+
if (!entry) {
|
|
113
|
+
const offered = (offer.accepts ?? []).map((a) => a?.network).filter(Boolean).join(', ') || 'nothing';
|
|
114
|
+
throw new X402Error('no-option', `The offer has no option this wallet can sign (offered: ${offered})`, { offer, url: target });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const usd = amountUsd(entry);
|
|
118
|
+
if (usd === null || usd > maxUsd) {
|
|
119
|
+
const price = usd === null ? 'a token this client cannot price' : `$${usd.toFixed(2)}`;
|
|
120
|
+
throw new X402Error('too-expensive', `Refusing to pay ${price} for ${target}; the ceiling is $${maxUsd} (maxUsd)`, {
|
|
121
|
+
offer,
|
|
122
|
+
url: target,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const { header, authorization, payment } = signPayment(entry, wallet, { validForSeconds });
|
|
127
|
+
|
|
128
|
+
const headers = baseHeaders(init);
|
|
129
|
+
// CoinPay's dialect reads the v1 name; v2 facilitators read the other.
|
|
130
|
+
// Sending both costs a few hundred bytes and works against either.
|
|
131
|
+
headers.set('x-payment', header);
|
|
132
|
+
headers.set('payment-signature', header);
|
|
133
|
+
if (!headers.has('accept')) headers.set('accept', 'application/json');
|
|
134
|
+
|
|
135
|
+
const paid = await f(target, { ...init, headers });
|
|
136
|
+
const text = await paid.text();
|
|
137
|
+
let json = null;
|
|
138
|
+
try {
|
|
139
|
+
json = JSON.parse(text);
|
|
140
|
+
} catch {
|
|
141
|
+
json = null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (paid.status === 402) {
|
|
145
|
+
const reason = json?.error ?? (text.slice(0, 200) || String(paid.status));
|
|
146
|
+
throw new X402Error('rejected', `${target} refused the payment: ${reason}`, {
|
|
147
|
+
status: 402,
|
|
148
|
+
body: json ?? text,
|
|
149
|
+
offer,
|
|
150
|
+
url: target,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const receipt = readReceipt(paid, json);
|
|
155
|
+
if (receipt.pass) {
|
|
156
|
+
store.set(originOf(target), {
|
|
157
|
+
token: receipt.pass,
|
|
158
|
+
header: receipt.header,
|
|
159
|
+
expires: receipt.expires,
|
|
160
|
+
boughtAt: new Date().toISOString(),
|
|
161
|
+
url: target,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
status: paid.status,
|
|
167
|
+
body: json ?? text,
|
|
168
|
+
receipt,
|
|
169
|
+
payer: wallet.address,
|
|
170
|
+
network: entry.network,
|
|
171
|
+
amount: authorization.value,
|
|
172
|
+
amountUsd: usd,
|
|
173
|
+
payTo: entry.payTo,
|
|
174
|
+
nonce: authorization.nonce,
|
|
175
|
+
payment,
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* `fetch`, paying once if asked to.
|
|
181
|
+
*
|
|
182
|
+
* A filed pass is presented on the way in. On a 402 the offer is paid, and
|
|
183
|
+
* then: a pass means the original request is made again with it, since a
|
|
184
|
+
* gateway's receipt is not the page; no pass means the paid response was
|
|
185
|
+
* the resource and is returned as it came.
|
|
186
|
+
*/
|
|
187
|
+
const paidFetch = async (input, init = {}, { pay: shouldPay = true } = {}) => {
|
|
188
|
+
const target = String(input instanceof Request ? input.url : input);
|
|
189
|
+
const headers = baseHeaders(init);
|
|
190
|
+
|
|
191
|
+
const filed = passFor(target);
|
|
192
|
+
if (filed) headers.set(filed.header, filed.token);
|
|
193
|
+
|
|
194
|
+
const first = await f(target, { ...init, headers });
|
|
195
|
+
if (first.status !== 402 || !shouldPay) return first;
|
|
196
|
+
|
|
197
|
+
const { offer, body } = await readOffer(first);
|
|
198
|
+
if (!offer) {
|
|
199
|
+
// A 402 that is not x402 is the server's to explain, so hand it back
|
|
200
|
+
// whole rather than as an exception about a protocol it does not speak.
|
|
201
|
+
return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
|
202
|
+
status: 402,
|
|
203
|
+
statusText: first.statusText,
|
|
204
|
+
headers: first.headers,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const result = await pay(target, { offer, init });
|
|
209
|
+
if (!result.receipt.pass) {
|
|
210
|
+
return new Response(typeof result.body === 'string' ? result.body : JSON.stringify(result.body), {
|
|
211
|
+
status: result.status,
|
|
212
|
+
headers: { 'content-type': typeof result.body === 'string' ? 'text/plain' : 'application/json' },
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const again = baseHeaders(init);
|
|
217
|
+
again.set(result.receipt.header, result.receipt.pass);
|
|
218
|
+
return f(target, { ...init, headers: again });
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
get address() {
|
|
223
|
+
return wallet?.address ?? null;
|
|
224
|
+
},
|
|
225
|
+
store,
|
|
226
|
+
fetch: paidFetch,
|
|
227
|
+
pay,
|
|
228
|
+
passFor,
|
|
229
|
+
};
|
|
230
|
+
}
|
package/src/eip712.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EIP-712 typed-data hashing and signing.
|
|
3
|
+
*
|
|
4
|
+
* x402's `exact` scheme on EVM is an EIP-3009 `TransferWithAuthorization`,
|
|
5
|
+
* and that is typed data rather than a transaction: the signature IS the
|
|
6
|
+
* payment, and whoever holds it can call `transferWithAuthorization` on the
|
|
7
|
+
* token and move the funds. So the one thing a payer has to get exactly right
|
|
8
|
+
* is this encoding.
|
|
9
|
+
*
|
|
10
|
+
* A port of the signer in CoinPay Wallet (`packages/extension/src/core/
|
|
11
|
+
* eip712.ts`), which was cross-checked byte for byte against ethers. The
|
|
12
|
+
* parity test in `test/eip712.test.js` holds this port to a digest and a
|
|
13
|
+
* signature produced by that original, so the two cannot drift.
|
|
14
|
+
*
|
|
15
|
+
* Built on `@noble/curves` and `@noble/hashes` rather than ethers or viem:
|
|
16
|
+
* one hash function and one signature do not justify a megabyte.
|
|
17
|
+
*
|
|
18
|
+
* Only the encodings EIP-3009 needs are implemented: atomic types plus
|
|
19
|
+
* `string` and `bytes`. Nested structs and arrays throw rather than encode
|
|
20
|
+
* wrongly, because a silently wrong encoding produces a signature that
|
|
21
|
+
* recovers to some other address, and the only symptom is an authorization
|
|
22
|
+
* nobody can spend.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
|
26
|
+
import { keccak_256 } from '@noble/hashes/sha3.js';
|
|
27
|
+
|
|
28
|
+
/** Fields of EIP712Domain, in the order the spec fixes. */
|
|
29
|
+
const DOMAIN_FIELDS = [
|
|
30
|
+
{ name: 'name', type: 'string' },
|
|
31
|
+
{ name: 'version', type: 'string' },
|
|
32
|
+
{ name: 'chainId', type: 'uint256' },
|
|
33
|
+
{ name: 'verifyingContract', type: 'address' },
|
|
34
|
+
{ name: 'salt', type: 'bytes32' },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const utf8 = new TextEncoder();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} hex
|
|
41
|
+
* @returns {Uint8Array}
|
|
42
|
+
*/
|
|
43
|
+
export function hexToBytes(hex) {
|
|
44
|
+
const clean = hex.startsWith('0x') || hex.startsWith('0X') ? hex.slice(2) : hex;
|
|
45
|
+
if (clean.length % 2 !== 0) throw new Error(`Odd-length hex: ${hex}`);
|
|
46
|
+
if (clean.length > 0 && !/^[0-9a-fA-F]+$/.test(clean)) throw new Error(`Not hex: ${hex}`);
|
|
47
|
+
const out = new Uint8Array(clean.length / 2);
|
|
48
|
+
for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {Uint8Array} bytes
|
|
54
|
+
* @returns {string} `0x`-prefixed lowercase hex
|
|
55
|
+
*/
|
|
56
|
+
export function bytesToHex(bytes) {
|
|
57
|
+
let hex = '';
|
|
58
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
|
|
59
|
+
return `0x${hex}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A 32-byte big-endian encoding of a non-negative integer. */
|
|
63
|
+
function encodeUint(value) {
|
|
64
|
+
let n;
|
|
65
|
+
try {
|
|
66
|
+
n = BigInt(value);
|
|
67
|
+
} catch {
|
|
68
|
+
throw new Error(`Not an integer: ${String(value)}`);
|
|
69
|
+
}
|
|
70
|
+
if (n < 0n) throw new Error(`Negative value for an unsigned field: ${n}`);
|
|
71
|
+
if (n >= 1n << 256n) throw new Error(`Value exceeds uint256: ${n}`);
|
|
72
|
+
const out = new Uint8Array(32);
|
|
73
|
+
for (let i = 31; i >= 0 && n > 0n; i--) {
|
|
74
|
+
out[i] = Number(n & 0xffn);
|
|
75
|
+
n >>= 8n;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A 20-byte address, left-padded into a 32-byte word. */
|
|
81
|
+
function encodeAddress(value) {
|
|
82
|
+
const bytes = hexToBytes(String(value));
|
|
83
|
+
if (bytes.length !== 20) throw new Error(`Address must be 20 bytes, got ${bytes.length}: ${String(value)}`);
|
|
84
|
+
const out = new Uint8Array(32);
|
|
85
|
+
out.set(bytes, 12);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Encode one field to its 32-byte EIP-712 representation. */
|
|
90
|
+
function encodeValue(type, value) {
|
|
91
|
+
if (type === 'string') return keccak_256(utf8.encode(String(value ?? '')));
|
|
92
|
+
if (type === 'bytes') return keccak_256(hexToBytes(String(value ?? '0x')));
|
|
93
|
+
if (type === 'address') return encodeAddress(value);
|
|
94
|
+
if (type === 'bool') {
|
|
95
|
+
const out = new Uint8Array(32);
|
|
96
|
+
out[31] = value ? 1 : 0;
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const fixedBytes = /^bytes([0-9]{1,2})$/.exec(type);
|
|
101
|
+
if (fixedBytes) {
|
|
102
|
+
const width = Number(fixedBytes[1]);
|
|
103
|
+
if (width < 1 || width > 32) throw new Error(`Invalid fixed-bytes width: ${type}`);
|
|
104
|
+
const bytes = hexToBytes(String(value));
|
|
105
|
+
if (bytes.length !== width) throw new Error(`${type} must be ${width} bytes, got ${bytes.length}`);
|
|
106
|
+
const out = new Uint8Array(32);
|
|
107
|
+
out.set(bytes, 0); // fixed bytes are RIGHT-padded
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (/^uint([0-9]{1,3})?$/.test(type)) return encodeUint(value);
|
|
112
|
+
|
|
113
|
+
// Arrays and nested structs would need recursive encoding. Refusing is the
|
|
114
|
+
// only safe response: a wrong encoding still yields a valid-looking
|
|
115
|
+
// signature, for an authorization that recovers to the wrong signer.
|
|
116
|
+
throw new Error(`Unsupported EIP-712 type: ${type}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The canonical type string, e.g.
|
|
121
|
+
* `TransferWithAuthorization(address from,address to,...)`.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} primaryType
|
|
124
|
+
* @param {Record<string, {name: string, type: string}[]>} types
|
|
125
|
+
*/
|
|
126
|
+
export function encodeType(primaryType, types) {
|
|
127
|
+
const fields = types[primaryType];
|
|
128
|
+
if (!fields) throw new Error(`Unknown type: ${primaryType}`);
|
|
129
|
+
return `${primaryType}(${fields.map((f) => `${f.type} ${f.name}`).join(',')})`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function typeHash(primaryType, types) {
|
|
133
|
+
return keccak_256(utf8.encode(encodeType(primaryType, types)));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `keccak256(typeHash ‖ encodeData(...))`. */
|
|
137
|
+
export function hashStruct(primaryType, types, data) {
|
|
138
|
+
const fields = types[primaryType];
|
|
139
|
+
if (!fields) throw new Error(`Unknown type: ${primaryType}`);
|
|
140
|
+
const parts = [typeHash(primaryType, types)];
|
|
141
|
+
for (const field of fields) parts.push(encodeValue(field.type, data[field.name]));
|
|
142
|
+
const buf = new Uint8Array(parts.length * 32);
|
|
143
|
+
parts.forEach((p, i) => buf.set(p, i * 32));
|
|
144
|
+
return keccak_256(buf);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The domain separator.
|
|
149
|
+
*
|
|
150
|
+
* Only the fields actually present are included, and in the spec's order. A
|
|
151
|
+
* domain that lists a field the token omits (or vice versa) hashes to a
|
|
152
|
+
* different separator, and the signature is then rejected on-chain.
|
|
153
|
+
*/
|
|
154
|
+
export function hashDomain(domain) {
|
|
155
|
+
const present = DOMAIN_FIELDS.filter((f) => domain[f.name] !== undefined);
|
|
156
|
+
if (present.length === 0) throw new Error('EIP-712 domain is empty');
|
|
157
|
+
return hashStruct('EIP712Domain', { EIP712Domain: present }, domain);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The 32-byte digest a signature is made over:
|
|
162
|
+
* `keccak256(0x19 ‖ 0x01 ‖ domainSeparator ‖ hashStruct(message))`.
|
|
163
|
+
*/
|
|
164
|
+
export function eip712Digest(domain, types, primaryType, message) {
|
|
165
|
+
const preimage = new Uint8Array(2 + 32 + 32);
|
|
166
|
+
preimage[0] = 0x19;
|
|
167
|
+
preimage[1] = 0x01;
|
|
168
|
+
preimage.set(hashDomain(domain), 2);
|
|
169
|
+
preimage.set(hashStruct(primaryType, types, message), 34);
|
|
170
|
+
return keccak_256(preimage);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Sign typed data, returning a 65-byte `r ‖ s ‖ v` signature as hex.
|
|
175
|
+
*
|
|
176
|
+
* `v` is 27/28 rather than 0/1. Both conventions exist, but Solidity's
|
|
177
|
+
* `ecrecover` — which is what `transferWithAuthorization` calls — expects
|
|
178
|
+
* 27/28, and a signature with a raw recovery bit simply recovers to the zero
|
|
179
|
+
* address there.
|
|
180
|
+
*
|
|
181
|
+
* @param {object} domain
|
|
182
|
+
* @param {Record<string, {name: string, type: string}[]>} types
|
|
183
|
+
* @param {string} primaryType
|
|
184
|
+
* @param {Record<string, unknown>} message
|
|
185
|
+
* @param {Uint8Array} privateKey 32 bytes
|
|
186
|
+
* @returns {string}
|
|
187
|
+
*/
|
|
188
|
+
export function signTypedData(domain, types, primaryType, message, privateKey) {
|
|
189
|
+
const digest = eip712Digest(domain, types, primaryType, message);
|
|
190
|
+
|
|
191
|
+
// `format: 'recovered'` yields [recovery, r(32), s(32)]. `prehash: false`
|
|
192
|
+
// because the digest is already the hash to sign — hashing it again would
|
|
193
|
+
// sign the wrong thing.
|
|
194
|
+
const signed = secp256k1.sign(digest, privateKey, { format: 'recovered', prehash: false });
|
|
195
|
+
if (signed.length !== 65) throw new Error(`Expected a 65-byte recovered signature, got ${signed.length}`);
|
|
196
|
+
|
|
197
|
+
const out = new Uint8Array(65);
|
|
198
|
+
out.set(signed.slice(1, 65), 0); // r ‖ s
|
|
199
|
+
out[64] = signed[0] + 27;
|
|
200
|
+
return bytesToHex(out);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The address that produced a signature over typed data, for checking one.
|
|
205
|
+
*
|
|
206
|
+
* The inverse of {@link signTypedData}: accepts the same 27/28 `v`, and
|
|
207
|
+
* returns a checksummed address. Used by the tests and by anyone verifying a
|
|
208
|
+
* proof locally before forwarding it.
|
|
209
|
+
*
|
|
210
|
+
* @returns {string}
|
|
211
|
+
*/
|
|
212
|
+
export function recoverTypedDataSigner(domain, types, primaryType, message, signature) {
|
|
213
|
+
const sig = hexToBytes(signature);
|
|
214
|
+
if (sig.length !== 65) throw new Error(`Expected a 65-byte signature, got ${sig.length}`);
|
|
215
|
+
const v = sig[64];
|
|
216
|
+
const recovery = v >= 27 ? v - 27 : v;
|
|
217
|
+
if (recovery !== 0 && recovery !== 1) throw new Error(`Bad recovery id in signature: ${v}`);
|
|
218
|
+
|
|
219
|
+
const recovered = new Uint8Array(65);
|
|
220
|
+
recovered[0] = recovery;
|
|
221
|
+
recovered.set(sig.slice(0, 64), 1);
|
|
222
|
+
|
|
223
|
+
const digest = eip712Digest(domain, types, primaryType, message);
|
|
224
|
+
const publicKey = secp256k1.recoverPublicKey(recovered, digest, { prehash: false, format: 'recovered' });
|
|
225
|
+
return addressOfPublicKey(publicKey);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The EIP-55 checksummed address of a secp256k1 public key, compressed or not.
|
|
230
|
+
*
|
|
231
|
+
* @param {Uint8Array} publicKey
|
|
232
|
+
* @returns {string}
|
|
233
|
+
*/
|
|
234
|
+
export function addressOfPublicKey(publicKey) {
|
|
235
|
+
const point = secp256k1.Point.fromBytes(publicKey);
|
|
236
|
+
const uncompressed = point.toBytes(false); // 0x04 ‖ X ‖ Y
|
|
237
|
+
const hash = keccak_256(uncompressed.slice(1));
|
|
238
|
+
return checksumAddress(bytesToHex(hash.slice(12)));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* EIP-55: uppercase the hex digits whose position hashes high.
|
|
243
|
+
*
|
|
244
|
+
* @param {string} address
|
|
245
|
+
* @returns {string}
|
|
246
|
+
*/
|
|
247
|
+
export function checksumAddress(address) {
|
|
248
|
+
const lower = address.toLowerCase().replace(/^0x/, '');
|
|
249
|
+
if (!/^[0-9a-f]{40}$/.test(lower)) throw new Error(`Not an address: ${address}`);
|
|
250
|
+
const hash = bytesToHex(keccak_256(utf8.encode(lower))).slice(2);
|
|
251
|
+
let out = '0x';
|
|
252
|
+
for (let i = 0; i < 40; i++) {
|
|
253
|
+
out += Number.parseInt(hash[i], 16) >= 8 ? lower[i].toUpperCase() : lower[i];
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export { createClient, X402Error, DEFAULT_MAX_USD } from './client.js';
|
|
2
|
+
export { walletFromKey, randomWallet } from './wallet.js';
|
|
3
|
+
export { memoryStore, fileStore, defaultPassPath, originOf, isLive } from './passes.js';
|
|
4
|
+
export {
|
|
5
|
+
X402_VERSION,
|
|
6
|
+
KNOWN_TOKEN_DOMAINS,
|
|
7
|
+
TRANSFER_WITH_AUTHORIZATION_TYPES,
|
|
8
|
+
evmChainId,
|
|
9
|
+
readOffer,
|
|
10
|
+
isOffer,
|
|
11
|
+
selectAccept,
|
|
12
|
+
domainFor,
|
|
13
|
+
requiredAmount,
|
|
14
|
+
amountUsd,
|
|
15
|
+
randomNonce,
|
|
16
|
+
buildAuthorization,
|
|
17
|
+
signPayment,
|
|
18
|
+
encodePaymentHeader,
|
|
19
|
+
decodePaymentHeader,
|
|
20
|
+
readReceipt,
|
|
21
|
+
} from './x402.js';
|
|
22
|
+
export {
|
|
23
|
+
eip712Digest,
|
|
24
|
+
signTypedData,
|
|
25
|
+
recoverTypedDataSigner,
|
|
26
|
+
addressOfPublicKey,
|
|
27
|
+
checksumAddress,
|
|
28
|
+
hashStruct,
|
|
29
|
+
hashDomain,
|
|
30
|
+
encodeType,
|
|
31
|
+
typeHash,
|
|
32
|
+
hexToBytes,
|
|
33
|
+
bytesToHex,
|
|
34
|
+
} from './eip712.js';
|
package/src/passes.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where passes live between requests.
|
|
3
|
+
*
|
|
4
|
+
* A gateway sells a day at a time, and a pass is a bearer token: whoever
|
|
5
|
+
* presents it gets in, and presenting it costs nothing. So the whole value of
|
|
6
|
+
* a client that remembers passes is that the second request to a site — and
|
|
7
|
+
* the thousandth, from a crawler — never pays again. Keyed by origin, because
|
|
8
|
+
* a pass opens a site rather than a page.
|
|
9
|
+
*
|
|
10
|
+
* Two stores. Memory for a process that lives as long as its passes are
|
|
11
|
+
* worth; a file for a CLI that is invoked once per URL and would otherwise
|
|
12
|
+
* buy the same day a thousand times.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {{
|
|
21
|
+
* token: string,
|
|
22
|
+
* header: string,
|
|
23
|
+
* expires: string|null,
|
|
24
|
+
* boughtAt: string,
|
|
25
|
+
* url: string|null,
|
|
26
|
+
* }} StoredPass
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Whether a pass is still worth presenting. Unknown expiry counts as live. */
|
|
30
|
+
export function isLive(pass, now = Date.now()) {
|
|
31
|
+
if (!pass?.token) return false;
|
|
32
|
+
if (!pass.expires) return true;
|
|
33
|
+
const at = Date.parse(pass.expires);
|
|
34
|
+
return Number.isNaN(at) ? true : at > now;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The origin a URL's pass is filed under. */
|
|
38
|
+
export function originOf(url) {
|
|
39
|
+
return new URL(String(url)).origin;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Passes kept in memory for the life of the process.
|
|
44
|
+
*
|
|
45
|
+
* @returns {import('../index.d.ts').PassStore}
|
|
46
|
+
*/
|
|
47
|
+
export function memoryStore() {
|
|
48
|
+
const passes = new Map();
|
|
49
|
+
return {
|
|
50
|
+
get: (origin) => {
|
|
51
|
+
const pass = passes.get(origin) ?? null;
|
|
52
|
+
return isLive(pass) ? pass : null;
|
|
53
|
+
},
|
|
54
|
+
set: (origin, pass) => {
|
|
55
|
+
passes.set(origin, pass);
|
|
56
|
+
},
|
|
57
|
+
delete: (origin) => {
|
|
58
|
+
passes.delete(origin);
|
|
59
|
+
},
|
|
60
|
+
all: () => Object.fromEntries(passes),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The default file: `$XDG_CONFIG_HOME/x402-client/passes.json`, or the same
|
|
66
|
+
* under `~/.config`.
|
|
67
|
+
*/
|
|
68
|
+
export function defaultPassPath() {
|
|
69
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
70
|
+
return join(base, 'x402-client', 'passes.json');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Passes kept in a JSON file, read on every get and rewritten on every set.
|
|
75
|
+
*
|
|
76
|
+
* The file is small — one line per site — and re-reading it is what lets two
|
|
77
|
+
* CLI invocations in a row, or two processes, share what one of them bought.
|
|
78
|
+
* Written with mode 0600 since a pass is a bearer token, and created with its
|
|
79
|
+
* directory when missing. A file that cannot be read is treated as empty
|
|
80
|
+
* rather than fatal: losing a cache costs a dollar, crashing costs the run.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} [path]
|
|
83
|
+
* @returns {import('../index.d.ts').PassStore}
|
|
84
|
+
*/
|
|
85
|
+
export function fileStore(path = defaultPassPath()) {
|
|
86
|
+
const read = () => {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
89
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
90
|
+
} catch {
|
|
91
|
+
return {};
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const write = (all) => {
|
|
95
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
96
|
+
writeFileSync(path, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
get: (origin) => {
|
|
101
|
+
const pass = read()[origin] ?? null;
|
|
102
|
+
return isLive(pass) ? pass : null;
|
|
103
|
+
},
|
|
104
|
+
set: (origin, pass) => {
|
|
105
|
+
const all = read();
|
|
106
|
+
all[origin] = pass;
|
|
107
|
+
write(all);
|
|
108
|
+
},
|
|
109
|
+
delete: (origin) => {
|
|
110
|
+
const all = read();
|
|
111
|
+
delete all[origin];
|
|
112
|
+
write(all);
|
|
113
|
+
},
|
|
114
|
+
all: () => read(),
|
|
115
|
+
path,
|
|
116
|
+
};
|
|
117
|
+
}
|
package/src/types.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EIP-3009 `TransferWithAuthorization`.
|
|
3
|
+
*
|
|
4
|
+
* This — not a bespoke `Payment` struct — is what x402's `exact` scheme signs
|
|
5
|
+
* on EVM. Whoever holds the signature can call `transferWithAuthorization` on
|
|
6
|
+
* the token and move the funds, which is why the facilitator can broadcast
|
|
7
|
+
* and pay the gas while the payer needs no native currency at all.
|
|
8
|
+
*/
|
|
9
|
+
export const TRANSFER_WITH_AUTHORIZATION_TYPES = {
|
|
10
|
+
TransferWithAuthorization: [
|
|
11
|
+
{ name: 'from', type: 'address' },
|
|
12
|
+
{ name: 'to', type: 'address' },
|
|
13
|
+
{ name: 'value', type: 'uint256' },
|
|
14
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
15
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
16
|
+
{ name: 'nonce', type: 'bytes32' },
|
|
17
|
+
],
|
|
18
|
+
};
|