@bsv/message-box-client 2.0.6 → 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 +1 -1
  2. package/dist/cjs/src/PeerPayClient.js +405 -5
  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 +401 -5
  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 +159 -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 +1 -1
  29. package/src/PeerPayClient.ts +460 -9
  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
@@ -36,7 +36,8 @@ jest.mock('@bsv/sdk', () => {
36
36
  internalizeAction: jest.fn(),
37
37
  createHmac: jest.fn<() => Promise<CreateHmacResult>>().mockResolvedValue({
38
38
  hmac: [1, 2, 3, 4, 5]
39
- })
39
+ }),
40
+ verifyHmac: jest.fn<() => Promise<{ valid: true }>>().mockResolvedValue({ valid: true as const })
40
41
  }))
41
42
  }
42
43
  })
@@ -242,4 +243,596 @@ describe('PeerPayClient Unit Tests', () => {
242
243
  expect(payments[1].token.amount).toBe(9)
243
244
  })
244
245
  })
246
+
247
+ // Test: listIncomingPaymentRequests
248
+ describe('listIncomingPaymentRequests', () => {
249
+ const futureExpiry = Date.now() + 60000
250
+ const pastExpiry = Date.now() - 60000
251
+
252
+ it('returns parsed request messages from payment_requests box', async () => {
253
+ jest.spyOn(peerPayClient, 'listMessages').mockResolvedValue([
254
+ {
255
+ messageId: 'msg1',
256
+ sender: 'sender1',
257
+ created_at: '2025-01-01T00:00:00Z',
258
+ updated_at: '2025-01-01T00:00:00Z',
259
+ body: JSON.stringify({
260
+ requestId: 'req1',
261
+ amount: 5000,
262
+ description: 'Test request',
263
+ expiresAt: futureExpiry,
264
+ senderIdentityKey: 'sender1',
265
+ requestProof: 'abcd1234'
266
+ })
267
+ }
268
+ ])
269
+ jest.spyOn(peerPayClient, 'acknowledgeMessage').mockResolvedValue('ok')
270
+
271
+ const requests = await peerPayClient.listIncomingPaymentRequests()
272
+
273
+ expect(requests).toHaveLength(1)
274
+ expect(requests[0]).toMatchObject({
275
+ messageId: 'msg1',
276
+ sender: 'sender1',
277
+ requestId: 'req1',
278
+ amount: 5000,
279
+ description: 'Test request'
280
+ })
281
+ })
282
+
283
+ it('filters expired requests and acknowledges them', async () => {
284
+ jest.spyOn(peerPayClient, 'listMessages').mockResolvedValue([
285
+ {
286
+ messageId: 'expired-msg',
287
+ sender: 'sender1',
288
+ created_at: '2025-01-01T00:00:00Z',
289
+ updated_at: '2025-01-01T00:00:00Z',
290
+ body: JSON.stringify({
291
+ requestId: 'req-expired',
292
+ amount: 5000,
293
+ description: 'Expired request',
294
+ expiresAt: pastExpiry,
295
+ senderIdentityKey: 'sender1',
296
+ requestProof: 'abcd1234'
297
+ })
298
+ },
299
+ {
300
+ messageId: 'active-msg',
301
+ sender: 'sender2',
302
+ created_at: '2025-01-01T00:00:00Z',
303
+ updated_at: '2025-01-01T00:00:00Z',
304
+ body: JSON.stringify({
305
+ requestId: 'req-active',
306
+ amount: 3000,
307
+ description: 'Active request',
308
+ expiresAt: futureExpiry,
309
+ senderIdentityKey: 'sender2',
310
+ requestProof: 'abcd1234'
311
+ })
312
+ }
313
+ ])
314
+ const ackSpy = jest.spyOn(peerPayClient, 'acknowledgeMessage').mockResolvedValue('ok')
315
+
316
+ const requests = await peerPayClient.listIncomingPaymentRequests()
317
+
318
+ expect(requests).toHaveLength(1)
319
+ expect(requests[0].requestId).toBe('req-active')
320
+ expect(ackSpy).toHaveBeenCalledWith({ messageIds: ['expired-msg'] })
321
+ })
322
+
323
+ it('filters cancelled requests and acknowledges both original and cancel messages', async () => {
324
+ jest.spyOn(peerPayClient, 'listMessages').mockResolvedValue([
325
+ {
326
+ messageId: 'original-msg',
327
+ sender: 'sender1',
328
+ created_at: '2025-01-01T00:00:00Z',
329
+ updated_at: '2025-01-01T00:00:00Z',
330
+ body: JSON.stringify({
331
+ requestId: 'req-cancel',
332
+ amount: 5000,
333
+ description: 'To be cancelled',
334
+ expiresAt: futureExpiry,
335
+ senderIdentityKey: 'sender1',
336
+ requestProof: 'abcd1234'
337
+ })
338
+ },
339
+ {
340
+ messageId: 'cancel-msg',
341
+ sender: 'sender1',
342
+ created_at: '2025-01-01T00:01:00Z',
343
+ updated_at: '2025-01-01T00:01:00Z',
344
+ body: JSON.stringify({
345
+ requestId: 'req-cancel',
346
+ senderIdentityKey: 'sender1',
347
+ cancelled: true,
348
+ requestProof: 'abcd1234'
349
+ })
350
+ },
351
+ {
352
+ messageId: 'other-msg',
353
+ sender: 'sender2',
354
+ created_at: '2025-01-01T00:00:00Z',
355
+ updated_at: '2025-01-01T00:00:00Z',
356
+ body: JSON.stringify({
357
+ requestId: 'req-other',
358
+ amount: 2000,
359
+ description: 'Other request',
360
+ expiresAt: futureExpiry,
361
+ senderIdentityKey: 'sender2',
362
+ requestProof: 'abcd1234'
363
+ })
364
+ }
365
+ ])
366
+ const ackSpy = jest.spyOn(peerPayClient, 'acknowledgeMessage').mockResolvedValue('ok')
367
+
368
+ const requests = await peerPayClient.listIncomingPaymentRequests()
369
+
370
+ expect(requests).toHaveLength(1)
371
+ expect(requests[0].requestId).toBe('req-other')
372
+ expect(ackSpy).toHaveBeenCalledWith({ messageIds: expect.arrayContaining(['original-msg', 'cancel-msg']) })
373
+ })
374
+
375
+ it('discards malformed messages (invalid JSON) and acknowledges them', async () => {
376
+ jest.spyOn(peerPayClient, 'listMessages').mockResolvedValue([
377
+ {
378
+ messageId: 'bad-msg',
379
+ sender: 'sender1',
380
+ created_at: '2025-01-01T00:00:00Z',
381
+ updated_at: '2025-01-01T00:00:00Z',
382
+ body: 'NOT VALID JSON {{{}'
383
+ },
384
+ {
385
+ messageId: 'good-msg',
386
+ sender: 'sender2',
387
+ created_at: '2025-01-01T00:00:00Z',
388
+ updated_at: '2025-01-01T00:00:00Z',
389
+ body: JSON.stringify({
390
+ requestId: 'req-good',
391
+ amount: 5000,
392
+ description: 'Valid request',
393
+ expiresAt: Date.now() + 60000,
394
+ senderIdentityKey: 'sender2',
395
+ requestProof: 'abcd1234'
396
+ })
397
+ }
398
+ ])
399
+ const ackSpy = jest.spyOn(peerPayClient, 'acknowledgeMessage').mockResolvedValue('ok')
400
+
401
+ const requests = await peerPayClient.listIncomingPaymentRequests()
402
+
403
+ expect(requests).toHaveLength(1)
404
+ expect(requests[0].requestId).toBe('req-good')
405
+ expect(ackSpy).toHaveBeenCalledWith(expect.objectContaining({
406
+ messageIds: expect.arrayContaining(['bad-msg'])
407
+ }))
408
+ })
409
+
410
+ it('discards messages with missing required fields and acknowledges them', async () => {
411
+ jest.spyOn(peerPayClient, 'listMessages').mockResolvedValue([
412
+ {
413
+ messageId: 'incomplete-msg',
414
+ sender: 'sender1',
415
+ created_at: '2025-01-01T00:00:00Z',
416
+ updated_at: '2025-01-01T00:00:00Z',
417
+ body: JSON.stringify({ requestId: 'req-incomplete' })
418
+ }
419
+ ])
420
+ const ackSpy = jest.spyOn(peerPayClient, 'acknowledgeMessage').mockResolvedValue('ok')
421
+
422
+ const requests = await peerPayClient.listIncomingPaymentRequests()
423
+
424
+ expect(requests).toHaveLength(0)
425
+ expect(ackSpy).toHaveBeenCalledWith(expect.objectContaining({
426
+ messageIds: expect.arrayContaining(['incomplete-msg'])
427
+ }))
428
+ })
429
+
430
+ it('only cancels requests from the same sender', async () => {
431
+ const futureExpiry = Date.now() + 60000
432
+ jest.spyOn(peerPayClient, 'listMessages').mockResolvedValue([
433
+ {
434
+ messageId: 'original-msg',
435
+ sender: 'sender1',
436
+ created_at: '2025-01-01T00:00:00Z',
437
+ updated_at: '2025-01-01T00:00:00Z',
438
+ body: JSON.stringify({
439
+ requestId: 'req-1',
440
+ amount: 5000,
441
+ description: 'Real request',
442
+ expiresAt: futureExpiry,
443
+ senderIdentityKey: 'sender1',
444
+ requestProof: 'abcd1234'
445
+ })
446
+ },
447
+ {
448
+ messageId: 'spoofed-cancel',
449
+ sender: 'attacker',
450
+ created_at: '2025-01-01T00:01:00Z',
451
+ updated_at: '2025-01-01T00:01:00Z',
452
+ body: JSON.stringify({
453
+ requestId: 'req-1',
454
+ senderIdentityKey: 'attacker',
455
+ cancelled: true,
456
+ requestProof: 'abcd1234'
457
+ })
458
+ }
459
+ ])
460
+ jest.spyOn(peerPayClient, 'acknowledgeMessage').mockResolvedValue('ok')
461
+
462
+ const requests = await peerPayClient.listIncomingPaymentRequests()
463
+
464
+ // The request should NOT be cancelled because the cancel came from a different sender
465
+ expect(requests).toHaveLength(1)
466
+ expect(requests[0].requestId).toBe('req-1')
467
+ })
468
+
469
+ it('filters out requests below minAmount and above maxAmount, acknowledges them', async () => {
470
+ jest.spyOn(peerPayClient, 'listMessages').mockResolvedValue([
471
+ {
472
+ messageId: 'too-small',
473
+ sender: 'sender1',
474
+ created_at: '2025-01-01T00:00:00Z',
475
+ updated_at: '2025-01-01T00:00:00Z',
476
+ body: JSON.stringify({
477
+ requestId: 'req-small',
478
+ amount: 100,
479
+ description: 'Too small',
480
+ expiresAt: futureExpiry,
481
+ senderIdentityKey: 'sender1',
482
+ requestProof: 'abcd1234'
483
+ })
484
+ },
485
+ {
486
+ messageId: 'too-large',
487
+ sender: 'sender2',
488
+ created_at: '2025-01-01T00:00:00Z',
489
+ updated_at: '2025-01-01T00:00:00Z',
490
+ body: JSON.stringify({
491
+ requestId: 'req-large',
492
+ amount: 99999,
493
+ description: 'Too large',
494
+ expiresAt: futureExpiry,
495
+ senderIdentityKey: 'sender2',
496
+ requestProof: 'abcd1234'
497
+ })
498
+ },
499
+ {
500
+ messageId: 'just-right',
501
+ sender: 'sender3',
502
+ created_at: '2025-01-01T00:00:00Z',
503
+ updated_at: '2025-01-01T00:00:00Z',
504
+ body: JSON.stringify({
505
+ requestId: 'req-ok',
506
+ amount: 5000,
507
+ description: 'Just right',
508
+ expiresAt: futureExpiry,
509
+ senderIdentityKey: 'sender3',
510
+ requestProof: 'abcd1234'
511
+ })
512
+ }
513
+ ])
514
+ const ackSpy = jest.spyOn(peerPayClient, 'acknowledgeMessage').mockResolvedValue('ok')
515
+
516
+ const requests = await peerPayClient.listIncomingPaymentRequests(undefined, { minAmount: 1000, maxAmount: 10000 })
517
+
518
+ expect(requests).toHaveLength(1)
519
+ expect(requests[0].requestId).toBe('req-ok')
520
+ expect(ackSpy).toHaveBeenCalledWith({ messageIds: expect.arrayContaining(['too-small', 'too-large']) })
521
+ })
522
+ })
523
+
524
+ // Test: fulfillPaymentRequest
525
+ describe('fulfillPaymentRequest', () => {
526
+ const mockRequest = {
527
+ messageId: 'req-msg-1',
528
+ sender: 'senderKey',
529
+ requestId: 'req-id-1',
530
+ amount: 5000,
531
+ description: 'Pay for goods',
532
+ expiresAt: Date.now() + 60000
533
+ }
534
+
535
+ it('sends payment for request.amount, sends paid response, acknowledges', async () => {
536
+ const sendPaymentSpy = jest.spyOn(peerPayClient, 'sendPayment').mockResolvedValue(undefined)
537
+ const sendMessageSpy = jest.spyOn(peerPayClient, 'sendMessage').mockResolvedValue({
538
+ status: 'success',
539
+ messageId: 'resp-msg-id'
540
+ })
541
+ const ackSpy = jest.spyOn(peerPayClient, 'acknowledgeMessage').mockResolvedValue('ok')
542
+
543
+ await peerPayClient.fulfillPaymentRequest({ request: mockRequest })
544
+
545
+ expect(sendPaymentSpy).toHaveBeenCalledWith(
546
+ { recipient: 'senderKey', amount: 5000 },
547
+ undefined
548
+ )
549
+
550
+ const responseBody = JSON.parse((sendMessageSpy.mock.calls[0][0] as any).body)
551
+ expect(responseBody).toMatchObject({ requestId: 'req-id-1', status: 'paid', amountPaid: 5000 })
552
+
553
+ expect(ackSpy).toHaveBeenCalledWith({ messageIds: ['req-msg-1'] })
554
+ })
555
+
556
+ it('includes note when provided', async () => {
557
+ jest.spyOn(peerPayClient, 'sendPayment').mockResolvedValue(undefined)
558
+ const sendMessageSpy = jest.spyOn(peerPayClient, 'sendMessage').mockResolvedValue({
559
+ status: 'success',
560
+ messageId: 'resp-msg-id'
561
+ })
562
+ jest.spyOn(peerPayClient, 'acknowledgeMessage').mockResolvedValue('ok')
563
+
564
+ await peerPayClient.fulfillPaymentRequest({ request: mockRequest, note: 'Here you go' })
565
+
566
+ const responseBody = JSON.parse((sendMessageSpy.mock.calls[0][0] as any).body)
567
+ expect(responseBody).toMatchObject({ note: 'Here you go' })
568
+ })
569
+ })
570
+
571
+ // Test: declinePaymentRequest
572
+ describe('declinePaymentRequest', () => {
573
+ const mockRequest = {
574
+ messageId: 'req-msg-2',
575
+ sender: 'senderKey2',
576
+ requestId: 'req-id-2',
577
+ amount: 3000,
578
+ description: 'Pay for service',
579
+ expiresAt: Date.now() + 60000
580
+ }
581
+
582
+ it('sends declined response to payment_request_responses and acknowledges request', async () => {
583
+ const sendMessageSpy = jest.spyOn(peerPayClient, 'sendMessage').mockResolvedValue({
584
+ status: 'success',
585
+ messageId: 'resp-msg-id'
586
+ })
587
+ const ackSpy = jest.spyOn(peerPayClient, 'acknowledgeMessage').mockResolvedValue('ok')
588
+
589
+ await peerPayClient.declinePaymentRequest({ request: mockRequest, note: 'Not today' })
590
+
591
+ expect(sendMessageSpy).toHaveBeenCalledWith(
592
+ expect.objectContaining({
593
+ recipient: 'senderKey2',
594
+ messageBox: 'payment_request_responses'
595
+ }),
596
+ undefined
597
+ )
598
+
599
+ const responseBody = JSON.parse((sendMessageSpy.mock.calls[0][0] as any).body)
600
+ expect(responseBody).toMatchObject({ requestId: 'req-id-2', status: 'declined', note: 'Not today' })
601
+
602
+ expect(ackSpy).toHaveBeenCalledWith({ messageIds: ['req-msg-2'] })
603
+ })
604
+ })
605
+
606
+ // Test: listPaymentRequestResponses
607
+ describe('listPaymentRequestResponses', () => {
608
+ it('returns parsed responses from payment_request_responses box', async () => {
609
+ jest.spyOn(peerPayClient, 'listMessages').mockResolvedValue([
610
+ {
611
+ messageId: 'resp-1',
612
+ sender: 'payer1',
613
+ created_at: '2025-01-01T00:00:00Z',
614
+ updated_at: '2025-01-01T00:00:00Z',
615
+ body: JSON.stringify({ requestId: 'req-1', status: 'paid', amountPaid: 5000 })
616
+ },
617
+ {
618
+ messageId: 'resp-2',
619
+ sender: 'payer2',
620
+ created_at: '2025-01-01T00:00:00Z',
621
+ updated_at: '2025-01-01T00:00:00Z',
622
+ body: JSON.stringify({ requestId: 'req-2', status: 'declined', note: 'No funds' })
623
+ }
624
+ ])
625
+
626
+ const responses = await peerPayClient.listPaymentRequestResponses()
627
+
628
+ expect(responses).toHaveLength(2)
629
+ expect(responses[0]).toMatchObject({ requestId: 'req-1', status: 'paid', amountPaid: 5000 })
630
+ expect(responses[1]).toMatchObject({ requestId: 'req-2', status: 'declined', note: 'No funds' })
631
+ })
632
+ })
633
+
634
+ // Test: listenForLivePaymentRequests
635
+ describe('listenForLivePaymentRequests', () => {
636
+ it('calls listenForLiveMessages on payment_requests box and converts messages to IncomingPaymentRequest', async () => {
637
+ const listenSpy = jest.spyOn(peerPayClient, 'listenForLiveMessages').mockResolvedValue(undefined)
638
+ const onRequest = jest.fn()
639
+
640
+ await peerPayClient.listenForLivePaymentRequests({ onRequest })
641
+
642
+ expect(listenSpy).toHaveBeenCalledWith(
643
+ expect.objectContaining({ messageBox: 'payment_requests' })
644
+ )
645
+
646
+ // Simulate a message arriving by calling the onMessage callback
647
+ const { onMessage } = (listenSpy.mock.calls[0][0] as any)
648
+ onMessage({
649
+ messageId: 'live-msg-1',
650
+ sender: 'sender1',
651
+ created_at: '2025-01-01T00:00:00Z',
652
+ updated_at: '2025-01-01T00:00:00Z',
653
+ body: JSON.stringify({
654
+ requestId: 'req-live-1',
655
+ amount: 3000,
656
+ description: 'Live request',
657
+ expiresAt: Date.now() + 60000,
658
+ senderIdentityKey: 'sender1',
659
+ requestProof: 'abcd1234'
660
+ })
661
+ })
662
+
663
+ expect(onRequest).toHaveBeenCalledWith(
664
+ expect.objectContaining({ messageId: 'live-msg-1', requestId: 'req-live-1', amount: 3000 })
665
+ )
666
+ })
667
+ })
668
+
669
+ // Test: listenForLivePaymentRequestResponses
670
+ describe('listenForLivePaymentRequestResponses', () => {
671
+ it('calls listenForLiveMessages on payment_request_responses box and parses responses', async () => {
672
+ const listenSpy = jest.spyOn(peerPayClient, 'listenForLiveMessages').mockResolvedValue(undefined)
673
+ const onResponse = jest.fn()
674
+
675
+ await peerPayClient.listenForLivePaymentRequestResponses({ onResponse })
676
+
677
+ expect(listenSpy).toHaveBeenCalledWith(
678
+ expect.objectContaining({ messageBox: 'payment_request_responses' })
679
+ )
680
+
681
+ // Simulate a message arriving
682
+ const { onMessage } = (listenSpy.mock.calls[0][0] as any)
683
+ onMessage({
684
+ messageId: 'live-resp-1',
685
+ sender: 'payer1',
686
+ created_at: '2025-01-01T00:00:00Z',
687
+ updated_at: '2025-01-01T00:00:00Z',
688
+ body: JSON.stringify({ requestId: 'req-1', status: 'paid', amountPaid: 5000 })
689
+ })
690
+
691
+ expect(onResponse).toHaveBeenCalledWith(
692
+ expect.objectContaining({ requestId: 'req-1', status: 'paid', amountPaid: 5000 })
693
+ )
694
+ })
695
+ })
696
+
697
+ // Test: allowPaymentRequestsFrom
698
+ describe('allowPaymentRequestsFrom', () => {
699
+ it('calls setMessageBoxPermission with messageBox=payment_requests and recipientFee=0', async () => {
700
+ const setPermSpy = jest.spyOn(peerPayClient, 'setMessageBoxPermission').mockResolvedValue(undefined)
701
+
702
+ await peerPayClient.allowPaymentRequestsFrom({ identityKey: 'trustedKey' })
703
+
704
+ expect(setPermSpy).toHaveBeenCalledWith({
705
+ messageBox: 'payment_requests',
706
+ sender: 'trustedKey',
707
+ recipientFee: 0
708
+ })
709
+ })
710
+ })
711
+
712
+ // Test: blockPaymentRequestsFrom
713
+ describe('blockPaymentRequestsFrom', () => {
714
+ it('calls setMessageBoxPermission with recipientFee=-1', async () => {
715
+ const setPermSpy = jest.spyOn(peerPayClient, 'setMessageBoxPermission').mockResolvedValue(undefined)
716
+
717
+ await peerPayClient.blockPaymentRequestsFrom({ identityKey: 'blockedKey' })
718
+
719
+ expect(setPermSpy).toHaveBeenCalledWith({
720
+ messageBox: 'payment_requests',
721
+ sender: 'blockedKey',
722
+ recipientFee: -1
723
+ })
724
+ })
725
+ })
726
+
727
+ // Test: listPaymentRequestPermissions
728
+ describe('listPaymentRequestPermissions', () => {
729
+ it('calls listMessageBoxPermissions and maps to { identityKey, allowed } array', async () => {
730
+ jest.spyOn(peerPayClient, 'listMessageBoxPermissions').mockResolvedValue([
731
+ {
732
+ sender: 'key1',
733
+ messageBox: 'payment_requests',
734
+ recipientFee: 0,
735
+ status: 'always_allow',
736
+ createdAt: '2025-01-01T00:00:00Z',
737
+ updatedAt: '2025-01-01T00:00:00Z'
738
+ },
739
+ {
740
+ sender: 'key2',
741
+ messageBox: 'payment_requests',
742
+ recipientFee: -1,
743
+ status: 'blocked',
744
+ createdAt: '2025-01-01T00:00:00Z',
745
+ updatedAt: '2025-01-01T00:00:00Z'
746
+ }
747
+ ])
748
+
749
+ const permissions = await peerPayClient.listPaymentRequestPermissions()
750
+
751
+ expect(permissions).toHaveLength(2)
752
+ expect(permissions[0]).toEqual({ identityKey: 'key1', allowed: true })
753
+ expect(permissions[1]).toEqual({ identityKey: 'key2', allowed: false })
754
+ })
755
+ })
756
+
757
+ // Test: requestPayment
758
+ describe('requestPayment', () => {
759
+ it('sends payment request message to payment_requests box with correct body fields', async () => {
760
+ jest.spyOn(peerPayClient, 'getIdentityKey').mockResolvedValue('myIdentityKey')
761
+ const sendMessageSpy = jest.spyOn(peerPayClient, 'sendMessage').mockResolvedValue({
762
+ status: 'success',
763
+ messageId: 'mockedMessageId'
764
+ })
765
+
766
+ const result = await peerPayClient.requestPayment({
767
+ recipient: 'recipientKey',
768
+ amount: 1000,
769
+ description: 'Please pay me',
770
+ expiresAt: Date.now() + 60000
771
+ })
772
+
773
+ expect(result).toHaveProperty('requestId')
774
+ expect(typeof result.requestId).toBe('string')
775
+ expect(sendMessageSpy).toHaveBeenCalledWith(
776
+ expect.objectContaining({
777
+ recipient: 'recipientKey',
778
+ messageBox: 'payment_requests',
779
+ body: expect.stringContaining('"amount":1000')
780
+ }),
781
+ undefined
782
+ )
783
+
784
+ const sentBody = JSON.parse((sendMessageSpy.mock.calls[0][0] as any).body)
785
+ expect(sentBody).toHaveProperty('requestId')
786
+ expect(sentBody).toHaveProperty('amount', 1000)
787
+ expect(sentBody).toHaveProperty('description', 'Please pay me')
788
+ expect(sentBody).toHaveProperty('senderIdentityKey', 'myIdentityKey')
789
+ expect(sentBody).toHaveProperty('requestProof')
790
+ expect(typeof sentBody.requestProof).toBe('string')
791
+ expect(sentBody.requestProof.length).toBeGreaterThan(0)
792
+
793
+ expect(result).toHaveProperty('requestProof')
794
+ })
795
+
796
+ it('throws if amount <= 0', async () => {
797
+ await expect(peerPayClient.requestPayment({
798
+ recipient: 'recipientKey',
799
+ amount: 0,
800
+ description: 'Bad request',
801
+ expiresAt: Date.now() + 60000
802
+ })).rejects.toThrow()
803
+ })
804
+ })
805
+
806
+ // Test: cancelPaymentRequest
807
+ describe('cancelPaymentRequest', () => {
808
+ it('sends cancellation message with requestId, real senderIdentityKey, and cancelled: true', async () => {
809
+ jest.spyOn(peerPayClient, 'getIdentityKey').mockResolvedValue('myIdentityKey')
810
+ const sendMessageSpy = jest.spyOn(peerPayClient, 'sendMessage').mockResolvedValue({
811
+ status: 'success',
812
+ messageId: 'mockedMessageId'
813
+ })
814
+
815
+ await peerPayClient.cancelPaymentRequest({
816
+ recipient: 'recipientKey',
817
+ requestId: 'existing-request-id',
818
+ requestProof: 'original-proof-hex'
819
+ })
820
+
821
+ expect(sendMessageSpy).toHaveBeenCalledWith(
822
+ expect.objectContaining({
823
+ recipient: 'recipientKey',
824
+ messageBox: 'payment_requests'
825
+ }),
826
+ undefined
827
+ )
828
+
829
+ const sentBody = JSON.parse((sendMessageSpy.mock.calls[0][0] as any).body)
830
+ expect(sentBody).toEqual({
831
+ requestId: 'existing-request-id',
832
+ senderIdentityKey: 'myIdentityKey',
833
+ requestProof: 'original-proof-hex',
834
+ cancelled: true
835
+ })
836
+ })
837
+ })
245
838
  })
package/src/types.ts CHANGED
@@ -187,3 +187,98 @@ export interface ListDevicesResponse {
187
187
  devices: RegisteredDevice[]
188
188
  description?: string // For error responses
189
189
  }
190
+
191
+ /**
192
+ * Base fields shared by both payment request and cancellation messages.
193
+ */
194
+ interface PaymentRequestBase {
195
+ /** Unique identifier for this request, generated via createNonce(). */
196
+ requestId: string
197
+ /** Identity key of the requester, used for correlation and cancellation verification. */
198
+ senderIdentityKey: string
199
+ }
200
+
201
+ /**
202
+ * A new payment request sent from requester to payer.
203
+ * Carried in the 'payment_requests' message box.
204
+ */
205
+ export interface PaymentRequestNew extends PaymentRequestBase {
206
+ /** Amount in satoshis being requested. */
207
+ amount: number
208
+ /** Human-readable reason for the request. */
209
+ description: string
210
+ /** Unix timestamp (ms) after which the request expires. Set by the sender. */
211
+ expiresAt: number
212
+ /** HMAC proof tying this request to the sender's identity. Used to authorize cancellations. */
213
+ requestProof: string
214
+ /** Omitted or false for a new payment request. */
215
+ cancelled?: false
216
+ }
217
+
218
+ /**
219
+ * A cancellation of a previously sent payment request.
220
+ * Carried in the 'payment_requests' message box.
221
+ */
222
+ export interface PaymentRequestCancellation extends PaymentRequestBase {
223
+ /** If true, this message cancels a previously sent request with the same requestId. */
224
+ cancelled: true
225
+ /** HMAC proof from the original request, proving cancellation authority. */
226
+ requestProof: string
227
+ }
228
+
229
+ /**
230
+ * Discriminated union: either a new payment request or a cancellation.
231
+ * Discriminant field: `cancelled` (true = cancellation, absent/false = new request).
232
+ */
233
+ export type PaymentRequestMessage = PaymentRequestNew | PaymentRequestCancellation
234
+
235
+ /**
236
+ * Represents a response to a payment request, sent from the payer back to the requester.
237
+ * Carried in the 'payment_request_responses' message box.
238
+ */
239
+ export interface PaymentRequestResponse {
240
+ /** The requestId of the original PaymentRequestMessage this responds to. */
241
+ requestId: string
242
+ /** Status of the response. */
243
+ status: 'paid' | 'declined'
244
+ /** Optional note from the payer. */
245
+ note?: string
246
+ /** Actual amount paid in satoshis (may differ from the requested amount). */
247
+ amountPaid?: number
248
+ }
249
+
250
+ /**
251
+ * Represents an incoming payment request as returned by listIncomingPaymentRequests().
252
+ * Combines the transport message metadata with the parsed request body.
253
+ * Only active (non-cancelled) requests are returned, so cancelled field is omitted.
254
+ */
255
+ export interface IncomingPaymentRequest {
256
+ /** Transport message ID used for acknowledgment. */
257
+ messageId: string
258
+ /** Identity key of the requester. */
259
+ sender: string
260
+ /** Unique identifier for this request. */
261
+ requestId: string
262
+ /** Amount in satoshis requested. */
263
+ amount: number
264
+ /** Human-readable reason for the request. */
265
+ description: string
266
+ /** Unix timestamp (ms) when the request expires. */
267
+ expiresAt: number
268
+ }
269
+
270
+ /** Default minimum satoshis for payment request filtering. */
271
+ export const DEFAULT_PAYMENT_REQUEST_MIN_AMOUNT = 1000
272
+ /** Default maximum satoshis for payment request filtering. */
273
+ export const DEFAULT_PAYMENT_REQUEST_MAX_AMOUNT = 10_000_000
274
+
275
+ /**
276
+ * Configurable min/max amount limits for incoming payment requests.
277
+ * Requests outside these bounds are auto-acknowledged and discarded.
278
+ */
279
+ export interface PaymentRequestLimits {
280
+ /** Minimum satoshis to accept in a request. Requests below this are discarded. Default: 1000. */
281
+ minAmount?: number
282
+ /** Maximum satoshis to accept in a request. Requests above this are discarded. Default: 10000000. */
283
+ maxAmount?: number
284
+ }