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 { 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],
@@ -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> = {}
): MoneroWalletRpcIncomingTransfer => ({
txHash: 'tx-hash-1',
@@ -21,7 +26,14 @@ const buildTransfer = (
...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',
paymentMethod: PaymentMethod.Xmr,
@@ -30,6 +42,16 @@ const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
...overrides
}) 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', () => {
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<Invoice>,
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
});
});
});
});
@@ -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<Invoice>,
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<void> {
await Promise.all([this.pollMoneroInvoices(), this.pollBitcoinInvoices()]);
}
private async pollMoneroInvoices(): Promise<void> {
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<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'];
await this.dataSource.transaction(async manager => {
@@ -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<Invoice>,
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."
)
);
});
});
@@ -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<Invoice>,
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<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 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.`
};
}
}
@@ -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 = {
pollInvoices: () => Promise<void>;
processInvoice: (invoiceId: string, transfers: MoneroWalletRpcIncomingTransfer[]) => Promise<void>;
pollMoneroInvoices: () => Promise<void>;
pollBitcoinInvoices: () => Promise<void>;
processInvoice: (invoiceId: string, transfers: InvoiceIncomingTransfer[]) => Promise<void>;
};