@bsv/message-box-client 2.0.5 → 2.0.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 (32) hide show
  1. package/dist/cjs/package.json +2 -2
  2. package/dist/cjs/src/PeerPayClient.js +463 -62
  3. package/dist/cjs/src/PeerPayClient.js.map +1 -1
  4. package/dist/cjs/src/__tests/PeerPayClientRequestIntegration.test.js +317 -0
  5. package/dist/cjs/src/__tests/PeerPayClientRequestIntegration.test.js.map +1 -0
  6. package/dist/cjs/src/__tests/PeerPayClientUnit.test.js +505 -1
  7. package/dist/cjs/src/__tests/PeerPayClientUnit.test.js.map +1 -1
  8. package/dist/cjs/src/types.js +5 -0
  9. package/dist/cjs/src/types.js.map +1 -1
  10. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  11. package/dist/esm/src/PeerPayClient.js +459 -61
  12. package/dist/esm/src/PeerPayClient.js.map +1 -1
  13. package/dist/esm/src/__tests/PeerPayClientRequestIntegration.test.js +312 -0
  14. package/dist/esm/src/__tests/PeerPayClientRequestIntegration.test.js.map +1 -0
  15. package/dist/esm/src/__tests/PeerPayClientUnit.test.js +505 -1
  16. package/dist/esm/src/__tests/PeerPayClientUnit.test.js.map +1 -1
  17. package/dist/esm/src/types.js +4 -1
  18. package/dist/esm/src/types.js.map +1 -1
  19. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  20. package/dist/types/src/PeerPayClient.d.ts +160 -0
  21. package/dist/types/src/PeerPayClient.d.ts.map +1 -1
  22. package/dist/types/src/__tests/PeerPayClientRequestIntegration.test.d.ts +10 -0
  23. package/dist/types/src/__tests/PeerPayClientRequestIntegration.test.d.ts.map +1 -0
  24. package/dist/types/src/types.d.ts +88 -0
  25. package/dist/types/src/types.d.ts.map +1 -1
  26. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  27. package/dist/umd/bundle.js +1 -1
  28. package/package.json +2 -2
  29. package/src/PeerPayClient.ts +526 -69
  30. package/src/__tests/PeerPayClientRequestIntegration.test.ts +364 -0
  31. package/src/__tests/PeerPayClientUnit.test.ts +594 -1
  32. package/src/types.ts +95 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsv/message-box-client",
3
- "version": "2.0.5",
3
+ "version": "2.0.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -83,6 +83,6 @@
83
83
  },
84
84
  "dependencies": {
85
85
  "@bsv/authsocket-client": "^2.0.2",
86
- "@bsv/sdk": "^2.0.4"
86
+ "@bsv/sdk": "^2.0.11"
87
87
  }
88
88
  }
@@ -11,23 +11,45 @@
11
11
  */
12
12
 
13
13
  import { MessageBoxClient } from './MessageBoxClient.js'
14
- import { PeerMessage } from './types.js'
15
- import { WalletInterface, P2PKH, PublicKey, createNonce, AtomicBEEF, AuthFetch, Base64String, OriginatorDomainNameStringUnder250Bytes } from '@bsv/sdk'
14
+ import { PeerMessage, PaymentRequestMessage, PaymentRequestResponse, IncomingPaymentRequest, PaymentRequestLimits, DEFAULT_PAYMENT_REQUEST_MIN_AMOUNT, DEFAULT_PAYMENT_REQUEST_MAX_AMOUNT } from './types.js'
15
+ import { WalletInterface, AtomicBEEF, AuthFetch, Base64String, OriginatorDomainNameStringUnder250Bytes, Brc29RemittanceModule, createNonce } from '@bsv/sdk'
16
16
 
17
17
  import * as Logger from './Utils/logger.js'
18
18
 
19
- function safeParse<T> (input: any): T {
19
+ function toNumberArray (tx: AtomicBEEF): number[] {
20
+ return Array.isArray(tx) ? tx : Array.from(tx)
21
+ }
22
+
23
+ function hexToBytes (hex: string): number[] {
24
+ const matches = hex.match(/.{1,2}/g)
25
+ return matches != null ? matches.map(byte => parseInt(byte, 16)) : []
26
+ }
27
+
28
+ function safeParse<T> (input: any): T | undefined {
20
29
  try {
21
30
  return typeof input === 'string' ? JSON.parse(input) : input
22
31
  } catch (e) {
23
32
  Logger.error('[PP CLIENT] Failed to parse input in safeParse:', input)
24
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
25
- const fallback = {} as T
26
- return fallback
33
+ return undefined
27
34
  }
28
35
  }
29
36
 
37
+ /**
38
+ * Validates that a parsed object has the required fields for a PaymentRequestMessage.
39
+ * Returns true for both new requests (has amount, description, expiresAt) and cancellations (has cancelled: true).
40
+ */
41
+ function isValidPaymentRequestMessage (obj: any): obj is PaymentRequestMessage {
42
+ if (typeof obj !== 'object' || obj === null) return false
43
+ if (typeof obj.requestId !== 'string') return false
44
+ if (typeof obj.senderIdentityKey !== 'string') return false
45
+ if (typeof obj.requestProof !== 'string') return false
46
+ if (obj.cancelled === true) return true
47
+ return typeof obj.amount === 'number' && typeof obj.description === 'string' && typeof obj.expiresAt === 'number'
48
+ }
49
+
30
50
  export const STANDARD_PAYMENT_MESSAGEBOX = 'payment_inbox'
51
+ export const PAYMENT_REQUESTS_MESSAGEBOX = 'payment_requests'
52
+ export const PAYMENT_REQUEST_RESPONSES_MESSAGEBOX = 'payment_request_responses'
31
53
  const STANDARD_PAYMENT_OUTPUT_INDEX = 0
32
54
 
33
55
  /**
@@ -79,6 +101,7 @@ export class PeerPayClient extends MessageBoxClient {
79
101
  private readonly peerPayWalletClient: WalletInterface
80
102
  private _authFetchInstance?: AuthFetch
81
103
  private readonly messageBox: string
104
+ private readonly settlementModule: Brc29RemittanceModule
82
105
 
83
106
  constructor (config: PeerPayClientConfig) {
84
107
  const { messageBoxHost = 'https://messagebox.babbage.systems', walletClient, enableLogging = false, originator } = config
@@ -89,6 +112,16 @@ export class PeerPayClient extends MessageBoxClient {
89
112
  this.messageBox = config.messageBox ?? STANDARD_PAYMENT_MESSAGEBOX
90
113
  this.peerPayWalletClient = walletClient
91
114
  this.originator = originator
115
+
116
+ this.settlementModule = new Brc29RemittanceModule({
117
+ protocolID: [2, '3241645161d8'],
118
+ labels: ['peerpay'],
119
+ description: 'PeerPay payment',
120
+ outputDescription: 'Payment for PeerPay transaction',
121
+ internalizeProtocol: 'wallet payment',
122
+ refundFeeSatoshis: 1000,
123
+ minRefundSatoshis: 1000
124
+ })
92
125
  }
93
126
 
94
127
  private get authFetchInstance (): AuthFetch {
@@ -98,6 +131,59 @@ export class PeerPayClient extends MessageBoxClient {
98
131
  return this._authFetchInstance
99
132
  }
100
133
 
134
+ /**
135
+ * Allows payment requests from a specific identity key by setting
136
+ * the recipientFee to 0 for the payment_requests message box.
137
+ *
138
+ * @param {Object} params - Parameters.
139
+ * @param {string} params.identityKey - The identity key to allow payment requests from.
140
+ * @returns {Promise<void>} Resolves when the permission is set.
141
+ */
142
+ async allowPaymentRequestsFrom ({ identityKey }: { identityKey: string }): Promise<void> {
143
+ await this.setMessageBoxPermission({
144
+ messageBox: PAYMENT_REQUESTS_MESSAGEBOX,
145
+ sender: identityKey,
146
+ recipientFee: 0
147
+ })
148
+ }
149
+
150
+ /**
151
+ * Blocks payment requests from a specific identity key by setting
152
+ * the recipientFee to -1 for the payment_requests message box.
153
+ *
154
+ * @param {Object} params - Parameters.
155
+ * @param {string} params.identityKey - The identity key to block payment requests from.
156
+ * @returns {Promise<void>} Resolves when the permission is set.
157
+ */
158
+ async blockPaymentRequestsFrom ({ identityKey }: { identityKey: string }): Promise<void> {
159
+ await this.setMessageBoxPermission({
160
+ messageBox: PAYMENT_REQUESTS_MESSAGEBOX,
161
+ sender: identityKey,
162
+ recipientFee: -1
163
+ })
164
+ }
165
+
166
+ /**
167
+ * Lists all permissions for the payment_requests message box, mapped to
168
+ * a simplified { identityKey, allowed } structure.
169
+ *
170
+ * A permission is considered "allowed" if recipientFee >= 0 (0 = always allow,
171
+ * positive = payment required). A recipientFee of -1 means blocked.
172
+ *
173
+ * @returns {Promise<Array<{ identityKey: string, allowed: boolean }>>} Resolved with the list of permissions.
174
+ */
175
+ async listPaymentRequestPermissions (): Promise<Array<{ identityKey: string, allowed: boolean }>> {
176
+ const permissions = await this.listMessageBoxPermissions({ messageBox: PAYMENT_REQUESTS_MESSAGEBOX })
177
+ // Filter to only per-sender entries (sender is not null/empty).
178
+ // Use the status field returned by the server to determine allowed state.
179
+ return permissions
180
+ .filter(p => p.sender != null && p.sender !== '')
181
+ .map(p => ({
182
+ identityKey: p.sender ?? '',
183
+ allowed: p.status !== 'blocked'
184
+ }))
185
+ }
186
+
101
187
  /**
102
188
  * Generates a valid payment token for a recipient.
103
189
  *
@@ -115,62 +201,40 @@ export class PeerPayClient extends MessageBoxClient {
115
201
  throw new Error('Invalid payment details: recipient and valid amount are required')
116
202
  };
117
203
 
118
- // Generate derivation paths using correct nonce function
119
- const derivationPrefix = await createNonce(this.peerPayWalletClient, 'self', this.originator)
120
- const derivationSuffix = await createNonce(this.peerPayWalletClient, 'self', this.originator)
121
-
122
- Logger.log(`[PP CLIENT] Derivation Prefix: ${derivationPrefix}`)
123
- Logger.log(`[PP CLIENT] Derivation Suffix: ${derivationSuffix}`)
124
- // Get recipient's derived public key
125
- const { publicKey: derivedKeyResult } = await this.peerPayWalletClient.getPublicKey({
126
- protocolID: [2, '3241645161d8'],
127
- keyID: `${derivationPrefix} ${derivationSuffix}`,
128
- counterparty: payment.recipient
129
- }, this.originator)
130
-
131
- Logger.log(`[PP CLIENT] Derived Public Key: ${derivedKeyResult}`)
132
-
133
- if (derivedKeyResult == null || derivedKeyResult.trim() === '') {
134
- throw new Error('Failed to derive recipient’s public key')
135
- }
136
-
137
- // Create locking script using recipient's public key
138
- const lockingScript = new P2PKH().lock(PublicKey.fromString(derivedKeyResult).toAddress()).toHex()
139
-
140
- Logger.log(`[PP CLIENT] Locking Script: ${lockingScript}`)
141
-
142
- // Create the payment action
143
- const paymentAction = await this.peerPayWalletClient.createAction({
144
- description: 'PeerPay payment',
145
- labels: ['peerpay'],
146
- outputs: [{
147
- satoshis: payment.amount,
148
- lockingScript,
149
- customInstructions: JSON.stringify({
150
- derivationPrefix,
151
- derivationSuffix,
152
- payee: payment.recipient
153
- }),
154
- outputDescription: 'Payment for PeerPay transaction'
155
- }],
156
- options: {
157
- randomizeOutputs: false
204
+ const result = await this.settlementModule.buildSettlement(
205
+ {
206
+ threadId: 'peerpay',
207
+ option: {
208
+ amountSatoshis: payment.amount,
209
+ payee: payment.recipient,
210
+ labels: ['peerpay'],
211
+ description: 'PeerPay payment'
212
+ }
213
+ },
214
+ {
215
+ wallet: this.peerPayWalletClient,
216
+ originator: this.originator,
217
+ now: () => Date.now(),
218
+ logger: Logger
158
219
  }
159
- }, this.originator)
220
+ )
160
221
 
161
- if (paymentAction.tx === undefined) {
162
- throw new Error('Transaction creation failed!')
222
+ if (result.action === 'terminate') {
223
+ if (result.termination.code === 'brc29.public_key_missing') {
224
+ throw new Error('Failed to derive recipient’s public key')
225
+ }
226
+ throw new Error(result.termination.message)
163
227
  }
164
228
 
165
- Logger.log('[PP CLIENT] Payment Action:', paymentAction)
229
+ Logger.log('[PP CLIENT] Payment Action Settlement Artifact:', result.artifact)
166
230
 
167
231
  return {
168
232
  customInstructions: {
169
- derivationPrefix,
170
- derivationSuffix
233
+ derivationPrefix: result.artifact.customInstructions.derivationPrefix,
234
+ derivationSuffix: result.artifact.customInstructions.derivationSuffix
171
235
  },
172
- transaction: paymentAction.tx,
173
- amount: payment.amount
236
+ transaction: result.artifact.transaction as AtomicBEEF,
237
+ amount: result.artifact.amountSatoshis
174
238
  }
175
239
  }
176
240
 
@@ -264,10 +328,12 @@ export class PeerPayClient extends MessageBoxClient {
264
328
  // Convert PeerMessage → IncomingPayment before calling onPayment
265
329
  onMessage: (message: PeerMessage) => {
266
330
  Logger.log('[MB CLIENT] Received Live Payment:', message)
331
+ const token = safeParse<PaymentToken>(message.body)
332
+ if (token == null) return
267
333
  const incomingPayment: IncomingPayment = {
268
334
  messageId: message.messageId,
269
335
  sender: message.sender,
270
- token: safeParse<PaymentToken>(message.body)
336
+ token
271
337
  }
272
338
  Logger.log('[PP CLIENT] Converted PeerMessage to IncomingPayment:', incomingPayment)
273
339
  onPayment(incomingPayment)
@@ -290,20 +356,33 @@ export class PeerPayClient extends MessageBoxClient {
290
356
  try {
291
357
  Logger.log(`[PP CLIENT] Processing payment: ${JSON.stringify(payment, null, 2)}`)
292
358
 
293
- const paymentResult = await this.peerPayWalletClient.internalizeAction({
294
- tx: payment.token.transaction,
295
- outputs: [{
296
- paymentRemittance: {
297
- derivationPrefix: payment.token.customInstructions.derivationPrefix,
298
- derivationSuffix: payment.token.customInstructions.derivationSuffix,
299
- senderIdentityKey: payment.sender
300
- },
301
- outputIndex: payment.token.outputIndex ?? STANDARD_PAYMENT_OUTPUT_INDEX,
302
- protocol: 'wallet payment'
303
- }],
304
- labels: ['peerpay'],
305
- description: 'PeerPay Payment'
306
- }, this.originator)
359
+ const acceptResult = await this.settlementModule.acceptSettlement(
360
+ {
361
+ threadId: 'peerpay',
362
+ sender: payment.sender,
363
+ settlement: {
364
+ customInstructions: {
365
+ derivationPrefix: payment.token.customInstructions.derivationPrefix,
366
+ derivationSuffix: payment.token.customInstructions.derivationSuffix
367
+ },
368
+ transaction: toNumberArray(payment.token.transaction),
369
+ amountSatoshis: payment.token.amount,
370
+ outputIndex: payment.token.outputIndex ?? STANDARD_PAYMENT_OUTPUT_INDEX
371
+ }
372
+ },
373
+ {
374
+ wallet: this.peerPayWalletClient,
375
+ originator: this.originator,
376
+ now: () => Date.now(),
377
+ logger: Logger
378
+ }
379
+ )
380
+
381
+ if (acceptResult.action === 'terminate') {
382
+ throw new Error(acceptResult.termination.message)
383
+ }
384
+
385
+ const paymentResult = acceptResult.receiptData?.internalizeResult
307
386
 
308
387
  Logger.log(`[PP CLIENT] Payment internalized successfully: ${JSON.stringify(paymentResult, null, 2)}`)
309
388
  Logger.log(`[PP CLIENT] Acknowledging payment with messageId: ${payment.messageId}`)
@@ -392,12 +471,390 @@ export class PeerPayClient extends MessageBoxClient {
392
471
  const messages = await this.listMessages({ messageBox: this.messageBox, host: overrideHost })
393
472
  return messages.map((msg: any) => {
394
473
  const parsedToken = safeParse<PaymentToken>(msg.body)
474
+ if (parsedToken == null) return null
395
475
 
396
476
  return {
397
477
  messageId: msg.messageId,
398
478
  sender: msg.sender,
399
479
  token: parsedToken
400
480
  }
481
+ }).filter((p): p is IncomingPayment => p != null)
482
+ }
483
+
484
+ /**
485
+ * Lists all responses to payment requests from the payment_request_responses message box.
486
+ *
487
+ * Retrieves messages and parses each as a PaymentRequestResponse.
488
+ *
489
+ * @param {string} [hostOverride] - Optional host override for the message box server.
490
+ * @returns {Promise<PaymentRequestResponse[]>} Resolves with an array of payment request responses.
491
+ */
492
+ async listPaymentRequestResponses (hostOverride?: string): Promise<PaymentRequestResponse[]> {
493
+ const messages = await this.listMessages({ messageBox: PAYMENT_REQUEST_RESPONSES_MESSAGEBOX, host: hostOverride })
494
+ return messages.map((msg: any) => safeParse<PaymentRequestResponse>(msg.body))
495
+ .filter((r): r is PaymentRequestResponse => r != null)
496
+ }
497
+
498
+ /**
499
+ * Listens for incoming payment requests in real time via WebSocket.
500
+ *
501
+ * Wraps listenForLiveMessages on the payment_requests box and converts each
502
+ * incoming PeerMessage into an IncomingPaymentRequest before calling onRequest.
503
+ *
504
+ * @param {Object} params - Listener configuration.
505
+ * @param {Function} params.onRequest - Callback invoked when a new payment request arrives.
506
+ * @param {string} [params.overrideHost] - Optional host override for the WebSocket connection.
507
+ * @returns {Promise<void>} Resolves when the listener is established.
508
+ */
509
+ async listenForLivePaymentRequests ({
510
+ onRequest,
511
+ overrideHost
512
+ }: {
513
+ onRequest: (request: IncomingPaymentRequest) => void
514
+ overrideHost?: string
515
+ }): Promise<void> {
516
+ await this.listenForLiveMessages({
517
+ messageBox: PAYMENT_REQUESTS_MESSAGEBOX,
518
+ overrideHost,
519
+ onMessage: (message: PeerMessage) => {
520
+ const body = safeParse<PaymentRequestMessage>(message.body)
521
+ if (body == null || body.cancelled === true) return // Skip cancellations and parse failures
522
+ const request: IncomingPaymentRequest = {
523
+ messageId: message.messageId,
524
+ sender: message.sender,
525
+ requestId: body.requestId,
526
+ amount: body.amount,
527
+ description: body.description,
528
+ expiresAt: body.expiresAt
529
+ }
530
+ onRequest(request)
531
+ }
401
532
  })
402
533
  }
534
+
535
+ /**
536
+ * Listens for payment request responses in real time via WebSocket.
537
+ *
538
+ * Wraps listenForLiveMessages on the payment_request_responses box and converts each
539
+ * incoming PeerMessage into a PaymentRequestResponse before calling onResponse.
540
+ *
541
+ * @param {Object} params - Listener configuration.
542
+ * @param {Function} params.onResponse - Callback invoked when a new response arrives.
543
+ * @param {string} [params.overrideHost] - Optional host override for the WebSocket connection.
544
+ * @returns {Promise<void>} Resolves when the listener is established.
545
+ */
546
+ async listenForLivePaymentRequestResponses ({
547
+ onResponse,
548
+ overrideHost
549
+ }: {
550
+ onResponse: (response: PaymentRequestResponse) => void
551
+ overrideHost?: string
552
+ }): Promise<void> {
553
+ await this.listenForLiveMessages({
554
+ messageBox: PAYMENT_REQUEST_RESPONSES_MESSAGEBOX,
555
+ overrideHost,
556
+ onMessage: (message: PeerMessage) => {
557
+ const response = safeParse<PaymentRequestResponse>(message.body)
558
+ if (response == null) return
559
+ onResponse(response)
560
+ }
561
+ })
562
+ }
563
+
564
+ /**
565
+ * Fulfills an incoming payment request by sending the requested payment and
566
+ * notifying the requester with a 'paid' response in the payment_request_responses box.
567
+ * Also acknowledges the original request message.
568
+ *
569
+ * @param {Object} params - Fulfillment parameters.
570
+ * @param {IncomingPaymentRequest} params.request - The incoming payment request to fulfill.
571
+ * @param {string} [params.note] - Optional note to include in the response.
572
+ * @param {string} [hostOverride] - Optional host override for the message box server.
573
+ * @returns {Promise<void>} Resolves when payment is sent and acknowledgment is complete.
574
+ */
575
+ async fulfillPaymentRequest (
576
+ params: { request: IncomingPaymentRequest, note?: string },
577
+ hostOverride?: string
578
+ ): Promise<void> {
579
+ const { request, note } = params
580
+
581
+ await this.sendPayment({ recipient: request.sender, amount: request.amount }, hostOverride)
582
+
583
+ const response: PaymentRequestResponse = {
584
+ requestId: request.requestId,
585
+ status: 'paid',
586
+ amountPaid: request.amount,
587
+ ...(note != null && { note })
588
+ }
589
+
590
+ await this.sendMessage({
591
+ recipient: request.sender,
592
+ messageBox: PAYMENT_REQUEST_RESPONSES_MESSAGEBOX,
593
+ body: JSON.stringify(response)
594
+ }, hostOverride)
595
+
596
+ await this.acknowledgeMessage({ messageIds: [request.messageId], host: hostOverride })
597
+ }
598
+
599
+ /**
600
+ * Declines an incoming payment request by notifying the requester with a 'declined'
601
+ * response in the payment_request_responses box and acknowledging the original request.
602
+ *
603
+ * @param {Object} params - Decline parameters.
604
+ * @param {IncomingPaymentRequest} params.request - The incoming payment request to decline.
605
+ * @param {string} [params.note] - Optional note explaining why the request was declined.
606
+ * @param {string} [hostOverride] - Optional host override for the message box server.
607
+ * @returns {Promise<void>} Resolves when the response is sent and request is acknowledged.
608
+ */
609
+ async declinePaymentRequest (
610
+ params: { request: IncomingPaymentRequest, note?: string },
611
+ hostOverride?: string
612
+ ): Promise<void> {
613
+ const { request, note } = params
614
+
615
+ const response: PaymentRequestResponse = {
616
+ requestId: request.requestId,
617
+ status: 'declined',
618
+ ...(note != null && { note })
619
+ }
620
+
621
+ await this.sendMessage({
622
+ recipient: request.sender,
623
+ messageBox: PAYMENT_REQUEST_RESPONSES_MESSAGEBOX,
624
+ body: JSON.stringify(response)
625
+ }, hostOverride)
626
+
627
+ await this.acknowledgeMessage({ messageIds: [request.messageId], host: hostOverride })
628
+ }
629
+
630
+ /**
631
+ * Sends a payment request to a recipient via the payment_requests message box.
632
+ *
633
+ * Generates a unique requestId using createNonce, looks up the caller's identity key,
634
+ * and sends a PaymentRequestMessage to the recipient.
635
+ *
636
+ * @param {Object} params - Payment request parameters.
637
+ * @param {string} params.recipient - The identity key of the intended payer.
638
+ * @param {number} params.amount - The amount in satoshis being requested (must be > 0).
639
+ * @param {string} params.description - Human-readable reason for the payment request.
640
+ * @param {number} params.expiresAt - Unix timestamp (ms) when the request expires.
641
+ * @param {string} [hostOverride] - Optional host override for the message box server.
642
+ * @returns {Promise<{ requestId: string }>} The generated requestId for this request.
643
+ * @throws {Error} If amount is <= 0.
644
+ */
645
+ async requestPayment (
646
+ params: { recipient: string, amount: number, description: string, expiresAt: number },
647
+ hostOverride?: string
648
+ ): Promise<{ requestId: string, requestProof: string }> {
649
+ if (params.amount <= 0) {
650
+ throw new Error('Invalid payment request: amount must be greater than 0')
651
+ }
652
+
653
+ const requestId = await createNonce(this.peerPayWalletClient, 'self', this.originator)
654
+ const senderIdentityKey = await this.getIdentityKey()
655
+
656
+ const proofData = Array.from(new TextEncoder().encode(requestId + params.recipient))
657
+ const { hmac } = await this.peerPayWalletClient.createHmac({
658
+ data: proofData,
659
+ protocolID: [2, 'payment request auth'],
660
+ keyID: requestId,
661
+ counterparty: params.recipient
662
+ }, this.originator)
663
+ const requestProof = Array.from(hmac).map(b => b.toString(16).padStart(2, '0')).join('')
664
+
665
+ const body: PaymentRequestMessage = {
666
+ requestId,
667
+ amount: params.amount,
668
+ description: params.description,
669
+ expiresAt: params.expiresAt,
670
+ senderIdentityKey,
671
+ requestProof
672
+ }
673
+
674
+ try {
675
+ await this.sendMessage({
676
+ recipient: params.recipient,
677
+ messageBox: PAYMENT_REQUESTS_MESSAGEBOX,
678
+ body: JSON.stringify(body)
679
+ }, hostOverride)
680
+ } catch (err: any) {
681
+ // Translate HTTP 403 (permission denied) into a user-friendly message.
682
+ if (typeof err?.message === 'string' && err.message.includes('403')) {
683
+ throw new Error('Payment request blocked — you are not on the recipient\'s whitelist.')
684
+ }
685
+ throw err
686
+ }
687
+
688
+ return { requestId, requestProof }
689
+ }
690
+
691
+ /**
692
+ * Lists all incoming payment requests from the payment_requests message box.
693
+ *
694
+ * Automatically filters out:
695
+ * - Expired requests (expiresAt < now), which are acknowledged and discarded.
696
+ * - Cancelled requests (a cancellation message with the same requestId exists),
697
+ * both the original and cancellation messages are acknowledged and discarded.
698
+ * - Out-of-range requests (when limits are provided), which are acknowledged and discarded.
699
+ *
700
+ * @param {string} [hostOverride] - Optional host override for the message box server.
701
+ * @param {PaymentRequestLimits} [limits] - Optional min/max satoshi limits for filtering.
702
+ * @returns {Promise<IncomingPaymentRequest[]>} Resolves with active, valid payment requests.
703
+ */
704
+ async listIncomingPaymentRequests (
705
+ hostOverride?: string,
706
+ limits?: PaymentRequestLimits
707
+ ): Promise<IncomingPaymentRequest[]> {
708
+ const messages = await this.listMessages({ messageBox: PAYMENT_REQUESTS_MESSAGEBOX, host: hostOverride })
709
+ const myIdentityKey = await this.getIdentityKey()
710
+ const now = Date.now()
711
+
712
+ // Parse and validate all messages, collecting malformed ones for ack
713
+ const malformedMessageIds: string[] = []
714
+ const parsed: Array<{ messageId: string, sender: string, body: PaymentRequestMessage }> = []
715
+
716
+ for (const msg of messages) {
717
+ const body = safeParse<PaymentRequestMessage>(msg.body)
718
+ if (body != null && isValidPaymentRequestMessage(body)) {
719
+ parsed.push({ messageId: msg.messageId as string, sender: msg.sender as string, body })
720
+ } else {
721
+ malformedMessageIds.push(msg.messageId as string)
722
+ }
723
+ }
724
+
725
+ // Collect cancelled requestIds — verify HMAC proof before accepting
726
+ const cancelledRequests = new Map<string, string>() // requestId → sender
727
+ const cancelMessageIds: string[] = []
728
+ for (const item of parsed) {
729
+ if (item.body.cancelled === true) {
730
+ // Verify cancellation HMAC proof
731
+ try {
732
+ const proofData = Array.from(new TextEncoder().encode(item.body.requestId + myIdentityKey))
733
+ await this.peerPayWalletClient.verifyHmac({
734
+ data: proofData,
735
+ hmac: hexToBytes(item.body.requestProof),
736
+ protocolID: [2, 'payment request auth'],
737
+ keyID: item.body.requestId,
738
+ counterparty: item.sender
739
+ }, this.originator)
740
+ cancelledRequests.set(item.body.requestId, item.sender)
741
+ cancelMessageIds.push(item.messageId)
742
+ } catch {
743
+ Logger.warn(`[PP CLIENT] Invalid cancellation proof for requestId=${item.body.requestId}, discarding`)
744
+ malformedMessageIds.push(item.messageId)
745
+ }
746
+ continue
747
+ }
748
+ }
749
+
750
+ const expiredMessageIds: string[] = []
751
+ const outOfRangeMessageIds: string[] = []
752
+ const cancelledOriginalMessageIds: string[] = []
753
+ const active: IncomingPaymentRequest[] = []
754
+
755
+ for (const item of parsed) {
756
+ // Skip cancellation messages themselves (already collected above)
757
+ if (item.body.cancelled === true) continue
758
+
759
+ const { requestId, amount, description, expiresAt } = item.body
760
+
761
+ // Filter expired
762
+ if (expiresAt < now) {
763
+ expiredMessageIds.push(item.messageId)
764
+ continue
765
+ }
766
+
767
+ // Filter cancelled originals — only if cancellation came from the same sender
768
+ if (cancelledRequests.has(requestId) && cancelledRequests.get(requestId) === item.sender) {
769
+ cancelledOriginalMessageIds.push(item.messageId)
770
+ continue
771
+ }
772
+
773
+ // Filter out-of-range — apply defaults for any missing limit fields
774
+ const effectiveMin = limits?.minAmount ?? DEFAULT_PAYMENT_REQUEST_MIN_AMOUNT
775
+ const effectiveMax = limits?.maxAmount ?? DEFAULT_PAYMENT_REQUEST_MAX_AMOUNT
776
+ if (amount < effectiveMin || amount > effectiveMax) {
777
+ outOfRangeMessageIds.push(item.messageId)
778
+ continue
779
+ }
780
+
781
+ // Verify HMAC proof — ensures message came from claimed sender
782
+ try {
783
+ const proofData = Array.from(new TextEncoder().encode(requestId + myIdentityKey))
784
+ await this.peerPayWalletClient.verifyHmac({
785
+ data: proofData,
786
+ hmac: hexToBytes(item.body.requestProof),
787
+ protocolID: [2, 'payment request auth'],
788
+ keyID: requestId,
789
+ counterparty: item.sender
790
+ }, this.originator)
791
+ } catch {
792
+ Logger.warn(`[PP CLIENT] Invalid requestProof for requestId=${requestId}, discarding`)
793
+ malformedMessageIds.push(item.messageId)
794
+ continue
795
+ }
796
+
797
+ active.push({
798
+ messageId: item.messageId,
799
+ sender: item.sender,
800
+ requestId,
801
+ amount,
802
+ description,
803
+ expiresAt
804
+ })
805
+ }
806
+
807
+ // Acknowledge expired
808
+ if (expiredMessageIds.length > 0) {
809
+ await this.acknowledgeMessage({ messageIds: expiredMessageIds, host: hostOverride })
810
+ }
811
+
812
+ // Acknowledge cancelled originals + cancel messages together
813
+ const cancelAckIds = [...cancelledOriginalMessageIds, ...cancelMessageIds]
814
+ if (cancelAckIds.length > 0) {
815
+ await this.acknowledgeMessage({ messageIds: cancelAckIds, host: hostOverride })
816
+ }
817
+
818
+ // Acknowledge out-of-range
819
+ if (outOfRangeMessageIds.length > 0) {
820
+ await this.acknowledgeMessage({ messageIds: outOfRangeMessageIds, host: hostOverride })
821
+ }
822
+
823
+ // Acknowledge malformed messages so they don't reappear
824
+ if (malformedMessageIds.length > 0) {
825
+ await this.acknowledgeMessage({ messageIds: malformedMessageIds, host: hostOverride })
826
+ }
827
+
828
+ return active
829
+ }
830
+
831
+ /**
832
+ * Cancels a previously sent payment request by sending a cancellation message
833
+ * with the same requestId and `cancelled: true`.
834
+ *
835
+ * @param {Object} params - Cancellation parameters.
836
+ * @param {string} params.recipient - The identity key of the recipient of the original request.
837
+ * @param {string} params.requestId - The requestId of the payment request to cancel.
838
+ * @param {string} [hostOverride] - Optional host override for the message box server.
839
+ * @returns {Promise<void>} Resolves when the cancellation message has been sent.
840
+ */
841
+ async cancelPaymentRequest (
842
+ params: { recipient: string, requestId: string, requestProof: string },
843
+ hostOverride?: string
844
+ ): Promise<void> {
845
+ const senderIdentityKey = await this.getIdentityKey()
846
+
847
+ const body: PaymentRequestMessage = {
848
+ requestId: params.requestId,
849
+ senderIdentityKey,
850
+ requestProof: params.requestProof,
851
+ cancelled: true
852
+ }
853
+
854
+ await this.sendMessage({
855
+ recipient: params.recipient,
856
+ messageBox: PAYMENT_REQUESTS_MESSAGEBOX,
857
+ body: JSON.stringify(body)
858
+ }, hostOverride)
859
+ }
403
860
  }