@bsv/sdk 1.8.6 → 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.
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/src/auth/Peer.js +21 -6
- package/dist/cjs/src/auth/Peer.js.map +1 -1
- package/dist/cjs/src/auth/clients/AuthFetch.js +229 -13
- package/dist/cjs/src/auth/clients/AuthFetch.js.map +1 -1
- package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js +189 -0
- package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js.map +1 -0
- package/dist/cjs/src/auth/transports/SimplifiedFetchTransport.js +162 -36
- package/dist/cjs/src/auth/transports/SimplifiedFetchTransport.js.map +1 -1
- package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js +134 -0
- package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js.map +1 -0
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/auth/Peer.js +21 -6
- package/dist/esm/src/auth/Peer.js.map +1 -1
- package/dist/esm/src/auth/clients/AuthFetch.js +229 -13
- package/dist/esm/src/auth/clients/AuthFetch.js.map +1 -1
- package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js +187 -0
- package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js.map +1 -0
- package/dist/esm/src/auth/transports/SimplifiedFetchTransport.js +162 -36
- package/dist/esm/src/auth/transports/SimplifiedFetchTransport.js.map +1 -1
- package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js +109 -0
- package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js.map +1 -0
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/auth/Peer.d.ts +1 -0
- package/dist/types/src/auth/Peer.d.ts.map +1 -1
- package/dist/types/src/auth/clients/AuthFetch.d.ts +37 -0
- package/dist/types/src/auth/clients/AuthFetch.d.ts.map +1 -1
- package/dist/types/src/auth/clients/__tests__/AuthFetch.test.d.ts +2 -0
- package/dist/types/src/auth/clients/__tests__/AuthFetch.test.d.ts.map +1 -0
- package/dist/types/src/auth/transports/SimplifiedFetchTransport.d.ts +6 -0
- package/dist/types/src/auth/transports/SimplifiedFetchTransport.d.ts.map +1 -1
- package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.test.d.ts +2 -0
- package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.test.d.ts.map +1 -0
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/dist/umd/bundle.js +3 -3
- package/dist/umd/bundle.js.map +1 -1
- package/package.json +1 -1
- package/src/auth/Peer.ts +25 -18
- package/src/auth/__tests/Peer.test.ts +238 -1
- package/src/auth/clients/AuthFetch.ts +327 -18
- package/src/auth/clients/__tests__/AuthFetch.test.ts +262 -0
- package/src/auth/transports/SimplifiedFetchTransport.ts +185 -35
- package/src/auth/transports/__tests__/SimplifiedFetchTransport.test.ts +126 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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'],
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
config.headers['x-bsv-payment'] = JSON.stringify({
|
|
626
|
+
return {
|
|
627
|
+
satoshisRequired,
|
|
628
|
+
transactionBase64: Utils.toBase64(tx),
|
|
506
629
|
derivationPrefix,
|
|
507
630
|
derivationSuffix,
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
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
|
+
})
|