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 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(...)` — exposed for advanced integrations that need
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 === 'bch' && req?.scheme === 'utxo'
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 X-PAYMENT header payload for BCH transfers.
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 = 1,
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: paymentRequirements.minAmountRequired,
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
- scheme: paymentRequirements.scheme || 'utxo',
115
- network: paymentRequirements.network || 'bch',
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
- const paymentAmountSats = signer.paymentAmountSats || paymentRequirements.minAmountRequired
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
- const { x402Version, accepts } = error.response.data || {}
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
- const cost = paymentRequirements.minAmountRequired
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 || 1,
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['X-PAYMENT'] = paymentHeader
239
- originalConfig.headers['Access-Control-Expose-Headers'] = 'X-PAYMENT-RESPONSE'
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-bch-axios",
3
- "version": "1.1.2",
3
+ "version": "2.1.0",
4
4
  "description": "Axios wrapper for x402 payment protocol with Bitcoin Cash (BCH) support.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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
- minAmountRequired: 1500,
116
+ amount: '1500',
106
117
  scheme: 'utxo',
107
- network: 'bch'
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.deepEqual(parsed, {
120
- x402Version: 2,
140
+ assert.equal(parsed.x402Version, 2)
141
+ assert.deepEqual(parsed.resource, resource)
142
+ assert.deepEqual(parsed.accepted, {
121
143
  scheme: 'utxo',
122
- network: 'bch',
123
- payload: {
124
- signature: 'mock-signature',
125
- authorization: {
126
- from: 'bitcoincash:qptest',
127
- to: 'bitcoincash:qprecv',
128
- value: 1500,
129
- txid: 'tx123',
130
- vout: 0,
131
- amount: 2000
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: 'bch',
217
+ network: 'bip122:000000000000000000651ef99cb9fcbe',
161
218
  scheme: 'utxo',
162
219
  payTo: 'bitcoincash:qprecv',
163
- minAmountRequired: 1500
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: 1,
172
- accepts: [cloneDeep(basePaymentRequirements)]
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
- assert.match(err.message, /No payment requirements/)
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
- axiosInstance.request.resolves({ data: 'ok' })
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.calledOnce)
334
+ assert.isTrue(axiosInstance.request.calledTwice)
259
335
 
260
- const updatedConfig = axiosInstance.request.firstCall.args[0]
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, 'X-PAYMENT')
339
+ assert.property(updatedConfig.headers, 'PAYMENT-SIGNATURE')
263
340
  assert.propertyVal(
264
341
  updatedConfig.headers,
265
342
  'Access-Control-Expose-Headers',
266
- 'X-PAYMENT-RESPONSE'
343
+ 'PAYMENT-RESPONSE'
267
344
  )
268
345
 
269
- const headerPayload = JSON.parse(updatedConfig.headers['X-PAYMENT'])
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
  })