wire bitcoin invoice creation and payment polling

This commit is contained in:
2026-09-04 14:11:54 +02:00
parent cae5b4fe50
commit b4618cf3b1
7 changed files with 448 additions and 70 deletions
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { BitcoinWalletModule } from '../bitcoinWallet/BitcoinWalletModule';
import { MoneroWalletModule } from '../moneroWallet/MoneroWalletModule'; import { MoneroWalletModule } from '../moneroWallet/MoneroWalletModule';
import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule'; import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
import { Invoice } from './entities/Invoice'; import { Invoice } from './entities/Invoice';
@@ -13,6 +14,7 @@ import { InvoiceService } from './services/InvoiceService';
imports: [ imports: [
TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails, InvoiceBtcDetails]), TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails, InvoiceBtcDetails]),
MoneroWalletModule, MoneroWalletModule,
BitcoinWalletModule,
ExchangeRateModule ExchangeRateModule
], ],
providers: [InvoicePaymentService, InvoiceService], providers: [InvoicePaymentService, InvoiceService],
@@ -1,17 +1,22 @@
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config'; import type { ConfigService } from '@nestjs/config';
import type { DataSource, EntityManager, Repository } from 'typeorm'; import type { DataSource, EntityManager, Repository } from 'typeorm';
import type { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer'; import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
import { Invoice } from '../entities/Invoice'; import { Invoice } from '../entities/Invoice';
import { InvoicePayment } from '../entities/InvoicePayment'; import { InvoicePayment } from '../entities/InvoicePayment';
import type { InvoiceIncomingTransfer } from '../types/InvoiceIncomingTransfer';
import { PaymentMethod } from '../types/PaymentMethod'; import { PaymentMethod } from '../types/PaymentMethod';
import type { InvoicePaymentServiceTest } from '../types/InvoicePaymentServiceTest'; import type { InvoicePaymentServiceTest } from '../types/InvoicePaymentServiceTest';
import { InvoicePaymentService } from './InvoicePaymentService'; 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> = {} overrides: Partial<MoneroWalletRpcIncomingTransfer> = {}
): MoneroWalletRpcIncomingTransfer => ({ ): MoneroWalletRpcIncomingTransfer => ({
txHash: 'tx-hash-1', txHash: 'tx-hash-1',
@@ -21,7 +26,14 @@ const buildTransfer = (
...overrides ...overrides
}); });
const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice => const buildBtcTransfer = (overrides: Partial<InvoiceIncomingTransfer> = {}): InvoiceIncomingTransfer => ({
txHash: 'tx-hash-1',
amountAtomic: '200000000',
confirmations: 1,
...overrides
});
const buildXmrInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
({ ({
id: 'invoice-1', id: 'invoice-1',
paymentMethod: PaymentMethod.Xmr, paymentMethod: PaymentMethod.Xmr,
@@ -30,6 +42,16 @@ const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
...overrides ...overrides
}) as Invoice; }) as Invoice;
const buildBtcInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
({
id: 'invoice-btc-1',
paymentMethod: PaymentMethod.Btc,
paymentAddress: 'bc1qtest',
btcDetails: { requiredConfirmations: 1 },
payments: [],
...overrides
}) as Invoice;
describe('InvoicePaymentService', () => { describe('InvoicePaymentService', () => {
let service: InvoicePaymentServiceTest; let service: InvoicePaymentServiceTest;
let invoiceRepo: { let invoiceRepo: {
@@ -41,7 +63,11 @@ describe('InvoicePaymentService', () => {
andWhere: jest.Mock; andWhere: jest.Mock;
getMany: jest.Mock; getMany: jest.Mock;
}; };
let walletRpcClient: { let moneroWalletRpcClient: {
getIncomingTransfers: jest.Mock;
};
let bitcoinWalletRpcClient: {
getInfo: jest.Mock;
getIncomingTransfers: jest.Mock; getIncomingTransfers: jest.Mock;
}; };
let configService: { 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([]) getIncomingTransfers: jest.fn().mockResolvedValue([])
}; };
configService = { configService = {
get: jest.fn().mockReturnValue({ get: jest.fn().mockReturnValue({
minByMethod: { minByMethod: {
[PaymentMethod.Xmr]: minIncomingAtomic [PaymentMethod.Xmr]: minXmrIncomingAtomic,
[PaymentMethod.Btc]: minBtcIncomingAtomic
} }
}) })
}; };
@@ -145,7 +177,8 @@ describe('InvoicePaymentService', () => {
service = new InvoicePaymentService( service = new InvoicePaymentService(
invoiceRepo as unknown as Repository<Invoice>, invoiceRepo as unknown as Repository<Invoice>,
dataSource as unknown as DataSource, 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 configService as unknown as ConfigService
) as unknown as InvoicePaymentServiceTest; ) as unknown as InvoicePaymentServiceTest;
@@ -157,87 +190,184 @@ describe('InvoicePaymentService', () => {
errorLogSpy.mockRestore(); errorLogSpy.mockRestore();
}); });
describe('pollInvoices', () => { describe('pollMoneroInvoices', () => {
it('returns early when there are no open invoices', async () => { it('returns early when there are no open invoices', async () => {
pollQueryBuilder.getMany.mockResolvedValue([]); pollQueryBuilder.getMany.mockResolvedValue([]);
await service.pollInvoices(); await service.pollMoneroInvoices();
expect(walletRpcClient.getIncomingTransfers).not.toHaveBeenCalled(); expect(moneroWalletRpcClient.getIncomingTransfers).not.toHaveBeenCalled();
expect(processInvoiceSpy).not.toHaveBeenCalled(); expect(processInvoiceSpy).not.toHaveBeenCalled();
}); });
it('returns early when incoming transfers cannot be fetched', async () => { it('returns early when incoming transfers cannot be fetched', async () => {
pollQueryBuilder.getMany.mockResolvedValue([buildInvoice()]); pollQueryBuilder.getMany.mockResolvedValue([buildXmrInvoice()]);
walletRpcClient.getIncomingTransfers.mockRejectedValue(new Error('rpc down')); 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(); expect(processInvoiceSpy).not.toHaveBeenCalled();
}); });
it('routes transfers to each invoice by subaddress index', async () => { it('routes transfers to each invoice by subaddress index', async () => {
pollQueryBuilder.getMany.mockResolvedValue([ pollQueryBuilder.getMany.mockResolvedValue([
buildInvoice({ buildXmrInvoice({
id: 'invoice-1', id: 'invoice-1',
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails'] moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
}), }),
buildInvoice({ buildXmrInvoice({
id: 'invoice-2', id: 'invoice-2',
moneroDetails: { paymentAddressIndex: 7, requiredConfirmations: 1 } as Invoice['moneroDetails'] moneroDetails: { paymentAddressIndex: 7, requiredConfirmations: 1 } as Invoice['moneroDetails']
}) })
]); ]);
walletRpcClient.getIncomingTransfers.mockResolvedValue([ moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([
buildTransfer({ subaddrIndex: 3, txHash: 'tx-a' }), buildXmrTransfer({ subaddrIndex: 3, txHash: 'tx-a' }),
buildTransfer({ subaddrIndex: 7, txHash: 'tx-b' }) buildXmrTransfer({ subaddrIndex: 7, txHash: 'tx-b' })
]); ]);
await service.pollInvoices(); await service.pollMoneroInvoices();
expect(processInvoiceSpy).toHaveBeenNthCalledWith(1, 'invoice-1', [ 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(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 () => { it('continues processing other invoices when one invoice fails', async () => {
pollQueryBuilder.getMany.mockResolvedValue([ pollQueryBuilder.getMany.mockResolvedValue([
buildInvoice({ id: 'invoice-1' }), buildXmrInvoice({ id: 'invoice-1' }),
buildInvoice({ id: 'invoice-2' }) buildXmrInvoice({ id: 'invoice-2' })
]); ]);
walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]); moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([buildXmrTransfer()]);
processInvoiceSpy.mockRestore(); processInvoiceSpy.mockRestore();
processInvoiceSpy = jest processInvoiceSpy = jest
.spyOn(service, 'processInvoice') .spyOn(service, 'processInvoice')
.mockRejectedValueOnce(new Error('invoice-1 failed')) .mockRejectedValueOnce(new Error('invoice-1 failed'))
.mockResolvedValueOnce(undefined); .mockResolvedValueOnce(undefined);
await service.pollInvoices(); await service.pollMoneroInvoices();
expect(processInvoiceSpy).toHaveBeenCalledTimes(2); expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
}); });
it('deduplicates subaddress indices when fetching incoming transfers', async () => { it('deduplicates subaddress indices when fetching incoming transfers', async () => {
pollQueryBuilder.getMany.mockResolvedValue([ pollQueryBuilder.getMany.mockResolvedValue([
buildInvoice({ buildXmrInvoice({
id: 'invoice-1', id: 'invoice-1',
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails'] moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
}), }),
buildInvoice({ buildXmrInvoice({
id: 'invoice-2', id: 'invoice-2',
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails'] 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); 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', () => { describe('processInvoice', () => {
@@ -248,39 +378,48 @@ describe('InvoicePaymentService', () => {
it('does nothing when the invoice is missing inside the transaction', async () => { it('does nothing when the invoice is missing inside the transaction', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(null); 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.createQueryBuilder).not.toHaveBeenCalled();
expect(paymentRepo.update).not.toHaveBeenCalled(); expect(paymentRepo.update).not.toHaveBeenCalled();
}); });
it('skips transfers below the configured minimum', async () => { it('skips transfers below the configured minimum', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice()); transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
await service.processInvoice('invoice-1', [ await service.processInvoice('invoice-1', [
buildTransfer({ amountAtomic: '99999999', txHash: 'dust-tx' }) buildXmrTransfer({ amountAtomic: xmrBelowMinIncomingAtomic, txHash: 'dust-tx' })
]); ]);
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
}); });
it('inserts a payment when the transfer amount equals the configured minimum', async () => { 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', [ 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({ expect(insertQueryBuilder.values).toHaveBeenCalledWith({
invoice: { id: 'invoice-1' }, invoice: { id: 'invoice-1' },
txHash: 'min-tx', txHash: 'min-tx',
amountAtomic: minIncomingAtomic, amountAtomic: minXmrIncomingAtomic,
confirmations: 1 confirmations: 1
}); });
}); });
it('processes a mixed batch of dust, new, and existing transfers', async () => { it('processes a mixed batch of dust, new, and existing transfers', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue( transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildInvoice({ buildXmrInvoice({
payments: [ payments: [
{ {
id: 'payment-1', id: 'payment-1',
@@ -293,9 +432,9 @@ describe('InvoicePaymentService', () => {
); );
await service.processInvoice('invoice-1', [ await service.processInvoice('invoice-1', [
buildTransfer({ txHash: 'dust-tx', amountAtomic: '99999999' }), buildXmrTransfer({ txHash: 'dust-tx', amountAtomic: xmrBelowMinIncomingAtomic }),
buildTransfer({ txHash: 'known-tx', confirmations: 4 }), buildXmrTransfer({ txHash: 'known-tx', confirmations: 4 }),
buildTransfer({ txHash: 'new-tx', amountAtomic: '300000000', confirmations: 2 }) buildXmrTransfer({ txHash: 'new-tx', amountAtomic: '300000000', confirmations: 2 })
]); ]);
expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 4 }); 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 () => { it('inserts a new payment for transfers at or above the minimum', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice()); transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
const transfer = buildTransfer({ txHash: 'new-tx', amountAtomic: '200000000', confirmations: 2 }); const transfer = buildXmrTransfer({ txHash: 'new-tx', amountAtomic: '200000000', confirmations: 2 });
await service.processInvoice('invoice-1', [transfer]); await service.processInvoice('invoice-1', [transfer]);
@@ -327,7 +466,7 @@ describe('InvoicePaymentService', () => {
it('updates confirmations for an existing payment when they change', async () => { it('updates confirmations for an existing payment when they change', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue( transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildInvoice({ buildXmrInvoice({
payments: [ payments: [
{ {
id: 'payment-1', 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.update).toHaveBeenCalledWith('payment-1', { confirmations: 5 });
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
@@ -347,7 +486,7 @@ describe('InvoicePaymentService', () => {
it('does not update an existing payment when confirmations are unchanged', async () => { it('does not update an existing payment when confirmations are unchanged', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue( transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildInvoice({ buildXmrInvoice({
payments: [ payments: [
{ {
id: 'payment-1', 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.update).not.toHaveBeenCalled();
expect(paymentRepo.createQueryBuilder).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
});
});
}); });
}); });
@@ -7,11 +7,13 @@ import type { Config } from '../../../types/Config';
import { groupIncomingMoneroTransfersBySubaddrIndex } from '../../../utils/monero/groupIncomingMoneroTransfersBySubaddrIndex'; import { groupIncomingMoneroTransfersBySubaddrIndex } from '../../../utils/monero/groupIncomingMoneroTransfersBySubaddrIndex';
import { isAtomicGte } from '../../../utils/atomic/isAtomicGte'; import { isAtomicGte } from '../../../utils/atomic/isAtomicGte';
import { getErrorMessage } from '../../../utils/getErrorMessage'; import { getErrorMessage } from '../../../utils/getErrorMessage';
import { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
import { Invoice } from '../entities/Invoice'; import { Invoice } from '../entities/Invoice';
import { InvoicePayment } from '../entities/InvoicePayment'; import { InvoicePayment } from '../entities/InvoicePayment';
import type { InvoiceIncomingTransfer } from '../types/InvoiceIncomingTransfer';
import { PaymentMethod } from '../types/PaymentMethod'; import { PaymentMethod } from '../types/PaymentMethod';
import { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
@Injectable() @Injectable()
export class InvoicePaymentService { export class InvoicePaymentService {
@@ -21,12 +23,17 @@ export class InvoicePaymentService {
@InjectRepository(Invoice) @InjectRepository(Invoice)
private readonly invoiceRepo: Repository<Invoice>, private readonly invoiceRepo: Repository<Invoice>,
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly walletRpcClient: MoneroWalletRpcClient, private readonly moneroWalletRpcClient: MoneroWalletRpcClient,
private readonly bitcoinWalletRpcClient: ElectrumWalletRpcClient,
private readonly configService: ConfigService private readonly configService: ConfigService
) {} ) {}
@Cron(CronExpression.EVERY_10_SECONDS) @Cron(CronExpression.EVERY_10_SECONDS)
private async pollInvoices(): Promise<void> { private async pollInvoices(): Promise<void> {
await Promise.all([this.pollMoneroInvoices(), this.pollBitcoinInvoices()]);
}
private async pollMoneroInvoices(): Promise<void> {
const now = new Date(); const now = new Date();
const invoices = await this.invoiceRepo const invoices = await this.invoiceRepo
@@ -55,7 +62,7 @@ export class InvoicePaymentService {
let transfers: MoneroWalletRpcIncomingTransfer[]; let transfers: MoneroWalletRpcIncomingTransfer[];
try { try {
transfers = await this.walletRpcClient.getIncomingTransfers(subaddrIndices); transfers = await this.moneroWalletRpcClient.getIncomingTransfers(subaddrIndices);
} catch (error) { } catch (error) {
this.logger.error(`Failed to fetch incoming Monero transfers: ${getErrorMessage(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<void> { private async pollBitcoinInvoices(): Promise<void> {
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<void> {
const { minByMethod } = this.configService.get('invoice') as Config['invoice']; const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
await this.dataSource.transaction(async manager => { await this.dataSource.transaction(async manager => {
@@ -1,6 +1,7 @@
import { Logger, ServiceUnavailableException } from '@nestjs/common'; import { Logger, ServiceUnavailableException } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config'; import type { ConfigService } from '@nestjs/config';
import type { Repository } from 'typeorm'; import type { Repository } from 'typeorm';
import type { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import type { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService'; import type { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { Invoice } from '../entities/Invoice'; import { Invoice } from '../entities/Invoice';
@@ -19,7 +20,10 @@ describe('InvoiceService', () => {
let configService: { let configService: {
get: jest.Mock; get: jest.Mock;
}; };
let walletRpcClient: { let moneroWalletRpcClient: {
createAddress: jest.Mock;
};
let bitcoinWalletRpcClient: {
createAddress: jest.Mock; createAddress: jest.Mock;
}; };
let exchangeRateService: { let exchangeRateService: {
@@ -45,6 +49,10 @@ describe('InvoiceService', () => {
return { confirmationTiers }; return { confirmationTiers };
} }
if (key === 'shopSettings.bitcoin') {
return { confirmationTiers };
}
if (key === 'order') { if (key === 'order') {
return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 }; return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 };
} }
@@ -53,13 +61,17 @@ describe('InvoiceService', () => {
}) })
}; };
walletRpcClient = { moneroWalletRpcClient = {
createAddress: jest.fn().mockResolvedValue({ createAddress: jest.fn().mockResolvedValue({
address: '4MoneroPaymentAddressExample', address: '4MoneroPaymentAddressExample',
address_index: 12 address_index: 12
}) })
}; };
bitcoinWalletRpcClient = {
createAddress: jest.fn().mockResolvedValue('bc1qtestpaymentaddress')
};
exchangeRateService = { exchangeRateService = {
getLiveFiatPerCrypto: jest.fn().mockReturnValue(150) getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
}; };
@@ -67,7 +79,8 @@ describe('InvoiceService', () => {
service = new InvoiceService( service = new InvoiceService(
invoiceRepo as unknown as Repository<Invoice>, invoiceRepo as unknown as Repository<Invoice>,
configService as unknown as ConfigService, 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 exchangeRateService as unknown as ExchangeRateService
); );
}); });
@@ -76,9 +89,9 @@ describe('InvoiceService', () => {
errorLogSpy.mockRestore(); errorLogSpy.mockRestore();
}); });
const issueCheckoutInvoice = () => const issueCheckoutInvoice = (paymentMethod: PaymentMethod = PaymentMethod.Xmr) =>
service.issueInvoice({ service.issueInvoice({
paymentMethod: PaymentMethod.Xmr, paymentMethod,
reason: InvoiceReason.Checkout, reason: InvoiceReason.Checkout,
contextId: 'session-uuid', contextId: 'session-uuid',
amountFiat: 15 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.") 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 () => { 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( await expect(issueCheckoutInvoice()).rejects.toThrow(
new ServiceUnavailableException("We can't take payments right now. Please try again in a few minutes.") 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 () => { it('creates a checkout invoice with converted totals and monero details', async () => {
const invoice = await issueCheckoutInvoice(); const invoice = await issueCheckoutInvoice();
expect(walletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid'); expect(moneroWalletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid');
expect(invoiceRepo.create).toHaveBeenCalledWith( expect(invoiceRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
reason: InvoiceReason.Checkout, reason: InvoiceReason.Checkout,
@@ -145,6 +158,10 @@ describe('InvoiceService', () => {
}; };
} }
if (key === 'shopSettings.bitcoin') {
return { confirmationTiers };
}
if (key === 'order') { if (key === 'order') {
return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 }; return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 };
} }
@@ -185,7 +202,7 @@ describe('InvoiceService', () => {
); );
exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(150); exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(150);
walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down')); moneroWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
await expect( await expect(
service.issueInvoice({ service.issueInvoice({
@@ -200,7 +217,7 @@ describe('InvoiceService', () => {
) )
); );
walletRpcClient.createAddress.mockResolvedValue({ moneroWalletRpcClient.createAddress.mockResolvedValue({
address: '4ShippingPaymentAddressExample', address: '4ShippingPaymentAddressExample',
address_index: 3 address_index: 3
}); });
@@ -212,6 +229,76 @@ describe('InvoiceService', () => {
amountFiat: 5 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."
)
);
}); });
}); });
@@ -5,9 +5,12 @@ import { Repository } from 'typeorm';
import dayjs from '../../../plugins/dayjs'; import dayjs from '../../../plugins/dayjs';
import type { Config } from '../../../types/Config'; import type { Config } from '../../../types/Config';
import { getErrorMessage } from '../../../utils/getErrorMessage'; import { getErrorMessage } from '../../../utils/getErrorMessage';
import { convertFiatToBtc } from '../../../utils/bitcoin/convertFiatToBtc';
import { convertBtcToBtcAtomic } from '../../../utils/bitcoin/convertBtcToBtcAtomic';
import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr'; import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
import { convertXmrToXmrAtomic } from '../../../utils/monero/convertXmrToXmrAtomic'; import { convertXmrToXmrAtomic } from '../../../utils/monero/convertXmrToXmrAtomic';
import { resolveMinConfirmations } from '../../../utils/confirmation/resolveMinConfirmations'; import { resolveMinConfirmations } from '../../../utils/confirmation/resolveMinConfirmations';
import { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService'; import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { Invoice } from '../entities/Invoice'; import { Invoice } from '../entities/Invoice';
@@ -24,7 +27,8 @@ export class InvoiceService {
@InjectRepository(Invoice) @InjectRepository(Invoice)
private readonly invoiceRepo: Repository<Invoice>, private readonly invoiceRepo: Repository<Invoice>,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly walletRpcClient: MoneroWalletRpcClient, private readonly moneroWalletRpcClient: MoneroWalletRpcClient,
private readonly bitcoinWalletRpcClient: ElectrumWalletRpcClient,
private readonly exchangeRateService: ExchangeRateService private readonly exchangeRateService: ExchangeRateService
) {} ) {}
@@ -32,6 +36,8 @@ export class InvoiceService {
switch (data.paymentMethod) { switch (data.paymentMethod) {
case PaymentMethod.Xmr: case PaymentMethod.Xmr:
return this.issueXmrInvoice(data); 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( const { rateUnavailableMessage, unavailableMessage, addressLabel, validityMs } = this.resolveReasonData(
reason, reason,
contextId contextId,
PaymentMethod.Xmr
); );
const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr); const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
@@ -54,7 +61,7 @@ export class InvoiceService {
let paymentAddressIndex: number; let paymentAddressIndex: number;
try { try {
const { address, address_index } = await this.walletRpcClient.createAddress(addressLabel); const { address, address_index } = await this.moneroWalletRpcClient.createAddress(addressLabel);
paymentAddress = address; paymentAddress = address;
paymentAddressIndex = address_index; paymentAddressIndex = address_index;
@@ -89,8 +96,63 @@ export class InvoiceService {
return this.invoiceRepo.save(invoice); return this.invoiceRepo.save(invoice);
} }
private resolveReasonData(reason: InvoiceReason, contextId: string): InvoiceReasonData { private async issueBtcInvoice({ reason, contextId, amountFiat }: IssueInvoiceData): Promise<Invoice> {
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 { checkoutValidityMs, shippingPaymentValidityMs } = this.configService.get('order') as Config['order'];
const cryptoLabel = paymentMethod === PaymentMethod.Btc ? 'BTC' : 'XMR';
switch (reason) { switch (reason) {
case InvoiceReason.Checkout: case InvoiceReason.Checkout:
@@ -105,8 +167,7 @@ export class InvoiceService {
addressLabel: `order-shipping - ${contextId}`, addressLabel: `order-shipping - ${contextId}`,
validityMs: shippingPaymentValidityMs, validityMs: shippingPaymentValidityMs,
unavailableMessage: "We can't take shipping payments right now. Please try again in a few minutes.", unavailableMessage: "We can't take shipping payments right now. Please try again in a few minutes.",
rateUnavailableMessage: rateUnavailableMessage: `We can't quote shipping in ${cryptoLabel} right now. Please try again in a few minutes.`
"We can't quote shipping in XMR right now. Please try again in a few minutes."
}; };
} }
} }
@@ -0,0 +1,5 @@
export type InvoiceIncomingTransfer = {
txHash: string;
amountAtomic: string;
confirmations: number;
};
@@ -1,6 +1,8 @@
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer'; import type { InvoiceIncomingTransfer } from './InvoiceIncomingTransfer';
export type InvoicePaymentServiceTest = { export type InvoicePaymentServiceTest = {
pollInvoices: () => Promise<void>; pollInvoices: () => Promise<void>;
processInvoice: (invoiceId: string, transfers: MoneroWalletRpcIncomingTransfer[]) => Promise<void>; pollMoneroInvoices: () => Promise<void>;
pollBitcoinInvoices: () => Promise<void>;
processInvoice: (invoiceId: string, transfers: InvoiceIncomingTransfer[]) => Promise<void>;
}; };