From a3913b9bc0b301ab406243f31adb266aa86e349b Mon Sep 17 00:00:00 2001 From: michael1011 Date: Mon, 20 Jan 2025 15:21:48 +0100 Subject: [PATCH] feat: be gentler to xpay --- lib/db/Migration.ts | 14 +- lib/db/models/LightningPayment.ts | 6 + .../LightningPaymentRepository.ts | 9 +- lib/lightning/PendingPaymentTracker.ts | 20 ++- .../ClnPendingPaymentTracker.ts | 23 ++- .../LndPendingPaymentTracker.ts | 13 +- .../NodePendingPaymentTracker.ts | 16 +- lib/service/Service.ts | 19 +-- lib/service/TimeoutDeltaProvider.ts | 5 +- lib/swap/NodeSwitch.ts | 65 ++++++-- lib/swap/PaymentHandler.ts | 4 +- package-lock.json | 8 +- package.json | 2 +- .../LightningPaymentRepository.spec.ts | 29 ++++ .../ClnPendingPaymentTracker.spec.ts | 6 +- .../LndPendingPaymentTracker.spec.ts | 16 +- .../lightning/PendingPaymentTracker.spec.ts | 29 +++- test/unit/swap/NodeSwitch.spec.ts | 148 ++++++++++++++---- 18 files changed, 346 insertions(+), 86 deletions(-) diff --git a/lib/db/Migration.ts b/lib/db/Migration.ts index af12a79b..d7e877be 100644 --- a/lib/db/Migration.ts +++ b/lib/db/Migration.ts @@ -93,7 +93,7 @@ const decodeInvoice = ( // TODO: integration tests for actual migrations class Migration { - private static latestSchemaVersion = 14; + private static latestSchemaVersion = 15; private toBackFill: number[] = []; @@ -657,6 +657,18 @@ class Migration { break; } + case 14: { + await this.sequelize + .getQueryInterface() + .addColumn(LightningPayment.tableName, 'retries', { + type: new DataTypes.INTEGER(), + allowNull: true, + }); + + await this.finishMigration(versionRow.version, currencies); + break; + } + default: throw `found unexpected database version ${versionRow.version}`; } diff --git a/lib/db/models/LightningPayment.ts b/lib/db/models/LightningPayment.ts index f1e58eee..ff65f6b8 100644 --- a/lib/db/models/LightningPayment.ts +++ b/lib/db/models/LightningPayment.ts @@ -14,6 +14,7 @@ type LightningPaymentType = { node: NodeType; status: LightningPaymentStatus; error?: string; + retries: number | null; }; class LightningPayment extends Model implements LightningPaymentType { @@ -21,6 +22,7 @@ class LightningPayment extends Model implements LightningPaymentType { public node!: NodeType; public status!: LightningPaymentStatus; public error?: string; + public retries!: number | null; public createdAt!: Date; public updatedAt!: Date; @@ -58,6 +60,10 @@ class LightningPayment extends Model implements LightningPaymentType { type: new DataTypes.STRING(), allowNull: true, }, + retries: { + type: new DataTypes.INTEGER(), + allowNull: true, + }, }, { sequelize, diff --git a/lib/db/repositories/LightningPaymentRepository.ts b/lib/db/repositories/LightningPaymentRepository.ts index ae048178..dc90d3fd 100644 --- a/lib/db/repositories/LightningPaymentRepository.ts +++ b/lib/db/repositories/LightningPaymentRepository.ts @@ -13,7 +13,7 @@ enum Errors { class LightningPaymentRepository { public static create = async ( - data: Omit, 'error'>, + data: Omit, 'error'>, 'retries'>, ) => { const existing = await LightningPayment.findOne({ where: { @@ -24,6 +24,7 @@ class LightningPaymentRepository { if (existing === null) { return LightningPayment.create({ ...data, + retries: 1, status: LightningPaymentStatus.Pending, }); } @@ -33,6 +34,7 @@ class LightningPaymentRepository { } return existing.update({ + retries: (existing.retries || 0) + 1, status: LightningPaymentStatus.Pending, }); }; @@ -69,6 +71,11 @@ class LightningPaymentRepository { public static findByPreimageHash = (preimageHash: string) => LightningPayment.findAll({ where: { preimageHash } }); + public static findByPreimageHashAndNode = ( + preimageHash: string, + node: NodeType, + ) => LightningPayment.findOne({ where: { preimageHash, node } }); + public static findByStatus = (status: LightningPaymentStatus) => LightningPayment.findAll({ where: { status }, diff --git a/lib/lightning/PendingPaymentTracker.ts b/lib/lightning/PendingPaymentTracker.ts index 24f422a8..cdb0d022 100644 --- a/lib/lightning/PendingPaymentTracker.ts +++ b/lib/lightning/PendingPaymentTracker.ts @@ -1,6 +1,6 @@ import Logger from '../Logger'; import { racePromise } from '../PromiseUtils'; -import { getHexBuffer, getHexString } from '../Utils'; +import { formatError, getHexBuffer, getHexString } from '../Utils'; import DefaultMap from '../consts/DefaultMap'; import LightningPayment, { LightningPaymentStatus, @@ -178,7 +178,9 @@ class PendingPaymentTracker { paymentPromise !== undefined ) { this.lightningTrackers[lightningClient.type].trackPayment( + lightningClient, preimageHash, + swap.invoice!, paymentPromise, ); this.logger.verbose( @@ -189,6 +191,22 @@ class PendingPaymentTracker { const isPermanentError = this.lightningTrackers[lightningClient.type].isPermanentError(e); + + // CLN xpay does throw errors while the payment is still pending + if ( + lightningClient.type === NodeType.CLN && + !isPermanentError && + formatError(e).includes('xpay') + ) { + this.lightningTrackers[lightningClient.type].watchPayment( + lightningClient, + swap.invoice!, + preimageHash, + ); + + return undefined; + } + await LightningPaymentRepository.setStatus( preimageHash, lightningClient.type, diff --git a/lib/lightning/paymentTrackers/ClnPendingPaymentTracker.ts b/lib/lightning/paymentTrackers/ClnPendingPaymentTracker.ts index bb939f70..797dd6f8 100644 --- a/lib/lightning/paymentTrackers/ClnPendingPaymentTracker.ts +++ b/lib/lightning/paymentTrackers/ClnPendingPaymentTracker.ts @@ -2,7 +2,7 @@ import Logger from '../../Logger'; import { formatError } from '../../Utils'; import { NodeType } from '../../db/models/ReverseSwap'; import LightningNursery from '../../swap/LightningNursery'; -import { LightningClient } from '../LightningClient'; +import { LightningClient, PaymentResponse } from '../LightningClient'; import ClnClient from '../cln/ClnClient'; import NodePendingPendingTracker from './NodePendingPaymentTracker'; @@ -33,6 +33,27 @@ class ClnPendingPaymentTracker extends NodePendingPendingTracker { clearInterval(this.checkInterval as unknown as number); }; + public trackPayment = ( + client: LightningClient, + preimageHash: string, + invoice: string, + promise: Promise, + ): void => { + promise + .then((result) => this.handleSucceededPayment(preimageHash, result)) + .catch((error) => { + // CLN xpay throws errors while the payment is still pending + if ( + !this.isPermanentError(error) && + formatError(error).includes('xpay') + ) { + this.watchPayment(client, invoice, preimageHash); + } else { + this.handleFailedPayment(preimageHash, error); + } + }); + }; + public watchPayment = ( client: LightningClient, invoice: string, diff --git a/lib/lightning/paymentTrackers/LndPendingPaymentTracker.ts b/lib/lightning/paymentTrackers/LndPendingPaymentTracker.ts index 04dc0a7b..49964497 100644 --- a/lib/lightning/paymentTrackers/LndPendingPaymentTracker.ts +++ b/lib/lightning/paymentTrackers/LndPendingPaymentTracker.ts @@ -3,7 +3,7 @@ import { formatError, getHexBuffer } from '../../Utils'; import { NodeType, nodeTypeToPrettyString } from '../../db/models/ReverseSwap'; import { Payment, PaymentFailureReason } from '../../proto/lnd/rpc_pb'; import LightningNursery from '../../swap/LightningNursery'; -import { LightningClient } from '../LightningClient'; +import { LightningClient, PaymentResponse } from '../LightningClient'; import LndClient from '../LndClient'; import NodePendingPendingTracker from './NodePendingPaymentTracker'; @@ -12,6 +12,17 @@ class LndPendingPaymentTracker extends NodePendingPendingTracker { super(logger, NodeType.LND); } + public trackPayment = ( + _client: LightningClient, + preimageHash: string, + _invoice: string, + promise: Promise, + ): void => { + promise + .then((result) => this.handleSucceededPayment(preimageHash, result)) + .catch((error) => this.handleFailedPayment(preimageHash, error)); + }; + public watchPayment = ( client: LightningClient, _: string, diff --git a/lib/lightning/paymentTrackers/NodePendingPaymentTracker.ts b/lib/lightning/paymentTrackers/NodePendingPaymentTracker.ts index 4cecd4fe..65cfe5b3 100644 --- a/lib/lightning/paymentTrackers/NodePendingPaymentTracker.ts +++ b/lib/lightning/paymentTrackers/NodePendingPaymentTracker.ts @@ -11,6 +11,13 @@ abstract class NodePendingPendingTracker { protected readonly nodeType: NodeType, ) {} + public abstract trackPayment( + client: LightningClient, + preimageHash: string, + invoice: string, + promise: Promise, + ): void; + public abstract watchPayment( client: LightningClient, invoice: string, @@ -21,15 +28,6 @@ abstract class NodePendingPendingTracker { public abstract parseErrorMessage(error: unknown): string; - public trackPayment = ( - preimageHash: string, - promise: Promise, - ) => { - promise - .then((result) => this.handleSucceededPayment(preimageHash, result)) - .catch((error) => this.handleFailedPayment(preimageHash, error)); - }; - protected handleSucceededPayment = async ( preimageHash: string, result: PaymentResponse, diff --git a/lib/service/Service.ts b/lib/service/Service.ts index 28f7fc43..fd6531ef 100644 --- a/lib/service/Service.ts +++ b/lib/service/Service.ts @@ -1325,9 +1325,9 @@ class Service { ]); swap.invoiceAmount = msatToSat(decodedInvoice.amountMsat); - const lightningClient = this.nodeSwitch.getSwapNode( + const lightningClient = await this.nodeSwitch.getSwapNode( this.currencies.get(lightningCurrency)!, - decodedInvoice.type, + decodedInvoice, swap, ); @@ -1399,13 +1399,14 @@ class Service { swap.invoiceAmount = msatToSat(decodedInvoice.amountMsat); - const { destination, features } = await this.nodeSwitch - .getSwapNode( - getCurrency(this.currencies, lightningCurrency)!, - decodedInvoice.type, - swap, - ) - .decodeInvoice(invoice); + const lightningClient = await this.nodeSwitch.getSwapNode( + getCurrency(this.currencies, lightningCurrency)!, + decodedInvoice, + swap, + ); + + const { destination, features } = + await lightningClient.decodeInvoice(invoice); if (this.nodeInfo.isOurNode(destination)) { throw Errors.DESTINATION_BOLTZ_NODE(); diff --git a/lib/service/TimeoutDeltaProvider.ts b/lib/service/TimeoutDeltaProvider.ts index c72cb56c..b0f16fc8 100644 --- a/lib/service/TimeoutDeltaProvider.ts +++ b/lib/service/TimeoutDeltaProvider.ts @@ -290,12 +290,11 @@ class TimeoutDeltaProvider { const decodedInvoice = await this.sidecar.decodeInvoiceOrOffer(invoice); const amountSat = msatToSat(decodedInvoice.amountMsat); - const lightningClient = this.nodeSwitch.getSwapNode( + const lightningClient = await this.nodeSwitch.getSwapNode( currency, - decodedInvoice.type, + decodedInvoice, { referral: referralId, - invoiceAmount: amountSat, }, ); diff --git a/lib/swap/NodeSwitch.ts b/lib/swap/NodeSwitch.ts index 41a4fb5c..dcabd53c 100644 --- a/lib/swap/NodeSwitch.ts +++ b/lib/swap/NodeSwitch.ts @@ -1,7 +1,10 @@ import Logger from '../Logger'; +import { getHexString } from '../Utils'; import ReverseSwap, { NodeType } from '../db/models/ReverseSwap'; +import LightningPaymentRepository from '../db/repositories/LightningPaymentRepository'; +import { msatToSat } from '../lightning/ChannelUtils'; import { LightningClient } from '../lightning/LightningClient'; -import { InvoiceType } from '../sidecar/DecodedInvoice'; +import DecodedInvoice, { InvoiceType } from '../sidecar/DecodedInvoice'; import { Currency } from '../wallet/WalletManager'; import Errors from './Errors'; @@ -14,6 +17,7 @@ type NodeSwitchConfig = { class NodeSwitch { private static readonly defaultClnAmountThreshold = 1_000_000; + private static readonly maxClnRetries = 1; private readonly clnAmountThreshold: number; private readonly referralIds = new Map(); @@ -21,7 +25,7 @@ class NodeSwitch { private readonly swapNode?: NodeType; constructor( - private logger: Logger, + private readonly logger: Logger, cfg?: NodeSwitchConfig, ) { this.clnAmountThreshold = @@ -65,19 +69,52 @@ class NodeSwitch { ); }; - public getSwapNode = ( + public getSwapNode = async ( currency: Currency, - invoiceType: InvoiceType, - swap: { id?: string; invoiceAmount?: number; referral?: string }, - ): LightningClient => { - const client = NodeSwitch.fallback( - currency, - invoiceType === InvoiceType.Bolt11 - ? this.swapNode !== undefined - ? NodeSwitch.switchOnNodeType(currency, this.swapNode) - : this.switch(currency, swap.invoiceAmount, swap.referral) - : currency.clnClient, - ); + decoded: DecodedInvoice, + swap: { + id?: string; + referral?: string; + }, + ): Promise => { + const selectNode = (preferredNode?: NodeType) => { + return NodeSwitch.fallback( + currency, + decoded.type === InvoiceType.Bolt11 + ? preferredNode !== undefined + ? NodeSwitch.switchOnNodeType(currency, preferredNode) + : this.switch( + currency, + msatToSat(decoded.amountMsat), + swap.referral, + ) + : currency.clnClient, + ); + }; + + let client = selectNode(this.swapNode); + + // Go easy on CLN xpay + if (client.type === NodeType.CLN && decoded.type === InvoiceType.Bolt11) { + if (decoded.paymentHash !== undefined) { + const existingPayment = + await LightningPaymentRepository.findByPreimageHashAndNode( + getHexString(decoded.paymentHash), + client.type, + ); + + if ( + existingPayment?.retries !== null && + existingPayment?.retries !== undefined && + existingPayment.retries >= NodeSwitch.maxClnRetries + ) { + this.logger.debug( + `Max CLN retries reached for invoice with hash ${getHexString(decoded.paymentHash)}; preferring LND`, + ); + client = selectNode(NodeType.LND); + } + } + } if (swap.id !== undefined) { this.logger.debug( diff --git a/lib/swap/PaymentHandler.ts b/lib/swap/PaymentHandler.ts index 8216ded0..06ec12dc 100644 --- a/lib/swap/PaymentHandler.ts +++ b/lib/swap/PaymentHandler.ts @@ -119,9 +119,9 @@ class PaymentHandler { ); const lightningCurrency = this.currencies.get(lightningSymbol)!; - const lightningClient = this.nodeSwitch.getSwapNode( + const lightningClient = await this.nodeSwitch.getSwapNode( lightningCurrency, - (await this.sidecar.decodeInvoiceOrOffer(swap.invoice!)).type, + await this.sidecar.decodeInvoiceOrOffer(swap.invoice!), swap, ); diff --git a/package-lock.json b/package-lock.json index 75c3f610..b3f68927 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,7 +70,7 @@ "cross-os": "^1.5.0", "eslint": "^9.18.0", "eslint-plugin-import": "^2.31.0", - "eslint-plugin-jest": "^28.10.0", + "eslint-plugin-jest": "^28.11.0", "eslint-plugin-n": "^17.15.1", "eslint-plugin-node": "^11.1.0", "git-cliff": "^2.7.0", @@ -5711,9 +5711,9 @@ } }, "node_modules/eslint-plugin-jest": { - "version": "28.10.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.10.0.tgz", - "integrity": "sha512-hyMWUxkBH99HpXT3p8hc7REbEZK3D+nk8vHXGgpB+XXsi0gO4PxMSP+pjfUzb67GnV9yawV9a53eUmcde1CCZA==", + "version": "28.11.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.11.0.tgz", + "integrity": "sha512-QAfipLcNCWLVocVbZW8GimKn5p5iiMcgGbRzz8z/P5q7xw+cNEpYqyzFMtIF/ZgF2HLOyy+dYBut+DoYolvqig==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 580c261b..412d15c7 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,7 @@ "cross-os": "^1.5.0", "eslint": "^9.18.0", "eslint-plugin-import": "^2.31.0", - "eslint-plugin-jest": "^28.10.0", + "eslint-plugin-jest": "^28.11.0", "eslint-plugin-n": "^17.15.1", "eslint-plugin-node": "^11.1.0", "git-cliff": "^2.7.0", diff --git a/test/integration/db/repositories/LightningPaymentRepository.spec.ts b/test/integration/db/repositories/LightningPaymentRepository.spec.ts index 2290319e..ad07e889 100644 --- a/test/integration/db/repositories/LightningPaymentRepository.spec.ts +++ b/test/integration/db/repositories/LightningPaymentRepository.spec.ts @@ -42,6 +42,7 @@ describe('LightningPaymentRepository', () => { node: NodeType.LND, preimageHash: swap.preimageHash, }); + expect(payment.retries).toEqual(1); expect(payment.node).toEqual(NodeType.LND); expect(payment.preimageHash).toEqual(swap.preimageHash); expect(payment.status).toEqual(LightningPaymentStatus.Pending); @@ -71,6 +72,7 @@ describe('LightningPaymentRepository', () => { test(`should update when payment with status ${LightningPaymentStatus.TemporaryFailure} exists already`, async () => { const swap = await Swap.create(createSubmarineSwapData()); const existing = await LightningPayment.create({ + retries: 3, node: NodeType.LND, preimageHash: swap.preimageHash, status: LightningPaymentStatus.TemporaryFailure, @@ -81,6 +83,7 @@ describe('LightningPaymentRepository', () => { preimageHash: swap.preimageHash, }); expect(payment.node).toEqual(NodeType.LND); + expect(payment.retries).toEqual(existing.retries! + 1); expect(payment.preimageHash).toEqual(swap.preimageHash); expect(payment.status).toEqual(LightningPaymentStatus.Pending); @@ -232,6 +235,32 @@ describe('LightningPaymentRepository', () => { }); }); + describe('findByPreimageHashAndNode', () => { + test('should find by preimage hash and node', async () => { + const swap = await Swap.create(createSubmarineSwapData()); + + await LightningPaymentRepository.create({ + node: NodeType.LND, + preimageHash: swap.preimageHash, + }); + + const fetched = + await LightningPaymentRepository.findByPreimageHashAndNode( + swap.preimageHash, + NodeType.LND, + ); + expect(fetched!.node).toEqual(NodeType.LND); + expect(fetched!.preimageHash).toEqual(swap.preimageHash); + + await expect( + LightningPaymentRepository.findByPreimageHashAndNode( + swap.preimageHash, + NodeType.CLN, + ), + ).resolves.toBeNull(); + }); + }); + describe('findByStatus', () => { test('should find by status', async () => { const swap = await Swap.create(createSubmarineSwapData()); diff --git a/test/integration/lightning/paymentTrackers/ClnPendingPaymentTracker.spec.ts b/test/integration/lightning/paymentTrackers/ClnPendingPaymentTracker.spec.ts index 0458cccd..fed4471f 100644 --- a/test/integration/lightning/paymentTrackers/ClnPendingPaymentTracker.spec.ts +++ b/test/integration/lightning/paymentTrackers/ClnPendingPaymentTracker.spec.ts @@ -49,7 +49,7 @@ describe('ClnPendingPaymentTracker', () => { const promise = clnClient.sendPayment(paymentRequest); - tracker.trackPayment(preimageHash, promise); + tracker.trackPayment(clnClient, preimageHash, paymentRequest, promise); await promise; expect(LightningPaymentRepository.setStatus).toHaveBeenCalledTimes(1); @@ -69,7 +69,7 @@ describe('ClnPendingPaymentTracker', () => { const promise = clnClient.sendPayment(paymentRequest); - tracker.trackPayment(preimageHash, promise); + tracker.trackPayment(clnClient, preimageHash, paymentRequest, promise); await expect(promise).rejects.toEqual(expect.anything()); expect(LightningPaymentRepository.setStatus).toHaveBeenCalledTimes(1); @@ -89,7 +89,7 @@ describe('ClnPendingPaymentTracker', () => { .tags.find((tag) => tag.tagName === 'payment_hash')!.data as string; const promise = clnClient.sendPayment(invoice); - tracker.trackPayment(preimageHash, promise); + tracker.trackPayment(clnClient, preimageHash, invoice, promise); await expect(promise).rejects.toEqual(expect.anything()); expect(LightningPaymentRepository.setStatus).toHaveBeenCalledTimes(1); diff --git a/test/integration/lightning/paymentTrackers/LndPendingPaymentTracker.spec.ts b/test/integration/lightning/paymentTrackers/LndPendingPaymentTracker.spec.ts index 8b32bb0c..10936ad7 100644 --- a/test/integration/lightning/paymentTrackers/LndPendingPaymentTracker.spec.ts +++ b/test/integration/lightning/paymentTrackers/LndPendingPaymentTracker.spec.ts @@ -53,7 +53,12 @@ describe('LndPendingPaymentTracker', () => { const promise = bitcoinLndClient.sendPayment(paymentRequest); - tracker.trackPayment(preimageHash, promise); + tracker.trackPayment( + bitcoinLndClient, + preimageHash, + paymentRequest, + promise, + ); await promise; expect(LightningPaymentRepository.setStatus).toHaveBeenCalledTimes(1); @@ -74,7 +79,12 @@ describe('LndPendingPaymentTracker', () => { const promise = bitcoinLndClient.sendPayment(paymentRequest); - tracker.trackPayment(preimageHash, promise); + tracker.trackPayment( + bitcoinLndClient, + preimageHash, + paymentRequest, + promise, + ); await expect(promise).rejects.toEqual( PaymentFailureReason.FAILURE_REASON_INCORRECT_PAYMENT_DETAILS, ); @@ -98,7 +108,7 @@ describe('LndPendingPaymentTracker', () => { .tags.find((tag) => tag.tagName === 'payment_hash')!.data as string; const promise = bitcoinLndClient.sendPayment(invoice); - tracker.trackPayment(preimageHash, promise); + tracker.trackPayment(bitcoinLndClient, preimageHash, invoice, promise); await expect(promise).rejects.toEqual( PaymentFailureReason.FAILURE_REASON_NO_ROUTE, ); diff --git a/test/unit/lightning/PendingPaymentTracker.spec.ts b/test/unit/lightning/PendingPaymentTracker.spec.ts index 98952106..5cca0641 100644 --- a/test/unit/lightning/PendingPaymentTracker.spec.ts +++ b/test/unit/lightning/PendingPaymentTracker.spec.ts @@ -67,7 +67,7 @@ describe('PendingPaymentTracker', () => { ); }); - test('should not throw when swap no referral can be found', async () => { + test('should not throw no referral for swap can be found', async () => { const swap = { pair: 'BTC/BTC', referral: 'test', @@ -120,5 +120,32 @@ describe('PendingPaymentTracker', () => { undefined, ); }); + + test('should watch payment for temporiraly failed CLN payments', async () => { + const clnClient = { + type: NodeType.CLN, + sendPayment: jest.fn().mockRejectedValue('xpay doing something weird'), + } as unknown as LightningClient; + + tracker.lightningTrackers[NodeType.CLN].watchPayment = jest.fn(); + + const swap = { + pair: 'BTC/BTC', + invoice: 'invoice', + } as unknown as Swap; + + const preimageHash = getHexString(randomBytes(32)); + + await expect( + tracker['sendPaymentWithNode'](swap, clnClient, preimageHash), + ).resolves.toEqual(undefined); + + expect( + tracker.lightningTrackers[NodeType.CLN].watchPayment, + ).toHaveBeenCalledTimes(1); + expect( + tracker.lightningTrackers[NodeType.CLN].watchPayment, + ).toHaveBeenCalledWith(clnClient, swap.invoice, preimageHash); + }); }); }); diff --git a/test/unit/swap/NodeSwitch.spec.ts b/test/unit/swap/NodeSwitch.spec.ts index b73e3113..d97afa76 100644 --- a/test/unit/swap/NodeSwitch.spec.ts +++ b/test/unit/swap/NodeSwitch.spec.ts @@ -1,10 +1,17 @@ +import { randomBytes } from 'crypto'; import Logger from '../../../lib/Logger'; +import { getHexString } from '../../../lib/Utils'; +import LightningPayment from '../../../lib/db/models/LightningPayment'; import ReverseSwap, { NodeType } from '../../../lib/db/models/ReverseSwap'; import Swap from '../../../lib/db/models/Swap'; +import LightningPaymentRepository from '../../../lib/db/repositories/LightningPaymentRepository'; +import { satToMsat } from '../../../lib/lightning/ChannelUtils'; import { LightningClient } from '../../../lib/lightning/LightningClient'; import LndClient from '../../../lib/lightning/LndClient'; import ClnClient from '../../../lib/lightning/cln/ClnClient'; -import { InvoiceType } from '../../../lib/sidecar/DecodedInvoice'; +import DecodedInvoice, { + InvoiceType, +} from '../../../lib/sidecar/DecodedInvoice'; import Errors from '../../../lib/swap/Errors'; import NodeSwitch from '../../../lib/swap/NodeSwitch'; import { Currency } from '../../../lib/wallet/WalletManager'; @@ -12,15 +19,23 @@ import { Currency } from '../../../lib/wallet/WalletManager'; describe('NodeSwitch', () => { const createNode = ( service: string, + type: NodeType, connected: boolean = true, ): LightningClient => ({ + type, serviceName: () => service, isConnected: () => connected, }) as LightningClient; - const clnClient = createNode(ClnClient.serviceName) as LndClient; - const lndClient = createNode(LndClient.serviceName) as ClnClient; + const clnClient = createNode( + ClnClient.serviceName, + NodeType.CLN, + ) as LndClient; + const lndClient = createNode( + LndClient.serviceName, + NodeType.LND, + ) as ClnClient; let currency = { clnClient, @@ -84,15 +99,21 @@ describe('NodeSwitch', () => { ${2_000_000} | ${undefined} | ${clnClient} | ${{ clnClient }} `( 'should get node for Swap of amount $amount and referral id $referral', - ({ amount, client, currency, referral }) => { - expect( + async ({ amount, client, currency, referral }) => { + await expect( new NodeSwitch(Logger.disabledLogger, { referralsIds: { breez: 'LND' }, - }).getSwapNode(currency, InvoiceType.Bolt11, { - referral, - invoiceAmount: amount, - } as Swap), - ).toEqual(client); + }).getSwapNode( + currency, + { + type: InvoiceType.Bolt11, + amountMsat: satToMsat(amount), + } as DecodedInvoice, + { + referral, + } as Swap, + ), + ).resolves.toEqual(client); }, ); @@ -100,13 +121,18 @@ describe('NodeSwitch', () => { type | client ${InvoiceType.Bolt11} | ${lndClient} ${InvoiceType.Bolt12Invoice} | ${clnClient} - `('should get node for Swap with invoice type $type', ({ type, client }) => { - expect( - new NodeSwitch(Logger.disabledLogger, {}).getSwapNode(currency, type, { - invoiceAmount: 1_000_001, - }), - ).toEqual(client); - }); + `( + 'should get node for Swap with invoice type $type', + async ({ type, client }) => { + await expect( + new NodeSwitch(Logger.disabledLogger, {}).getSwapNode( + currency, + { type, amountMsat: satToMsat(1_000_001) } as DecodedInvoice, + {}, + ), + ).resolves.toEqual(client); + }, + ); test.each` swapNode | currency | expected @@ -115,15 +141,71 @@ describe('NodeSwitch', () => { ${'LND'} | ${{ clnClient }} | ${clnClient} `( 'should get node for Swap with swapNode $swapNode configured', - ({ swapNode, currency, expected }) => { - expect( + async ({ swapNode, currency, expected }) => { + await expect( new NodeSwitch(Logger.disabledLogger, { swapNode, - }).getSwapNode(currency, InvoiceType.Bolt11, {} as Swap), - ).toEqual(expected); + }).getSwapNode( + currency, + { type: InvoiceType.Bolt11 } as DecodedInvoice, + {} as Swap, + ), + ).resolves.toEqual(expected); }, ); + describe('xpay handling', () => { + afterAll(() => { + LightningPaymentRepository.findByPreimageHashAndNode = jest + .fn() + .mockResolvedValue(null); + }); + + test.each` + retries | expected + ${null} | ${clnClient} + ${undefined} | ${clnClient} + ${0} | ${clnClient} + ${NodeSwitch['maxClnRetries'] - 1} | ${clnClient} + ${NodeSwitch['maxClnRetries']} | ${lndClient} + ${NodeSwitch['maxClnRetries'] + 1} | ${lndClient} + ${1} | ${lndClient} + ${2} | ${lndClient} + ${3} | ${lndClient} + `( + 'should fallback to LND when xpay max retries are reached', + async ({ retries, expected }) => { + const decoded = { + type: InvoiceType.Bolt11, + paymentHash: randomBytes(32), + amountMsat: satToMsat(21_000), + } as DecodedInvoice; + + LightningPaymentRepository.findByPreimageHashAndNode = jest + .fn() + .mockResolvedValue({ retries } as LightningPayment); + + await expect( + new NodeSwitch(Logger.disabledLogger, {}).getSwapNode( + currency, + decoded, + {}, + ), + ).resolves.toEqual(expected); + + expect( + LightningPaymentRepository.findByPreimageHashAndNode, + ).toHaveBeenCalledTimes(1); + expect( + LightningPaymentRepository.findByPreimageHashAndNode, + ).toHaveBeenCalledWith( + getHexString(decoded.paymentHash!), + NodeType.CLN, + ); + }, + ); + }); + test.each` type | node ${NodeType.LND} | ${lndClient} @@ -172,14 +254,14 @@ describe('NodeSwitch', () => { }); test.each` - currency | client | expected - ${{ lndClient: lndClient, clnClient: clnClient }} | ${lndClient} | ${lndClient} - ${{ lndClient: lndClient, clnClient: clnClient }} | ${clnClient} | ${clnClient} - ${{ lndClient: lndClient, clnClient: clnClient }} | ${undefined} | ${lndClient} - ${{ clnClient: clnClient }} | ${lndClient} | ${lndClient} - ${{ clnClient: clnClient }} | ${undefined} | ${clnClient} - ${{ lndClient: createNode('LND', false), clnClient: clnClient }} | ${undefined} | ${clnClient} - ${{}} | ${lndClient} | ${lndClient} + currency | client | expected + ${{ lndClient: lndClient, clnClient: clnClient }} | ${lndClient} | ${lndClient} + ${{ lndClient: lndClient, clnClient: clnClient }} | ${clnClient} | ${clnClient} + ${{ lndClient: lndClient, clnClient: clnClient }} | ${undefined} | ${lndClient} + ${{ clnClient: clnClient }} | ${lndClient} | ${lndClient} + ${{ clnClient: clnClient }} | ${undefined} | ${clnClient} + ${{ lndClient: createNode('LND', NodeType.LND, false), clnClient: clnClient }} | ${undefined} | ${clnClient} + ${{}} | ${lndClient} | ${lndClient} `( 'should fallback based on client availability', ({ currency, client, expected }) => { @@ -192,12 +274,14 @@ describe('NodeSwitch', () => { Errors.NO_AVAILABLE_LIGHTNING_CLIENT().message, ); expect(() => - NodeSwitch.fallback({ lndClient: createNode('LND', false) } as Currency), + NodeSwitch.fallback({ + lndClient: createNode('LND', NodeType.LND, false), + } as Currency), ).toThrow(Errors.NO_AVAILABLE_LIGHTNING_CLIENT().message); expect(() => NodeSwitch.fallback({ - lndClient: createNode('LND', false), - clnClient: createNode('CLN', false), + lndClient: createNode('LND', NodeType.LND, false), + clnClient: createNode('CLN', NodeType.CLN, false), } as Currency), ).toThrow(Errors.NO_AVAILABLE_LIGHTNING_CLIENT().message); });