x402-bch-axios 1.1.2 → 2.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/README.md +81 -4
- package/index.js +143 -18
- package/package.json +1 -1
- package/test/unit/index-unit.js +237 -30
package/README.md
CHANGED
|
@@ -4,6 +4,10 @@ JavaScript helpers for handling HTTP 402 responses against Bitcoin Cash powered
|
|
|
4
4
|
x402 endpoints. This package ports the `withPaymentInterceptor` experience from
|
|
5
5
|
the TypeScript `x402-axios` client and adapts it for BCH UTXO payments.
|
|
6
6
|
|
|
7
|
+
**Version 2.0+**: This library now supports x402-bch protocol v2, which includes
|
|
8
|
+
CAIP-2 network identifiers, updated header names, and restructured payment payloads.
|
|
9
|
+
Backward compatibility with v1 responses is maintained.
|
|
10
|
+
|
|
7
11
|
## Installation
|
|
8
12
|
|
|
9
13
|
```bash
|
|
@@ -50,11 +54,84 @@ console.log(response.data)
|
|
|
50
54
|
- waits for a 402 response,
|
|
51
55
|
- selects the BCH `utxo` payment requirement (or uses your selector),
|
|
52
56
|
- funds or reuses a tracked UTXO,
|
|
53
|
-
- replays the request with the `X-PAYMENT` header.
|
|
57
|
+
- replays the request with the `PAYMENT-SIGNATURE` header (v2) or `X-PAYMENT` header (v1).
|
|
54
58
|
- `selectPaymentRequirements(accepts)` — utility for filtering BCH
|
|
55
|
-
requirements.
|
|
56
|
-
- `createPaymentHeader(
|
|
57
|
-
direct x402 payload handling.
|
|
59
|
+
requirements. Supports both v1 (`bch`) and v2 CAIP-2 (`bip122:*`) network formats.
|
|
60
|
+
- `createPaymentHeader(signer, paymentRequirements, x402Version, txid, vout, resource?, extensions?)` — exposed for advanced integrations that need
|
|
61
|
+
direct x402 payload handling. Returns v2 format by default.
|
|
62
|
+
|
|
63
|
+
## Protocol Version 2 Changes
|
|
64
|
+
|
|
65
|
+
This library supports x402-bch protocol v2 with the following changes:
|
|
66
|
+
|
|
67
|
+
### Header Names
|
|
68
|
+
- **v2**: `PAYMENT-SIGNATURE` (replaces `X-PAYMENT`)
|
|
69
|
+
- **v2**: `PAYMENT-RESPONSE` (replaces `X-PAYMENT-RESPONSE`)
|
|
70
|
+
|
|
71
|
+
### Network Identifiers
|
|
72
|
+
- **v1**: `bch` (simple string)
|
|
73
|
+
- **v2**: `bip122:000000000000000000651ef99cb9fcbe` (CAIP-2 format for BCH mainnet)
|
|
74
|
+
|
|
75
|
+
The library automatically detects and supports both formats.
|
|
76
|
+
|
|
77
|
+
### Payment Payload Structure
|
|
78
|
+
|
|
79
|
+
**v2 Format:**
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"x402Version": 2,
|
|
83
|
+
"resource": {
|
|
84
|
+
"url": "http://localhost:4021/weather",
|
|
85
|
+
"description": "Access to weather data",
|
|
86
|
+
"mimeType": "application/json"
|
|
87
|
+
},
|
|
88
|
+
"accepted": {
|
|
89
|
+
"scheme": "utxo",
|
|
90
|
+
"network": "bip122:000000000000000000651ef99cb9fcbe",
|
|
91
|
+
"amount": "1000",
|
|
92
|
+
"asset": "0x0000000000000000000000000000000000000001",
|
|
93
|
+
"payTo": "bitcoincash:...",
|
|
94
|
+
"maxTimeoutSeconds": 60,
|
|
95
|
+
"extra": {}
|
|
96
|
+
},
|
|
97
|
+
"payload": {
|
|
98
|
+
"signature": "...",
|
|
99
|
+
"authorization": {
|
|
100
|
+
"from": "bitcoincash:...",
|
|
101
|
+
"to": "bitcoincash:...",
|
|
102
|
+
"value": "1000",
|
|
103
|
+
"txid": "...",
|
|
104
|
+
"vout": 0,
|
|
105
|
+
"amount": "2000"
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"extensions": {}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Key differences from v1:**
|
|
113
|
+
- Removed top-level `scheme` and `network` fields
|
|
114
|
+
- Added `accepted` field containing the selected PaymentRequirements
|
|
115
|
+
- Added optional `resource` and `extensions` fields
|
|
116
|
+
- Field name change: `minAmountRequired` → `amount` (library supports both for compatibility)
|
|
117
|
+
|
|
118
|
+
### Response Parsing
|
|
119
|
+
|
|
120
|
+
The library supports both v1 and v2 response formats:
|
|
121
|
+
- **v2**: Parses from `PAYMENT-REQUIRED` header (base64-encoded JSON)
|
|
122
|
+
- **v1**: Falls back to response body format
|
|
123
|
+
|
|
124
|
+
## Migration from v1
|
|
125
|
+
|
|
126
|
+
If you're upgrading from v1, the library maintains backward compatibility:
|
|
127
|
+
- v1 responses are automatically detected and handled
|
|
128
|
+
- v1 field names (`minAmountRequired`, `bch` network) are supported
|
|
129
|
+
- No code changes required for basic usage
|
|
130
|
+
|
|
131
|
+
However, for full v2 support:
|
|
132
|
+
- Update your server to send v2 responses
|
|
133
|
+
- Use CAIP-2 network identifiers in payment requirements
|
|
134
|
+
- Expect `PAYMENT-SIGNATURE` and `PAYMENT-RESPONSE` headers
|
|
58
135
|
|
|
59
136
|
## Licence
|
|
60
137
|
|
package/index.js
CHANGED
|
@@ -62,15 +62,34 @@ export function createSigner (privateKeyWIF, paymentAmountSats) {
|
|
|
62
62
|
|
|
63
63
|
export const createBCHSigner = createSigner
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Normalizes network identifier to support both v1 and v2 formats.
|
|
67
|
+
* v1 uses 'bch', v2 uses CAIP-2 format 'bip122:000000000000000000651ef99cb9fcbe'
|
|
68
|
+
*
|
|
69
|
+
* @param {string} network - Network identifier
|
|
70
|
+
* @returns {boolean} True if the network is BCH (mainnet)
|
|
71
|
+
*/
|
|
72
|
+
function isBCHNetwork (network) {
|
|
73
|
+
if (!network) return false
|
|
74
|
+
// v1 format
|
|
75
|
+
if (network === 'bch') return true
|
|
76
|
+
// v2 CAIP-2 format for BCH mainnet
|
|
77
|
+
if (network === 'bip122:000000000000000000651ef99cb9fcbe') return true
|
|
78
|
+
// v2 CAIP-2 format pattern matching (bip122:*)
|
|
79
|
+
if (network.startsWith('bip122:')) return true
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
|
|
65
83
|
/**
|
|
66
84
|
* Selects BCH `utxo` payment requirements from a 402 accepts array.
|
|
85
|
+
* Supports both v1 ('bch') and v2 (CAIP-2 'bip122:*') network formats.
|
|
67
86
|
*
|
|
68
87
|
* @param {Array} accepts - Array of payment requirements objects
|
|
69
88
|
* @returns {Object} First BCH `utxo` payment requirement
|
|
70
89
|
*/
|
|
71
90
|
export function selectPaymentRequirements (accepts = []) {
|
|
72
91
|
const bchRequirements = accepts.filter(req => {
|
|
73
|
-
return req?.network
|
|
92
|
+
return isBCHNetwork(req?.network) && req?.scheme === 'utxo'
|
|
74
93
|
})
|
|
75
94
|
|
|
76
95
|
if (bchRequirements.length === 0) {
|
|
@@ -81,42 +100,65 @@ export function selectPaymentRequirements (accepts = []) {
|
|
|
81
100
|
}
|
|
82
101
|
|
|
83
102
|
/**
|
|
84
|
-
* Builds the
|
|
103
|
+
* Builds the PAYMENT-SIGNATURE header payload for BCH transfers.
|
|
85
104
|
*
|
|
86
105
|
* @param {ReturnType<typeof createSigner>} signer
|
|
87
106
|
* @param {Object} paymentRequirements
|
|
88
107
|
* @param {number} x402Version
|
|
89
108
|
* @param {string|null} txid
|
|
90
109
|
* @param {number|null} vout
|
|
110
|
+
* @param {Object|null} resource - Optional ResourceInfo object
|
|
111
|
+
* @param {Object|null} extensions - Optional extensions object
|
|
91
112
|
* @returns {Promise<string>}
|
|
92
113
|
*/
|
|
93
114
|
export async function createPaymentHeader (
|
|
94
115
|
signer,
|
|
95
116
|
paymentRequirements,
|
|
96
|
-
x402Version =
|
|
117
|
+
x402Version = 2,
|
|
97
118
|
txid = null,
|
|
98
|
-
vout = null
|
|
119
|
+
vout = null,
|
|
120
|
+
resource = null,
|
|
121
|
+
extensions = null
|
|
99
122
|
) {
|
|
123
|
+
// Support both v1 (minAmountRequired) and v2 (amount) field names
|
|
124
|
+
const amountRequired = paymentRequirements.amount || paymentRequirements.minAmountRequired
|
|
125
|
+
|
|
126
|
+
// Check if this is "check my tab" mode (txid === "*")
|
|
127
|
+
const isCheckMyTabMode = txid === '*'
|
|
128
|
+
|
|
100
129
|
const authorization = {
|
|
101
130
|
from: signer.address,
|
|
102
131
|
to: paymentRequirements.payTo,
|
|
103
|
-
value:
|
|
104
|
-
txid,
|
|
105
|
-
vout,
|
|
106
|
-
amount: signer.paymentAmountSats
|
|
132
|
+
value: amountRequired,
|
|
133
|
+
txid: isCheckMyTabMode ? '*' : txid,
|
|
134
|
+
vout: isCheckMyTabMode ? null : vout,
|
|
135
|
+
amount: isCheckMyTabMode ? null : signer.paymentAmountSats
|
|
107
136
|
}
|
|
108
137
|
|
|
109
138
|
const messageToSign = JSON.stringify(authorization)
|
|
110
139
|
const signature = signer.signMessage(messageToSign)
|
|
111
140
|
|
|
141
|
+
// Build accepted PaymentRequirements object
|
|
142
|
+
const accepted = {
|
|
143
|
+
scheme: paymentRequirements.scheme || 'utxo',
|
|
144
|
+
network: paymentRequirements.network || 'bip122:000000000000000000651ef99cb9fcbe',
|
|
145
|
+
amount: amountRequired,
|
|
146
|
+
asset: paymentRequirements.asset,
|
|
147
|
+
payTo: paymentRequirements.payTo,
|
|
148
|
+
maxTimeoutSeconds: paymentRequirements.maxTimeoutSeconds,
|
|
149
|
+
extra: paymentRequirements.extra || {}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Build v2 PaymentPayload structure
|
|
112
153
|
const paymentHeader = {
|
|
113
154
|
x402Version,
|
|
114
|
-
|
|
115
|
-
|
|
155
|
+
...(resource && { resource }),
|
|
156
|
+
accepted,
|
|
116
157
|
payload: {
|
|
117
158
|
signature,
|
|
118
159
|
authorization
|
|
119
|
-
}
|
|
160
|
+
},
|
|
161
|
+
...(extensions && { extensions })
|
|
120
162
|
}
|
|
121
163
|
|
|
122
164
|
return JSON.stringify(paymentHeader)
|
|
@@ -124,7 +166,9 @@ export async function createPaymentHeader (
|
|
|
124
166
|
|
|
125
167
|
async function sendPayment (signer, paymentRequirements, bchServerConfig = {}) {
|
|
126
168
|
const { apiType, bchServerURL } = bchServerConfig
|
|
127
|
-
|
|
169
|
+
// Support both v1 (minAmountRequired) and v2 (amount) field names
|
|
170
|
+
const amountRequired = paymentRequirements.amount || paymentRequirements.minAmountRequired
|
|
171
|
+
const paymentAmountSats = signer.paymentAmountSats || amountRequired
|
|
128
172
|
|
|
129
173
|
const bchWallet = new dependencies.BCHWallet(signer.wif, {
|
|
130
174
|
interface: apiType,
|
|
@@ -191,18 +235,97 @@ export function withPaymentInterceptor (
|
|
|
191
235
|
return Promise.reject(new Error('Missing axios request configuration'))
|
|
192
236
|
}
|
|
193
237
|
|
|
238
|
+
// Prevent infinite loops
|
|
194
239
|
if (originalConfig.__is402Retry) {
|
|
195
240
|
return Promise.reject(error)
|
|
196
241
|
}
|
|
197
242
|
|
|
198
|
-
|
|
243
|
+
// If this is a "check my tab" retry that failed, fall back to UTXO generation
|
|
244
|
+
if (originalConfig.__is402CheckMyTab) {
|
|
245
|
+
// Clear the flag and proceed with UTXO generation
|
|
246
|
+
originalConfig.__is402CheckMyTab = false
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Parse payment requirements - v2 can come from header, v1 from body
|
|
250
|
+
let paymentRequired = null
|
|
251
|
+
let x402Version = 1
|
|
252
|
+
let accepts = []
|
|
253
|
+
let resource = null
|
|
254
|
+
let extensions = null
|
|
255
|
+
|
|
256
|
+
// Try v2 PAYMENT-REQUIRED header first (base64-encoded)
|
|
257
|
+
const paymentRequiredHeader = error.response.headers['payment-required'] ||
|
|
258
|
+
error.response.headers['PAYMENT-REQUIRED']
|
|
259
|
+
if (paymentRequiredHeader) {
|
|
260
|
+
try {
|
|
261
|
+
const decoded = Buffer.from(paymentRequiredHeader, 'base64').toString('utf-8')
|
|
262
|
+
paymentRequired = JSON.parse(decoded)
|
|
263
|
+
x402Version = paymentRequired.x402Version || 2
|
|
264
|
+
accepts = paymentRequired.accepts || []
|
|
265
|
+
resource = paymentRequired.resource
|
|
266
|
+
extensions = paymentRequired.extensions
|
|
267
|
+
} catch (parseError) {
|
|
268
|
+
// If header parsing fails, fall back to body
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Fall back to body format (v1 or v2)
|
|
273
|
+
if (!paymentRequired) {
|
|
274
|
+
const body = error.response.data || {}
|
|
275
|
+
x402Version = body.x402Version || 1
|
|
276
|
+
accepts = body.accepts || []
|
|
277
|
+
resource = body.resource
|
|
278
|
+
extensions = body.extensions
|
|
279
|
+
}
|
|
280
|
+
|
|
199
281
|
if (!accepts || !Array.isArray(accepts) || accepts.length === 0) {
|
|
200
282
|
return Promise.reject(new Error('No payment requirements found in 402 response'))
|
|
201
283
|
}
|
|
202
284
|
|
|
203
285
|
const paymentRequirements = paymentRequirementsSelector(accepts)
|
|
204
|
-
|
|
286
|
+
// Support both v1 (minAmountRequired) and v2 (amount) field names
|
|
287
|
+
// Convert to number for calculations (v2 uses strings, v1 uses numbers)
|
|
288
|
+
const cost = Number(paymentRequirements.amount || paymentRequirements.minAmountRequired)
|
|
289
|
+
|
|
290
|
+
// Try "check my tab" mode first if no UTXO is tracked
|
|
291
|
+
if (!currentUtxo.txid && !originalConfig.__is402CheckMyTab) {
|
|
292
|
+
// Attempt "check my tab" mode
|
|
293
|
+
const checkMyTabHeader = await createPaymentHeader(
|
|
294
|
+
signer,
|
|
295
|
+
paymentRequirements,
|
|
296
|
+
x402Version || 2,
|
|
297
|
+
'*', // txid = "*" for check my tab mode
|
|
298
|
+
null, // vout = null for check my tab mode
|
|
299
|
+
resource,
|
|
300
|
+
extensions
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
originalConfig.__is402CheckMyTab = true
|
|
304
|
+
originalConfig.__is402Retry = true
|
|
305
|
+
originalConfig.headers['PAYMENT-SIGNATURE'] = checkMyTabHeader
|
|
306
|
+
originalConfig.headers['Access-Control-Expose-Headers'] = 'PAYMENT-RESPONSE'
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
const checkMyTabResponse = await axiosInstance.request(originalConfig)
|
|
310
|
+
// "Check my tab" succeeded - return response and continue using check my tab mode
|
|
311
|
+
// Don't update currentUtxo since we're using check my tab mode
|
|
312
|
+
return checkMyTabResponse
|
|
313
|
+
} catch (checkMyTabError) {
|
|
314
|
+
// "Check my tab" failed - check if it's a 402 error
|
|
315
|
+
if (checkMyTabError.response && checkMyTabError.response.status === 402) {
|
|
316
|
+
// 402 error from "check my tab" - fall back to UTXO generation
|
|
317
|
+
// Reset flags and continue with standard flow
|
|
318
|
+
originalConfig.__is402CheckMyTab = false
|
|
319
|
+
originalConfig.__is402Retry = false
|
|
320
|
+
// Continue to UTXO generation logic below
|
|
321
|
+
} else {
|
|
322
|
+
// Non-402 error (network error, etc.) - reject it
|
|
323
|
+
return Promise.reject(checkMyTabError)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
205
327
|
|
|
328
|
+
// Standard mode: use existing UTXO or generate new one
|
|
206
329
|
let txid = null
|
|
207
330
|
let vout = null
|
|
208
331
|
let satsLeft = null
|
|
@@ -229,14 +352,16 @@ export function withPaymentInterceptor (
|
|
|
229
352
|
const paymentHeader = await createPaymentHeader(
|
|
230
353
|
signer,
|
|
231
354
|
paymentRequirements,
|
|
232
|
-
x402Version ||
|
|
355
|
+
x402Version || 2,
|
|
233
356
|
txid,
|
|
234
|
-
vout
|
|
357
|
+
vout,
|
|
358
|
+
resource,
|
|
359
|
+
extensions
|
|
235
360
|
)
|
|
236
361
|
|
|
237
362
|
originalConfig.__is402Retry = true
|
|
238
|
-
originalConfig.headers['
|
|
239
|
-
originalConfig.headers['Access-Control-Expose-Headers'] = '
|
|
363
|
+
originalConfig.headers['PAYMENT-SIGNATURE'] = paymentHeader
|
|
364
|
+
originalConfig.headers['Access-Control-Expose-Headers'] = 'PAYMENT-RESPONSE'
|
|
240
365
|
|
|
241
366
|
const secondResponse = await axiosInstance.request(originalConfig)
|
|
242
367
|
return secondResponse
|
package/package.json
CHANGED
package/test/unit/index-unit.js
CHANGED
|
@@ -73,7 +73,7 @@ describe('#index.js', () => {
|
|
|
73
73
|
})
|
|
74
74
|
|
|
75
75
|
describe('#selectPaymentRequirements', () => {
|
|
76
|
-
it('should select the first BCH utxo requirement', () => {
|
|
76
|
+
it('should select the first BCH utxo requirement (v1 format)', () => {
|
|
77
77
|
const accepts = [
|
|
78
78
|
{ network: 'eth', scheme: 'account' },
|
|
79
79
|
{ network: 'bch', scheme: 'utxo', payTo: 'addr1' },
|
|
@@ -84,6 +84,17 @@ describe('#index.js', () => {
|
|
|
84
84
|
assert.deepEqual(req, { network: 'bch', scheme: 'utxo', payTo: 'addr1' })
|
|
85
85
|
})
|
|
86
86
|
|
|
87
|
+
it('should select the first BCH utxo requirement (v2 CAIP-2 format)', () => {
|
|
88
|
+
const accepts = [
|
|
89
|
+
{ network: 'eip155:84532', scheme: 'exact' },
|
|
90
|
+
{ network: 'bip122:000000000000000000651ef99cb9fcbe', scheme: 'utxo', payTo: 'addr1' },
|
|
91
|
+
{ network: 'bch', scheme: 'utxo', payTo: 'addr2' }
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
const req = selectPaymentRequirements(accepts)
|
|
95
|
+
assert.deepEqual(req, { network: 'bip122:000000000000000000651ef99cb9fcbe', scheme: 'utxo', payTo: 'addr1' })
|
|
96
|
+
})
|
|
97
|
+
|
|
87
98
|
it('should throw if no BCH utxo requirement exists', () => {
|
|
88
99
|
assert.throws(
|
|
89
100
|
() => selectPaymentRequirements([{ network: 'btc', scheme: 'utxo' }]),
|
|
@@ -93,7 +104,7 @@ describe('#index.js', () => {
|
|
|
93
104
|
})
|
|
94
105
|
|
|
95
106
|
describe('#createPaymentHeader', () => {
|
|
96
|
-
it('should build a valid payment header payload', async () => {
|
|
107
|
+
it('should build a valid v2 payment header payload', async () => {
|
|
97
108
|
const signer = {
|
|
98
109
|
address: 'bitcoincash:qptest',
|
|
99
110
|
paymentAmountSats: 2000,
|
|
@@ -102,9 +113,18 @@ describe('#index.js', () => {
|
|
|
102
113
|
|
|
103
114
|
const paymentRequirements = {
|
|
104
115
|
payTo: 'bitcoincash:qprecv',
|
|
105
|
-
|
|
116
|
+
amount: '1500',
|
|
106
117
|
scheme: 'utxo',
|
|
107
|
-
network: '
|
|
118
|
+
network: 'bip122:000000000000000000651ef99cb9fcbe',
|
|
119
|
+
asset: '0x0000000000000000000000000000000000000001',
|
|
120
|
+
maxTimeoutSeconds: 60,
|
|
121
|
+
extra: {}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const resource = {
|
|
125
|
+
url: 'http://localhost:4021/weather',
|
|
126
|
+
description: 'Access to weather data',
|
|
127
|
+
mimeType: 'application/json'
|
|
108
128
|
}
|
|
109
129
|
|
|
110
130
|
const header = await createPaymentHeader(
|
|
@@ -112,28 +132,65 @@ describe('#index.js', () => {
|
|
|
112
132
|
paymentRequirements,
|
|
113
133
|
2,
|
|
114
134
|
'tx123',
|
|
115
|
-
0
|
|
135
|
+
0,
|
|
136
|
+
resource
|
|
116
137
|
)
|
|
117
138
|
|
|
118
139
|
const parsed = JSON.parse(header)
|
|
119
|
-
assert.
|
|
120
|
-
|
|
140
|
+
assert.equal(parsed.x402Version, 2)
|
|
141
|
+
assert.deepEqual(parsed.resource, resource)
|
|
142
|
+
assert.deepEqual(parsed.accepted, {
|
|
121
143
|
scheme: 'utxo',
|
|
122
|
-
network: '
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
144
|
+
network: 'bip122:000000000000000000651ef99cb9fcbe',
|
|
145
|
+
amount: '1500',
|
|
146
|
+
asset: '0x0000000000000000000000000000000000000001',
|
|
147
|
+
payTo: 'bitcoincash:qprecv',
|
|
148
|
+
maxTimeoutSeconds: 60,
|
|
149
|
+
extra: {}
|
|
150
|
+
})
|
|
151
|
+
assert.deepEqual(parsed.payload, {
|
|
152
|
+
signature: 'mock-signature',
|
|
153
|
+
authorization: {
|
|
154
|
+
from: 'bitcoincash:qptest',
|
|
155
|
+
to: 'bitcoincash:qprecv',
|
|
156
|
+
value: '1500',
|
|
157
|
+
txid: 'tx123',
|
|
158
|
+
vout: 0,
|
|
159
|
+
amount: 2000
|
|
133
160
|
}
|
|
134
161
|
})
|
|
162
|
+
// v2 should not have top-level scheme/network
|
|
163
|
+
assert.isUndefined(parsed.scheme)
|
|
164
|
+
assert.isUndefined(parsed.network)
|
|
135
165
|
assert.isTrue(signer.signMessage.calledOnce)
|
|
136
166
|
})
|
|
167
|
+
|
|
168
|
+
it('should support v1 minAmountRequired field for backward compatibility', async () => {
|
|
169
|
+
const signer = {
|
|
170
|
+
address: 'bitcoincash:qptest',
|
|
171
|
+
paymentAmountSats: 2000,
|
|
172
|
+
signMessage: sandbox.stub().returns('mock-signature')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const paymentRequirements = {
|
|
176
|
+
payTo: 'bitcoincash:qprecv',
|
|
177
|
+
minAmountRequired: 1500, // v1 field name
|
|
178
|
+
scheme: 'utxo',
|
|
179
|
+
network: 'bch'
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const header = await createPaymentHeader(
|
|
183
|
+
signer,
|
|
184
|
+
paymentRequirements,
|
|
185
|
+
2,
|
|
186
|
+
'tx123',
|
|
187
|
+
0
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
const parsed = JSON.parse(header)
|
|
191
|
+
assert.equal(parsed.accepted.amount, 1500)
|
|
192
|
+
assert.equal(parsed.payload.authorization.value, 1500)
|
|
193
|
+
})
|
|
137
194
|
})
|
|
138
195
|
|
|
139
196
|
describe('#withPaymentInterceptor', () => {
|
|
@@ -157,19 +214,31 @@ describe('#index.js', () => {
|
|
|
157
214
|
}
|
|
158
215
|
|
|
159
216
|
const basePaymentRequirements = {
|
|
160
|
-
network: '
|
|
217
|
+
network: 'bip122:000000000000000000651ef99cb9fcbe',
|
|
161
218
|
scheme: 'utxo',
|
|
162
219
|
payTo: 'bitcoincash:qprecv',
|
|
163
|
-
|
|
220
|
+
amount: '1500',
|
|
221
|
+
asset: '0x0000000000000000000000000000000000000001',
|
|
222
|
+
maxTimeoutSeconds: 60,
|
|
223
|
+
extra: {}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const baseResource = {
|
|
227
|
+
url: 'http://localhost:4021/weather',
|
|
228
|
+
description: 'Access to weather data',
|
|
229
|
+
mimeType: 'application/json'
|
|
164
230
|
}
|
|
165
231
|
|
|
166
232
|
function create402Error (overrides = {}) {
|
|
167
233
|
const defaultError = {
|
|
168
234
|
response: {
|
|
169
235
|
status: 402,
|
|
236
|
+
headers: {},
|
|
170
237
|
data: {
|
|
171
|
-
x402Version:
|
|
172
|
-
|
|
238
|
+
x402Version: 2,
|
|
239
|
+
resource: cloneDeep(baseResource),
|
|
240
|
+
accepts: [cloneDeep(basePaymentRequirements)],
|
|
241
|
+
extensions: {}
|
|
173
242
|
}
|
|
174
243
|
},
|
|
175
244
|
config: {
|
|
@@ -223,14 +292,16 @@ describe('#index.js', () => {
|
|
|
223
292
|
|
|
224
293
|
const [, errorHandler] = axiosInstance.interceptors.response.use.firstCall.args
|
|
225
294
|
const error = create402Error({
|
|
226
|
-
response: { status: 402, data: { accepts: [] } }
|
|
295
|
+
response: { status: 402, headers: {}, data: { accepts: [] } }
|
|
227
296
|
})
|
|
228
297
|
|
|
229
298
|
try {
|
|
230
299
|
await errorHandler(error)
|
|
231
300
|
assert.fail('Expected rejection')
|
|
232
301
|
} catch (err) {
|
|
233
|
-
|
|
302
|
+
// Should reject with "No payment requirements found in 402 response"
|
|
303
|
+
// or "No BCH payment requirements found in 402 response" from selector
|
|
304
|
+
assert.match(err.message, /No.*payment requirements/)
|
|
234
305
|
}
|
|
235
306
|
})
|
|
236
307
|
|
|
@@ -244,7 +315,12 @@ describe('#index.js', () => {
|
|
|
244
315
|
.resolves({ txid: 'tx123', vout: 0, satsSent: 2000 })
|
|
245
316
|
__internals.sendPayment = sendPaymentStub
|
|
246
317
|
|
|
247
|
-
|
|
318
|
+
// First call (check my tab) returns 402, second call (with UTXO) succeeds
|
|
319
|
+
axiosInstance.request
|
|
320
|
+
.onFirstCall()
|
|
321
|
+
.rejects(create402Error())
|
|
322
|
+
.onSecondCall()
|
|
323
|
+
.resolves({ data: 'ok' })
|
|
248
324
|
|
|
249
325
|
withPaymentInterceptor(axiosInstance, signer)
|
|
250
326
|
|
|
@@ -255,18 +331,21 @@ describe('#index.js', () => {
|
|
|
255
331
|
|
|
256
332
|
assert.deepEqual(response, { data: 'ok' })
|
|
257
333
|
assert.isTrue(sendPaymentStub.calledOnce)
|
|
258
|
-
assert.isTrue(axiosInstance.request.
|
|
334
|
+
assert.isTrue(axiosInstance.request.calledTwice)
|
|
259
335
|
|
|
260
|
-
|
|
336
|
+
// Check the second call (with UTXO) - first call was check my tab
|
|
337
|
+
const updatedConfig = axiosInstance.request.secondCall.args[0]
|
|
261
338
|
assert.isTrue(updatedConfig.__is402Retry)
|
|
262
|
-
assert.property(updatedConfig.headers, '
|
|
339
|
+
assert.property(updatedConfig.headers, 'PAYMENT-SIGNATURE')
|
|
263
340
|
assert.propertyVal(
|
|
264
341
|
updatedConfig.headers,
|
|
265
342
|
'Access-Control-Expose-Headers',
|
|
266
|
-
'
|
|
343
|
+
'PAYMENT-RESPONSE'
|
|
267
344
|
)
|
|
268
345
|
|
|
269
|
-
const headerPayload = JSON.parse(updatedConfig.headers['
|
|
346
|
+
const headerPayload = JSON.parse(updatedConfig.headers['PAYMENT-SIGNATURE'])
|
|
347
|
+
assert.equal(headerPayload.x402Version, 2)
|
|
348
|
+
assert.deepEqual(headerPayload.accepted, basePaymentRequirements)
|
|
270
349
|
assert.equal(headerPayload.payload.authorization.txid, 'tx123')
|
|
271
350
|
assert.equal(__internals.currentUtxo.txid, 'tx123')
|
|
272
351
|
assert.equal(__internals.currentUtxo.satsLeft, 500)
|
|
@@ -298,5 +377,133 @@ describe('#index.js', () => {
|
|
|
298
377
|
assert.equal(__internals.currentUtxo.txid, 'cached')
|
|
299
378
|
assert.equal(__internals.currentUtxo.satsLeft, 500)
|
|
300
379
|
})
|
|
380
|
+
|
|
381
|
+
it('should parse v2 response from PAYMENT-REQUIRED header', async () => {
|
|
382
|
+
const axiosInstance = createAxiosInstance()
|
|
383
|
+
const signer = createSignerStub()
|
|
384
|
+
signer.signMessage.returns('signed')
|
|
385
|
+
|
|
386
|
+
const sendPaymentStub = sandbox
|
|
387
|
+
.stub()
|
|
388
|
+
.resolves({ txid: 'tx123', vout: 0, satsSent: 2000 })
|
|
389
|
+
__internals.sendPayment = sendPaymentStub
|
|
390
|
+
|
|
391
|
+
// First call (check my tab) returns 402, second call (with UTXO) succeeds
|
|
392
|
+
axiosInstance.request
|
|
393
|
+
.onFirstCall()
|
|
394
|
+
.rejects({
|
|
395
|
+
response: {
|
|
396
|
+
status: 402,
|
|
397
|
+
headers: {},
|
|
398
|
+
data: {
|
|
399
|
+
x402Version: 2,
|
|
400
|
+
accepts: [cloneDeep(basePaymentRequirements)]
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
config: { headers: {} }
|
|
404
|
+
})
|
|
405
|
+
.onSecondCall()
|
|
406
|
+
.resolves({ data: 'ok' })
|
|
407
|
+
|
|
408
|
+
withPaymentInterceptor(axiosInstance, signer)
|
|
409
|
+
|
|
410
|
+
const [, errorHandler] = axiosInstance.interceptors.response.use.firstCall.args
|
|
411
|
+
|
|
412
|
+
// Create v2 response with PAYMENT-REQUIRED header
|
|
413
|
+
const paymentRequired = {
|
|
414
|
+
x402Version: 2,
|
|
415
|
+
resource: baseResource,
|
|
416
|
+
accepts: [cloneDeep(basePaymentRequirements)],
|
|
417
|
+
extensions: {}
|
|
418
|
+
}
|
|
419
|
+
const headerValue = Buffer.from(JSON.stringify(paymentRequired)).toString('base64')
|
|
420
|
+
|
|
421
|
+
const error = {
|
|
422
|
+
response: {
|
|
423
|
+
status: 402,
|
|
424
|
+
headers: {
|
|
425
|
+
'payment-required': headerValue
|
|
426
|
+
},
|
|
427
|
+
data: {}
|
|
428
|
+
},
|
|
429
|
+
config: {
|
|
430
|
+
headers: {}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const response = await errorHandler(error)
|
|
435
|
+
|
|
436
|
+
assert.deepEqual(response, { data: 'ok' })
|
|
437
|
+
assert.isTrue(sendPaymentStub.calledOnce)
|
|
438
|
+
assert.isTrue(axiosInstance.request.calledTwice)
|
|
439
|
+
|
|
440
|
+
// Check the second call (with UTXO) - first call was check my tab
|
|
441
|
+
const updatedConfig = axiosInstance.request.secondCall.args[0]
|
|
442
|
+
const headerPayload = JSON.parse(updatedConfig.headers['PAYMENT-SIGNATURE'])
|
|
443
|
+
assert.equal(headerPayload.x402Version, 2)
|
|
444
|
+
assert.deepEqual(headerPayload.resource, baseResource)
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('should support v1 response format for backward compatibility', async () => {
|
|
448
|
+
const axiosInstance = createAxiosInstance()
|
|
449
|
+
const signer = createSignerStub()
|
|
450
|
+
signer.signMessage.returns('signed')
|
|
451
|
+
|
|
452
|
+
const sendPaymentStub = sandbox
|
|
453
|
+
.stub()
|
|
454
|
+
.resolves({ txid: 'tx123', vout: 0, satsSent: 2000 })
|
|
455
|
+
__internals.sendPayment = sendPaymentStub
|
|
456
|
+
|
|
457
|
+
// First call (check my tab) returns 402, second call (with UTXO) succeeds
|
|
458
|
+
axiosInstance.request
|
|
459
|
+
.onFirstCall()
|
|
460
|
+
.rejects({
|
|
461
|
+
response: {
|
|
462
|
+
status: 402,
|
|
463
|
+
headers: {},
|
|
464
|
+
data: {
|
|
465
|
+
x402Version: 1,
|
|
466
|
+
accepts: [{
|
|
467
|
+
network: 'bch',
|
|
468
|
+
scheme: 'utxo',
|
|
469
|
+
payTo: 'bitcoincash:qprecv',
|
|
470
|
+
minAmountRequired: 1500
|
|
471
|
+
}]
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
config: { headers: {} }
|
|
475
|
+
})
|
|
476
|
+
.onSecondCall()
|
|
477
|
+
.resolves({ data: 'ok' })
|
|
478
|
+
|
|
479
|
+
withPaymentInterceptor(axiosInstance, signer)
|
|
480
|
+
|
|
481
|
+
const [, errorHandler] = axiosInstance.interceptors.response.use.firstCall.args
|
|
482
|
+
|
|
483
|
+
// Create v1 response format
|
|
484
|
+
const error = {
|
|
485
|
+
response: {
|
|
486
|
+
status: 402,
|
|
487
|
+
headers: {},
|
|
488
|
+
data: {
|
|
489
|
+
x402Version: 1,
|
|
490
|
+
accepts: [{
|
|
491
|
+
network: 'bch',
|
|
492
|
+
scheme: 'utxo',
|
|
493
|
+
payTo: 'bitcoincash:qprecv',
|
|
494
|
+
minAmountRequired: 1500
|
|
495
|
+
}]
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
config: {
|
|
499
|
+
headers: {}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const response = await errorHandler(error)
|
|
504
|
+
|
|
505
|
+
assert.deepEqual(response, { data: 'ok' })
|
|
506
|
+
assert.isTrue(sendPaymentStub.calledOnce)
|
|
507
|
+
})
|
|
301
508
|
})
|
|
302
509
|
})
|