@bsv/sdk 2.0.12 → 2.0.13

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.
Files changed (77) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js +827 -0
  3. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +1 -0
  4. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +654 -0
  5. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +1 -0
  6. package/dist/cjs/src/transaction/MerklePath.js +132 -0
  7. package/dist/cjs/src/transaction/MerklePath.js.map +1 -1
  8. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  9. package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js +825 -0
  10. package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +1 -0
  11. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +619 -0
  12. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +1 -0
  13. package/dist/esm/src/transaction/MerklePath.js +132 -0
  14. package/dist/esm/src/transaction/MerklePath.js.map +1 -1
  15. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  16. package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts +21 -0
  17. package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts.map +1 -0
  18. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts +2 -0
  19. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts.map +1 -0
  20. package/dist/types/src/transaction/MerklePath.d.ts +27 -0
  21. package/dist/types/src/transaction/MerklePath.d.ts.map +1 -1
  22. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  23. package/dist/umd/bundle.js +1 -1
  24. package/dist/umd/bundle.js.map +1 -1
  25. package/docs/reference/storage.md +1 -1
  26. package/docs/reference/transaction.md +40 -0
  27. package/package.json +1 -1
  28. package/src/auth/clients/__tests__/AuthFetch.additional.test.ts +1131 -0
  29. package/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts +770 -0
  30. package/src/compat/__tests/Mnemonic.additional.test.ts +64 -0
  31. package/src/identity/__tests/IdentityClient.additional.test.ts +767 -0
  32. package/src/kvstore/__tests/LocalKVStore.additional.test.ts +611 -0
  33. package/src/kvstore/__tests/kvStoreInterpreter.test.ts +327 -0
  34. package/src/overlay-tools/__tests/HostReputationTracker.additional.test.ts +561 -0
  35. package/src/overlay-tools/__tests/LookupResolver.additional.test.ts +612 -0
  36. package/src/overlay-tools/__tests/withDoubleSpendRetry.test.ts +278 -0
  37. package/src/primitives/__tests/BigNumber.additional.test.ts +79 -0
  38. package/src/primitives/__tests/Curve.additional.test.ts +208 -0
  39. package/src/primitives/__tests/ECDSA.additional.test.ts +122 -0
  40. package/src/primitives/__tests/Hash.additional.test.ts +59 -0
  41. package/src/primitives/__tests/JacobianPoint.test.ts +308 -0
  42. package/src/primitives/__tests/Point.additional.test.ts +503 -0
  43. package/src/primitives/__tests/PublicKey.additional.test.ts +383 -0
  44. package/src/primitives/__tests/Random.additional.test.ts +262 -0
  45. package/src/primitives/__tests/Signature.test.ts +333 -0
  46. package/src/primitives/__tests/TransactionSignature.additional.test.ts +241 -0
  47. package/src/registry/__tests/RegistryClient.additional.test.ts +750 -0
  48. package/src/remittance/__tests/BasicBRC29.additional.test.ts +657 -0
  49. package/src/remittance/__tests/RemittanceManager.additional.test.ts +1272 -0
  50. package/src/script/__tests/LockingUnlockingScript.test.ts +79 -0
  51. package/src/script/__tests/Script.additional.test.ts +100 -0
  52. package/src/script/__tests/ScriptEvaluationError.test.ts +98 -0
  53. package/src/script/__tests/Spend.additional.test.ts +837 -0
  54. package/src/script/templates/__tests/RPuzzle.test.ts +134 -0
  55. package/src/transaction/MerklePath.ts +155 -0
  56. package/src/transaction/__tests/BeefParty.additional.test.ts +22 -0
  57. package/src/transaction/__tests/Broadcaster.test.ts +159 -0
  58. package/src/transaction/__tests/MerklePath.bench.test.ts +105 -0
  59. package/src/transaction/__tests/MerklePath.test.ts +80 -0
  60. package/src/transaction/__tests/Transaction.additional.test.ts +225 -0
  61. package/src/transaction/broadcasters/__tests/ARC.additional.test.ts +585 -0
  62. package/src/transaction/broadcasters/__tests/Teranode.test.ts +349 -0
  63. package/src/transaction/chaintrackers/__tests/BlockHeadersService.test.ts +253 -0
  64. package/src/transaction/chaintrackers/__tests/DefaultChainTracker.test.ts +44 -0
  65. package/src/transaction/chaintrackers/__tests/WhatsOnChain.additional.test.ts +193 -0
  66. package/src/transaction/fee-models/__tests/SatoshisPerKilobyte.test.ts +262 -0
  67. package/src/transaction/http/__tests/BinaryFetchClient.test.ts +212 -0
  68. package/src/transaction/http/__tests/DefaultHttpClient.additional.test.ts +192 -0
  69. package/src/transaction/http/__tests/DefaultHttpClient.test.ts +71 -0
  70. package/src/wallet/__tests/ProtoWallet.additional.test.ts +134 -0
  71. package/src/wallet/__tests/WERR.test.ts +212 -0
  72. package/src/wallet/__tests/WalletClient.additional.test.ts +699 -0
  73. package/src/wallet/__tests/WalletClient.substrate.test.ts +759 -0
  74. package/src/wallet/__tests/WalletError.test.ts +290 -0
  75. package/src/wallet/__tests/validationHelpers.test.ts +1218 -0
  76. package/src/wallet/substrates/__tests/HTTPWalletJSON.test.ts +496 -0
  77. package/src/wallet/substrates/__tests/HTTPWalletWire.test.ts +273 -0
@@ -0,0 +1,1131 @@
1
+ /**
2
+ * AuthFetch additional tests.
3
+ *
4
+ * Covers branches not exercised by the primary AuthFetch.test.ts:
5
+ * - fetch(): retryCounter exhaustion, non-auth fallback path, stale-session retry
6
+ * - serializeRequest(): all header/body/URL variants
7
+ * - handleFetchAndValidate(): success, x-bsv header spoofing, non-ok response
8
+ * - handlePaymentAndRetry(): missing/invalid headers, incompatible context regeneration
9
+ * - describeRequestBodyForLogging(): all body types
10
+ * - getMaxPaymentAttempts(): edge cases
11
+ * - getPaymentRetryDelay(): values at different attempt counts
12
+ * - wait(): zero / positive
13
+ * - isPaymentContextCompatible(): match / mismatch branches
14
+ * - consumeReceivedCertificates(): drains the internal buffer
15
+ * - sendCertificateRequest(): creates new peer when none exists
16
+ * - logPaymentAttempt(): all three log levels
17
+ * - createPaymentErrorEntry(): Error vs non-Error values
18
+ * - buildPaymentFailureError(): shapes the error correctly
19
+ */
20
+
21
+ import { jest } from '@jest/globals'
22
+ import { AuthFetch } from '../AuthFetch.js'
23
+ import { Utils, PrivateKey } from '../../../primitives/index.js'
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Module mock for createNonce (matches what primary test does)
27
+ // ---------------------------------------------------------------------------
28
+
29
+ jest.mock('../../utils/createNonce.js', () => ({
30
+ createNonce: jest.fn()
31
+ }))
32
+
33
+ import { createNonce } from '../../utils/createNonce.js'
34
+
35
+ const createNonceMock = createNonce as jest.MockedFunction<typeof createNonce>
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Helpers
39
+ // ---------------------------------------------------------------------------
40
+
41
+ function buildWallet (): any {
42
+ const identityKey = new PrivateKey(10).toPublicKey().toString()
43
+ const derivedKey = new PrivateKey(11).toPublicKey().toString()
44
+ return {
45
+ getPublicKey: jest.fn(async (opts: any) =>
46
+ opts?.identityKey === true ? { publicKey: identityKey } : { publicKey: derivedKey }
47
+ ),
48
+ createAction: jest.fn(async () => ({
49
+ tx: Utils.toArray('mock-tx', 'utf8')
50
+ })),
51
+ createHmac: jest.fn(async () => ({ hmac: new Array(32).fill(0) }))
52
+ }
53
+ }
54
+
55
+ function make402Response (overrides: Record<string, string> = {}): Response {
56
+ const headers: Record<string, string> = {
57
+ 'x-bsv-payment-version': '1.0',
58
+ 'x-bsv-payment-satoshis-required': '10',
59
+ 'x-bsv-auth-identity-key': 'srv-key',
60
+ 'x-bsv-payment-derivation-prefix': 'pfx',
61
+ ...overrides
62
+ }
63
+ return new Response('', { status: 402, headers })
64
+ }
65
+
66
+ afterEach(() => {
67
+ jest.restoreAllMocks()
68
+ createNonceMock.mockReset()
69
+ })
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // 1. fetch() – retryCounter exhaustion
73
+ // ---------------------------------------------------------------------------
74
+
75
+ describe('AuthFetch.fetch – retryCounter', () => {
76
+ it('throws when retryCounter reaches 0', async () => {
77
+ const authFetch = new AuthFetch(buildWallet())
78
+ await expect(
79
+ authFetch.fetch('https://example.com', { retryCounter: 0 })
80
+ ).rejects.toThrow('Request failed after maximum number of retries.')
81
+ })
82
+
83
+ it('decrements retryCounter before making the request', async () => {
84
+ // Verify that the stale-session retry path calls fetch() again, which means
85
+ // retryCounter gets decremented. We intercept the recursive fetch() call using
86
+ // a spy so a real Peer is never constructed inside the unit-test environment.
87
+ const authFetch = new AuthFetch(buildWallet())
88
+
89
+ let fetchCallCount = 0
90
+
91
+ const originalFetch = authFetch.fetch.bind(authFetch)
92
+ jest.spyOn(authFetch, 'fetch').mockImplementation(async (url, config) => {
93
+ fetchCallCount++
94
+ if (fetchCallCount === 1) {
95
+ // First call: run the real code path so the stale-session branch triggers
96
+ return originalFetch(url, config)
97
+ }
98
+ // Subsequent calls (recursive retry after stale-session): throw to prove
99
+ // the retry occurred with a decremented retryCounter.
100
+ throw new Error('second call')
101
+ })
102
+
103
+ // Inject a stub peer that throws a stale-session error on toPeer()
104
+ const peerStub = {
105
+ listenForCertificatesReceived: jest.fn(),
106
+ listenForCertificatesRequested: jest.fn(),
107
+ listenForGeneralMessages: jest.fn(() => 1),
108
+ stopListeningForGeneralMessages: jest.fn(),
109
+ toPeer: jest.fn(async () => {
110
+ throw new Error('Session not found for nonce xyz')
111
+ })
112
+ }
113
+ ;(authFetch as any).peers['https://example.com'] = {
114
+ peer: peerStub,
115
+ identityKey: 'some-key',
116
+ supportsMutualAuth: true,
117
+ pendingCertificateRequests: []
118
+ }
119
+
120
+ // With retryCounter: 2, the stale-session branch retries once; the spy
121
+ // intercepts the recursive call and throws 'second call'.
122
+ await expect(
123
+ authFetch.fetch('https://example.com/path', { retryCounter: 2 })
124
+ ).rejects.toThrow('second call')
125
+ expect(fetchCallCount).toBe(2)
126
+ })
127
+ })
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // 2. fetch() – supportsMutualAuth === false fallback
131
+ // ---------------------------------------------------------------------------
132
+
133
+ describe('AuthFetch.fetch – non-auth fallback (supportsMutualAuth=false)', () => {
134
+ it('falls back to handleFetchAndValidate when peer does not support mutual auth', async () => {
135
+ const authFetch = new AuthFetch(buildWallet())
136
+
137
+ const handleFetchSpy = jest
138
+ .spyOn(authFetch as any, 'handleFetchAndValidate')
139
+ .mockResolvedValue(new Response('ok', { status: 200 }))
140
+
141
+ const peerStub = {
142
+ peer: { toPeer: jest.fn() },
143
+ supportsMutualAuth: false,
144
+ pendingCertificateRequests: []
145
+ }
146
+ ;(authFetch as any).peers['https://example.com'] = peerStub
147
+
148
+ const result = await authFetch.fetch('https://example.com/resource')
149
+
150
+ expect(handleFetchSpy).toHaveBeenCalledTimes(1)
151
+ expect(result.status).toBe(200)
152
+ })
153
+
154
+ it('rejects when handleFetchAndValidate throws in non-auth fallback', async () => {
155
+ const authFetch = new AuthFetch(buildWallet())
156
+
157
+ jest
158
+ .spyOn(authFetch as any, 'handleFetchAndValidate')
159
+ .mockRejectedValue(new Error('fetch validation failed'))
160
+
161
+ const peerStub = {
162
+ peer: { toPeer: jest.fn() },
163
+ supportsMutualAuth: false,
164
+ pendingCertificateRequests: []
165
+ }
166
+ ;(authFetch as any).peers['https://example.com'] = peerStub
167
+
168
+ await expect(authFetch.fetch('https://example.com/resource')).rejects.toThrow(
169
+ 'fetch validation failed'
170
+ )
171
+ })
172
+ })
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // 3. handleFetchAndValidate
176
+ // ---------------------------------------------------------------------------
177
+
178
+ describe('AuthFetch.handleFetchAndValidate (private)', () => {
179
+ it('returns the response when fetch succeeds with no x-bsv headers', async () => {
180
+ const authFetch = new AuthFetch(buildWallet())
181
+
182
+ const mockResponse = new Response('body', {
183
+ status: 200,
184
+ headers: { 'Content-Type': 'text/plain' }
185
+ })
186
+ jest.spyOn(global, 'fetch').mockResolvedValue(mockResponse)
187
+
188
+ const peerToUse: any = { supportsMutualAuth: undefined }
189
+ const response = await (authFetch as any).handleFetchAndValidate(
190
+ 'https://example.com',
191
+ { method: 'GET' },
192
+ peerToUse
193
+ )
194
+
195
+ expect(response.status).toBe(200)
196
+ expect(peerToUse.supportsMutualAuth).toBe(false)
197
+ })
198
+
199
+ it('throws when response contains an x-bsv header (spoofing detection)', async () => {
200
+ const authFetch = new AuthFetch(buildWallet())
201
+
202
+ // The source iterates response.headers.forEach((value, name) => ...)
203
+ // and checks if the VALUE starts with 'x-bsv'. To trigger spoofing
204
+ // detection we need a header whose value starts with 'x-bsv'.
205
+ const mockResponse = new Response('', {
206
+ status: 200,
207
+ headers: { 'x-custom-header': 'x-bsv-auth-identity-key' }
208
+ })
209
+ jest.spyOn(global, 'fetch').mockResolvedValue(mockResponse)
210
+
211
+ await expect(
212
+ (authFetch as any).handleFetchAndValidate(
213
+ 'https://example.com',
214
+ {},
215
+ { supportsMutualAuth: undefined }
216
+ )
217
+ ).rejects.toThrow('The server is trying to claim it has been authenticated')
218
+ })
219
+
220
+ it('throws when response is not ok', async () => {
221
+ const authFetch = new AuthFetch(buildWallet())
222
+
223
+ const mockResponse = new Response('Not Found', { status: 404 })
224
+ jest.spyOn(global, 'fetch').mockResolvedValue(mockResponse)
225
+
226
+ await expect(
227
+ (authFetch as any).handleFetchAndValidate(
228
+ 'https://example.com',
229
+ {},
230
+ { supportsMutualAuth: undefined }
231
+ )
232
+ ).rejects.toThrow('Request failed with status: 404')
233
+ })
234
+ })
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // 4. handlePaymentAndRetry – missing/invalid headers
238
+ // ---------------------------------------------------------------------------
239
+
240
+ describe('AuthFetch.handlePaymentAndRetry – header validation', () => {
241
+ it('throws when x-bsv-payment-version header is missing', async () => {
242
+ const authFetch = new AuthFetch(buildWallet())
243
+ const response = new Response('', { status: 402 }) // no payment headers
244
+
245
+ await expect(
246
+ (authFetch as any).handlePaymentAndRetry('https://example.com', {}, response)
247
+ ).rejects.toThrow('Unsupported x-bsv-payment-version response header')
248
+ })
249
+
250
+ it('throws when x-bsv-payment-version header has wrong value', async () => {
251
+ const authFetch = new AuthFetch(buildWallet())
252
+ const response = make402Response({ 'x-bsv-payment-version': '2.0' })
253
+
254
+ await expect(
255
+ (authFetch as any).handlePaymentAndRetry('https://example.com', {}, response)
256
+ ).rejects.toThrow('Unsupported x-bsv-payment-version response header')
257
+ })
258
+
259
+ it('throws when x-bsv-payment-satoshis-required header is missing', async () => {
260
+ const authFetch = new AuthFetch(buildWallet())
261
+ const response = make402Response({ 'x-bsv-payment-satoshis-required': '' })
262
+ // Force re-check: create a response without the satoshis header entirely
263
+ const headersRaw: Record<string, string> = {
264
+ 'x-bsv-payment-version': '1.0',
265
+ 'x-bsv-auth-identity-key': 'srv-key',
266
+ 'x-bsv-payment-derivation-prefix': 'pfx'
267
+ // satoshis-required intentionally omitted
268
+ }
269
+ const respNoSatoshis = new Response('', { status: 402, headers: headersRaw })
270
+
271
+ await expect(
272
+ (authFetch as any).handlePaymentAndRetry('https://example.com', {}, respNoSatoshis)
273
+ ).rejects.toThrow('Missing x-bsv-payment-satoshis-required response header')
274
+ })
275
+
276
+ it('throws when satoshis value is NaN', async () => {
277
+ const authFetch = new AuthFetch(buildWallet())
278
+ const response = make402Response({ 'x-bsv-payment-satoshis-required': 'not-a-number' })
279
+
280
+ await expect(
281
+ (authFetch as any).handlePaymentAndRetry('https://example.com', {}, response)
282
+ ).rejects.toThrow('Invalid x-bsv-payment-satoshis-required response header value')
283
+ })
284
+
285
+ it('throws when satoshis value is zero', async () => {
286
+ const authFetch = new AuthFetch(buildWallet())
287
+ const response = make402Response({ 'x-bsv-payment-satoshis-required': '0' })
288
+
289
+ await expect(
290
+ (authFetch as any).handlePaymentAndRetry('https://example.com', {}, response)
291
+ ).rejects.toThrow('Invalid x-bsv-payment-satoshis-required response header value')
292
+ })
293
+
294
+ it('throws when x-bsv-auth-identity-key header is missing', async () => {
295
+ const authFetch = new AuthFetch(buildWallet())
296
+ const headersRaw: Record<string, string> = {
297
+ 'x-bsv-payment-version': '1.0',
298
+ 'x-bsv-payment-satoshis-required': '10',
299
+ 'x-bsv-payment-derivation-prefix': 'pfx'
300
+ // identity-key omitted
301
+ }
302
+ const response = new Response('', { status: 402, headers: headersRaw })
303
+
304
+ await expect(
305
+ (authFetch as any).handlePaymentAndRetry('https://example.com', {}, response)
306
+ ).rejects.toThrow('Missing x-bsv-auth-identity-key response header')
307
+ })
308
+
309
+ it('throws when x-bsv-payment-derivation-prefix header is missing', async () => {
310
+ const authFetch = new AuthFetch(buildWallet())
311
+ const headersRaw: Record<string, string> = {
312
+ 'x-bsv-payment-version': '1.0',
313
+ 'x-bsv-payment-satoshis-required': '10',
314
+ 'x-bsv-auth-identity-key': 'srv-key'
315
+ // derivation-prefix omitted
316
+ }
317
+ const response = new Response('', { status: 402, headers: headersRaw })
318
+
319
+ await expect(
320
+ (authFetch as any).handlePaymentAndRetry('https://example.com', {}, response)
321
+ ).rejects.toThrow('Missing x-bsv-payment-derivation-prefix response header')
322
+ })
323
+
324
+ it('throws when derivation-prefix is an empty string', async () => {
325
+ const authFetch = new AuthFetch(buildWallet())
326
+ const response = make402Response({ 'x-bsv-payment-derivation-prefix': '' })
327
+
328
+ await expect(
329
+ (authFetch as any).handlePaymentAndRetry('https://example.com', {}, response)
330
+ ).rejects.toThrow('Missing x-bsv-payment-derivation-prefix response header')
331
+ })
332
+ })
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // 5. handlePaymentAndRetry – incompatible context triggers new context creation
336
+ // ---------------------------------------------------------------------------
337
+
338
+ describe('AuthFetch.handlePaymentAndRetry – context compatibility', () => {
339
+ it('regenerates context when server changes payment requirements', async () => {
340
+ const authFetch = new AuthFetch(buildWallet())
341
+ jest.spyOn(authFetch as any, 'logPaymentAttempt').mockImplementation(() => {})
342
+ jest.spyOn(authFetch as any, 'wait').mockResolvedValue(undefined)
343
+
344
+ createNonceMock.mockResolvedValue('new-suffix')
345
+
346
+ const existingContext = {
347
+ satoshisRequired: 5, // server now asks for 10
348
+ transactionBase64: Utils.toBase64([1, 2, 3]),
349
+ derivationPrefix: 'pfx',
350
+ derivationSuffix: 'old-suffix',
351
+ serverIdentityKey: 'srv-key',
352
+ clientIdentityKey: 'client-key',
353
+ attempts: 0,
354
+ maxAttempts: 3,
355
+ errors: [],
356
+ requestSummary: {
357
+ url: 'https://example.com',
358
+ method: 'GET',
359
+ headers: {},
360
+ bodyType: 'none',
361
+ bodyByteLength: 0
362
+ }
363
+ }
364
+
365
+ const fetchSpy = jest
366
+ .spyOn(authFetch, 'fetch')
367
+ .mockResolvedValue(new Response('ok', { status: 200 }))
368
+
369
+ const response = make402Response({ 'x-bsv-payment-satoshis-required': '10' }) // changed from 5
370
+ await (authFetch as any).handlePaymentAndRetry(
371
+ 'https://example.com',
372
+ { paymentContext: existingContext },
373
+ response
374
+ )
375
+
376
+ // createNonce should have been called because the context was regenerated
377
+ expect(createNonceMock).toHaveBeenCalled()
378
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
379
+ })
380
+ })
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // 6. handlePaymentAndRetry – maxAttempts exceeded before first try
384
+ // ---------------------------------------------------------------------------
385
+
386
+ describe('AuthFetch.handlePaymentAndRetry – maxAttempts exceeded pre-check', () => {
387
+ it('throws immediately when attempts >= maxAttempts', async () => {
388
+ const authFetch = new AuthFetch(buildWallet())
389
+ jest.spyOn(authFetch as any, 'logPaymentAttempt').mockImplementation(() => {})
390
+
391
+ const exhaustedContext = {
392
+ satoshisRequired: 10,
393
+ transactionBase64: Utils.toBase64([1]),
394
+ derivationPrefix: 'pfx',
395
+ derivationSuffix: 'sfx',
396
+ serverIdentityKey: 'srv-key',
397
+ clientIdentityKey: 'client-key',
398
+ attempts: 3,
399
+ maxAttempts: 3,
400
+ errors: [],
401
+ requestSummary: {
402
+ url: 'https://example.com',
403
+ method: 'GET',
404
+ headers: {},
405
+ bodyType: 'none',
406
+ bodyByteLength: 0
407
+ }
408
+ }
409
+
410
+ const response = make402Response()
411
+ await expect(
412
+ (authFetch as any).handlePaymentAndRetry(
413
+ 'https://example.com',
414
+ { paymentContext: exhaustedContext },
415
+ response
416
+ )
417
+ ).rejects.toThrow('Paid request to https://example.com failed after 3/3 attempts')
418
+ })
419
+ })
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // 7. serializeRequest – header and body branches
423
+ // ---------------------------------------------------------------------------
424
+
425
+ describe('AuthFetch.serializeRequest (private)', () => {
426
+ it('serializes a GET request with no body or headers', async () => {
427
+ const authFetch = new AuthFetch(buildWallet())
428
+ const nonce = new Array(32).fill(0)
429
+ const writer = await (authFetch as any).serializeRequest(
430
+ 'GET',
431
+ {},
432
+ undefined,
433
+ new URL('https://example.com/path'),
434
+ nonce
435
+ )
436
+ expect(writer).toBeDefined()
437
+ expect(writer.toArray().length).toBeGreaterThan(32)
438
+ })
439
+
440
+ it('serializes a POST request with a JSON body', async () => {
441
+ const authFetch = new AuthFetch(buildWallet())
442
+ const nonce = new Array(32).fill(1)
443
+ const writer = await (authFetch as any).serializeRequest(
444
+ 'POST',
445
+ { 'content-type': 'application/json' },
446
+ { hello: 'world' },
447
+ new URL('https://example.com/api'),
448
+ nonce
449
+ )
450
+ expect(writer.toArray().length).toBeGreaterThan(32)
451
+ })
452
+
453
+ it('serializes a request with search params', async () => {
454
+ const authFetch = new AuthFetch(buildWallet())
455
+ const nonce = new Array(32).fill(2)
456
+ const writer = await (authFetch as any).serializeRequest(
457
+ 'GET',
458
+ {},
459
+ undefined,
460
+ new URL('https://example.com/api?q=hello'),
461
+ nonce
462
+ )
463
+ expect(writer.toArray().length).toBeGreaterThan(32)
464
+ })
465
+
466
+ it('includes x-bsv-* custom headers', async () => {
467
+ const authFetch = new AuthFetch(buildWallet())
468
+ const nonce = new Array(32).fill(3)
469
+ const writer = await (authFetch as any).serializeRequest(
470
+ 'GET',
471
+ { 'x-bsv-custom': 'value123' },
472
+ undefined,
473
+ new URL('https://example.com/'),
474
+ nonce
475
+ )
476
+ expect(writer.toArray().length).toBeGreaterThan(32)
477
+ })
478
+
479
+ it('includes authorization header', async () => {
480
+ const authFetch = new AuthFetch(buildWallet())
481
+ const nonce = new Array(32).fill(4)
482
+ const writer = await (authFetch as any).serializeRequest(
483
+ 'GET',
484
+ { authorization: 'Bearer token123' },
485
+ undefined,
486
+ new URL('https://example.com/'),
487
+ nonce
488
+ )
489
+ expect(writer.toArray().length).toBeGreaterThan(32)
490
+ })
491
+
492
+ it('throws for x-bsv-auth-* headers', async () => {
493
+ const authFetch = new AuthFetch(buildWallet())
494
+ const nonce = new Array(32).fill(5)
495
+ await expect(
496
+ (authFetch as any).serializeRequest(
497
+ 'GET',
498
+ { 'x-bsv-auth-identity-key': 'spoofed' },
499
+ undefined,
500
+ new URL('https://example.com/'),
501
+ nonce
502
+ )
503
+ ).rejects.toThrow('No BSV auth headers allowed here!')
504
+ })
505
+
506
+ it('throws for unsupported headers', async () => {
507
+ const authFetch = new AuthFetch(buildWallet())
508
+ const nonce = new Array(32).fill(6)
509
+ await expect(
510
+ (authFetch as any).serializeRequest(
511
+ 'GET',
512
+ { 'accept': 'application/json' },
513
+ undefined,
514
+ new URL('https://example.com/'),
515
+ nonce
516
+ )
517
+ ).rejects.toThrow('Unsupported header')
518
+ })
519
+
520
+ it('normalizes content-type by stripping parameters', async () => {
521
+ const authFetch = new AuthFetch(buildWallet())
522
+ const nonce = new Array(32).fill(7)
523
+ // Should not throw — content-type is allowed but parameters are stripped
524
+ const writer = await (authFetch as any).serializeRequest(
525
+ 'POST',
526
+ { 'content-type': 'application/json; charset=utf-8' },
527
+ '{"x":1}',
528
+ new URL('https://example.com/api'),
529
+ nonce
530
+ )
531
+ expect(writer.toArray().length).toBeGreaterThan(32)
532
+ })
533
+
534
+ it('defaults POST body to {} when content-type is application/json and body is undefined', async () => {
535
+ const authFetch = new AuthFetch(buildWallet())
536
+ const nonce = new Array(32).fill(8)
537
+ // Should not throw
538
+ const writer = await (authFetch as any).serializeRequest(
539
+ 'POST',
540
+ { 'content-type': 'application/json' },
541
+ undefined,
542
+ new URL('https://example.com/api'),
543
+ nonce
544
+ )
545
+ expect(writer.toArray().length).toBeGreaterThan(32)
546
+ })
547
+
548
+ it('defaults DELETE body to empty string when no content-type and body is undefined', async () => {
549
+ const authFetch = new AuthFetch(buildWallet())
550
+ const nonce = new Array(32).fill(9)
551
+ const writer = await (authFetch as any).serializeRequest(
552
+ 'DELETE',
553
+ {},
554
+ undefined,
555
+ new URL('https://example.com/resource'),
556
+ nonce
557
+ )
558
+ expect(writer.toArray().length).toBeGreaterThan(32)
559
+ })
560
+ })
561
+
562
+ // ---------------------------------------------------------------------------
563
+ // 8. describeRequestBodyForLogging – all body types
564
+ // ---------------------------------------------------------------------------
565
+
566
+ describe('AuthFetch.describeRequestBodyForLogging (private)', () => {
567
+ let authFetch: AuthFetch
568
+
569
+ beforeEach(() => {
570
+ authFetch = new AuthFetch(buildWallet())
571
+ })
572
+
573
+ it('returns type=none for null body', () => {
574
+ const result = (authFetch as any).describeRequestBodyForLogging(null)
575
+ expect(result).toEqual({ type: 'none', byteLength: 0 })
576
+ })
577
+
578
+ it('returns type=none for undefined body', () => {
579
+ const result = (authFetch as any).describeRequestBodyForLogging(undefined)
580
+ expect(result).toEqual({ type: 'none', byteLength: 0 })
581
+ })
582
+
583
+ it('returns type=string with correct byteLength', () => {
584
+ const result = (authFetch as any).describeRequestBodyForLogging('hello')
585
+ expect(result.type).toBe('string')
586
+ expect(result.byteLength).toBe(5)
587
+ })
588
+
589
+ it('returns type=number[] for number array', () => {
590
+ const result = (authFetch as any).describeRequestBodyForLogging([1, 2, 3])
591
+ expect(result).toEqual({ type: 'number[]', byteLength: 3 })
592
+ })
593
+
594
+ it('returns type=array for non-number array', () => {
595
+ const result = (authFetch as any).describeRequestBodyForLogging(['a', 'b'])
596
+ expect(result).toEqual({ type: 'array', byteLength: 2 })
597
+ })
598
+
599
+ it('returns type=ArrayBuffer for ArrayBuffer', () => {
600
+ const buf = new ArrayBuffer(8)
601
+ const result = (authFetch as any).describeRequestBodyForLogging(buf)
602
+ expect(result).toEqual({ type: 'ArrayBuffer', byteLength: 8 })
603
+ })
604
+
605
+ it('returns typed array name for Uint8Array', () => {
606
+ const arr = new Uint8Array([1, 2, 3, 4])
607
+ const result = (authFetch as any).describeRequestBodyForLogging(arr)
608
+ expect(result.type).toBe('Uint8Array')
609
+ expect(result.byteLength).toBe(4)
610
+ })
611
+
612
+ it('returns type=Blob for Blob', () => {
613
+ const blob = new Blob(['hello world'])
614
+ const result = (authFetch as any).describeRequestBodyForLogging(blob)
615
+ expect(result.type).toBe('Blob')
616
+ expect(result.byteLength).toBeGreaterThan(0)
617
+ })
618
+
619
+ it('returns type=URLSearchParams for URLSearchParams', () => {
620
+ const params = new URLSearchParams({ key: 'value' })
621
+ const result = (authFetch as any).describeRequestBodyForLogging(params)
622
+ expect(result.type).toBe('URLSearchParams')
623
+ expect(result.byteLength).toBeGreaterThan(0)
624
+ })
625
+
626
+ it('returns type=FormData for FormData', () => {
627
+ const fd = new FormData()
628
+ fd.append('field', 'value')
629
+ const result = (authFetch as any).describeRequestBodyForLogging(fd)
630
+ expect(result.type).toBe('FormData')
631
+ expect(result.byteLength).toBe(0)
632
+ })
633
+
634
+ it('returns type=object for a plain object', () => {
635
+ const result = (authFetch as any).describeRequestBodyForLogging({ a: 1 })
636
+ expect(result.type).toBe('object')
637
+ expect(result.byteLength).toBeGreaterThan(0)
638
+ })
639
+
640
+ it('returns type=ReadableStream for ReadableStream', () => {
641
+ const stream = new ReadableStream()
642
+ const result = (authFetch as any).describeRequestBodyForLogging(stream)
643
+ expect(result).toEqual({ type: 'ReadableStream', byteLength: 0 })
644
+ })
645
+
646
+ it('falls back to typeof for an unrecognised type', () => {
647
+ // A Symbol cannot be JSON-stringified, triggering the fallback
648
+ const sym = Symbol('test')
649
+ const result = (authFetch as any).describeRequestBodyForLogging(sym)
650
+ expect(result.type).toBe('symbol')
651
+ expect(result.byteLength).toBe(0)
652
+ })
653
+ })
654
+
655
+ // ---------------------------------------------------------------------------
656
+ // 9. normalizeBodyToNumberArray – edge cases
657
+ // ---------------------------------------------------------------------------
658
+
659
+ describe('AuthFetch.normalizeBodyToNumberArray (private)', () => {
660
+ let authFetch: AuthFetch
661
+
662
+ beforeEach(() => {
663
+ authFetch = new AuthFetch(buildWallet())
664
+ })
665
+
666
+ it('returns [] for null', async () => {
667
+ const result = await (authFetch as any).normalizeBodyToNumberArray(null)
668
+ expect(result).toEqual([])
669
+ })
670
+
671
+ it('returns [] for undefined', async () => {
672
+ const result = await (authFetch as any).normalizeBodyToNumberArray(undefined)
673
+ expect(result).toEqual([])
674
+ })
675
+
676
+ it('converts a string to a number array', async () => {
677
+ const result = await (authFetch as any).normalizeBodyToNumberArray('abc')
678
+ expect(result.length).toBe(3)
679
+ })
680
+
681
+ it('converts a number[] to a JSON-encoded number array', async () => {
682
+ // Arrays are objects, so they hit the typeof === 'object' branch first
683
+ // and are serialized via JSON.stringify before the number[] guard runs.
684
+ const input = [1, 2, 3]
685
+ const result = await (authFetch as any).normalizeBodyToNumberArray(input)
686
+ // '[1,2,3]' encoded as UTF-8 bytes
687
+ const expected = Utils.toArray(JSON.stringify(input), 'utf8')
688
+ expect(result).toEqual(expected)
689
+ })
690
+
691
+ it('converts ArrayBuffer to a JSON-encoded number array', async () => {
692
+ // ArrayBuffer is an object, so it hits the typeof === 'object' branch first.
693
+ const buf = new Uint8Array([10, 20, 30]).buffer
694
+ const result = await (authFetch as any).normalizeBodyToNumberArray(buf)
695
+ // JSON.stringify of an ArrayBuffer produces '{}'
696
+ const expected = Utils.toArray(JSON.stringify(buf), 'utf8')
697
+ expect(result).toEqual(expected)
698
+ })
699
+
700
+ it('converts Uint8Array to a JSON-encoded number array', async () => {
701
+ // Uint8Array is an object, so it hits the typeof === 'object' branch first.
702
+ const arr = new Uint8Array([5, 6, 7])
703
+ const result = await (authFetch as any).normalizeBodyToNumberArray(arr)
704
+ // JSON.stringify of a Uint8Array produces e.g. '{"0":5,"1":6,"2":7}'
705
+ const expected = Utils.toArray(JSON.stringify(arr), 'utf8')
706
+ expect(result).toEqual(expected)
707
+ })
708
+
709
+ it('converts Blob via JSON.stringify (object branch)', async () => {
710
+ // Blob is an object — hits the typeof === 'object' branch before the Blob check.
711
+ const blob = new Blob(['hi'])
712
+ const result = await (authFetch as any).normalizeBodyToNumberArray(blob)
713
+ // JSON.stringify(new Blob(...)) → '{}'
714
+ const expected = Utils.toArray(JSON.stringify(blob), 'utf8')
715
+ expect(result).toEqual(expected)
716
+ })
717
+
718
+ it('converts FormData via JSON.stringify (object branch)', async () => {
719
+ // FormData is an object — hits the typeof === 'object' branch before the FormData check.
720
+ const fd = new FormData()
721
+ fd.append('name', 'alice')
722
+ const result = await (authFetch as any).normalizeBodyToNumberArray(fd)
723
+ // JSON.stringify(FormData) → '{}'
724
+ const expected = Utils.toArray(JSON.stringify(fd), 'utf8')
725
+ expect(result).toEqual(expected)
726
+ })
727
+
728
+ it('converts URLSearchParams via JSON.stringify (object branch)', async () => {
729
+ // URLSearchParams is an object — hits typeof === 'object' branch first.
730
+ const params = new URLSearchParams({ q: 'hello' })
731
+ const result = await (authFetch as any).normalizeBodyToNumberArray(params)
732
+ // JSON.stringify(URLSearchParams) → '{}'
733
+ const expected = Utils.toArray(JSON.stringify(params), 'utf8')
734
+ expect(result).toEqual(expected)
735
+ })
736
+
737
+ it('converts ReadableStream via JSON.stringify (object branch)', async () => {
738
+ // ReadableStream is an object, so it hits the typeof === 'object' branch first
739
+ // and is serialized via JSON.stringify (produces '{}') rather than throwing.
740
+ const stream = new ReadableStream()
741
+ const result = await (authFetch as any).normalizeBodyToNumberArray(stream)
742
+ const expected = Utils.toArray(JSON.stringify(stream), 'utf8')
743
+ expect(result).toEqual(expected)
744
+ })
745
+
746
+ it('converts a plain object via JSON.stringify', async () => {
747
+ const obj = { key: 'value' }
748
+ const result = await (authFetch as any).normalizeBodyToNumberArray(obj)
749
+ expect(result.length).toBeGreaterThan(0)
750
+ })
751
+ })
752
+
753
+ // ---------------------------------------------------------------------------
754
+ // 10. getMaxPaymentAttempts
755
+ // ---------------------------------------------------------------------------
756
+
757
+ describe('AuthFetch.getMaxPaymentAttempts (private)', () => {
758
+ let authFetch: AuthFetch
759
+
760
+ beforeEach(() => {
761
+ authFetch = new AuthFetch(buildWallet())
762
+ })
763
+
764
+ it('returns 3 by default', () => {
765
+ expect((authFetch as any).getMaxPaymentAttempts({})).toBe(3)
766
+ })
767
+
768
+ it('returns the configured positive integer', () => {
769
+ expect((authFetch as any).getMaxPaymentAttempts({ paymentRetryAttempts: 5 })).toBe(5)
770
+ })
771
+
772
+ it('floors the value for a float', () => {
773
+ expect((authFetch as any).getMaxPaymentAttempts({ paymentRetryAttempts: 4.9 })).toBe(4)
774
+ })
775
+
776
+ it('returns 3 when paymentRetryAttempts is 0', () => {
777
+ expect((authFetch as any).getMaxPaymentAttempts({ paymentRetryAttempts: 0 })).toBe(3)
778
+ })
779
+
780
+ it('returns 3 when paymentRetryAttempts is negative', () => {
781
+ expect((authFetch as any).getMaxPaymentAttempts({ paymentRetryAttempts: -1 })).toBe(3)
782
+ })
783
+
784
+ it('returns 3 when paymentRetryAttempts is a string', () => {
785
+ expect((authFetch as any).getMaxPaymentAttempts({ paymentRetryAttempts: 'five' as any })).toBe(3)
786
+ })
787
+ })
788
+
789
+ // ---------------------------------------------------------------------------
790
+ // 11. getPaymentRetryDelay
791
+ // ---------------------------------------------------------------------------
792
+
793
+ describe('AuthFetch.getPaymentRetryDelay (private)', () => {
794
+ let authFetch: AuthFetch
795
+
796
+ beforeEach(() => {
797
+ authFetch = new AuthFetch(buildWallet())
798
+ })
799
+
800
+ it('returns 250 for attempt 1 (250 * 1)', () => {
801
+ expect((authFetch as any).getPaymentRetryDelay(1)).toBe(250)
802
+ })
803
+
804
+ it('returns 500 for attempt 2', () => {
805
+ expect((authFetch as any).getPaymentRetryDelay(2)).toBe(500)
806
+ })
807
+
808
+ it('caps multiplier at 5 for attempt >= 5', () => {
809
+ expect((authFetch as any).getPaymentRetryDelay(5)).toBe(1250)
810
+ expect((authFetch as any).getPaymentRetryDelay(10)).toBe(1250)
811
+ expect((authFetch as any).getPaymentRetryDelay(100)).toBe(1250)
812
+ })
813
+ })
814
+
815
+ // ---------------------------------------------------------------------------
816
+ // 12. wait()
817
+ // ---------------------------------------------------------------------------
818
+
819
+ describe('AuthFetch.wait (private)', () => {
820
+ it('resolves immediately for ms <= 0', async () => {
821
+ const authFetch = new AuthFetch(buildWallet())
822
+ const start = Date.now()
823
+ await (authFetch as any).wait(0)
824
+ expect(Date.now() - start).toBeLessThan(50)
825
+ })
826
+
827
+ it('resolves immediately for negative ms', async () => {
828
+ const authFetch = new AuthFetch(buildWallet())
829
+ const start = Date.now()
830
+ await (authFetch as any).wait(-100)
831
+ expect(Date.now() - start).toBeLessThan(50)
832
+ })
833
+
834
+ it('uses a timer for positive ms', async () => {
835
+ jest.useFakeTimers()
836
+ try {
837
+ const authFetch = new AuthFetch(buildWallet())
838
+ let resolved = false
839
+ const promise = (authFetch as any).wait(500).then(() => { resolved = true })
840
+ expect(resolved).toBe(false)
841
+ await jest.advanceTimersByTimeAsync(500)
842
+ await promise
843
+ expect(resolved).toBe(true)
844
+ } finally {
845
+ jest.useRealTimers()
846
+ }
847
+ })
848
+ })
849
+
850
+ // ---------------------------------------------------------------------------
851
+ // 13. isPaymentContextCompatible
852
+ // ---------------------------------------------------------------------------
853
+
854
+ describe('AuthFetch.isPaymentContextCompatible (private)', () => {
855
+ let authFetch: AuthFetch
856
+
857
+ beforeEach(() => {
858
+ authFetch = new AuthFetch(buildWallet())
859
+ })
860
+
861
+ function makeCtx (overrides: Partial<any> = {}): any {
862
+ return {
863
+ satoshisRequired: 10,
864
+ serverIdentityKey: 'srv',
865
+ derivationPrefix: 'pfx',
866
+ ...overrides
867
+ }
868
+ }
869
+
870
+ it('returns true when all fields match', () => {
871
+ const ctx = makeCtx()
872
+ expect((authFetch as any).isPaymentContextCompatible(ctx, 10, 'srv', 'pfx')).toBe(true)
873
+ })
874
+
875
+ it('returns false when satoshis differ', () => {
876
+ const ctx = makeCtx()
877
+ expect((authFetch as any).isPaymentContextCompatible(ctx, 20, 'srv', 'pfx')).toBe(false)
878
+ })
879
+
880
+ it('returns false when serverIdentityKey differs', () => {
881
+ const ctx = makeCtx()
882
+ expect((authFetch as any).isPaymentContextCompatible(ctx, 10, 'other', 'pfx')).toBe(false)
883
+ })
884
+
885
+ it('returns false when derivationPrefix differs', () => {
886
+ const ctx = makeCtx()
887
+ expect((authFetch as any).isPaymentContextCompatible(ctx, 10, 'srv', 'other')).toBe(false)
888
+ })
889
+ })
890
+
891
+ // ---------------------------------------------------------------------------
892
+ // 14. consumeReceivedCertificates
893
+ // ---------------------------------------------------------------------------
894
+
895
+ describe('AuthFetch.consumeReceivedCertificates', () => {
896
+ it('returns all received certs and empties the buffer', () => {
897
+ const authFetch = new AuthFetch(buildWallet())
898
+ ;(authFetch as any).certificatesReceived.push(
899
+ { serialNumber: 'cert1' },
900
+ { serialNumber: 'cert2' }
901
+ )
902
+
903
+ const certs = authFetch.consumeReceivedCertificates()
904
+ expect(certs).toHaveLength(2)
905
+ expect(certs[0]).toMatchObject({ serialNumber: 'cert1' })
906
+
907
+ // Buffer should now be empty
908
+ const second = authFetch.consumeReceivedCertificates()
909
+ expect(second).toHaveLength(0)
910
+ })
911
+
912
+ it('returns an empty array when no certs have been received', () => {
913
+ const authFetch = new AuthFetch(buildWallet())
914
+ expect(authFetch.consumeReceivedCertificates()).toEqual([])
915
+ })
916
+ })
917
+
918
+ // ---------------------------------------------------------------------------
919
+ // 15. sendCertificateRequest – creates a new peer when none exists
920
+ // ---------------------------------------------------------------------------
921
+
922
+ describe('AuthFetch.sendCertificateRequest – new peer creation', () => {
923
+ it('creates a new Peer transport when no peer exists for the base URL', async () => {
924
+ const authFetch = new AuthFetch(buildWallet())
925
+
926
+ // Verify there is no existing peer
927
+ expect((authFetch as any).peers['https://new-server.com']).toBeUndefined()
928
+
929
+ const fakeCerts = [{ serialNumber: 'abc' }]
930
+ let capturedListener: ((senderKey: string, certs: any[]) => void) | undefined
931
+
932
+ const peerProto = {
933
+ listenForCertificatesReceived: jest.fn((cb: any) => {
934
+ capturedListener = cb
935
+ return 99
936
+ }),
937
+ stopListeningForCertificatesReceived: jest.fn(),
938
+ requestCertificates: jest.fn(async () => {
939
+ capturedListener?.('srv-key', fakeCerts as any)
940
+ })
941
+ }
942
+
943
+ // Intercept Peer constructor by injecting peer directly after fetch call starts
944
+ const origFetch = authFetch.sendCertificateRequest.bind(authFetch)
945
+ jest.spyOn(authFetch as any, 'sendCertificateRequest').mockImplementationOnce(
946
+ async (url: string, certs: any) => {
947
+ // Manually inject our stub peer so Peer constructor is bypassed
948
+ ;(authFetch as any).peers['https://new-server.com'] = {
949
+ peer: peerProto,
950
+ pendingCertificateRequests: []
951
+ }
952
+ return origFetch(url, certs)
953
+ }
954
+ )
955
+
956
+ const result = await authFetch.sendCertificateRequest(
957
+ 'https://new-server.com/path',
958
+ { certifiers: [], types: {} } as any
959
+ )
960
+
961
+ expect(result).toHaveLength(1)
962
+ })
963
+ })
964
+
965
+ // ---------------------------------------------------------------------------
966
+ // 16. logPaymentAttempt – all levels
967
+ // ---------------------------------------------------------------------------
968
+
969
+ describe('AuthFetch.logPaymentAttempt (private)', () => {
970
+ let authFetch: AuthFetch
971
+
972
+ beforeEach(() => {
973
+ authFetch = new AuthFetch(buildWallet())
974
+ })
975
+
976
+ it('calls console.error for level=error', () => {
977
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
978
+ ;(authFetch as any).logPaymentAttempt('error', 'test error', { a: 1 })
979
+ expect(spy).toHaveBeenCalledWith('[AuthFetch][Payment] test error', { a: 1 })
980
+ })
981
+
982
+ it('calls console.warn for level=warn', () => {
983
+ const spy = jest.spyOn(console, 'warn').mockImplementation(() => {})
984
+ ;(authFetch as any).logPaymentAttempt('warn', 'test warn', { b: 2 })
985
+ expect(spy).toHaveBeenCalledWith('[AuthFetch][Payment] test warn', { b: 2 })
986
+ })
987
+
988
+ it('calls console.info for level=info when available', () => {
989
+ const spy = jest.spyOn(console, 'info').mockImplementation(() => {})
990
+ ;(authFetch as any).logPaymentAttempt('info', 'test info', { c: 3 })
991
+ expect(spy).toHaveBeenCalledWith('[AuthFetch][Payment] test info', { c: 3 })
992
+ })
993
+
994
+ it('falls back to console.log for level=info when console.info is not a function', () => {
995
+ const originalInfo = console.info
996
+ ;(console as any).info = undefined
997
+ const spy = jest.spyOn(console, 'log').mockImplementation(() => {})
998
+ try {
999
+ ;(authFetch as any).logPaymentAttempt('info', 'fallback log', {})
1000
+ expect(spy).toHaveBeenCalledWith('[AuthFetch][Payment] fallback log', {})
1001
+ } finally {
1002
+ console.info = originalInfo
1003
+ }
1004
+ })
1005
+ })
1006
+
1007
+ // ---------------------------------------------------------------------------
1008
+ // 17. createPaymentErrorEntry
1009
+ // ---------------------------------------------------------------------------
1010
+
1011
+ describe('AuthFetch.createPaymentErrorEntry (private)', () => {
1012
+ let authFetch: AuthFetch
1013
+
1014
+ beforeEach(() => {
1015
+ authFetch = new AuthFetch(buildWallet())
1016
+ })
1017
+
1018
+ it('extracts message and stack from an Error instance', () => {
1019
+ const err = new Error('something went wrong')
1020
+ const entry = (authFetch as any).createPaymentErrorEntry(2, err)
1021
+ expect(entry.attempt).toBe(2)
1022
+ expect(entry.message).toBe('something went wrong')
1023
+ expect(typeof entry.stack).toBe('string')
1024
+ expect(typeof entry.timestamp).toBe('string')
1025
+ })
1026
+
1027
+ it('converts non-Error to string message', () => {
1028
+ const entry = (authFetch as any).createPaymentErrorEntry(1, 'just a string error')
1029
+ expect(entry.message).toBe('just a string error')
1030
+ expect(entry.stack).toBeUndefined()
1031
+ })
1032
+
1033
+ it('converts numeric error to string message', () => {
1034
+ const entry = (authFetch as any).createPaymentErrorEntry(1, 42)
1035
+ expect(entry.message).toBe('42')
1036
+ })
1037
+ })
1038
+
1039
+ // ---------------------------------------------------------------------------
1040
+ // 18. buildPaymentFailureError
1041
+ // ---------------------------------------------------------------------------
1042
+
1043
+ describe('AuthFetch.buildPaymentFailureError (private)', () => {
1044
+ let authFetch: AuthFetch
1045
+
1046
+ beforeEach(() => {
1047
+ authFetch = new AuthFetch(buildWallet())
1048
+ })
1049
+
1050
+ function makeContext (): any {
1051
+ return {
1052
+ satoshisRequired: 10,
1053
+ transactionBase64: 'tx-base64',
1054
+ derivationPrefix: 'pfx',
1055
+ derivationSuffix: 'sfx',
1056
+ serverIdentityKey: 'srv',
1057
+ clientIdentityKey: 'cli',
1058
+ attempts: 3,
1059
+ maxAttempts: 3,
1060
+ errors: [],
1061
+ requestSummary: { url: 'https://ex.com', method: 'GET', headers: {}, bodyType: 'none', bodyByteLength: 0 }
1062
+ }
1063
+ }
1064
+
1065
+ it('creates an Error with a descriptive message', () => {
1066
+ const err = (authFetch as any).buildPaymentFailureError(
1067
+ 'https://example.com/pay',
1068
+ makeContext(),
1069
+ new Error('last error')
1070
+ )
1071
+ expect(err).toBeInstanceOf(Error)
1072
+ expect(err.message).toContain('https://example.com/pay')
1073
+ expect(err.message).toContain('3/3')
1074
+ expect(err.message).toContain('10 satoshis')
1075
+ })
1076
+
1077
+ it('attaches details to the error', () => {
1078
+ const err = (authFetch as any).buildPaymentFailureError(
1079
+ 'https://example.com/pay',
1080
+ makeContext(),
1081
+ new Error('x')
1082
+ )
1083
+ expect(err.details).toBeDefined()
1084
+ expect(err.details.payment.satoshis).toBe(10)
1085
+ expect(err.details.attempts.used).toBe(3)
1086
+ })
1087
+
1088
+ it('sets cause when lastError is an Error', () => {
1089
+ const cause = new Error('root cause')
1090
+ const err = (authFetch as any).buildPaymentFailureError(
1091
+ 'https://example.com/pay',
1092
+ makeContext(),
1093
+ cause
1094
+ )
1095
+ expect(err.cause).toBe(cause)
1096
+ })
1097
+
1098
+ it('does not set cause when lastError is a string', () => {
1099
+ const err = (authFetch as any).buildPaymentFailureError(
1100
+ 'https://example.com/pay',
1101
+ makeContext(),
1102
+ 'string error'
1103
+ )
1104
+ expect(err.cause).toBeUndefined()
1105
+ })
1106
+ })
1107
+
1108
+ // ---------------------------------------------------------------------------
1109
+ // 19. buildPaymentRequestSummary
1110
+ // ---------------------------------------------------------------------------
1111
+
1112
+ describe('AuthFetch.buildPaymentRequestSummary (private)', () => {
1113
+ it('builds a summary with correct fields', () => {
1114
+ const authFetch = new AuthFetch(buildWallet())
1115
+ const summary = (authFetch as any).buildPaymentRequestSummary(
1116
+ 'https://example.com/resource',
1117
+ { method: 'post', headers: { 'X-Custom': 'value' }, body: 'hello' }
1118
+ )
1119
+ expect(summary.url).toBe('https://example.com/resource')
1120
+ expect(summary.method).toBe('POST')
1121
+ expect(summary.headers).toMatchObject({ 'X-Custom': 'value' })
1122
+ expect(summary.bodyType).toBe('string')
1123
+ expect(summary.bodyByteLength).toBe(5)
1124
+ })
1125
+
1126
+ it('defaults method to GET when not provided', () => {
1127
+ const authFetch = new AuthFetch(buildWallet())
1128
+ const summary = (authFetch as any).buildPaymentRequestSummary('https://example.com', {})
1129
+ expect(summary.method).toBe('GET')
1130
+ })
1131
+ })