diff --git a/backend/src/modules/payment/PaymentModule.ts b/backend/src/modules/payment/PaymentModule.ts index 01f931f..d1dc126 100644 --- a/backend/src/modules/payment/PaymentModule.ts +++ b/backend/src/modules/payment/PaymentModule.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BitcoinWalletModule } from '../bitcoinWallet/BitcoinWalletModule'; import { MoneroWalletModule } from '../moneroWallet/MoneroWalletModule'; import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule'; import { Invoice } from './entities/Invoice'; @@ -13,6 +14,7 @@ import { InvoiceService } from './services/InvoiceService'; imports: [ TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails, InvoiceBtcDetails]), MoneroWalletModule, + BitcoinWalletModule, ExchangeRateModule ], providers: [InvoicePaymentService, InvoiceService], diff --git a/backend/src/modules/payment/services/InvoicePaymentService.spec.ts b/backend/src/modules/payment/services/InvoicePaymentService.spec.ts index cb919c5..fb31435 100644 --- a/backend/src/modules/payment/services/InvoicePaymentService.spec.ts +++ b/backend/src/modules/payment/services/InvoicePaymentService.spec.ts @@ -1,17 +1,22 @@ import { Logger } from '@nestjs/common'; import type { ConfigService } from '@nestjs/config'; import type { DataSource, EntityManager, Repository } from 'typeorm'; +import type { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient'; import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer'; import { Invoice } from '../entities/Invoice'; import { InvoicePayment } from '../entities/InvoicePayment'; +import type { InvoiceIncomingTransfer } from '../types/InvoiceIncomingTransfer'; import { PaymentMethod } from '../types/PaymentMethod'; import type { InvoicePaymentServiceTest } from '../types/InvoicePaymentServiceTest'; import { InvoicePaymentService } from './InvoicePaymentService'; -const minIncomingAtomic = '100000000'; +const minXmrIncomingAtomic = '100000000'; +const minBtcIncomingAtomic = '7'; +const xmrBelowMinIncomingAtomic = String(BigInt(minXmrIncomingAtomic) - 1n); +const btcBelowMinIncomingAtomic = String(BigInt(minBtcIncomingAtomic) - 1n); -const buildTransfer = ( +const buildXmrTransfer = ( overrides: Partial = {} ): MoneroWalletRpcIncomingTransfer => ({ txHash: 'tx-hash-1', @@ -21,7 +26,14 @@ const buildTransfer = ( ...overrides }); -const buildInvoice = (overrides: Partial = {}): Invoice => +const buildBtcTransfer = (overrides: Partial = {}): InvoiceIncomingTransfer => ({ + txHash: 'tx-hash-1', + amountAtomic: '200000000', + confirmations: 1, + ...overrides +}); + +const buildXmrInvoice = (overrides: Partial = {}): Invoice => ({ id: 'invoice-1', paymentMethod: PaymentMethod.Xmr, @@ -30,6 +42,16 @@ const buildInvoice = (overrides: Partial = {}): Invoice => ...overrides }) as Invoice; +const buildBtcInvoice = (overrides: Partial = {}): Invoice => + ({ + id: 'invoice-btc-1', + paymentMethod: PaymentMethod.Btc, + paymentAddress: 'bc1qtest', + btcDetails: { requiredConfirmations: 1 }, + payments: [], + ...overrides + }) as Invoice; + describe('InvoicePaymentService', () => { let service: InvoicePaymentServiceTest; let invoiceRepo: { @@ -41,7 +63,11 @@ describe('InvoicePaymentService', () => { andWhere: jest.Mock; getMany: jest.Mock; }; - let walletRpcClient: { + let moneroWalletRpcClient: { + getIncomingTransfers: jest.Mock; + }; + let bitcoinWalletRpcClient: { + getInfo: jest.Mock; getIncomingTransfers: jest.Mock; }; let configService: { @@ -130,14 +156,20 @@ describe('InvoicePaymentService', () => { ) }; - walletRpcClient = { + moneroWalletRpcClient = { + getIncomingTransfers: jest.fn().mockResolvedValue([]) + }; + + bitcoinWalletRpcClient = { + getInfo: jest.fn().mockResolvedValue({ blockchain_height: 900_000 }), getIncomingTransfers: jest.fn().mockResolvedValue([]) }; configService = { get: jest.fn().mockReturnValue({ minByMethod: { - [PaymentMethod.Xmr]: minIncomingAtomic + [PaymentMethod.Xmr]: minXmrIncomingAtomic, + [PaymentMethod.Btc]: minBtcIncomingAtomic } }) }; @@ -145,7 +177,8 @@ describe('InvoicePaymentService', () => { service = new InvoicePaymentService( invoiceRepo as unknown as Repository, dataSource as unknown as DataSource, - walletRpcClient as unknown as MoneroWalletRpcClient, + moneroWalletRpcClient as unknown as MoneroWalletRpcClient, + bitcoinWalletRpcClient as unknown as ElectrumWalletRpcClient, configService as unknown as ConfigService ) as unknown as InvoicePaymentServiceTest; @@ -157,87 +190,184 @@ describe('InvoicePaymentService', () => { errorLogSpy.mockRestore(); }); - describe('pollInvoices', () => { + describe('pollMoneroInvoices', () => { it('returns early when there are no open invoices', async () => { pollQueryBuilder.getMany.mockResolvedValue([]); - await service.pollInvoices(); + await service.pollMoneroInvoices(); - expect(walletRpcClient.getIncomingTransfers).not.toHaveBeenCalled(); + expect(moneroWalletRpcClient.getIncomingTransfers).not.toHaveBeenCalled(); expect(processInvoiceSpy).not.toHaveBeenCalled(); }); it('returns early when incoming transfers cannot be fetched', async () => { - pollQueryBuilder.getMany.mockResolvedValue([buildInvoice()]); - walletRpcClient.getIncomingTransfers.mockRejectedValue(new Error('rpc down')); + pollQueryBuilder.getMany.mockResolvedValue([buildXmrInvoice()]); + moneroWalletRpcClient.getIncomingTransfers.mockRejectedValue(new Error('rpc down')); - await service.pollInvoices(); + await service.pollMoneroInvoices(); - expect(walletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]); + expect(moneroWalletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]); expect(processInvoiceSpy).not.toHaveBeenCalled(); }); it('routes transfers to each invoice by subaddress index', async () => { pollQueryBuilder.getMany.mockResolvedValue([ - buildInvoice({ + buildXmrInvoice({ id: 'invoice-1', moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails'] }), - buildInvoice({ + buildXmrInvoice({ id: 'invoice-2', moneroDetails: { paymentAddressIndex: 7, requiredConfirmations: 1 } as Invoice['moneroDetails'] }) ]); - walletRpcClient.getIncomingTransfers.mockResolvedValue([ - buildTransfer({ subaddrIndex: 3, txHash: 'tx-a' }), - buildTransfer({ subaddrIndex: 7, txHash: 'tx-b' }) + moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([ + buildXmrTransfer({ subaddrIndex: 3, txHash: 'tx-a' }), + buildXmrTransfer({ subaddrIndex: 7, txHash: 'tx-b' }) ]); - await service.pollInvoices(); + await service.pollMoneroInvoices(); expect(processInvoiceSpy).toHaveBeenNthCalledWith(1, 'invoice-1', [ - expect.objectContaining({ txHash: 'tx-a', subaddrIndex: 3 }) + expect.objectContaining({ txHash: 'tx-a' }) ]); expect(processInvoiceSpy).toHaveBeenNthCalledWith(2, 'invoice-2', [ - expect.objectContaining({ txHash: 'tx-b', subaddrIndex: 7 }) + expect.objectContaining({ txHash: 'tx-b' }) ]); }); it('continues processing other invoices when one invoice fails', async () => { pollQueryBuilder.getMany.mockResolvedValue([ - buildInvoice({ id: 'invoice-1' }), - buildInvoice({ id: 'invoice-2' }) + buildXmrInvoice({ id: 'invoice-1' }), + buildXmrInvoice({ id: 'invoice-2' }) ]); - walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]); + moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([buildXmrTransfer()]); processInvoiceSpy.mockRestore(); processInvoiceSpy = jest .spyOn(service, 'processInvoice') .mockRejectedValueOnce(new Error('invoice-1 failed')) .mockResolvedValueOnce(undefined); - await service.pollInvoices(); + await service.pollMoneroInvoices(); expect(processInvoiceSpy).toHaveBeenCalledTimes(2); }); it('deduplicates subaddress indices when fetching incoming transfers', async () => { pollQueryBuilder.getMany.mockResolvedValue([ - buildInvoice({ + buildXmrInvoice({ id: 'invoice-1', moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails'] }), - buildInvoice({ + buildXmrInvoice({ id: 'invoice-2', moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails'] }) ]); - walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]); + moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([buildXmrTransfer()]); - await service.pollInvoices(); + await service.pollMoneroInvoices(); - expect(walletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]); + expect(moneroWalletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]); expect(processInvoiceSpy).toHaveBeenCalledTimes(2); }); + it('processes invoices with no matching transfers as an empty batch', async () => { + pollQueryBuilder.getMany.mockResolvedValue([buildXmrInvoice({ id: 'invoice-1' })]); + moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([ + buildXmrTransfer({ subaddrIndex: 7, txHash: 'tx-other' }) + ]); + + await service.pollMoneroInvoices(); + + expect(processInvoiceSpy).toHaveBeenCalledWith('invoice-1', []); + }); + }); + + describe('pollBitcoinInvoices', () => { + it('returns early when there are no open invoices', async () => { + pollQueryBuilder.getMany.mockResolvedValue([]); + + await service.pollBitcoinInvoices(); + + expect(bitcoinWalletRpcClient.getInfo).not.toHaveBeenCalled(); + expect(processInvoiceSpy).not.toHaveBeenCalled(); + }); + + it('returns early when wallet info cannot be fetched', async () => { + pollQueryBuilder.getMany.mockResolvedValue([buildBtcInvoice()]); + bitcoinWalletRpcClient.getInfo.mockRejectedValue(new Error('rpc down')); + + await service.pollBitcoinInvoices(); + + expect(bitcoinWalletRpcClient.getIncomingTransfers).not.toHaveBeenCalled(); + expect(processInvoiceSpy).not.toHaveBeenCalled(); + }); + + it('fetches transfers per invoice using the current block height', async () => { + pollQueryBuilder.getMany.mockResolvedValue([ + buildBtcInvoice({ id: 'invoice-btc-1', paymentAddress: 'bc1qone' }), + buildBtcInvoice({ id: 'invoice-btc-2', paymentAddress: 'bc1qtwo' }) + ]); + bitcoinWalletRpcClient.getIncomingTransfers + .mockResolvedValueOnce([buildBtcTransfer({ txHash: 'tx-a' })]) + .mockResolvedValueOnce([buildBtcTransfer({ txHash: 'tx-b' })]); + + await service.pollBitcoinInvoices(); + + expect(bitcoinWalletRpcClient.getInfo).toHaveBeenCalled(); + expect(bitcoinWalletRpcClient.getIncomingTransfers).toHaveBeenNthCalledWith(1, 'bc1qone', 900_000); + expect(bitcoinWalletRpcClient.getIncomingTransfers).toHaveBeenNthCalledWith(2, 'bc1qtwo', 900_000); + expect(processInvoiceSpy).toHaveBeenNthCalledWith(1, 'invoice-btc-1', [ + expect.objectContaining({ txHash: 'tx-a' }) + ]); + expect(processInvoiceSpy).toHaveBeenNthCalledWith(2, 'invoice-btc-2', [ + expect.objectContaining({ txHash: 'tx-b' }) + ]); + }); + + it('continues processing other invoices when one invoice fails', async () => { + pollQueryBuilder.getMany.mockResolvedValue([ + buildBtcInvoice({ id: 'invoice-btc-1' }), + buildBtcInvoice({ id: 'invoice-btc-2' }) + ]); + bitcoinWalletRpcClient.getIncomingTransfers.mockResolvedValue([buildBtcTransfer()]); + processInvoiceSpy.mockRestore(); + processInvoiceSpy = jest + .spyOn(service, 'processInvoice') + .mockRejectedValueOnce(new Error('invoice-btc-1 failed')) + .mockResolvedValueOnce(undefined); + + await service.pollBitcoinInvoices(); + + expect(processInvoiceSpy).toHaveBeenCalledTimes(2); + }); + + it('continues processing other invoices when incoming transfers cannot be fetched for one invoice', async () => { + pollQueryBuilder.getMany.mockResolvedValue([ + buildBtcInvoice({ id: 'invoice-btc-1', paymentAddress: 'bc1qone' }), + buildBtcInvoice({ id: 'invoice-btc-2', paymentAddress: 'bc1qtwo' }) + ]); + bitcoinWalletRpcClient.getIncomingTransfers + .mockRejectedValueOnce(new Error('rpc down')) + .mockResolvedValueOnce([buildBtcTransfer({ txHash: 'tx-b' })]); + + await service.pollBitcoinInvoices(); + + expect(bitcoinWalletRpcClient.getIncomingTransfers).toHaveBeenCalledTimes(2); + expect(processInvoiceSpy).toHaveBeenCalledTimes(1); + expect(processInvoiceSpy).toHaveBeenCalledWith('invoice-btc-2', [ + expect.objectContaining({ txHash: 'tx-b' }) + ]); + }); + + it('passes null block height when wallet info has no blockchain height', async () => { + pollQueryBuilder.getMany.mockResolvedValue([buildBtcInvoice({ paymentAddress: 'bc1qtest' })]); + bitcoinWalletRpcClient.getInfo.mockResolvedValue({ server_height: 900_000 }); + + await service.pollBitcoinInvoices(); + + expect(bitcoinWalletRpcClient.getIncomingTransfers).toHaveBeenCalledWith('bc1qtest', null); + }); }); describe('processInvoice', () => { @@ -248,39 +378,48 @@ describe('InvoicePaymentService', () => { it('does nothing when the invoice is missing inside the transaction', async () => { transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(null); - await service.processInvoice('invoice-1', [buildTransfer()]); + await service.processInvoice('invoice-1', [buildXmrTransfer()]); + + expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); + expect(paymentRepo.update).not.toHaveBeenCalled(); + }); + + it('does nothing when there are no transfers to process', async () => { + transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice()); + + await service.processInvoice('invoice-1', []); expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); expect(paymentRepo.update).not.toHaveBeenCalled(); }); it('skips transfers below the configured minimum', async () => { - transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice()); + transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice()); await service.processInvoice('invoice-1', [ - buildTransfer({ amountAtomic: '99999999', txHash: 'dust-tx' }) + buildXmrTransfer({ amountAtomic: xmrBelowMinIncomingAtomic, txHash: 'dust-tx' }) ]); expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); }); it('inserts a payment when the transfer amount equals the configured minimum', async () => { - transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice()); + transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice()); await service.processInvoice('invoice-1', [ - buildTransfer({ txHash: 'min-tx', amountAtomic: minIncomingAtomic, confirmations: 1 }) + buildXmrTransfer({ txHash: 'min-tx', amountAtomic: minXmrIncomingAtomic, confirmations: 1 }) ]); expect(insertQueryBuilder.values).toHaveBeenCalledWith({ invoice: { id: 'invoice-1' }, txHash: 'min-tx', - amountAtomic: minIncomingAtomic, + amountAtomic: minXmrIncomingAtomic, confirmations: 1 }); }); it('processes a mixed batch of dust, new, and existing transfers', async () => { transactionalInvoiceQueryBuilder.getOne.mockResolvedValue( - buildInvoice({ + buildXmrInvoice({ payments: [ { id: 'payment-1', @@ -293,9 +432,9 @@ describe('InvoicePaymentService', () => { ); await service.processInvoice('invoice-1', [ - buildTransfer({ txHash: 'dust-tx', amountAtomic: '99999999' }), - buildTransfer({ txHash: 'known-tx', confirmations: 4 }), - buildTransfer({ txHash: 'new-tx', amountAtomic: '300000000', confirmations: 2 }) + buildXmrTransfer({ txHash: 'dust-tx', amountAtomic: xmrBelowMinIncomingAtomic }), + buildXmrTransfer({ txHash: 'known-tx', confirmations: 4 }), + buildXmrTransfer({ txHash: 'new-tx', amountAtomic: '300000000', confirmations: 2 }) ]); expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 4 }); @@ -310,8 +449,8 @@ describe('InvoicePaymentService', () => { it('inserts a new payment for transfers at or above the minimum', async () => { - transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice()); - const transfer = buildTransfer({ txHash: 'new-tx', amountAtomic: '200000000', confirmations: 2 }); + transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice()); + const transfer = buildXmrTransfer({ txHash: 'new-tx', amountAtomic: '200000000', confirmations: 2 }); await service.processInvoice('invoice-1', [transfer]); @@ -327,7 +466,7 @@ describe('InvoicePaymentService', () => { it('updates confirmations for an existing payment when they change', async () => { transactionalInvoiceQueryBuilder.getOne.mockResolvedValue( - buildInvoice({ + buildXmrInvoice({ payments: [ { id: 'payment-1', @@ -339,7 +478,7 @@ describe('InvoicePaymentService', () => { }) ); - await service.processInvoice('invoice-1', [buildTransfer({ txHash: 'known-tx', confirmations: 5 })]); + await service.processInvoice('invoice-1', [buildXmrTransfer({ txHash: 'known-tx', confirmations: 5 })]); expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 5 }); expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); @@ -347,7 +486,7 @@ describe('InvoicePaymentService', () => { it('does not update an existing payment when confirmations are unchanged', async () => { transactionalInvoiceQueryBuilder.getOne.mockResolvedValue( - buildInvoice({ + buildXmrInvoice({ payments: [ { id: 'payment-1', @@ -359,10 +498,35 @@ describe('InvoicePaymentService', () => { }) ); - await service.processInvoice('invoice-1', [buildTransfer({ txHash: 'known-tx', confirmations: 3 })]); + await service.processInvoice('invoice-1', [buildXmrTransfer({ txHash: 'known-tx', confirmations: 3 })]); expect(paymentRepo.update).not.toHaveBeenCalled(); expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); }); + + it('skips transfers below the configured minimum for bitcoin invoices', async () => { + transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildBtcInvoice()); + + await service.processInvoice('invoice-btc-1', [ + buildBtcTransfer({ amountAtomic: btcBelowMinIncomingAtomic, txHash: 'dust-tx' }) + ]); + + expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); + + it('inserts a payment when the transfer amount equals the configured minimum for bitcoin invoices', async () => { + transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildBtcInvoice()); + + await service.processInvoice('invoice-btc-1', [ + buildBtcTransfer({ txHash: 'min-tx', amountAtomic: minBtcIncomingAtomic, confirmations: 1 }) + ]); + + expect(insertQueryBuilder.values).toHaveBeenCalledWith({ + invoice: { id: 'invoice-btc-1' }, + txHash: 'min-tx', + amountAtomic: minBtcIncomingAtomic, + confirmations: 1 + }); + }); }); }); diff --git a/backend/src/modules/payment/services/InvoicePaymentService.ts b/backend/src/modules/payment/services/InvoicePaymentService.ts index 23fe173..1d11f83 100644 --- a/backend/src/modules/payment/services/InvoicePaymentService.ts +++ b/backend/src/modules/payment/services/InvoicePaymentService.ts @@ -7,11 +7,13 @@ import type { Config } from '../../../types/Config'; import { groupIncomingMoneroTransfersBySubaddrIndex } from '../../../utils/monero/groupIncomingMoneroTransfersBySubaddrIndex'; import { isAtomicGte } from '../../../utils/atomic/isAtomicGte'; import { getErrorMessage } from '../../../utils/getErrorMessage'; +import { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient'; import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; -import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer'; import { Invoice } from '../entities/Invoice'; import { InvoicePayment } from '../entities/InvoicePayment'; +import type { InvoiceIncomingTransfer } from '../types/InvoiceIncomingTransfer'; import { PaymentMethod } from '../types/PaymentMethod'; +import { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer'; @Injectable() export class InvoicePaymentService { @@ -21,12 +23,17 @@ export class InvoicePaymentService { @InjectRepository(Invoice) private readonly invoiceRepo: Repository, private readonly dataSource: DataSource, - private readonly walletRpcClient: MoneroWalletRpcClient, + private readonly moneroWalletRpcClient: MoneroWalletRpcClient, + private readonly bitcoinWalletRpcClient: ElectrumWalletRpcClient, private readonly configService: ConfigService ) {} @Cron(CronExpression.EVERY_10_SECONDS) private async pollInvoices(): Promise { + await Promise.all([this.pollMoneroInvoices(), this.pollBitcoinInvoices()]); + } + + private async pollMoneroInvoices(): Promise { const now = new Date(); const invoices = await this.invoiceRepo @@ -55,7 +62,7 @@ export class InvoicePaymentService { let transfers: MoneroWalletRpcIncomingTransfer[]; try { - transfers = await this.walletRpcClient.getIncomingTransfers(subaddrIndices); + transfers = await this.moneroWalletRpcClient.getIncomingTransfers(subaddrIndices); } catch (error) { this.logger.error(`Failed to fetch incoming Monero transfers: ${getErrorMessage(error)}`); @@ -77,7 +84,57 @@ export class InvoicePaymentService { } } - private async processInvoice(invoiceId: string, transfers: MoneroWalletRpcIncomingTransfer[]): Promise { + private async pollBitcoinInvoices(): Promise { + const now = new Date(); + + const invoices = await this.invoiceRepo + .createQueryBuilder('invoice') + .innerJoinAndSelect('invoice.btcDetails', 'btcDetails') + .where('invoice.paymentMethod = :paymentMethod', { paymentMethod: PaymentMethod.Btc }) + .andWhere( + new Brackets(qb => { + qb.where('invoice.expiresAt > :now', { now }).orWhere( + `"btcDetails"."requiredConfirmations" > 0 AND EXISTS ( + SELECT 1 FROM invoice_payments pollPayment + WHERE pollPayment."invoiceId" = invoice.id + AND pollPayment.confirmations < "btcDetails"."requiredConfirmations" + )` + ); + }) + ) + .getMany(); + + if (invoices.length === 0) { + return; + } + + let blockHeight: number | null; + + try { + const info = await this.bitcoinWalletRpcClient.getInfo(); + + blockHeight = info.blockchain_height ?? null; + } catch (error) { + this.logger.error(`Failed to fetch Bitcoin wallet info: ${getErrorMessage(error)}`); + + return; + } + + for (const invoice of invoices) { + try { + const transfers = await this.bitcoinWalletRpcClient.getIncomingTransfers( + invoice.paymentAddress, + blockHeight + ); + + await this.processInvoice(invoice.id, transfers); + } catch (error) { + this.logger.error(`Failed to process invoice ${invoice.id}: ${getErrorMessage(error)}`); + } + } + } + + private async processInvoice(invoiceId: string, transfers: InvoiceIncomingTransfer[]): Promise { const { minByMethod } = this.configService.get('invoice') as Config['invoice']; await this.dataSource.transaction(async manager => { diff --git a/backend/src/modules/payment/services/InvoiceService.spec.ts b/backend/src/modules/payment/services/InvoiceService.spec.ts index 6117671..3b959ab 100644 --- a/backend/src/modules/payment/services/InvoiceService.spec.ts +++ b/backend/src/modules/payment/services/InvoiceService.spec.ts @@ -1,6 +1,7 @@ import { Logger, ServiceUnavailableException } from '@nestjs/common'; import type { ConfigService } from '@nestjs/config'; import type { Repository } from 'typeorm'; +import type { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient'; import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; import type { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService'; import { Invoice } from '../entities/Invoice'; @@ -19,7 +20,10 @@ describe('InvoiceService', () => { let configService: { get: jest.Mock; }; - let walletRpcClient: { + let moneroWalletRpcClient: { + createAddress: jest.Mock; + }; + let bitcoinWalletRpcClient: { createAddress: jest.Mock; }; let exchangeRateService: { @@ -45,6 +49,10 @@ describe('InvoiceService', () => { return { confirmationTiers }; } + if (key === 'shopSettings.bitcoin') { + return { confirmationTiers }; + } + if (key === 'order') { return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 }; } @@ -53,13 +61,17 @@ describe('InvoiceService', () => { }) }; - walletRpcClient = { + moneroWalletRpcClient = { createAddress: jest.fn().mockResolvedValue({ address: '4MoneroPaymentAddressExample', address_index: 12 }) }; + bitcoinWalletRpcClient = { + createAddress: jest.fn().mockResolvedValue('bc1qtestpaymentaddress') + }; + exchangeRateService = { getLiveFiatPerCrypto: jest.fn().mockReturnValue(150) }; @@ -67,7 +79,8 @@ describe('InvoiceService', () => { service = new InvoiceService( invoiceRepo as unknown as Repository, configService as unknown as ConfigService, - walletRpcClient as unknown as MoneroWalletRpcClient, + moneroWalletRpcClient as unknown as MoneroWalletRpcClient, + bitcoinWalletRpcClient as unknown as ElectrumWalletRpcClient, exchangeRateService as unknown as ExchangeRateService ); }); @@ -76,9 +89,9 @@ describe('InvoiceService', () => { errorLogSpy.mockRestore(); }); - const issueCheckoutInvoice = () => + const issueCheckoutInvoice = (paymentMethod: PaymentMethod = PaymentMethod.Xmr) => service.issueInvoice({ - paymentMethod: PaymentMethod.Xmr, + paymentMethod, reason: InvoiceReason.Checkout, contextId: 'session-uuid', amountFiat: 15 @@ -91,11 +104,11 @@ describe('InvoiceService', () => { new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.") ); - expect(walletRpcClient.createAddress).not.toHaveBeenCalled(); + expect(moneroWalletRpcClient.createAddress).not.toHaveBeenCalled(); }); it('throws and logs when wallet address allocation fails for checkout invoices', async () => { - walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down')); + moneroWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down')); await expect(issueCheckoutInvoice()).rejects.toThrow( new ServiceUnavailableException("We can't take payments right now. Please try again in a few minutes.") @@ -108,7 +121,7 @@ describe('InvoiceService', () => { it('creates a checkout invoice with converted totals and monero details', async () => { const invoice = await issueCheckoutInvoice(); - expect(walletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid'); + expect(moneroWalletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid'); expect(invoiceRepo.create).toHaveBeenCalledWith( expect.objectContaining({ reason: InvoiceReason.Checkout, @@ -145,6 +158,10 @@ describe('InvoiceService', () => { }; } + if (key === 'shopSettings.bitcoin') { + return { confirmationTiers }; + } + if (key === 'order') { return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 }; } @@ -185,7 +202,7 @@ describe('InvoiceService', () => { ); exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(150); - walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down')); + moneroWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down')); await expect( service.issueInvoice({ @@ -200,7 +217,7 @@ describe('InvoiceService', () => { ) ); - walletRpcClient.createAddress.mockResolvedValue({ + moneroWalletRpcClient.createAddress.mockResolvedValue({ address: '4ShippingPaymentAddressExample', address_index: 3 }); @@ -212,6 +229,76 @@ describe('InvoiceService', () => { amountFiat: 5 }); - expect(walletRpcClient.createAddress).toHaveBeenCalledWith('order-shipping - order-1'); + expect(moneroWalletRpcClient.createAddress).toHaveBeenCalledWith('order-shipping - order-1'); + }); + + it('throws when the live BTC rate is unavailable for checkout invoices', async () => { + exchangeRateService.getLiveFiatPerCrypto.mockImplementation( + (method: PaymentMethod) => (method === PaymentMethod.Btc ? null : 150) + ); + + await expect(issueCheckoutInvoice(PaymentMethod.Btc)).rejects.toThrow( + new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.") + ); + + expect(bitcoinWalletRpcClient.createAddress).not.toHaveBeenCalled(); + }); + + it('throws and logs when Bitcoin address allocation fails for checkout invoices', async () => { + exchangeRateService.getLiveFiatPerCrypto.mockImplementation( + (method: PaymentMethod) => (method === PaymentMethod.Btc ? 60_000 : 150) + ); + bitcoinWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down')); + + await expect(issueCheckoutInvoice(PaymentMethod.Btc)).rejects.toThrow( + new ServiceUnavailableException("We can't take payments right now. Please try again in a few minutes.") + ); + + expect(errorLogSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to allocate Bitcoin payment address')); + expect(invoiceRepo.save).not.toHaveBeenCalled(); + }); + + it('creates a checkout invoice with converted totals and bitcoin details', async () => { + exchangeRateService.getLiveFiatPerCrypto.mockImplementation( + (method: PaymentMethod) => (method === PaymentMethod.Btc ? 60_000 : 150) + ); + + const invoice = await issueCheckoutInvoice(PaymentMethod.Btc); + + expect(bitcoinWalletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid'); + expect(invoiceRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + reason: InvoiceReason.Checkout, + paymentMethod: PaymentMethod.Btc, + amountFiat: 15, + fiatCurrency: 'USD', + paymentAddress: 'bc1qtestpaymentaddress', + expectedTotalAtomic: '25000', + expiresAt: expect.any(Date), + btcDetails: { + fiatPerBtcAtCreation: 60_000, + requiredConfirmations: 1 + } + }) + ); + expect(invoiceRepo.save).toHaveBeenCalled(); + expect(invoice).toEqual(expect.objectContaining({ id: 'invoice-1', amountFiat: 15 })); + }); + + it('uses BTC-specific shipping rate messages', async () => { + exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null); + + await expect( + service.issueInvoice({ + paymentMethod: PaymentMethod.Btc, + reason: InvoiceReason.Shipping, + contextId: 'order-1', + amountFiat: 5 + }) + ).rejects.toThrow( + new ServiceUnavailableException( + "We can't quote shipping in BTC right now. Please try again in a few minutes." + ) + ); }); }); diff --git a/backend/src/modules/payment/services/InvoiceService.ts b/backend/src/modules/payment/services/InvoiceService.ts index c916ca2..1c5fcd0 100644 --- a/backend/src/modules/payment/services/InvoiceService.ts +++ b/backend/src/modules/payment/services/InvoiceService.ts @@ -5,9 +5,12 @@ import { Repository } from 'typeorm'; import dayjs from '../../../plugins/dayjs'; import type { Config } from '../../../types/Config'; import { getErrorMessage } from '../../../utils/getErrorMessage'; +import { convertFiatToBtc } from '../../../utils/bitcoin/convertFiatToBtc'; +import { convertBtcToBtcAtomic } from '../../../utils/bitcoin/convertBtcToBtcAtomic'; import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr'; import { convertXmrToXmrAtomic } from '../../../utils/monero/convertXmrToXmrAtomic'; import { resolveMinConfirmations } from '../../../utils/confirmation/resolveMinConfirmations'; +import { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient'; import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService'; import { Invoice } from '../entities/Invoice'; @@ -24,7 +27,8 @@ export class InvoiceService { @InjectRepository(Invoice) private readonly invoiceRepo: Repository, private readonly configService: ConfigService, - private readonly walletRpcClient: MoneroWalletRpcClient, + private readonly moneroWalletRpcClient: MoneroWalletRpcClient, + private readonly bitcoinWalletRpcClient: ElectrumWalletRpcClient, private readonly exchangeRateService: ExchangeRateService ) {} @@ -32,6 +36,8 @@ export class InvoiceService { switch (data.paymentMethod) { case PaymentMethod.Xmr: return this.issueXmrInvoice(data); + case PaymentMethod.Btc: + return this.issueBtcInvoice(data); } } @@ -41,7 +47,8 @@ export class InvoiceService { const { rateUnavailableMessage, unavailableMessage, addressLabel, validityMs } = this.resolveReasonData( reason, - contextId + contextId, + PaymentMethod.Xmr ); const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr); @@ -54,7 +61,7 @@ export class InvoiceService { let paymentAddressIndex: number; try { - const { address, address_index } = await this.walletRpcClient.createAddress(addressLabel); + const { address, address_index } = await this.moneroWalletRpcClient.createAddress(addressLabel); paymentAddress = address; paymentAddressIndex = address_index; @@ -89,8 +96,63 @@ export class InvoiceService { return this.invoiceRepo.save(invoice); } - private resolveReasonData(reason: InvoiceReason, contextId: string): InvoiceReasonData { + private async issueBtcInvoice({ reason, contextId, amountFiat }: IssueInvoiceData): Promise { + const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings']; + const { confirmationTiers } = this.configService.get('shopSettings.bitcoin') as Config['shopSettings']['bitcoin']; + + const { rateUnavailableMessage, unavailableMessage, addressLabel, validityMs } = this.resolveReasonData( + reason, + contextId, + PaymentMethod.Btc + ); + + const fiatPerBtc = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Btc); + + if (fiatPerBtc === null) { + throw new ServiceUnavailableException(rateUnavailableMessage); + } + + let paymentAddress: string; + + try { + paymentAddress = await this.bitcoinWalletRpcClient.createAddress(addressLabel); + } catch (error) { + this.logger.error(`Failed to allocate Bitcoin payment address: ${getErrorMessage(error)}`); + + throw new ServiceUnavailableException(unavailableMessage); + } + + const requiredConfirmations = resolveMinConfirmations(amountFiat, confirmationTiers); + + const expiresAt = dayjs().add(validityMs, 'millisecond').toDate(); + + const expectedTotalBtc = convertFiatToBtc(amountFiat, fiatPerBtc); + const expectedTotalAtomic = convertBtcToBtcAtomic(expectedTotalBtc); + + const invoice = this.invoiceRepo.create({ + reason, + paymentMethod: PaymentMethod.Btc, + amountFiat, + fiatCurrency: shopFiatCurrency, + expiresAt, + paymentAddress, + expectedTotalAtomic, + btcDetails: { + fiatPerBtcAtCreation: fiatPerBtc, + requiredConfirmations + } + }); + + return this.invoiceRepo.save(invoice); + } + + private resolveReasonData( + reason: InvoiceReason, + contextId: string, + paymentMethod: PaymentMethod + ): InvoiceReasonData { const { checkoutValidityMs, shippingPaymentValidityMs } = this.configService.get('order') as Config['order']; + const cryptoLabel = paymentMethod === PaymentMethod.Btc ? 'BTC' : 'XMR'; switch (reason) { case InvoiceReason.Checkout: @@ -105,8 +167,7 @@ export class InvoiceService { addressLabel: `order-shipping - ${contextId}`, validityMs: shippingPaymentValidityMs, unavailableMessage: "We can't take shipping payments right now. Please try again in a few minutes.", - rateUnavailableMessage: - "We can't quote shipping in XMR right now. Please try again in a few minutes." + rateUnavailableMessage: `We can't quote shipping in ${cryptoLabel} right now. Please try again in a few minutes.` }; } } diff --git a/backend/src/modules/payment/types/InvoiceIncomingTransfer.ts b/backend/src/modules/payment/types/InvoiceIncomingTransfer.ts new file mode 100644 index 0000000..e8bb594 --- /dev/null +++ b/backend/src/modules/payment/types/InvoiceIncomingTransfer.ts @@ -0,0 +1,5 @@ +export type InvoiceIncomingTransfer = { + txHash: string; + amountAtomic: string; + confirmations: number; +}; diff --git a/backend/src/modules/payment/types/InvoicePaymentServiceTest.ts b/backend/src/modules/payment/types/InvoicePaymentServiceTest.ts index be44805..0b5b39f 100644 --- a/backend/src/modules/payment/types/InvoicePaymentServiceTest.ts +++ b/backend/src/modules/payment/types/InvoicePaymentServiceTest.ts @@ -1,6 +1,8 @@ -import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer'; +import type { InvoiceIncomingTransfer } from './InvoiceIncomingTransfer'; export type InvoicePaymentServiceTest = { pollInvoices: () => Promise; - processInvoice: (invoiceId: string, transfers: MoneroWalletRpcIncomingTransfer[]) => Promise; + pollMoneroInvoices: () => Promise; + pollBitcoinInvoices: () => Promise; + processInvoice: (invoiceId: string, transfers: InvoiceIncomingTransfer[]) => Promise; };