x402-bch-axios 2.1.0 → 2.2.0

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/index.js CHANGED
@@ -164,34 +164,183 @@ export async function createPaymentHeader (
164
164
  return JSON.stringify(paymentHeader)
165
165
  }
166
166
 
167
- async function sendPayment (signer, paymentRequirements, bchServerConfig = {}) {
168
- const { apiType, bchServerURL } = bchServerConfig
169
- // Support both v1 (minAmountRequired) and v2 (amount) field names
170
- const amountRequired = paymentRequirements.amount || paymentRequirements.minAmountRequired
171
- const paymentAmountSats = signer.paymentAmountSats || amountRequired
167
+ // Send the payment using bch.fullstack.cash. In this case, we can use bch-js to execute
168
+ // the payment in a more optimized way.
169
+ // Assumption: A UTXO that is equal to or larger than paymentAmountSats is not a SLP token UTXO.
170
+ async function sendPaymentFullstack (signer, paymentRequirements, bchServerConfig = {}) {
171
+ try {
172
+ const { apiType, bchServerURL, bearerToken } = bchServerConfig
173
+
174
+ // Private key in WIF format.
175
+ const wif = signer.wif
176
+ const payToAddr = paymentRequirements.payTo
177
+
178
+ // Support both v1 (minAmountRequired) and v2 (amount) field names
179
+ const amountRequired = paymentRequirements.amount || paymentRequirements.minAmountRequired
180
+ const paymentAmountSats = signer.paymentAmountSats || amountRequired
181
+
182
+ // Get bch-js
183
+ const bchWallet = new dependencies.BCHWallet(signer.wif, {
184
+ interface: apiType,
185
+ restURL: bchServerURL,
186
+ bearerToken
187
+ })
188
+ await bchWallet.walletInfoPromise
189
+ const bchjs = bchWallet.bchjs
190
+
191
+ // Generate the bitcoincash: address from the private key.
192
+ const ecPair = bchjs.ECPair.fromWIF(wif)
193
+ const payFromAddr = bchjs.ECPair.toCashAddress(ecPair)
194
+ // console.log(`payFromAddr: ${payFromAddr}`)
195
+
196
+ // console.log('bchjs.restURL: ', bchjs.restURL)
197
+
198
+ // Get the UTXOs controlled by the private key.
199
+ let utxos = []
200
+ let utxoData
201
+ try {
202
+ utxoData = await bchjs.Electrumx.utxo(payFromAddr)
203
+ } catch (err) {
204
+ throw new Error(`Error retrieving UTXOs for address ${payFromAddr}: ${err.message}`)
205
+ }
206
+ if (utxoData.utxos && Array.isArray(utxoData.utxos)) {
207
+ utxos = utxoData.utxos
208
+ }
172
209
 
173
- const bchWallet = new dependencies.BCHWallet(signer.wif, {
174
- interface: apiType,
175
- restURL: bchServerURL
176
- })
177
- // console.log(`sendPayment() - interface: ${apiType}, restURL: ${bchServerURL}, wif: ${signer.wif}, payTo: ${paymentRequirements.payTo}, paymentAmountSats: ${paymentAmountSats}`)
178
- console.log(`Sending ${paymentAmountSats} for x402 API payment to ${paymentRequirements.payTo}`)
179
- await bchWallet.initialize()
180
-
181
- const retryQueue = new dependencies.RetryQueue()
182
- const receivers = [
183
- {
184
- address: paymentRequirements.payTo,
185
- amountSat: paymentAmountSats
210
+ // Filter out UTXOs that are equal to or greater than the paymentAmountSats
211
+ utxos = utxos.filter(utxo => utxo.value >= paymentAmountSats)
212
+ // console.log(`UTXOs available for payment: ${JSON.stringify(utxos, null, 2)}`)
213
+
214
+ // Choose the first UTXO that is big enough to pay for the transaction.
215
+ const utxo = utxos[0]
216
+
217
+ // instance of transaction builder
218
+ const transactionBuilder = new bchjs.TransactionBuilder()
219
+
220
+ // Essential variables of a transaction.
221
+ const satoshisToSend = paymentAmountSats
222
+ const originalAmount = utxo.value
223
+ const vout = utxo.tx_pos
224
+ const txid = utxo.tx_hash
225
+
226
+ // add input with txid and index of vout
227
+ transactionBuilder.addInput(txid, vout)
228
+
229
+ // get byte count to calculate fee. paying 1.2 sat/byte
230
+ const byteCount = bchjs.BitcoinCash.getByteCount({ P2PKH: 1 }, { P2PKH: 2 })
231
+ // console.log(`Transaction byte count: ${byteCount}`)
232
+ const satoshisPerByte = 1.2
233
+ const txFee = Math.floor(satoshisPerByte * byteCount)
234
+ // console.log(`Transaction fee: ${txFee}`)
235
+
236
+ // amount to send back to the sending address.
237
+ // It's the original amount - 1 sat/byte for tx size
238
+ const remainder = originalAmount - satoshisToSend - txFee
239
+
240
+ if (remainder < 0) {
241
+ throw new Error('Not enough BCH to complete transaction!')
186
242
  }
187
- ]
188
243
 
189
- const txid = await retryQueue.addToQueue(bchWallet.send.bind(bchWallet), receivers)
244
+ // add output w/ address and amount to send
245
+ transactionBuilder.addOutput(payToAddr, satoshisToSend)
246
+ transactionBuilder.addOutput(payFromAddr, remainder)
247
+
248
+ // Sign the transaction with the HD node.
249
+ let redeemScript
250
+ transactionBuilder.sign(
251
+ 0,
252
+ ecPair,
253
+ redeemScript,
254
+ transactionBuilder.hashTypes.SIGHASH_ALL,
255
+ originalAmount
256
+ )
257
+
258
+ // build tx
259
+ const tx = transactionBuilder.build()
260
+ // output rawhex
261
+ const hex = tx.toHex()
262
+ // console.log(`TX hex: ${hex}`);
263
+ console.log(' ')
264
+
265
+ // Broadcast transation to the network
266
+ const txid2 = await bchjs.RawTransactions.sendRawTransaction([hex])
267
+ // console.log(`Transaction ID: ${txid2}`)
268
+
269
+ return {
270
+ txid: txid2,
271
+ vout: 0,
272
+ satsSent: paymentAmountSats
273
+ }
274
+ } catch (err) {
275
+ console.error('Error in x402-bch-axios/sendPaymentFullstack(): ', err)
276
+ throw err
277
+ }
278
+ }
190
279
 
191
- return {
192
- txid,
193
- vout: 0,
194
- satsSent: paymentAmountSats
280
+ async function sendPaymentGeneric (signer, paymentRequirements, bchServerConfig = {}) {
281
+ try {
282
+ const { apiType, bchServerURL, bearerToken } = bchServerConfig
283
+ // Support both v1 (minAmountRequired) and v2 (amount) field names
284
+ const amountRequired = paymentRequirements.amount || paymentRequirements.minAmountRequired
285
+ const paymentAmountSats = signer.paymentAmountSats || amountRequired
286
+
287
+ const bchWallet = new dependencies.BCHWallet(signer.wif, {
288
+ interface: apiType,
289
+ restURL: bchServerURL,
290
+ bearerToken
291
+ })
292
+ // console.log(`sendPayment() - interface: ${apiType}, restURL: ${bchServerURL}, wif: ${signer.wif}, payTo: ${paymentRequirements.payTo}, paymentAmountSats: ${paymentAmountSats}`)
293
+ console.log(`Sending ${paymentAmountSats} for x402 API payment to ${paymentRequirements.payTo}`)
294
+ await bchWallet.initialize()
295
+
296
+ const retryQueue = new dependencies.RetryQueue()
297
+ const receivers = [
298
+ {
299
+ address: paymentRequirements.payTo,
300
+ amountSat: paymentAmountSats
301
+ }
302
+ ]
303
+
304
+ // Wrap send function to detect "Insufficient balance" errors
305
+ const sendWithRetry = async (receivers) => {
306
+ try {
307
+ return await bchWallet.send(receivers)
308
+ } catch (error) {
309
+ // Check if error message contains "Insufficient balance"
310
+ if (error.message && error.message.includes('Insufficient balance')) {
311
+ return null
312
+ }
313
+
314
+ // Re-throw other errors normally (will be retried)
315
+ throw error
316
+ }
317
+ }
318
+
319
+ const txid = await retryQueue.addToQueue(sendWithRetry, receivers)
320
+
321
+ if (txid === null) {
322
+ throw new Error('Insufficient balance')
323
+ }
324
+
325
+ return {
326
+ txid,
327
+ vout: 0,
328
+ satsSent: paymentAmountSats
329
+ }
330
+ } catch (err) {
331
+ console.error('Error in x402-bch-axios/sendPayment(): ', err.message)
332
+ throw err
333
+ }
334
+ }
335
+
336
+ // Route the payment to the appropriate function based on the BCH server URL.
337
+ async function sendPayment (signer, paymentRequirements, bchServerConfig = {}) {
338
+ const { bchServerURL } = bchServerConfig
339
+ if (bchServerURL.includes('bch.fullstack.cash')) {
340
+ // If the BCH server URL is a Fullstack server, use an optimized payment function.
341
+ return sendPaymentFullstack(signer, paymentRequirements, bchServerConfig)
342
+ } else {
343
+ return sendPaymentGeneric(signer, paymentRequirements, bchServerConfig)
195
344
  }
196
345
  }
197
346
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-bch-axios",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Axios wrapper for x402 payment protocol with Bitcoin Cash (BCH) support.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -26,7 +26,7 @@
26
26
  "repository": "x402-bch/x402-bch-axios",
27
27
  "dependencies": {
28
28
  "@chris.troutner/retry-queue": "1.0.11",
29
- "minimal-slp-wallet": "7.0.2"
29
+ "minimal-slp-wallet": "7.0.5"
30
30
  },
31
31
  "devDependencies": {
32
32
  "c8": "10.1.3",
@@ -506,4 +506,687 @@ describe('#index.js', () => {
506
506
  assert.isTrue(sendPaymentStub.calledOnce)
507
507
  })
508
508
  })
509
+
510
+ describe('#sendPayment', () => {
511
+ function createSignerStub () {
512
+ return {
513
+ wif: 'test-wif',
514
+ paymentAmountSats: 2000,
515
+ address: 'bitcoincash:qptest'
516
+ }
517
+ }
518
+
519
+ function createPaymentRequirementsStub () {
520
+ return {
521
+ payTo: 'bitcoincash:qprecv',
522
+ amount: '1500',
523
+ scheme: 'utxo',
524
+ network: 'bip122:000000000000000000651ef99cb9fcbe'
525
+ }
526
+ }
527
+
528
+ it('should route to sendPaymentGeneric when URL is not fullstack', async () => {
529
+ const signer = createSignerStub()
530
+ const paymentRequirements = createPaymentRequirementsStub()
531
+ const bchServerConfig = {
532
+ apiType: 'rest-api',
533
+ bchServerURL: 'https://api.example.com'
534
+ }
535
+
536
+ const mockBchWallet = {
537
+ initialize: sandbox.stub().resolves(),
538
+ send: sandbox.stub().resolves('tx123')
539
+ }
540
+
541
+ const mockRetryQueue = {
542
+ addToQueue: sandbox.stub().resolves('tx123')
543
+ }
544
+
545
+ const BCHWalletStub = sandbox.stub().returns(mockBchWallet)
546
+ const RetryQueueStub = sandbox.stub().returns(mockRetryQueue)
547
+
548
+ __setDependencies({
549
+ BCHWallet: BCHWalletStub,
550
+ RetryQueue: RetryQueueStub
551
+ })
552
+
553
+ const result = await __internals.sendPayment(signer, paymentRequirements, bchServerConfig)
554
+
555
+ assert.deepEqual(result, {
556
+ txid: 'tx123',
557
+ vout: 0,
558
+ satsSent: 2000
559
+ })
560
+
561
+ assert.isTrue(BCHWalletStub.calledOnce)
562
+ assert.deepEqual(BCHWalletStub.firstCall.args[0], 'test-wif')
563
+ assert.deepEqual(BCHWalletStub.firstCall.args[1], {
564
+ interface: 'rest-api',
565
+ restURL: 'https://api.example.com',
566
+ bearerToken: undefined
567
+ })
568
+ assert.isTrue(mockBchWallet.initialize.calledOnce)
569
+ assert.isTrue(RetryQueueStub.calledOnce)
570
+ assert.isTrue(mockRetryQueue.addToQueue.calledOnce)
571
+
572
+ // Verify sendWithRetry was called with receivers
573
+ const sendWithRetry = mockRetryQueue.addToQueue.firstCall.args[0]
574
+ const receivers = mockRetryQueue.addToQueue.firstCall.args[1]
575
+ assert.isFunction(sendWithRetry)
576
+ assert.deepEqual(receivers, [{
577
+ address: 'bitcoincash:qprecv',
578
+ amountSat: 2000
579
+ }])
580
+
581
+ __resetDependencies()
582
+ })
583
+
584
+ it('should route to sendPaymentFullstack when URL contains bch.fullstack.cash', async () => {
585
+ const signer = createSignerStub()
586
+ const paymentRequirements = createPaymentRequirementsStub()
587
+ const bchServerConfig = {
588
+ apiType: 'rest-api',
589
+ bchServerURL: 'https://bch.fullstack.cash/v5/'
590
+ }
591
+
592
+ const mockEcPair = { ecpair: true }
593
+ const mockUtxos = [{
594
+ tx_hash: 'utxo-txid',
595
+ tx_pos: 1,
596
+ value: 5000
597
+ }]
598
+
599
+ const mockTransactionBuilder = {
600
+ addInput: sandbox.stub(),
601
+ addOutput: sandbox.stub(),
602
+ sign: sandbox.stub(),
603
+ build: sandbox.stub().returns({
604
+ toHex: sandbox.stub().returns('raw-hex')
605
+ }),
606
+ hashTypes: {
607
+ SIGHASH_ALL: 1
608
+ }
609
+ }
610
+
611
+ const mockBchjs = {
612
+ ECPair: {
613
+ fromWIF: sandbox.stub().returns(mockEcPair),
614
+ toCashAddress: sandbox.stub().returns('bitcoincash:qptest')
615
+ },
616
+ Electrumx: {
617
+ utxo: sandbox.stub().resolves({ utxos: mockUtxos })
618
+ },
619
+ TransactionBuilder: sandbox.stub().returns(mockTransactionBuilder),
620
+ BitcoinCash: {
621
+ getByteCount: sandbox.stub().returns(250)
622
+ },
623
+ RawTransactions: {
624
+ sendRawTransaction: sandbox.stub().resolves('tx123')
625
+ }
626
+ }
627
+
628
+ const mockBchWallet = {
629
+ walletInfoPromise: Promise.resolve(),
630
+ bchjs: mockBchjs
631
+ }
632
+
633
+ const BCHWalletStub = sandbox.stub().returns(mockBchWallet)
634
+
635
+ __setDependencies({
636
+ BCHWallet: BCHWalletStub
637
+ })
638
+
639
+ const result = await __internals.sendPayment(signer, paymentRequirements, bchServerConfig)
640
+
641
+ assert.deepEqual(result, {
642
+ txid: 'tx123',
643
+ vout: 0,
644
+ satsSent: 2000
645
+ })
646
+
647
+ assert.isTrue(BCHWalletStub.calledOnce)
648
+ assert.deepEqual(BCHWalletStub.firstCall.args[0], 'test-wif')
649
+ assert.deepEqual(BCHWalletStub.firstCall.args[1], {
650
+ interface: 'rest-api',
651
+ restURL: 'https://bch.fullstack.cash/v5/',
652
+ bearerToken: undefined
653
+ })
654
+ assert.isTrue(mockBchjs.ECPair.fromWIF.calledOnce)
655
+ assert.isTrue(mockBchjs.Electrumx.utxo.calledOnce)
656
+ assert.isTrue(mockBchjs.TransactionBuilder.calledOnce)
657
+ assert.isTrue(mockTransactionBuilder.addInput.calledOnce)
658
+ assert.isTrue(mockTransactionBuilder.addOutput.calledTwice)
659
+ assert.isTrue(mockTransactionBuilder.sign.calledOnce)
660
+ assert.isTrue(mockBchjs.RawTransactions.sendRawTransaction.calledOnce)
661
+ assert.deepEqual(mockBchjs.RawTransactions.sendRawTransaction.firstCall.args[0], ['raw-hex'])
662
+
663
+ __resetDependencies()
664
+ })
665
+
666
+ it('should throw "Insufficient balance" error when sendWithRetry returns null', async () => {
667
+ const signer = createSignerStub()
668
+ const paymentRequirements = createPaymentRequirementsStub()
669
+ const bchServerConfig = {
670
+ bchServerURL: 'https://api.example.com'
671
+ }
672
+
673
+ const mockBchWallet = {
674
+ initialize: sandbox.stub().resolves(),
675
+ send: sandbox.stub().rejects(new Error('Insufficient balance'))
676
+ }
677
+
678
+ const mockRetryQueue = {
679
+ addToQueue: sandbox.stub().resolves(null) // sendWithRetry returns null for insufficient balance
680
+ }
681
+
682
+ const BCHWalletStub = sandbox.stub().returns(mockBchWallet)
683
+ const RetryQueueStub = sandbox.stub().returns(mockRetryQueue)
684
+
685
+ __setDependencies({
686
+ BCHWallet: BCHWalletStub,
687
+ RetryQueue: RetryQueueStub
688
+ })
689
+
690
+ try {
691
+ await __internals.sendPayment(signer, paymentRequirements, bchServerConfig)
692
+ assert.fail('Expected "Insufficient balance" error to be thrown')
693
+ } catch (err) {
694
+ assert.equal(err.message, 'Insufficient balance')
695
+ }
696
+
697
+ // Verify sendWithRetry was called and handled the error
698
+ assert.isTrue(mockRetryQueue.addToQueue.calledOnce)
699
+ const sendWithRetry = mockRetryQueue.addToQueue.firstCall.args[0]
700
+
701
+ // Test sendWithRetry directly to verify it returns null for insufficient balance
702
+ try {
703
+ const result = await sendWithRetry([{ address: 'test', amountSat: 1000 }])
704
+ assert.strictEqual(result, null)
705
+ } catch (err) {
706
+ assert.fail('sendWithRetry should return null, not throw')
707
+ }
708
+
709
+ __resetDependencies()
710
+ })
711
+
712
+ it('should handle "Insufficient balance" error in sendWithRetry wrapper', async () => {
713
+ const signer = createSignerStub()
714
+ const paymentRequirements = createPaymentRequirementsStub()
715
+ const bchServerConfig = {
716
+ bchServerURL: 'https://api.example.com'
717
+ }
718
+
719
+ const insufficientBalanceError = new Error('Insufficient balance')
720
+ const mockBchWallet = {
721
+ initialize: sandbox.stub().resolves(),
722
+ send: sandbox.stub().rejects(insufficientBalanceError)
723
+ }
724
+
725
+ const mockRetryQueue = {
726
+ addToQueue: sandbox.stub().callsFake(async (fn, args) => {
727
+ // Simulate what RetryQueue does - call the function
728
+ return await fn(args)
729
+ })
730
+ }
731
+
732
+ const BCHWalletStub = sandbox.stub().returns(mockBchWallet)
733
+ const RetryQueueStub = sandbox.stub().returns(mockRetryQueue)
734
+
735
+ __setDependencies({
736
+ BCHWallet: BCHWalletStub,
737
+ RetryQueue: RetryQueueStub
738
+ })
739
+
740
+ try {
741
+ await __internals.sendPayment(signer, paymentRequirements, bchServerConfig)
742
+ assert.fail('Expected "Insufficient balance" error to be thrown')
743
+ } catch (err) {
744
+ assert.equal(err.message, 'Insufficient balance')
745
+ }
746
+
747
+ // Verify that sendWithRetry was called and returned null
748
+ assert.isTrue(mockRetryQueue.addToQueue.calledOnce)
749
+ const sendWithRetry = mockRetryQueue.addToQueue.firstCall.args[0]
750
+ const receivers = mockRetryQueue.addToQueue.firstCall.args[1]
751
+
752
+ // Call sendWithRetry directly to verify it returns null
753
+ const result = await sendWithRetry(receivers)
754
+ assert.strictEqual(result, null)
755
+
756
+ __resetDependencies()
757
+ })
758
+
759
+ it('should re-throw other errors from sendWithRetry for retry queue to handle', async () => {
760
+ const networkError = new Error('Network timeout')
761
+ const mockBchWallet = {
762
+ initialize: sandbox.stub().resolves(),
763
+ send: sandbox.stub().rejects(networkError)
764
+ }
765
+
766
+ // RetryQueue should eventually succeed after retries
767
+ const mockRetryQueue = {
768
+ addToQueue: sandbox.stub().resolves('tx456')
769
+ }
770
+
771
+ const BCHWalletStub = sandbox.stub().returns(mockBchWallet)
772
+ const RetryQueueStub = sandbox.stub().returns(mockRetryQueue)
773
+
774
+ __setDependencies({
775
+ BCHWallet: BCHWalletStub,
776
+ RetryQueue: RetryQueueStub
777
+ })
778
+
779
+ // Test sendWithRetry directly to verify it re-throws non-insufficient-balance errors
780
+ const receivers = [{ address: 'test', amountSat: 1000 }]
781
+
782
+ // Extract sendWithRetry by simulating what happens in sendPayment
783
+ const sendWithRetry = async (receivers) => {
784
+ try {
785
+ return await mockBchWallet.send(receivers)
786
+ } catch (error) {
787
+ if (error.message && error.message.includes('Insufficient balance')) {
788
+ return null
789
+ }
790
+ throw error
791
+ }
792
+ }
793
+
794
+ // Verify that sendWithRetry re-throws non-insufficient-balance errors
795
+ try {
796
+ await sendWithRetry(receivers)
797
+ assert.fail('Expected network error to be thrown')
798
+ } catch (err) {
799
+ assert.equal(err.message, 'Network timeout')
800
+ }
801
+
802
+ // Verify that send was called
803
+ assert.isTrue(mockBchWallet.send.calledOnce)
804
+
805
+ __resetDependencies()
806
+ })
807
+
808
+ it('should use paymentAmountSats from signer when available', async () => {
809
+ const signer = createSignerStub()
810
+ signer.paymentAmountSats = 5000
811
+ const paymentRequirements = createPaymentRequirementsStub()
812
+ const bchServerConfig = {
813
+ bchServerURL: 'https://api.example.com'
814
+ }
815
+
816
+ const mockBchWallet = {
817
+ initialize: sandbox.stub().resolves(),
818
+ send: sandbox.stub().resolves('tx789')
819
+ }
820
+
821
+ const mockRetryQueue = {
822
+ addToQueue: sandbox.stub().resolves('tx789')
823
+ }
824
+
825
+ const BCHWalletStub = sandbox.stub().returns(mockBchWallet)
826
+ const RetryQueueStub = sandbox.stub().returns(mockRetryQueue)
827
+
828
+ __setDependencies({
829
+ BCHWallet: BCHWalletStub,
830
+ RetryQueue: RetryQueueStub
831
+ })
832
+
833
+ await __internals.sendPayment(signer, paymentRequirements, bchServerConfig)
834
+
835
+ const receivers = mockRetryQueue.addToQueue.firstCall.args[1]
836
+ assert.equal(receivers[0].amountSat, 5000)
837
+
838
+ __resetDependencies()
839
+ })
840
+
841
+ it('should use amountRequired from paymentRequirements when signer.paymentAmountSats is not set', async () => {
842
+ const signer = createSignerStub()
843
+ delete signer.paymentAmountSats
844
+ const paymentRequirements = createPaymentRequirementsStub()
845
+ paymentRequirements.amount = '3000'
846
+ const bchServerConfig = {
847
+ bchServerURL: 'https://api.example.com'
848
+ }
849
+
850
+ const mockBchWallet = {
851
+ initialize: sandbox.stub().resolves(),
852
+ send: sandbox.stub().resolves('tx999')
853
+ }
854
+
855
+ const mockRetryQueue = {
856
+ addToQueue: sandbox.stub().resolves('tx999')
857
+ }
858
+
859
+ const BCHWalletStub = sandbox.stub().returns(mockBchWallet)
860
+ const RetryQueueStub = sandbox.stub().returns(mockRetryQueue)
861
+
862
+ __setDependencies({
863
+ BCHWallet: BCHWalletStub,
864
+ RetryQueue: RetryQueueStub
865
+ })
866
+
867
+ await __internals.sendPayment(signer, paymentRequirements, bchServerConfig)
868
+
869
+ const receivers = mockRetryQueue.addToQueue.firstCall.args[1]
870
+ assert.equal(receivers[0].amountSat, '3000')
871
+
872
+ __resetDependencies()
873
+ })
874
+
875
+ it('should only match "Insufficient balance" error message (case-sensitive)', async () => {
876
+ const mockBchWallet = {
877
+ initialize: sandbox.stub().resolves(),
878
+ send: sandbox.stub().rejects(new Error('INSUFFICIENT BALANCE'))
879
+ }
880
+
881
+ // Test sendWithRetry directly
882
+ const sendWithRetry = async (receivers) => {
883
+ try {
884
+ return await mockBchWallet.send(receivers)
885
+ } catch (error) {
886
+ if (error.message && error.message.includes('Insufficient balance')) {
887
+ return null
888
+ }
889
+ throw error
890
+ }
891
+ }
892
+
893
+ const receivers = [{ address: 'test', amountSat: 1000 }]
894
+
895
+ // This should NOT return null because the error message doesn't match (case-sensitive check)
896
+ // The includes() method is case-sensitive, so 'INSUFFICIENT BALANCE' won't match 'Insufficient balance'
897
+ try {
898
+ const result = await sendWithRetry(receivers)
899
+ // If it returns null, that's unexpected
900
+ if (result === null) {
901
+ assert.fail('Should not return null for case-mismatched error message')
902
+ }
903
+ } catch (err) {
904
+ // Expected - error should be re-thrown because it doesn't match
905
+ assert.equal(err.message, 'INSUFFICIENT BALANCE')
906
+ }
907
+
908
+ __resetDependencies()
909
+ })
910
+
911
+ it('should propagate errors from wallet initialization', async () => {
912
+ const signer = createSignerStub()
913
+ const paymentRequirements = createPaymentRequirementsStub()
914
+ const bchServerConfig = {
915
+ bchServerURL: 'https://api.example.com'
916
+ }
917
+
918
+ const initError = new Error('Failed to initialize wallet')
919
+ const mockBchWallet = {
920
+ initialize: sandbox.stub().rejects(initError),
921
+ send: sandbox.stub()
922
+ }
923
+
924
+ const BCHWalletStub = sandbox.stub().returns(mockBchWallet)
925
+ const RetryQueueStub = sandbox.stub()
926
+
927
+ __setDependencies({
928
+ BCHWallet: BCHWalletStub,
929
+ RetryQueue: RetryQueueStub
930
+ })
931
+
932
+ try {
933
+ await __internals.sendPayment(signer, paymentRequirements, bchServerConfig)
934
+ assert.fail('Expected initialization error to be thrown')
935
+ } catch (err) {
936
+ assert.equal(err.message, 'Failed to initialize wallet')
937
+ }
938
+
939
+ assert.isTrue(mockBchWallet.initialize.calledOnce)
940
+ assert.isTrue(RetryQueueStub.notCalled)
941
+
942
+ __resetDependencies()
943
+ })
944
+
945
+ it('should support v1 minAmountRequired field in sendPaymentGeneric', async () => {
946
+ const signer = createSignerStub()
947
+ const paymentRequirements = createPaymentRequirementsStub()
948
+ delete paymentRequirements.amount
949
+ paymentRequirements.minAmountRequired = 2500
950
+ const bchServerConfig = {
951
+ bchServerURL: 'https://api.example.com'
952
+ }
953
+
954
+ const mockBchWallet = {
955
+ initialize: sandbox.stub().resolves(),
956
+ send: sandbox.stub().resolves('tx-v1')
957
+ }
958
+
959
+ const mockRetryQueue = {
960
+ addToQueue: sandbox.stub().resolves('tx-v1')
961
+ }
962
+
963
+ const BCHWalletStub = sandbox.stub().returns(mockBchWallet)
964
+ const RetryQueueStub = sandbox.stub().returns(mockRetryQueue)
965
+
966
+ __setDependencies({
967
+ BCHWallet: BCHWalletStub,
968
+ RetryQueue: RetryQueueStub
969
+ })
970
+
971
+ await __internals.sendPayment(signer, paymentRequirements, bchServerConfig)
972
+
973
+ const receivers = mockRetryQueue.addToQueue.firstCall.args[1]
974
+ // Should use paymentAmountSats from signer (2000) when available, not minAmountRequired
975
+ assert.equal(receivers[0].amountSat, 2000)
976
+
977
+ __resetDependencies()
978
+ })
979
+
980
+ it('should handle UTXO retrieval errors in sendPaymentFullstack', async () => {
981
+ const signer = createSignerStub()
982
+ const paymentRequirements = createPaymentRequirementsStub()
983
+ const bchServerConfig = {
984
+ apiType: 'rest-api',
985
+ bchServerURL: 'https://bch.fullstack.cash/v5/'
986
+ }
987
+
988
+ const mockEcPair = { ecpair: true }
989
+ const mockBchjs = {
990
+ ECPair: {
991
+ fromWIF: sandbox.stub().returns(mockEcPair),
992
+ toCashAddress: sandbox.stub().returns('bitcoincash:qptest')
993
+ },
994
+ Electrumx: {
995
+ utxo: sandbox.stub().rejects(new Error('Network error'))
996
+ }
997
+ }
998
+
999
+ const mockBchWallet = {
1000
+ walletInfoPromise: Promise.resolve(),
1001
+ bchjs: mockBchjs
1002
+ }
1003
+
1004
+ const BCHWalletStub = sandbox.stub().returns(mockBchWallet)
1005
+
1006
+ __setDependencies({
1007
+ BCHWallet: BCHWalletStub
1008
+ })
1009
+
1010
+ try {
1011
+ await __internals.sendPayment(signer, paymentRequirements, bchServerConfig)
1012
+ assert.fail('Expected error to be thrown')
1013
+ } catch (err) {
1014
+ assert.match(err.message, /Error retrieving UTXOs/)
1015
+ assert.match(err.message, /Network error/)
1016
+ }
1017
+
1018
+ __resetDependencies()
1019
+ })
1020
+
1021
+ it('should throw error when insufficient balance in sendPaymentFullstack', async () => {
1022
+ const signer = createSignerStub()
1023
+ const paymentRequirements = createPaymentRequirementsStub()
1024
+ const bchServerConfig = {
1025
+ apiType: 'rest-api',
1026
+ bchServerURL: 'https://bch.fullstack.cash/v5/'
1027
+ }
1028
+
1029
+ const mockEcPair = { ecpair: true }
1030
+ const mockUtxos = [{
1031
+ tx_hash: 'utxo-txid',
1032
+ tx_pos: 1,
1033
+ value: 1000 // Less than paymentAmountSats (2000)
1034
+ }]
1035
+
1036
+ const mockBchjs = {
1037
+ ECPair: {
1038
+ fromWIF: sandbox.stub().returns(mockEcPair),
1039
+ toCashAddress: sandbox.stub().returns('bitcoincash:qptest')
1040
+ },
1041
+ Electrumx: {
1042
+ utxo: sandbox.stub().resolves({ utxos: mockUtxos })
1043
+ }
1044
+ }
1045
+
1046
+ const mockBchWallet = {
1047
+ walletInfoPromise: Promise.resolve(),
1048
+ bchjs: mockBchjs
1049
+ }
1050
+
1051
+ const BCHWalletStub = sandbox.stub().returns(mockBchWallet)
1052
+
1053
+ __setDependencies({
1054
+ BCHWallet: BCHWalletStub
1055
+ })
1056
+
1057
+ try {
1058
+ await __internals.sendPayment(signer, paymentRequirements, bchServerConfig)
1059
+ assert.fail('Expected error to be thrown')
1060
+ } catch (err) {
1061
+ // Should fail because utxos[0] is undefined after filtering
1062
+ assert.isDefined(err)
1063
+ }
1064
+
1065
+ __resetDependencies()
1066
+ })
1067
+
1068
+ it('should handle transaction building errors in sendPaymentFullstack', async () => {
1069
+ const signer = createSignerStub()
1070
+ const paymentRequirements = createPaymentRequirementsStub()
1071
+ const bchServerConfig = {
1072
+ apiType: 'rest-api',
1073
+ bchServerURL: 'https://bch.fullstack.cash/v5/'
1074
+ }
1075
+
1076
+ const mockEcPair = { ecpair: true }
1077
+ const mockUtxos = [{
1078
+ tx_hash: 'utxo-txid',
1079
+ tx_pos: 1,
1080
+ value: 5000
1081
+ }]
1082
+
1083
+ const mockTransactionBuilder = {
1084
+ addInput: sandbox.stub(),
1085
+ addOutput: sandbox.stub(),
1086
+ sign: sandbox.stub(),
1087
+ build: sandbox.stub().throws(new Error('Build failed')),
1088
+ hashTypes: {
1089
+ SIGHASH_ALL: 1
1090
+ }
1091
+ }
1092
+
1093
+ const mockBchjs = {
1094
+ ECPair: {
1095
+ fromWIF: sandbox.stub().returns(mockEcPair),
1096
+ toCashAddress: sandbox.stub().returns('bitcoincash:qptest')
1097
+ },
1098
+ Electrumx: {
1099
+ utxo: sandbox.stub().resolves({ utxos: mockUtxos })
1100
+ },
1101
+ TransactionBuilder: sandbox.stub().returns(mockTransactionBuilder),
1102
+ BitcoinCash: {
1103
+ getByteCount: sandbox.stub().returns(250)
1104
+ }
1105
+ }
1106
+
1107
+ const mockBchWallet = {
1108
+ walletInfoPromise: Promise.resolve(),
1109
+ bchjs: mockBchjs
1110
+ }
1111
+
1112
+ const BCHWalletStub = sandbox.stub().returns(mockBchWallet)
1113
+
1114
+ __setDependencies({
1115
+ BCHWallet: BCHWalletStub
1116
+ })
1117
+
1118
+ try {
1119
+ await __internals.sendPayment(signer, paymentRequirements, bchServerConfig)
1120
+ assert.fail('Expected error to be thrown')
1121
+ } catch (err) {
1122
+ assert.equal(err.message, 'Build failed')
1123
+ }
1124
+
1125
+ __resetDependencies()
1126
+ })
1127
+
1128
+ it('should throw error when remainder is negative in sendPaymentFullstack', async () => {
1129
+ const signer = createSignerStub()
1130
+ signer.paymentAmountSats = 10000 // Large amount
1131
+ const paymentRequirements = createPaymentRequirementsStub()
1132
+ const bchServerConfig = {
1133
+ apiType: 'rest-api',
1134
+ bchServerURL: 'https://bch.fullstack.cash/v5/'
1135
+ }
1136
+
1137
+ const mockEcPair = { ecpair: true }
1138
+ // UTXO value must be >= paymentAmountSats (10000) to pass filter
1139
+ // But remainder = value - paymentAmountSats - txFee must be negative
1140
+ // With txFee = 1.2 * 250 = 300, we need value < 10300
1141
+ // So use value = 10200: remainder = 10200 - 10000 - 300 = -100 < 0
1142
+ const mockUtxos = [{
1143
+ tx_hash: 'utxo-txid',
1144
+ tx_pos: 1,
1145
+ value: 10200 // >= paymentAmountSats but insufficient after fee
1146
+ }]
1147
+
1148
+ const mockTransactionBuilder = {
1149
+ addInput: sandbox.stub(),
1150
+ addOutput: sandbox.stub(),
1151
+ sign: sandbox.stub(),
1152
+ hashTypes: {
1153
+ SIGHASH_ALL: 1
1154
+ }
1155
+ }
1156
+
1157
+ const mockBchjs = {
1158
+ ECPair: {
1159
+ fromWIF: sandbox.stub().returns(mockEcPair),
1160
+ toCashAddress: sandbox.stub().returns('bitcoincash:qptest')
1161
+ },
1162
+ Electrumx: {
1163
+ utxo: sandbox.stub().resolves({ utxos: mockUtxos })
1164
+ },
1165
+ TransactionBuilder: sandbox.stub().returns(mockTransactionBuilder),
1166
+ BitcoinCash: {
1167
+ getByteCount: sandbox.stub().returns(250)
1168
+ }
1169
+ }
1170
+
1171
+ const mockBchWallet = {
1172
+ walletInfoPromise: Promise.resolve(),
1173
+ bchjs: mockBchjs
1174
+ }
1175
+
1176
+ const BCHWalletStub = sandbox.stub().returns(mockBchWallet)
1177
+
1178
+ __setDependencies({
1179
+ BCHWallet: BCHWalletStub
1180
+ })
1181
+
1182
+ try {
1183
+ await __internals.sendPayment(signer, paymentRequirements, bchServerConfig)
1184
+ assert.fail('Expected error to be thrown')
1185
+ } catch (err) {
1186
+ assert.equal(err.message, 'Not enough BCH to complete transaction!')
1187
+ }
1188
+
1189
+ __resetDependencies()
1190
+ })
1191
+ })
509
1192
  })