@profullstack/coinpay 0.6.11 → 0.8.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/README.md +64 -0
- package/bin/coinpay.js +946 -59
- package/package.json +20 -3
- package/src/auth.js +2 -1
- package/src/client.d.ts +52 -0
- package/src/client.js +53 -7
- package/src/index.d.ts +34 -1
- package/src/index.js +32 -0
- package/src/invoices.d.ts +77 -0
- package/src/invoices.js +237 -0
- package/src/tokens.d.ts +14 -0
- package/src/tokens.js +34 -0
- package/src/wallet.d.ts +11 -0
- package/src/wallet.js +229 -1
- package/src/webhooks.js +37 -3
- package/src/x402-browser.js +318 -0
- package/src/x402-pay-server.js +272 -0
- package/src/x402-v2.js +416 -0
- package/src/x402.js +164 -19
package/src/x402-v2.js
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402 v2 protocol primitives.
|
|
3
|
+
*
|
|
4
|
+
* `x402.js` speaks a dialect only CoinPayPortal understands: `x402Version: 1`,
|
|
5
|
+
* bare network names like `"base"`, `maxAmountRequired` instead of `amount`,
|
|
6
|
+
* and an EIP-712 domain of our own invention (`name: 'x402'`). Nothing else in
|
|
7
|
+
* the ecosystem can read a 402 we emit, and no proof from a standard client
|
|
8
|
+
* verifies against us.
|
|
9
|
+
*
|
|
10
|
+
* That is survivable while CoinPay Wallet is the only payer. It stops being
|
|
11
|
+
* survivable the moment MetaMask or Phantom is expected to pay, because those
|
|
12
|
+
* wallets sign standard structures and nothing else: MetaMask will happily
|
|
13
|
+
* sign our bespoke domain, but the resulting signature authorises nothing
|
|
14
|
+
* on-chain and only we could ever interpret it.
|
|
15
|
+
*
|
|
16
|
+
* This module is the real thing, and is shared by the browser payer and the
|
|
17
|
+
* facilitator so the two cannot drift.
|
|
18
|
+
*
|
|
19
|
+
* Field shapes here were taken from live `/supported` and
|
|
20
|
+
* `/discovery/resources` responses on facilitator.goplausible.xyz and
|
|
21
|
+
* Coinbase's facilitator, not from memory of the spec.
|
|
22
|
+
*
|
|
23
|
+
* @module x402-v2
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The protocol version the ecosystem is actually on. */
|
|
27
|
+
export const X402_VERSION = 2;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* CAIP-2 chain identifiers.
|
|
31
|
+
*
|
|
32
|
+
* The ecosystem addresses chains as `namespace:reference`, never as a bare
|
|
33
|
+
* name — `"base"` is meaningless to another facilitator, `eip155:8453` is not.
|
|
34
|
+
*
|
|
35
|
+
* `eip155:*` and `solana:*` are verified against live facilitator responses.
|
|
36
|
+
* Solana's reference is the first 32 characters of the genesis hash, per
|
|
37
|
+
* CAIP-30.
|
|
38
|
+
*/
|
|
39
|
+
export const CAIP2 = {
|
|
40
|
+
ethereum: 'eip155:1',
|
|
41
|
+
polygon: 'eip155:137',
|
|
42
|
+
base: 'eip155:8453',
|
|
43
|
+
'base-sepolia': 'eip155:84532',
|
|
44
|
+
solana: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
|
|
45
|
+
// CAIP-2 namespace for Bitcoin-family chains is `bip122`, referenced by the
|
|
46
|
+
// genesis block hash. Bitcoin Cash shares Bitcoin's genesis block, so the two
|
|
47
|
+
// are not distinguishable by CAIP-2 alone; BCH is left out rather than given
|
|
48
|
+
// an identifier that collides with Bitcoin's.
|
|
49
|
+
bitcoin: 'bip122:000000000019d6689c085ae165831e93',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Rails CoinPayPortal supports that have no CAIP-2 identity.
|
|
54
|
+
*
|
|
55
|
+
* Nobody else's facilitator accepts these, so there is no interoperable name
|
|
56
|
+
* to use. They keep their legacy identifiers and are flagged here so callers
|
|
57
|
+
* can tell "not yet mapped" from "deliberately outside CAIP-2".
|
|
58
|
+
*/
|
|
59
|
+
export const NON_CAIP2_NETWORKS = new Set(['bitcoin-cash', 'lightning', 'stripe']);
|
|
60
|
+
|
|
61
|
+
/** Legacy bare name -> CAIP-2, for translating our own v1 records. */
|
|
62
|
+
export function toCaip2(network) {
|
|
63
|
+
if (!network) return '';
|
|
64
|
+
if (network.includes(':')) return network; // already CAIP-2
|
|
65
|
+
return CAIP2[network] ?? network;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** CAIP-2 -> legacy bare name, for code that still keys on the old names. */
|
|
69
|
+
export function fromCaip2(caip2) {
|
|
70
|
+
if (!caip2) return '';
|
|
71
|
+
if (!caip2.includes(':')) return caip2; // already a bare name
|
|
72
|
+
const match = Object.entries(CAIP2).find(([, id]) => id === caip2);
|
|
73
|
+
return match ? match[0] : caip2;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Numeric EVM chain id from a CAIP-2 id, or null if it is not an eip155 chain. */
|
|
77
|
+
export function evmChainId(network) {
|
|
78
|
+
const caip2 = toCaip2(network);
|
|
79
|
+
const match = /^eip155:(\d+)$/.exec(caip2);
|
|
80
|
+
return match ? Number(match[1]) : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* EIP-712 domains for the tokens we quote, keyed by `chainId:address`.
|
|
85
|
+
*
|
|
86
|
+
* A payer cannot sign without `name` and `version`, and they are not derivable:
|
|
87
|
+
* `version()` is not part of ERC-20 and tokens disagree about the value. So a
|
|
88
|
+
* v2 `accepts` entry has to carry them, which means the quoting side has to
|
|
89
|
+
* know them.
|
|
90
|
+
*
|
|
91
|
+
* Every entry below was read from the deployed contract — `name()`,
|
|
92
|
+
* `version()` and `DOMAIN_SEPARATOR()` over JSON-RPC — not copied from
|
|
93
|
+
* documentation. The Base values additionally match what GoPlausible's
|
|
94
|
+
* discovery index publishes for the same token.
|
|
95
|
+
*
|
|
96
|
+
* A token that is not in this table cannot be quoted for v2, because guessing
|
|
97
|
+
* its domain produces signatures that recover to the wrong address and fail
|
|
98
|
+
* verification with no useful diagnostic.
|
|
99
|
+
*/
|
|
100
|
+
export const TOKEN_DOMAINS = {
|
|
101
|
+
// USDC. All three deployments report the same domain.
|
|
102
|
+
'eip155:1:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': { name: 'USD Coin', version: '2' },
|
|
103
|
+
'eip155:137:0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359': { name: 'USD Coin', version: '2' },
|
|
104
|
+
'eip155:8453:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913': { name: 'USD Coin', version: '2' },
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** The published EIP-712 domain for a token, or null if we do not know it. */
|
|
108
|
+
export function tokenDomain(network, asset) {
|
|
109
|
+
if (!asset) return null;
|
|
110
|
+
const caip2 = toCaip2(network);
|
|
111
|
+
const exact = TOKEN_DOMAINS[`${caip2}:${asset}`];
|
|
112
|
+
if (exact) return exact;
|
|
113
|
+
|
|
114
|
+
// Addresses are case-insensitive, so a caller passing a differently-cased
|
|
115
|
+
// address must not silently miss.
|
|
116
|
+
const wanted = `${caip2}:${asset}`.toLowerCase();
|
|
117
|
+
const match = Object.entries(TOKEN_DOMAINS).find(([key]) => key.toLowerCase() === wanted);
|
|
118
|
+
return match ? match[1] : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The v2-quotable tokens, as `{ methodKey: {network, asset, decimals} }`.
|
|
123
|
+
*
|
|
124
|
+
* Only tokens: EIP-3009 is an ERC-20 extension, so native ETH or POL cannot be
|
|
125
|
+
* paid under the `exact` scheme at all — there is no contract to authorise
|
|
126
|
+
* against. Quoting native currency as `exact` would advertise something no
|
|
127
|
+
* wallet can sign.
|
|
128
|
+
*/
|
|
129
|
+
export const V2_METHODS = {
|
|
130
|
+
usdc_eth: {
|
|
131
|
+
network: 'eip155:1',
|
|
132
|
+
asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
|
|
133
|
+
decimals: 6,
|
|
134
|
+
label: 'USDC on Ethereum',
|
|
135
|
+
},
|
|
136
|
+
usdc_polygon: {
|
|
137
|
+
network: 'eip155:137',
|
|
138
|
+
asset: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359',
|
|
139
|
+
decimals: 6,
|
|
140
|
+
label: 'USDC on Polygon',
|
|
141
|
+
},
|
|
142
|
+
usdc_base: {
|
|
143
|
+
network: 'eip155:8453',
|
|
144
|
+
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
145
|
+
decimals: 6,
|
|
146
|
+
label: 'USDC on Base',
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* EIP-3009 `TransferWithAuthorization` type.
|
|
152
|
+
*
|
|
153
|
+
* This — not a bespoke `Payment` struct — is what x402's `exact` scheme signs
|
|
154
|
+
* on EVM. The signature IS the payment: whoever holds it can call
|
|
155
|
+
* `transferWithAuthorization` on the token and move the funds, which is why
|
|
156
|
+
* the facilitator can broadcast and pay the gas while the payer needs no
|
|
157
|
+
* native currency at all.
|
|
158
|
+
*/
|
|
159
|
+
export const TRANSFER_WITH_AUTHORIZATION_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
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Build the EIP-712 domain for an EIP-3009 authorization.
|
|
172
|
+
*
|
|
173
|
+
* The domain belongs to the TOKEN, not to x402: `verifyingContract` is the
|
|
174
|
+
* token's own address, and `name`/`version` are the values that token returns
|
|
175
|
+
* from its own EIP-712 domain. Tokens disagree about `version` ("1" vs "2")
|
|
176
|
+
* and it is not derivable, which is why a v2 `accepts` entry carries it in
|
|
177
|
+
* `extra` — that is what `extra: { name: 'USD Coin', version: '2' }` in live
|
|
178
|
+
* discovery data is for.
|
|
179
|
+
*
|
|
180
|
+
* Getting this wrong is not a soft failure: the recovered signer is simply
|
|
181
|
+
* some other address, so the authorization is void.
|
|
182
|
+
*
|
|
183
|
+
* @param {object} args
|
|
184
|
+
* @param {string} args.network CAIP-2 id or legacy name
|
|
185
|
+
* @param {string} args.asset token contract address
|
|
186
|
+
* @param {string} args.name token's EIP-712 domain name
|
|
187
|
+
* @param {string} args.version token's EIP-712 domain version
|
|
188
|
+
*/
|
|
189
|
+
export function buildEip3009Domain({ network, asset, name, version }) {
|
|
190
|
+
const chainId = evmChainId(network);
|
|
191
|
+
if (chainId === null) {
|
|
192
|
+
throw new Error(`Not an EVM network, cannot build an EIP-3009 domain: ${network}`);
|
|
193
|
+
}
|
|
194
|
+
if (!asset) {
|
|
195
|
+
throw new Error('EIP-3009 requires the token contract address as verifyingContract');
|
|
196
|
+
}
|
|
197
|
+
if (!name || !version) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
'EIP-3009 requires the token EIP-712 domain name and version; ' +
|
|
200
|
+
'a v2 `accepts` entry supplies them in `extra.name` / `extra.version`',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { name, version, chainId, verifyingContract: asset };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A random 32-byte nonce, hex encoded.
|
|
209
|
+
*
|
|
210
|
+
* EIP-3009 nonces are arbitrary bytes32 rather than a sequence — the token
|
|
211
|
+
* tracks used ones per authorizer, so they need only be unpredictable and not
|
|
212
|
+
* repeated. Uses WebCrypto, which is present in browsers, Workers and Node 18+.
|
|
213
|
+
*/
|
|
214
|
+
export function randomNonce() {
|
|
215
|
+
const bytes = new Uint8Array(32);
|
|
216
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
217
|
+
return `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Build the authorization message to be signed.
|
|
222
|
+
*
|
|
223
|
+
* `validAfter` is 0 rather than "now": clocks differ between the payer and the
|
|
224
|
+
* chain, and a validAfter in the payer's near future makes the authorization
|
|
225
|
+
* briefly unusable for no benefit.
|
|
226
|
+
*
|
|
227
|
+
* @param {object} args
|
|
228
|
+
* @param {string} args.from payer address
|
|
229
|
+
* @param {string} args.to payee address (`payTo` from `accepts`)
|
|
230
|
+
* @param {string|bigint} args.value amount in the token's smallest unit
|
|
231
|
+
* @param {number} [args.validForSeconds=600]
|
|
232
|
+
* @param {string} [args.nonce] defaults to a fresh random nonce
|
|
233
|
+
*/
|
|
234
|
+
export function buildAuthorization({ from, to, value, validForSeconds = 600, nonce }) {
|
|
235
|
+
if (!from) throw new Error('buildAuthorization requires `from`');
|
|
236
|
+
if (!to) throw new Error('buildAuthorization requires `to`');
|
|
237
|
+
if (value === undefined || value === null || value === '') {
|
|
238
|
+
throw new Error('buildAuthorization requires `value`');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const now = Math.floor(Date.now() / 1000);
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
from,
|
|
245
|
+
to,
|
|
246
|
+
value: String(value),
|
|
247
|
+
validAfter: '0',
|
|
248
|
+
validBefore: String(now + validForSeconds),
|
|
249
|
+
nonce: nonce ?? randomNonce(),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Assemble the payment payload that goes in the `X-PAYMENT` header.
|
|
255
|
+
*
|
|
256
|
+
* @param {object} args
|
|
257
|
+
* @param {string} args.network CAIP-2 id
|
|
258
|
+
* @param {string} args.signature 65-byte EIP-712 signature, hex
|
|
259
|
+
* @param {object} args.authorization from `buildAuthorization`
|
|
260
|
+
* @param {string} [args.scheme='exact']
|
|
261
|
+
*/
|
|
262
|
+
export function buildExactEvmPayment({ network, signature, authorization, scheme = 'exact' }) {
|
|
263
|
+
return {
|
|
264
|
+
x402Version: X402_VERSION,
|
|
265
|
+
scheme,
|
|
266
|
+
network: toCaip2(network),
|
|
267
|
+
payload: { signature, authorization },
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* base64 encoding of a payment payload, as the `X-PAYMENT` header carries it.
|
|
273
|
+
*
|
|
274
|
+
* Uses base64url-safe output only where the runtime gives it; the header is
|
|
275
|
+
* standard base64 in the spec, so plain base64 is what is produced.
|
|
276
|
+
*/
|
|
277
|
+
export function encodePaymentHeader(payment) {
|
|
278
|
+
const json = JSON.stringify(payment);
|
|
279
|
+
if (typeof globalThis.btoa === 'function') {
|
|
280
|
+
// Browsers: btoa is latin1-only, so UTF-8 must be widened first.
|
|
281
|
+
const bytes = new TextEncoder().encode(json);
|
|
282
|
+
let binary = '';
|
|
283
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
284
|
+
return globalThis.btoa(binary);
|
|
285
|
+
}
|
|
286
|
+
return Buffer.from(json, 'utf-8').toString('base64');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Inverse of {@link encodePaymentHeader}. */
|
|
290
|
+
export function decodePaymentHeader(header) {
|
|
291
|
+
let json;
|
|
292
|
+
if (typeof globalThis.atob === 'function') {
|
|
293
|
+
const binary = globalThis.atob(header);
|
|
294
|
+
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
295
|
+
json = new TextDecoder().decode(bytes);
|
|
296
|
+
} else {
|
|
297
|
+
json = Buffer.from(header, 'base64').toString('utf-8');
|
|
298
|
+
}
|
|
299
|
+
return JSON.parse(json);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Pick the best `accepts` entry a given set of wallets can actually pay.
|
|
304
|
+
*
|
|
305
|
+
* A 402 lists several options and the payer must choose one it can sign. The
|
|
306
|
+
* order of `accepts` is the merchant's preference, so it is honoured rather
|
|
307
|
+
* than re-ranked — the first entry whose network is supported wins.
|
|
308
|
+
*
|
|
309
|
+
* @param {object[]} accepts the 402's `accepts` array
|
|
310
|
+
* @param {string[]} capabilities CAIP-2 ids (or bare names) the payer can sign
|
|
311
|
+
* @returns {object|null}
|
|
312
|
+
*/
|
|
313
|
+
export function selectAcceptEntry(accepts, capabilities) {
|
|
314
|
+
if (!Array.isArray(accepts) || accepts.length === 0) return null;
|
|
315
|
+
const supported = new Set(capabilities.map(toCaip2));
|
|
316
|
+
return accepts.find((entry) => supported.has(toCaip2(entry?.network))) ?? null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Build a v2 `402 Payment Required` body.
|
|
321
|
+
*
|
|
322
|
+
* `buildPaymentRequired` in x402.js emits our v1 dialect — `x402Version: 1`,
|
|
323
|
+
* bare network names, `maxAmountRequired` — which no standard payer can read.
|
|
324
|
+
* A facilitator that verifies v2 but only ever quotes v1 still cannot be paid
|
|
325
|
+
* by anyone, so this is the other half of speaking the protocol.
|
|
326
|
+
*
|
|
327
|
+
* Only tokens with a known EIP-712 domain are quoted. An entry a payer cannot
|
|
328
|
+
* sign is worse than an absent one: it looks payable, fails at signing time,
|
|
329
|
+
* and the failure surfaces as an opaque signature error.
|
|
330
|
+
*
|
|
331
|
+
* @param {object} options
|
|
332
|
+
* @param {string|Record<string,string>} options.payTo merchant address, or per-network map
|
|
333
|
+
* @param {number} [options.amountUsd] price in USD (USDC is quoted 1:1)
|
|
334
|
+
* @param {string} [options.amount] explicit smallest-unit amount, overrides amountUsd
|
|
335
|
+
* @param {string[]} [options.methods] keys from V2_METHODS; defaults to all
|
|
336
|
+
* @param {string} options.resource the URL being sold
|
|
337
|
+
* @param {string} [options.description]
|
|
338
|
+
* @param {string} [options.mimeType='application/json']
|
|
339
|
+
* @param {number} [options.maxTimeoutSeconds=300]
|
|
340
|
+
*/
|
|
341
|
+
export function buildPaymentRequiredV2({
|
|
342
|
+
payTo,
|
|
343
|
+
amountUsd,
|
|
344
|
+
amount,
|
|
345
|
+
methods,
|
|
346
|
+
resource,
|
|
347
|
+
description = 'Payment required',
|
|
348
|
+
mimeType = 'application/json',
|
|
349
|
+
maxTimeoutSeconds = 300,
|
|
350
|
+
} = {}) {
|
|
351
|
+
if (!payTo) throw new Error('buildPaymentRequiredV2 requires `payTo`');
|
|
352
|
+
if (!resource) throw new Error('buildPaymentRequiredV2 requires `resource`');
|
|
353
|
+
if (amount === undefined && amountUsd === undefined) {
|
|
354
|
+
throw new Error('buildPaymentRequiredV2 requires `amount` or `amountUsd`');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const keys = methods ?? Object.keys(V2_METHODS);
|
|
358
|
+
const accepts = [];
|
|
359
|
+
|
|
360
|
+
for (const key of keys) {
|
|
361
|
+
const method = V2_METHODS[key];
|
|
362
|
+
if (!method) continue;
|
|
363
|
+
|
|
364
|
+
const domain = tokenDomain(method.network, method.asset);
|
|
365
|
+
if (!domain) continue; // unsignable — leave it out rather than advertise it
|
|
366
|
+
|
|
367
|
+
const address =
|
|
368
|
+
typeof payTo === 'object' ? (payTo[method.network] ?? payTo[fromCaip2(method.network)] ?? payTo[key]) : payTo;
|
|
369
|
+
if (!address) continue; // merchant has no address on this chain
|
|
370
|
+
|
|
371
|
+
// USDC is a dollar stablecoin with 6 decimals, so a USD price converts
|
|
372
|
+
// exactly. Rounding up rather than down: rounding a fraction of a cent
|
|
373
|
+
// down would quote less than the asking price and verify as underpayment.
|
|
374
|
+
const smallestUnit =
|
|
375
|
+
amount !== undefined
|
|
376
|
+
? String(amount)
|
|
377
|
+
: String(Math.ceil(amountUsd * 10 ** method.decimals));
|
|
378
|
+
|
|
379
|
+
accepts.push({
|
|
380
|
+
scheme: 'exact',
|
|
381
|
+
network: method.network,
|
|
382
|
+
amount: smallestUnit,
|
|
383
|
+
asset: method.asset,
|
|
384
|
+
payTo: address,
|
|
385
|
+
resource,
|
|
386
|
+
description,
|
|
387
|
+
mimeType,
|
|
388
|
+
maxTimeoutSeconds,
|
|
389
|
+
// The token's EIP-712 domain. Without this the payer cannot construct a
|
|
390
|
+
// signature at all — it is the whole reason `extra` exists on a v2 entry.
|
|
391
|
+
extra: { name: domain.name, version: domain.version },
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (accepts.length === 0) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
'No payable options could be built. Check `payTo` covers at least one supported chain.',
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return { x402Version: X402_VERSION, accepts };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The amount an `accepts` entry asks for, in the asset's smallest unit.
|
|
406
|
+
*
|
|
407
|
+
* v2 names this `amount`; our own v1 emitted `maxAmountRequired`. Both are
|
|
408
|
+
* read so a payer can pay a CoinPayPortal 402 that has not been migrated yet.
|
|
409
|
+
*/
|
|
410
|
+
export function requiredAmount(entry) {
|
|
411
|
+
const raw = entry?.amount ?? entry?.maxAmountRequired;
|
|
412
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
413
|
+
throw new Error('`accepts` entry has neither `amount` nor `maxAmountRequired`');
|
|
414
|
+
}
|
|
415
|
+
return String(raw);
|
|
416
|
+
}
|
package/src/x402.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* @module x402
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { buildPaymentRequiredV2 } from './x402-v2.js';
|
|
12
|
+
|
|
11
13
|
/**
|
|
12
14
|
* All supported payment methods with their network/asset metadata.
|
|
13
15
|
*
|
|
@@ -331,6 +333,82 @@ function _networkToMethodKey(network) {
|
|
|
331
333
|
return map[network] || network;
|
|
332
334
|
}
|
|
333
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Decode an X-PAYMENT header without verifying it.
|
|
338
|
+
*
|
|
339
|
+
* Returns null on anything malformed — the caller treats that as "no usable
|
|
340
|
+
* proof", and the facilitator remains the only thing that decides validity.
|
|
341
|
+
*/
|
|
342
|
+
function decodePaymentHeader(paymentHeader) {
|
|
343
|
+
try {
|
|
344
|
+
return JSON.parse(Buffer.from(paymentHeader, 'base64').toString('utf-8'));
|
|
345
|
+
} catch {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Work out what this proof was supposed to pay, by finding the offer entry for
|
|
352
|
+
* the method the payer chose.
|
|
353
|
+
*
|
|
354
|
+
* The price comes from the offer the server just built — never from the proof
|
|
355
|
+
* itself, which is the payer's word for what they owe.
|
|
356
|
+
*
|
|
357
|
+
* @returns {{amount: string, resource: string, payTo?: string, asset?: string}|null}
|
|
358
|
+
* null if the proof matches no advertised method, in which case there is no
|
|
359
|
+
* price to hold it to.
|
|
360
|
+
*/
|
|
361
|
+
export function expectedForProof(paymentHeader, offer, resource) {
|
|
362
|
+
const payment = decodePaymentHeader(paymentHeader);
|
|
363
|
+
if (!payment?.payload) return null;
|
|
364
|
+
|
|
365
|
+
// A v2 proof describes itself differently: the network sits at the top level
|
|
366
|
+
// and the payload holds a signed authorization rather than loose fields.
|
|
367
|
+
// Matching it against the offer the same way as v1 would find nothing and
|
|
368
|
+
// report "no price to check against" for a perfectly good payment.
|
|
369
|
+
const authorization = payment.payload.authorization;
|
|
370
|
+
if (authorization) {
|
|
371
|
+
const entries = offer?.accepts || [];
|
|
372
|
+
const match =
|
|
373
|
+
entries.find((e) => e.network === payment.network && e.payTo === authorization.to) ||
|
|
374
|
+
entries.find((e) => e.network === payment.network);
|
|
375
|
+
|
|
376
|
+
if (!match) return null;
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
amount: match.amount ?? match.maxAmountRequired,
|
|
380
|
+
resource,
|
|
381
|
+
// v2 verification needs both: an EIP-3009 signature says nothing about
|
|
382
|
+
// which token it is denominated in, so without the asset there is no
|
|
383
|
+
// domain to check it against.
|
|
384
|
+
payTo: match.payTo,
|
|
385
|
+
asset: match.asset,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const { network, asset } = payment.payload;
|
|
390
|
+
const methodKey = payment.payload.methodKey || payment.payload.extra?.methodKey;
|
|
391
|
+
|
|
392
|
+
const entries = offer?.accepts || [];
|
|
393
|
+
|
|
394
|
+
// Prefer an exact method match: a network alone is ambiguous, since e.g.
|
|
395
|
+
// ethereum offers both ETH and USDC at very different smallest-unit prices.
|
|
396
|
+
let match = methodKey
|
|
397
|
+
? entries.find((e) => e.extra?.methodKey === methodKey)
|
|
398
|
+
: undefined;
|
|
399
|
+
|
|
400
|
+
if (!match && network) {
|
|
401
|
+
const sameNetwork = entries.filter((e) => e.network === network);
|
|
402
|
+
match =
|
|
403
|
+
sameNetwork.find((e) => e.asset === asset) ||
|
|
404
|
+
(sameNetwork.length === 1 ? sameNetwork[0] : undefined);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (!match) return null;
|
|
408
|
+
|
|
409
|
+
return { amount: match.maxAmountRequired, resource };
|
|
410
|
+
}
|
|
411
|
+
|
|
334
412
|
/**
|
|
335
413
|
* Create Express/Next.js middleware that gates routes behind x402 payments.
|
|
336
414
|
*
|
|
@@ -373,9 +451,23 @@ export function createX402Middleware(globalOptions) {
|
|
|
373
451
|
description = 'Payment required',
|
|
374
452
|
facilitatorUrl = DEFAULT_FACILITATOR_URL,
|
|
375
453
|
apiBaseUrl = 'https://coinpayportal.com',
|
|
454
|
+
allowPendingConfirmation: globalAllowPendingConfirmation = false,
|
|
455
|
+
/**
|
|
456
|
+
* Which protocol version to quote in the 402.
|
|
457
|
+
*
|
|
458
|
+
* Defaults to 1 so existing integrations keep the offer they have today —
|
|
459
|
+
* their payers only understand our dialect, and switching the quote out
|
|
460
|
+
* from under them would break every one at once. Set to 2 to advertise the
|
|
461
|
+
* real protocol, which is what any standard wallet (MetaMask, Phantom) can
|
|
462
|
+
* actually pay.
|
|
463
|
+
*/
|
|
464
|
+
x402Version: protocolVersion = 1,
|
|
376
465
|
} = globalOptions;
|
|
377
466
|
|
|
378
467
|
if (!apiKey) throw new Error('x402 middleware requires an apiKey');
|
|
468
|
+
if (protocolVersion !== 1 && protocolVersion !== 2) {
|
|
469
|
+
throw new Error(`Unsupported x402Version: ${protocolVersion} (expected 1 or 2)`);
|
|
470
|
+
}
|
|
379
471
|
if (!payTo) throw new Error('x402 middleware requires a payTo address');
|
|
380
472
|
|
|
381
473
|
// Mutable rates cache — can be updated externally or via ratesEndpoint
|
|
@@ -408,6 +500,8 @@ export function createX402Middleware(globalOptions) {
|
|
|
408
500
|
const routeAmount = routeOptions.amount;
|
|
409
501
|
const routeDescription = routeOptions.description || description;
|
|
410
502
|
const routeMethods = routeOptions.methods || methods;
|
|
503
|
+
const allowPendingConfirmation =
|
|
504
|
+
routeOptions.allowPendingConfirmation ?? globalAllowPendingConfirmation;
|
|
411
505
|
|
|
412
506
|
if (!routeAmountUsd && !routeAmount) {
|
|
413
507
|
throw new Error('x402 route requires amountUsd or amount');
|
|
@@ -415,32 +509,58 @@ export function createX402Middleware(globalOptions) {
|
|
|
415
509
|
|
|
416
510
|
return async function x402Middleware(req, res, next) {
|
|
417
511
|
const paymentHeader = req.headers['x-payment'] || req.headers['X-Payment'];
|
|
512
|
+
const resource = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
|
|
513
|
+
|
|
514
|
+
// Build the offer regardless of branch: on a 402 it is the response body,
|
|
515
|
+
// and on a paid request it is the price the proof has to actually cover.
|
|
516
|
+
let offer;
|
|
517
|
+
try {
|
|
518
|
+
offer =
|
|
519
|
+
protocolVersion === 2
|
|
520
|
+
? buildPaymentRequiredV2({
|
|
521
|
+
payTo,
|
|
522
|
+
amountUsd: routeAmountUsd,
|
|
523
|
+
amount: routeAmount,
|
|
524
|
+
methods: routeMethods,
|
|
525
|
+
resource,
|
|
526
|
+
description: routeDescription,
|
|
527
|
+
})
|
|
528
|
+
: buildPaymentRequired({
|
|
529
|
+
payTo,
|
|
530
|
+
amountUsd: routeAmountUsd,
|
|
531
|
+
amount: routeAmount,
|
|
532
|
+
network: routeOptions.network,
|
|
533
|
+
rates: currentRates,
|
|
534
|
+
methods: routeMethods,
|
|
535
|
+
resource,
|
|
536
|
+
description: routeDescription,
|
|
537
|
+
facilitatorUrl,
|
|
538
|
+
});
|
|
539
|
+
} catch (err) {
|
|
540
|
+
return res.status(500).json({ error: 'Failed to build payment options', details: err.message });
|
|
541
|
+
}
|
|
418
542
|
|
|
419
543
|
if (!paymentHeader) {
|
|
420
|
-
|
|
421
|
-
try {
|
|
422
|
-
const body = buildPaymentRequired({
|
|
423
|
-
payTo,
|
|
424
|
-
amountUsd: routeAmountUsd,
|
|
425
|
-
amount: routeAmount,
|
|
426
|
-
network: routeOptions.network,
|
|
427
|
-
rates: currentRates,
|
|
428
|
-
methods: routeMethods,
|
|
429
|
-
resource,
|
|
430
|
-
description: routeDescription,
|
|
431
|
-
facilitatorUrl,
|
|
432
|
-
});
|
|
433
|
-
return res.status(402).json(body);
|
|
434
|
-
} catch (err) {
|
|
435
|
-
return res.status(500).json({ error: 'Failed to build payment options', details: err.message });
|
|
436
|
-
}
|
|
544
|
+
return res.status(402).json(offer);
|
|
437
545
|
}
|
|
438
546
|
|
|
439
547
|
// Verify the payment
|
|
440
548
|
try {
|
|
549
|
+
// Find the advertised price for the method the payer actually used.
|
|
550
|
+
// Without this the facilitator has nothing to compare the proof
|
|
551
|
+
// against, and any proof unlocks any priced route.
|
|
552
|
+
const expected = expectedForProof(paymentHeader, offer, resource);
|
|
553
|
+
if (!expected) {
|
|
554
|
+
return res.status(402).json({
|
|
555
|
+
error: 'Invalid payment proof',
|
|
556
|
+
details: 'Proof does not correspond to any offered payment method',
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
441
560
|
const result = await verifyX402Payment(paymentHeader, {
|
|
442
561
|
apiKey,
|
|
443
562
|
apiBaseUrl,
|
|
563
|
+
expected,
|
|
444
564
|
});
|
|
445
565
|
|
|
446
566
|
if (!result.valid) {
|
|
@@ -450,6 +570,17 @@ export function createX402Middleware(globalOptions) {
|
|
|
450
570
|
});
|
|
451
571
|
}
|
|
452
572
|
|
|
573
|
+
// On non-EVM rails the paid amount is self-reported until settlement
|
|
574
|
+
// confirms it on-chain, so serving now means serving on trust.
|
|
575
|
+
if (result.payment?.pendingConfirmation && !allowPendingConfirmation) {
|
|
576
|
+
return res.status(402).json({
|
|
577
|
+
error: 'Payment not confirmed',
|
|
578
|
+
details:
|
|
579
|
+
'This payment cannot be confirmed until it settles on-chain. ' +
|
|
580
|
+
'Set allowPendingConfirmation: true to serve on unconfirmed proofs.',
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
453
584
|
// Attach payment info to request for downstream use
|
|
454
585
|
req.x402Payment = result.payment;
|
|
455
586
|
next();
|
|
@@ -472,10 +603,13 @@ export function createX402Middleware(globalOptions) {
|
|
|
472
603
|
* @param {Object} options
|
|
473
604
|
* @param {string} options.apiKey - CoinPayPortal API key
|
|
474
605
|
* @param {string} [options.apiBaseUrl='https://coinpayportal.com'] - API base URL
|
|
606
|
+
* @param {{amount: string, resource: string}} options.expected - What this
|
|
607
|
+
* request is charging, and for what. Required: the facilitator refuses to
|
|
608
|
+
* verify a proof it cannot hold to a price.
|
|
475
609
|
* @returns {Promise<{valid: boolean, payment?: Object, reason?: string}>}
|
|
476
610
|
*/
|
|
477
611
|
export async function verifyX402Payment(paymentHeader, options = {}) {
|
|
478
|
-
const { apiKey, apiBaseUrl = 'https://coinpayportal.com' } = options;
|
|
612
|
+
const { apiKey, apiBaseUrl = 'https://coinpayportal.com', expected } = options;
|
|
479
613
|
|
|
480
614
|
if (!paymentHeader) {
|
|
481
615
|
return { valid: false, reason: 'Missing payment header' };
|
|
@@ -495,6 +629,7 @@ export async function verifyX402Payment(paymentHeader, options = {}) {
|
|
|
495
629
|
return { valid: false, reason: 'Missing scheme, signature, or payload in payment proof' };
|
|
496
630
|
}
|
|
497
631
|
|
|
632
|
+
|
|
498
633
|
// Check expiry if present
|
|
499
634
|
if (payment.payload?.expiresAt) {
|
|
500
635
|
const expiresAt = typeof payment.payload.expiresAt === 'number'
|
|
@@ -505,6 +640,16 @@ export async function verifyX402Payment(paymentHeader, options = {}) {
|
|
|
505
640
|
}
|
|
506
641
|
}
|
|
507
642
|
|
|
643
|
+
// A proof is only meaningful against a price. Without one there is nothing
|
|
644
|
+
// to check it covers, which is exactly how a $0.01 proof used to unlock a
|
|
645
|
+
// $5.00 resource.
|
|
646
|
+
if (!expected?.amount || !expected?.resource) {
|
|
647
|
+
return {
|
|
648
|
+
valid: false,
|
|
649
|
+
reason: 'Missing expected amount/resource — refusing to verify a proof with no price to check it against',
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
508
653
|
// Call CoinPayPortal facilitator to verify
|
|
509
654
|
try {
|
|
510
655
|
const response = await fetch(`${apiBaseUrl}/api/x402/verify`, {
|
|
@@ -513,7 +658,7 @@ export async function verifyX402Payment(paymentHeader, options = {}) {
|
|
|
513
658
|
'Content-Type': 'application/json',
|
|
514
659
|
...(apiKey ? { 'X-API-Key': apiKey } : {}),
|
|
515
660
|
},
|
|
516
|
-
body: JSON.stringify({ payment }),
|
|
661
|
+
body: JSON.stringify({ payment, expected }),
|
|
517
662
|
});
|
|
518
663
|
|
|
519
664
|
const data = await response.json();
|