wire bitcoin invoice creation and payment polling
This commit is contained in:
@@ -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>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user