Slave/btc rbf edge case fix #5

Merged
nobswebdev merged 2 commits from slave/btc-rbf-edge-case-fix into master 2026-09-08 17:02:48 +00:00
2 changed files with 109 additions and 32 deletions
Showing only changes of commit 4458edb0d1 - Show all commits
@@ -87,6 +87,7 @@ describe('InvoicePaymentService', () => {
}; };
let paymentRepo: { let paymentRepo: {
update: jest.Mock; update: jest.Mock;
delete: jest.Mock;
createQueryBuilder: jest.Mock; createQueryBuilder: jest.Mock;
}; };
let insertQueryBuilder: { let insertQueryBuilder: {
@@ -133,6 +134,7 @@ describe('InvoicePaymentService', () => {
paymentRepo = { paymentRepo = {
update: jest.fn().mockResolvedValue(undefined), update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder) createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder)
}; };
@@ -384,13 +386,35 @@ describe('InvoicePaymentService', () => {
expect(paymentRepo.update).not.toHaveBeenCalled(); expect(paymentRepo.update).not.toHaveBeenCalled();
}); });
it('does nothing when there are no transfers to process', async () => { it('does not mutate payments when there are no transfers and no existing payments', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice()); transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
await service.processInvoice('invoice-1', []); 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();
expect(paymentRepo.delete).not.toHaveBeenCalled();
});
it('removes unconfirmed payments that are no longer reported when transfers are empty', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildBtcInvoice({
payments: [
{
id: 'payment-ghost',
txHash: 'ghost',
amountAtomic: '50000',
confirmations: 0
} as InvoicePayment
]
})
);
await service.processInvoice('invoice-btc-1', []);
expect(paymentRepo.delete).toHaveBeenCalledWith('payment-ghost');
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
expect(paymentRepo.update).not.toHaveBeenCalled();
}); });
it('skips transfers below the configured minimum', async () => { it('skips transfers below the configured minimum', async () => {
@@ -528,5 +552,34 @@ describe('InvoicePaymentService', () => {
confirmations: 1 confirmations: 1
}); });
}); });
it('removes unconfirmed payments that are no longer reported', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildBtcInvoice({
payments: [
{
id: 'payment-original',
txHash: 'original',
amountAtomic: '50000',
confirmations: 0
} as InvoicePayment,
{
id: 'payment-replacement',
txHash: 'replacement',
amountAtomic: '50000',
confirmations: 3
} as InvoicePayment
]
})
);
await service.processInvoice('invoice-btc-1', [
buildBtcTransfer({ txHash: 'replacement', amountAtomic: '50000', confirmations: 3 })
]);
expect(paymentRepo.delete).toHaveBeenCalledWith('payment-original');
expect(paymentRepo.update).not.toHaveBeenCalled();
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
});
}); });
}); });
@@ -135,8 +135,6 @@ export class InvoicePaymentService {
} }
private async processInvoice(invoiceId: string, transfers: InvoiceIncomingTransfer[]): Promise<void> { private async processInvoice(invoiceId: string, transfers: InvoiceIncomingTransfer[]): Promise<void> {
const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
await this.dataSource.transaction(async manager => { await this.dataSource.transaction(async manager => {
const invoiceRepo = manager.getRepository(Invoice); const invoiceRepo = manager.getRepository(Invoice);
const paymentRepo = manager.getRepository(InvoicePayment); const paymentRepo = manager.getRepository(InvoicePayment);
@@ -152,36 +150,62 @@ export class InvoicePaymentService {
return; return;
} }
const minIncomingAtomic = minByMethod[invoice.paymentMethod]; await this.upsertIncomingPayments(paymentRepo, invoice, transfers);
const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment]));
for (const transfer of transfers) { await this.pruneAbsentUnconfirmedPayments(paymentRepo, invoice, transfers);
const existing = knownByTxHash.get(transfer.txHash);
if (existing) {
if (existing.confirmations !== transfer.confirmations) {
await paymentRepo.update(existing.id, { confirmations: transfer.confirmations });
}
continue;
}
if (!isAtomicGte(transfer.amountAtomic, minIncomingAtomic)) {
continue;
}
await paymentRepo
.createQueryBuilder()
.insert()
.values({
invoice: { id: invoiceId },
txHash: transfer.txHash,
amountAtomic: transfer.amountAtomic,
confirmations: transfer.confirmations
})
.orIgnore()
.execute();
}
}); });
} }
private async upsertIncomingPayments(
paymentRepo: Repository<InvoicePayment>,
invoice: Invoice,
transfers: InvoiceIncomingTransfer[]
): Promise<void> {
const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
const minIncomingAtomic = minByMethod[invoice.paymentMethod];
const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment]));
for (const transfer of transfers) {
const existing = knownByTxHash.get(transfer.txHash);
if (existing) {
if (existing.confirmations !== transfer.confirmations) {
await paymentRepo.update(existing.id, { confirmations: transfer.confirmations });
}
continue;
}
if (!isAtomicGte(transfer.amountAtomic, minIncomingAtomic)) {
continue;
}
await paymentRepo
.createQueryBuilder()
.insert()
.values({
invoice: { id: invoice.id },
txHash: transfer.txHash,
amountAtomic: transfer.amountAtomic,
confirmations: transfer.confirmations
})
.orIgnore()
.execute();
}
}
private async pruneAbsentUnconfirmedPayments(
paymentRepo: Repository<InvoicePayment>,
invoice: Invoice,
transfers: InvoiceIncomingTransfer[]
): Promise<void> {
const activeTxHashes = new Set(transfers.map(transfer => transfer.txHash));
for (const payment of invoice.payments ?? []) {
if (payment.confirmations === 0 && !activeTxHashes.has(payment.txHash)) {
await paymentRepo.delete(payment.id);
}
}
}
} }