@bsv/sdk 1.8.5 → 1.8.7

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 (48) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/auth/Peer.js +21 -6
  3. package/dist/cjs/src/auth/Peer.js.map +1 -1
  4. package/dist/cjs/src/auth/clients/AuthFetch.js +229 -13
  5. package/dist/cjs/src/auth/clients/AuthFetch.js.map +1 -1
  6. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js +189 -0
  7. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js.map +1 -0
  8. package/dist/cjs/src/auth/transports/SimplifiedFetchTransport.js +162 -36
  9. package/dist/cjs/src/auth/transports/SimplifiedFetchTransport.js.map +1 -1
  10. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js +134 -0
  11. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js.map +1 -0
  12. package/dist/cjs/src/overlay-tools/LookupResolver.js +4 -4
  13. package/dist/cjs/src/overlay-tools/LookupResolver.js.map +1 -1
  14. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  15. package/dist/esm/src/auth/Peer.js +21 -6
  16. package/dist/esm/src/auth/Peer.js.map +1 -1
  17. package/dist/esm/src/auth/clients/AuthFetch.js +229 -13
  18. package/dist/esm/src/auth/clients/AuthFetch.js.map +1 -1
  19. package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js +187 -0
  20. package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js.map +1 -0
  21. package/dist/esm/src/auth/transports/SimplifiedFetchTransport.js +162 -36
  22. package/dist/esm/src/auth/transports/SimplifiedFetchTransport.js.map +1 -1
  23. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js +109 -0
  24. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js.map +1 -0
  25. package/dist/esm/src/overlay-tools/LookupResolver.js +4 -4
  26. package/dist/esm/src/overlay-tools/LookupResolver.js.map +1 -1
  27. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  28. package/dist/types/src/auth/Peer.d.ts +1 -0
  29. package/dist/types/src/auth/Peer.d.ts.map +1 -1
  30. package/dist/types/src/auth/clients/AuthFetch.d.ts +37 -0
  31. package/dist/types/src/auth/clients/AuthFetch.d.ts.map +1 -1
  32. package/dist/types/src/auth/clients/__tests__/AuthFetch.test.d.ts +2 -0
  33. package/dist/types/src/auth/clients/__tests__/AuthFetch.test.d.ts.map +1 -0
  34. package/dist/types/src/auth/transports/SimplifiedFetchTransport.d.ts +6 -0
  35. package/dist/types/src/auth/transports/SimplifiedFetchTransport.d.ts.map +1 -1
  36. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.test.d.ts +2 -0
  37. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.test.d.ts.map +1 -0
  38. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  39. package/dist/umd/bundle.js +3 -3
  40. package/dist/umd/bundle.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/auth/Peer.ts +25 -18
  43. package/src/auth/__tests/Peer.test.ts +238 -1
  44. package/src/auth/clients/AuthFetch.ts +327 -18
  45. package/src/auth/clients/__tests__/AuthFetch.test.ts +262 -0
  46. package/src/auth/transports/SimplifiedFetchTransport.ts +185 -35
  47. package/src/auth/transports/__tests__/SimplifiedFetchTransport.test.ts +126 -0
  48. package/src/overlay-tools/LookupResolver.ts +3 -3
@@ -18,6 +18,8 @@ interface SimplifiedFetchRequestOptions {
18
18
  headers?: Record<string, string>
19
19
  body?: any
20
20
  retryCounter?: number
21
+ paymentContext?: PaymentRetryContext
22
+ paymentRetryAttempts?: number
21
23
  }
22
24
 
23
25
  interface AuthPeer {
@@ -27,6 +29,32 @@ interface AuthPeer {
27
29
  pendingCertificateRequests: Array<true>
28
30
  }
29
31
 
32
+ interface PaymentErrorLogEntry {
33
+ attempt: number
34
+ timestamp: string
35
+ message: string
36
+ stack?: string
37
+ }
38
+
39
+ interface PaymentRetryContext {
40
+ satoshisRequired: number
41
+ transactionBase64: string
42
+ derivationPrefix: string
43
+ derivationSuffix: string
44
+ serverIdentityKey: string
45
+ clientIdentityKey: string
46
+ attempts: number
47
+ maxAttempts: number
48
+ errors: PaymentErrorLogEntry[]
49
+ requestSummary: {
50
+ url: string
51
+ method: string
52
+ headers: Record<string, string>
53
+ bodyType: string
54
+ bodyByteLength: number
55
+ }
56
+ }
57
+
30
58
  const PAYMENT_VERSION = '1.0'
31
59
 
32
60
  /**
@@ -445,16 +473,12 @@ export class AuthFetch {
445
473
  config: SimplifiedFetchRequestOptions = {},
446
474
  originalResponse: Response
447
475
  ): Promise<Response | null> {
448
- // Make sure the server is using the correct payment version
449
476
  const paymentVersion = originalResponse.headers.get('x-bsv-payment-version')
450
477
  if (!paymentVersion || paymentVersion !== PAYMENT_VERSION) {
451
478
  throw new Error(`Unsupported x-bsv-payment-version response header. Client version: ${PAYMENT_VERSION}, Server version: ${paymentVersion}`)
452
479
  }
453
480
 
454
- // Get required headers from the 402 response
455
- const satoshisRequiredHeader = originalResponse.headers.get(
456
- 'x-bsv-payment-satoshis-required'
457
- )
481
+ const satoshisRequiredHeader = originalResponse.headers.get('x-bsv-payment-satoshis-required')
458
482
  if (!satoshisRequiredHeader) {
459
483
  throw new Error('Missing x-bsv-payment-satoshis-required response header.')
460
484
  }
@@ -473,18 +497,117 @@ export class AuthFetch {
473
497
  throw new Error('Missing x-bsv-payment-derivation-prefix response header.')
474
498
  }
475
499
 
476
- // Create a random suffix for the derivation path
500
+ let paymentContext = config.paymentContext
501
+ if (paymentContext != null) {
502
+ const requirementsChanged = !this.isPaymentContextCompatible(
503
+ paymentContext,
504
+ satoshisRequired,
505
+ serverIdentityKey,
506
+ derivationPrefix
507
+ )
508
+ if (requirementsChanged) {
509
+ this.logPaymentAttempt('warn', 'Server adjusted payment requirements; regenerating transaction', this.composePaymentLogDetails(url, paymentContext))
510
+ paymentContext = await this.createPaymentContext(
511
+ url,
512
+ config,
513
+ satoshisRequired,
514
+ serverIdentityKey,
515
+ derivationPrefix
516
+ )
517
+ }
518
+ } else {
519
+ paymentContext = await this.createPaymentContext(
520
+ url,
521
+ config,
522
+ satoshisRequired,
523
+ serverIdentityKey,
524
+ derivationPrefix
525
+ )
526
+ }
527
+
528
+ if (paymentContext.attempts >= paymentContext.maxAttempts) {
529
+ throw this.buildPaymentFailureError(url, paymentContext, new Error('Maximum payment attempts exceeded before retrying'))
530
+ }
531
+
532
+ const headersWithPayment: Record<string, string> = {
533
+ ...(config.headers ?? {})
534
+ }
535
+ headersWithPayment['x-bsv-payment'] = JSON.stringify({
536
+ derivationPrefix: paymentContext.derivationPrefix,
537
+ derivationSuffix: paymentContext.derivationSuffix,
538
+ transaction: paymentContext.transactionBase64
539
+ })
540
+
541
+ const nextConfig: SimplifiedFetchRequestOptions = {
542
+ ...config,
543
+ headers: headersWithPayment,
544
+ paymentContext
545
+ }
546
+
547
+ if (typeof nextConfig.retryCounter !== 'number') {
548
+ nextConfig.retryCounter = 3
549
+ }
550
+
551
+ const attemptNumber = paymentContext.attempts + 1
552
+ const maxAttempts = paymentContext.maxAttempts
553
+ paymentContext.attempts = attemptNumber
554
+ const attemptDetails = this.composePaymentLogDetails(url, paymentContext)
555
+ this.logPaymentAttempt('warn', `Attempting paid request (${attemptNumber}/${maxAttempts})`, attemptDetails)
556
+
557
+ try {
558
+ const response = await this.fetch(url, nextConfig)
559
+ this.logPaymentAttempt('info', `Paid request attempt ${attemptNumber} succeeded`, attemptDetails)
560
+ return response
561
+ } catch (error) {
562
+ const errorEntry = this.createPaymentErrorEntry(paymentContext.attempts, error)
563
+ paymentContext.errors.push(errorEntry)
564
+ this.logPaymentAttempt('error', `Paid request attempt ${attemptNumber} failed`, {
565
+ ...attemptDetails,
566
+ error: {
567
+ message: errorEntry.message,
568
+ stack: errorEntry.stack
569
+ }
570
+ })
571
+
572
+ if (paymentContext.attempts >= paymentContext.maxAttempts) {
573
+ throw this.buildPaymentFailureError(url, paymentContext, error)
574
+ }
575
+
576
+ const delayMs = this.getPaymentRetryDelay(paymentContext.attempts)
577
+ await this.wait(delayMs)
578
+ return this.handlePaymentAndRetry(url, nextConfig, originalResponse)
579
+ }
580
+ }
581
+
582
+ private isPaymentContextCompatible (
583
+ context: PaymentRetryContext,
584
+ satoshisRequired: number,
585
+ serverIdentityKey: string,
586
+ derivationPrefix: string
587
+ ): boolean {
588
+ return (
589
+ context.satoshisRequired === satoshisRequired &&
590
+ context.serverIdentityKey === serverIdentityKey &&
591
+ context.derivationPrefix === derivationPrefix
592
+ )
593
+ }
594
+
595
+ private async createPaymentContext (
596
+ url: string,
597
+ config: SimplifiedFetchRequestOptions,
598
+ satoshisRequired: number,
599
+ serverIdentityKey: string,
600
+ derivationPrefix: string
601
+ ): Promise<PaymentRetryContext> {
477
602
  const derivationSuffix = await createNonce(this.wallet, undefined, this.originator)
478
603
 
479
- // Derive the script hex from the server identity key
480
604
  const { publicKey: derivedPublicKey } = await this.wallet.getPublicKey({
481
- protocolID: [2, '3241645161d8'], // wallet payment protocol
605
+ protocolID: [2, '3241645161d8'],
482
606
  keyID: `${derivationPrefix} ${derivationSuffix}`,
483
607
  counterparty: serverIdentityKey
484
608
  }, this.originator)
485
609
  const lockingScript = new P2PKH().lock(PublicKey.fromString(derivedPublicKey).toAddress()).toHex()
486
610
 
487
- // Create the payment transaction using createAction
488
611
  const { tx } = await this.wallet.createAction({
489
612
  description: `Payment for request to ${new URL(url).origin}`,
490
613
  outputs: [{
@@ -498,19 +621,205 @@ export class AuthFetch {
498
621
  }
499
622
  }, this.originator)
500
623
 
624
+ const { publicKey: clientIdentityKey } = await this.wallet.getPublicKey({ identityKey: true }, this.originator)
501
625
 
502
-
503
- // Attach the payment to the request headers
504
- config.headers = config.headers || {}
505
- config.headers['x-bsv-payment'] = JSON.stringify({
626
+ return {
627
+ satoshisRequired,
628
+ transactionBase64: Utils.toBase64(tx),
506
629
  derivationPrefix,
507
630
  derivationSuffix,
508
- transaction: Utils.toBase64(tx)
509
- })
510
- config.retryCounter ??= 3
631
+ serverIdentityKey,
632
+ clientIdentityKey,
633
+ attempts: 0,
634
+ maxAttempts: this.getMaxPaymentAttempts(config),
635
+ errors: [],
636
+ requestSummary: this.buildPaymentRequestSummary(url, config)
637
+ }
638
+ }
639
+
640
+ private getMaxPaymentAttempts (config: SimplifiedFetchRequestOptions): number {
641
+ const attempts = typeof config.paymentRetryAttempts === 'number' ? config.paymentRetryAttempts : undefined
642
+ if (typeof attempts === 'number' && attempts > 0) {
643
+ return Math.floor(attempts)
644
+ }
645
+ return 3
646
+ }
647
+
648
+ private buildPaymentRequestSummary (
649
+ url: string,
650
+ config: SimplifiedFetchRequestOptions
651
+ ): PaymentRetryContext['requestSummary'] {
652
+ const headers = { ...(config.headers ?? {}) }
653
+ const method = typeof config.method === 'string' ? config.method.toUpperCase() : 'GET'
654
+ const bodySummary = this.describeRequestBodyForLogging(config.body)
655
+
656
+ return {
657
+ url,
658
+ method,
659
+ headers,
660
+ bodyType: bodySummary.type,
661
+ bodyByteLength: bodySummary.byteLength
662
+ }
663
+ }
664
+
665
+ private describeRequestBodyForLogging (body: any): { type: string, byteLength: number } {
666
+ if (body == null) {
667
+ return { type: 'none', byteLength: 0 }
668
+ }
669
+
670
+ if (typeof body === 'string') {
671
+ return { type: 'string', byteLength: Utils.toArray(body, 'utf8').length }
672
+ }
673
+
674
+ if (Array.isArray(body)) {
675
+ if (body.every((item) => typeof item === 'number')) {
676
+ return { type: 'number[]', byteLength: body.length }
677
+ }
678
+ return { type: 'array', byteLength: body.length }
679
+ }
680
+
681
+ if (typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer) {
682
+ return { type: 'ArrayBuffer', byteLength: body.byteLength }
683
+ }
684
+
685
+ if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(body)) {
686
+ return {
687
+ type: body.constructor != null ? body.constructor.name : 'TypedArray',
688
+ byteLength: body.byteLength
689
+ }
690
+ }
691
+
692
+ if (typeof Blob !== 'undefined' && body instanceof Blob) {
693
+ return { type: 'Blob', byteLength: body.size }
694
+ }
695
+
696
+ if (typeof FormData !== 'undefined' && body instanceof FormData) {
697
+ return { type: 'FormData', byteLength: 0 }
698
+ }
699
+
700
+ if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
701
+ const serialized = body.toString()
702
+ return { type: 'URLSearchParams', byteLength: Utils.toArray(serialized, 'utf8').length }
703
+ }
704
+
705
+ if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) {
706
+ return { type: 'ReadableStream', byteLength: 0 }
707
+ }
708
+
709
+ try {
710
+ const serialized = JSON.stringify(body)
711
+ if (typeof serialized === 'string') {
712
+ return { type: 'object', byteLength: Utils.toArray(serialized, 'utf8').length }
713
+ }
714
+ } catch (_) {
715
+ // Ignore JSON serialization issues for logging purposes.
716
+ }
717
+
718
+ return { type: typeof body, byteLength: 0 }
719
+ }
720
+
721
+ private composePaymentLogDetails (url: string, context: PaymentRetryContext): Record<string, any> {
722
+ return {
723
+ url,
724
+ request: context.requestSummary,
725
+ payment: {
726
+ satoshis: context.satoshisRequired,
727
+ transactionBase64: context.transactionBase64,
728
+ derivationPrefix: context.derivationPrefix,
729
+ derivationSuffix: context.derivationSuffix,
730
+ serverIdentityKey: context.serverIdentityKey,
731
+ clientIdentityKey: context.clientIdentityKey
732
+ },
733
+ attempts: {
734
+ used: context.attempts,
735
+ max: context.maxAttempts
736
+ },
737
+ errors: context.errors
738
+ }
739
+ }
740
+
741
+ private logPaymentAttempt (
742
+ level: 'info' | 'warn' | 'error',
743
+ message: string,
744
+ details: Record<string, any>
745
+ ): void {
746
+ const prefix = '[AuthFetch][Payment]'
747
+ if (level === 'error') {
748
+ console.error(`${prefix} ${message}`, details)
749
+ } else if (level === 'warn') {
750
+ console.warn(`${prefix} ${message}`, details)
751
+ } else {
752
+ if (typeof console.info === 'function') {
753
+ console.info(`${prefix} ${message}`, details)
754
+ } else {
755
+ console.log(`${prefix} ${message}`, details)
756
+ }
757
+ }
758
+ }
759
+
760
+ private createPaymentErrorEntry (attempt: number, error: unknown): PaymentErrorLogEntry {
761
+ const entry: PaymentErrorLogEntry = {
762
+ attempt,
763
+ timestamp: new Date().toISOString(),
764
+ message: '',
765
+ stack: undefined
766
+ }
767
+
768
+ if (error instanceof Error) {
769
+ entry.message = error.message
770
+ entry.stack = error.stack ?? undefined
771
+ } else {
772
+ entry.message = String(error)
773
+ }
774
+
775
+ return entry
776
+ }
777
+
778
+ private getPaymentRetryDelay (attempt: number): number {
779
+ const baseDelay = 250
780
+ const multiplier = Math.min(attempt, 5)
781
+ return baseDelay * multiplier
782
+ }
783
+
784
+ private async wait (ms: number): Promise<void> {
785
+ if (ms <= 0) {
786
+ return
787
+ }
788
+ await new Promise(resolve => setTimeout(resolve, ms))
789
+ }
790
+
791
+ private buildPaymentFailureError (
792
+ url: string,
793
+ context: PaymentRetryContext,
794
+ lastError: unknown
795
+ ): Error {
796
+ const message = `Paid request to ${url} failed after ${context.attempts}/${context.maxAttempts} attempts. Sent ${context.satoshisRequired} satoshis to ${context.serverIdentityKey}.`
797
+ const error = new Error(message)
798
+
799
+ const failureDetails = {
800
+ request: context.requestSummary,
801
+ payment: {
802
+ satoshis: context.satoshisRequired,
803
+ transactionBase64: context.transactionBase64,
804
+ derivationPrefix: context.derivationPrefix,
805
+ derivationSuffix: context.derivationSuffix,
806
+ serverIdentityKey: context.serverIdentityKey,
807
+ clientIdentityKey: context.clientIdentityKey
808
+ },
809
+ attempts: {
810
+ used: context.attempts,
811
+ max: context.maxAttempts
812
+ },
813
+ errors: context.errors
814
+ }
815
+
816
+ ;(error as any).details = failureDetails
817
+
818
+ if (lastError instanceof Error) {
819
+ ;(error as any).cause = lastError
820
+ }
511
821
 
512
- // Re-attempt request with payment attached
513
- return this.fetch(url, config)
822
+ return error
514
823
  }
515
824
 
516
825
  private async normalizeBodyToNumberArray(body: BodyInit | null | undefined): Promise<number[]> {
@@ -0,0 +1,262 @@
1
+ import { jest } from '@jest/globals'
2
+ import { AuthFetch } from '../AuthFetch.js'
3
+ import { Utils, PrivateKey } from '../../../primitives/index.js'
4
+
5
+ jest.mock('../../utils/createNonce.js', () => ({
6
+ createNonce: jest.fn()
7
+ }))
8
+
9
+ import { createNonce } from '../../utils/createNonce.js'
10
+
11
+ type Mutable<T> = {
12
+ -readonly [P in keyof T]: T[P]
13
+ }
14
+
15
+ interface TestPaymentContext {
16
+ satoshisRequired: number
17
+ transactionBase64: string
18
+ derivationPrefix: string
19
+ derivationSuffix: string
20
+ serverIdentityKey: string
21
+ clientIdentityKey: string
22
+ attempts: number
23
+ maxAttempts: number
24
+ errors: Array<{
25
+ attempt: number
26
+ timestamp: string
27
+ message: string
28
+ stack?: string
29
+ }>
30
+ requestSummary: {
31
+ url: string
32
+ method: string
33
+ headers: Record<string, string>
34
+ bodyType: string
35
+ bodyByteLength: number
36
+ }
37
+ }
38
+
39
+ const createNonceMock = createNonce as jest.MockedFunction<typeof createNonce>
40
+
41
+ function createWalletStub (): any {
42
+ const identityKey = new PrivateKey(10).toPublicKey().toString()
43
+ const derivedKey = new PrivateKey(11).toPublicKey().toString()
44
+
45
+ return {
46
+ getPublicKey: jest.fn(async (options: Record<string, any>) => {
47
+ if (options?.identityKey === true) {
48
+ return { publicKey: identityKey }
49
+ }
50
+ return { publicKey: derivedKey }
51
+ }),
52
+ createAction: jest.fn(async () => ({
53
+ tx: Utils.toArray('mock-transaction', 'utf8')
54
+ })),
55
+ createHmac: jest.fn(async () => ({
56
+ hmac: new Array(32).fill(7)
57
+ }))
58
+ }
59
+ }
60
+
61
+ function createPaymentRequiredResponse (overrides: Record<string, string> = {}): Response {
62
+ const headers: Record<string, string> = {
63
+ 'x-bsv-payment-version': '1.0',
64
+ 'x-bsv-payment-satoshis-required': '5',
65
+ 'x-bsv-auth-identity-key': 'server-key',
66
+ 'x-bsv-payment-derivation-prefix': 'prefix',
67
+ ...overrides
68
+ }
69
+ return new Response('', { status: 402, headers })
70
+ }
71
+
72
+ afterEach(() => {
73
+ jest.restoreAllMocks()
74
+ createNonceMock.mockReset()
75
+ })
76
+
77
+ describe('AuthFetch payment handling', () => {
78
+ test('createPaymentContext builds a complete retry context', async () => {
79
+ const wallet = createWalletStub()
80
+ const authFetch = new AuthFetch(wallet as any)
81
+
82
+ createNonceMock.mockResolvedValueOnce('suffix-from-test')
83
+
84
+ const config = {
85
+ method: 'POST',
86
+ headers: { 'Content-Type': 'application/json' },
87
+ body: { hello: 'world' }
88
+ }
89
+
90
+ const context = await (authFetch as any).createPaymentContext(
91
+ 'https://api.example.com/resource',
92
+ config,
93
+ 42,
94
+ 'remote-identity-key',
95
+ 'test-prefix'
96
+ ) as TestPaymentContext
97
+
98
+ expect(context.satoshisRequired).toBe(42)
99
+ expect(context.serverIdentityKey).toBe('remote-identity-key')
100
+ expect(context.derivationPrefix).toBe('test-prefix')
101
+ expect(context.derivationSuffix).toBe('suffix-from-test')
102
+ expect(context.transactionBase64).toBe(Utils.toBase64(Utils.toArray('mock-transaction', 'utf8')))
103
+ expect(context.clientIdentityKey).toEqual(expect.any(String))
104
+ expect(context.attempts).toBe(0)
105
+ expect(context.maxAttempts).toBe(3)
106
+ expect(context.errors).toEqual([])
107
+ expect(context.requestSummary).toMatchObject({
108
+ url: 'https://api.example.com/resource',
109
+ method: 'POST',
110
+ headers: { 'Content-Type': 'application/json' },
111
+ bodyType: 'object'
112
+ })
113
+ expect(context.requestSummary.bodyByteLength).toBe(
114
+ Utils.toArray(JSON.stringify(config.body), 'utf8').length
115
+ )
116
+
117
+ expect(wallet.createAction).toHaveBeenCalledWith(
118
+ expect.objectContaining({
119
+ description: expect.stringContaining('https://api.example.com'),
120
+ outputs: [
121
+ expect.objectContaining({
122
+ satoshis: 42,
123
+ customInstructions: expect.stringContaining('remote-identity-key')
124
+ })
125
+ ]
126
+ }),
127
+ undefined
128
+ )
129
+ })
130
+
131
+ test('handlePaymentAndRetry reuses compatible contexts and adds payment header', async () => {
132
+ const wallet = createWalletStub()
133
+ const authFetch = new AuthFetch(wallet as any)
134
+
135
+ const paymentContext: TestPaymentContext = {
136
+ satoshisRequired: 5,
137
+ transactionBase64: Utils.toBase64([1, 2, 3]),
138
+ derivationPrefix: 'prefix',
139
+ derivationSuffix: 'suffix',
140
+ serverIdentityKey: 'server-key',
141
+ clientIdentityKey: 'client-key',
142
+ attempts: 0,
143
+ maxAttempts: 3,
144
+ errors: [],
145
+ requestSummary: {
146
+ url: 'https://api.example.com/resource',
147
+ method: 'POST',
148
+ headers: { 'X-Test': '1' },
149
+ bodyType: 'none',
150
+ bodyByteLength: 0
151
+ }
152
+ }
153
+
154
+ const fetchSpy = jest.spyOn(authFetch, 'fetch').mockResolvedValue({ status: 200 } as Response)
155
+ jest.spyOn(authFetch as any, 'logPaymentAttempt').mockImplementation(() => {})
156
+ const createPaymentContextSpy = jest.spyOn(authFetch as any, 'createPaymentContext')
157
+
158
+ const config: Mutable<any> = {
159
+ headers: { 'x-custom': 'value' },
160
+ paymentContext
161
+ }
162
+
163
+ const response = createPaymentRequiredResponse()
164
+
165
+ const result = await (authFetch as any).handlePaymentAndRetry(
166
+ 'https://api.example.com/resource',
167
+ config,
168
+ response
169
+ )
170
+
171
+ expect(result).toEqual({ status: 200 })
172
+ expect(paymentContext.attempts).toBe(1)
173
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
174
+ const callArgs = fetchSpy.mock.calls[0] as [string, any]
175
+ const nextConfig = callArgs?.[1]
176
+ expect(nextConfig).toBeDefined()
177
+ expect(nextConfig.paymentContext).toBe(paymentContext)
178
+ expect(nextConfig.retryCounter).toBe(3)
179
+
180
+ const paymentHeader = JSON.parse(nextConfig.headers['x-bsv-payment'])
181
+ expect(paymentHeader).toEqual({
182
+ derivationPrefix: 'prefix',
183
+ derivationSuffix: 'suffix',
184
+ transaction: Utils.toBase64([1, 2, 3])
185
+ })
186
+
187
+ expect(createPaymentContextSpy).not.toHaveBeenCalled()
188
+ })
189
+
190
+ test('handlePaymentAndRetry exhausts attempts and throws detailed error', async () => {
191
+ const wallet = createWalletStub()
192
+ const authFetch = new AuthFetch(wallet as any)
193
+ jest.spyOn(authFetch as any, 'logPaymentAttempt').mockImplementation(() => {})
194
+ jest.spyOn(authFetch as any, 'wait').mockResolvedValue(undefined)
195
+
196
+ const firstError = new Error('payment attempt 1 failed')
197
+ const secondError = new Error('payment attempt 2 failed')
198
+ jest.spyOn(authFetch, 'fetch')
199
+ .mockRejectedValueOnce(firstError)
200
+ .mockRejectedValueOnce(secondError)
201
+
202
+ const paymentContext: TestPaymentContext = {
203
+ satoshisRequired: 5,
204
+ transactionBase64: Utils.toBase64([9, 9, 9]),
205
+ derivationPrefix: 'prefix',
206
+ derivationSuffix: 'suffix',
207
+ serverIdentityKey: 'server-key',
208
+ clientIdentityKey: 'client-key',
209
+ attempts: 0,
210
+ maxAttempts: 2,
211
+ errors: [],
212
+ requestSummary: {
213
+ url: 'https://api.example.com/resource',
214
+ method: 'GET',
215
+ headers: {},
216
+ bodyType: 'none',
217
+ bodyByteLength: 0
218
+ }
219
+ }
220
+
221
+ const config: Mutable<any> = { paymentContext }
222
+ const response = createPaymentRequiredResponse()
223
+
224
+ await expect((async () => {
225
+ try {
226
+ await (authFetch as any).handlePaymentAndRetry(
227
+ 'https://api.example.com/resource',
228
+ config,
229
+ response
230
+ )
231
+ } catch (error) {
232
+ const err = error as any
233
+ expect(err.message).toBe(
234
+ 'Paid request to https://api.example.com/resource failed after 2/2 attempts. Sent 5 satoshis to server-key.'
235
+ )
236
+ expect(err.details).toMatchObject({
237
+ attempts: { used: 2, max: 2 },
238
+ payment: expect.objectContaining({
239
+ satoshis: 5,
240
+ serverIdentityKey: 'server-key',
241
+ clientIdentityKey: 'client-key'
242
+ })
243
+ })
244
+ expect(err.details.errors).toHaveLength(2)
245
+ expect(err.details.errors[0]).toEqual(expect.objectContaining({
246
+ attempt: 1,
247
+ message: 'payment attempt 1 failed'
248
+ }))
249
+ expect(err.details.errors[1]).toEqual(expect.objectContaining({
250
+ attempt: 2,
251
+ message: 'payment attempt 2 failed'
252
+ }))
253
+ expect(typeof err.details.errors[0].timestamp).toBe('string')
254
+ expect(err.cause).toBe(secondError)
255
+ throw error
256
+ }
257
+ })()).rejects.toThrow('Paid request to https://api.example.com/resource failed after 2/2 attempts. Sent 5 satoshis to server-key.')
258
+
259
+ expect(paymentContext.attempts).toBe(2)
260
+ expect(paymentContext.errors).toHaveLength(2)
261
+ })
262
+ })