diff --git a/docs/deployment.md b/docs/deployment.md index d57bed1b..91c95ee3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -106,16 +106,21 @@ otpsecretpath = "/home/boltz/.boltz/otpSecret.dat" base = "BTC" quote = "BTC" rate = 1 -timeoutDelta = 400 maxSwapAmount = 10_000_000 minSwapAmount = 10_000 +# Expiry of the invoices generated for reverse swaps of this pair +# If not set, half of the expiry time of the reverse swap will be used +invoiceExpiry = 7200 + +timeoutDelta = 400 # Alternatively, the timeouts of swaps of a pair can be set like this # [pairs.timeoutDelta] # reverse = 1440 # swapMinimal = 1440 # swapMaximal = 2880 + [[pairs]] base = "L-BTC" quote = "BTC" diff --git a/lib/Boltz.ts b/lib/Boltz.ts index 53acfd12..1cb4ddce 100644 --- a/lib/Boltz.ts +++ b/lib/Boltz.ts @@ -15,18 +15,18 @@ import GrpcService from './grpc/GrpcService'; import ClnClient from './lightning/ClnClient'; import LndClient from './lightning/LndClient'; import ChainClient from './chain/ChainClient'; -import Config, { ConfigType, TokenConfig } from './Config'; import { CurrencyType } from './consts/Enums'; import { formatError, getVersion } from './Utils'; import ElementsClient from './chain/ElementsClient'; import { registerExitHandler } from './ExitHandler'; import BackupScheduler from './backup/BackupScheduler'; -import { Ethereum, NetworkDetails, Rsk } from './wallet/ethereum/EvmNetworks'; +import Config, { ConfigType, TokenConfig } from './Config'; import { LightningClient } from './lightning/LightningClient'; import EthereumManager from './wallet/ethereum/EthereumManager'; import WalletManager, { Currency } from './wallet/WalletManager'; import ChainTipRepository from './db/repositories/ChainTipRepository'; import NotificationProvider from './notifications/NotificationProvider'; +import { Ethereum, NetworkDetails, Rsk } from './wallet/ethereum/EvmNetworks'; class Boltz { private readonly logger: Logger; @@ -187,7 +187,10 @@ class Boltz { await this.walletManager.init(this.config.currencies); await this.service.init(this.config.pairs); - await this.service.swapManager.init(Array.from(this.currencies.values())); + await this.service.swapManager.init( + Array.from(this.currencies.values()), + this.config.pairs, + ); await this.notifications.init(); diff --git a/lib/Config.ts b/lib/Config.ts index 335463af..50c20ece 100644 --- a/lib/Config.ts +++ b/lib/Config.ts @@ -60,8 +60,6 @@ type CurrencyConfig = BaseCurrencyConfig & { cln?: ClnConfig; routingOffsetExceptions?: RoutingOffsetException[]; - // Expiry for invoices of this currency in seconds - invoiceExpiry?: number; // Max fee ratio for LND's sendPayment maxPaymentFeeRatio?: number; diff --git a/lib/consts/Types.ts b/lib/consts/Types.ts index d22a940f..ab96c680 100644 --- a/lib/consts/Types.ts +++ b/lib/consts/Types.ts @@ -37,6 +37,10 @@ export type PairConfig = { // If there is a hardcoded rate the APIs of the exchanges will not be queried rate?: number; + // Expiry for invoices of this pair in seconds + // Defaults to 50% of the expiry time of reverse swaps + invoiceExpiry?: number; + // The timeout of the swaps on this pair in minutes timeoutDelta?: PairTimeoutBlocksDelta | number; diff --git a/lib/lightning/LightningClient.ts b/lib/lightning/LightningClient.ts index adfbb49a..78770eec 100644 --- a/lib/lightning/LightningClient.ts +++ b/lib/lightning/LightningClient.ts @@ -1,8 +1,8 @@ import bolt11 from 'bolt11'; import * as lndrpc from '../proto/lnd/rpc_pb'; +import { IBaseClient } from '../BaseClient'; import { ClientStatus } from '../consts/Enums'; import { BalancerFetcher } from '../wallet/providers/WalletProviderInterface'; -import { IBaseClient } from '../BaseClient'; enum InvoiceState { Open, diff --git a/lib/service/InvoiceExpiryHelper.ts b/lib/service/InvoiceExpiryHelper.ts index 6581c94b..e5aab48d 100644 --- a/lib/service/InvoiceExpiryHelper.ts +++ b/lib/service/InvoiceExpiryHelper.ts @@ -1,21 +1,36 @@ -import { CurrencyConfig } from '../Config'; +import { getPairId } from '../Utils'; +import { PairConfig } from '../consts/Types'; +import TimeoutDeltaProvider from './TimeoutDeltaProvider'; class InvoiceExpiryHelper { private static readonly defaultInvoiceExpiry = 3600; private readonly invoiceExpiry = new Map(); - constructor(currencies: CurrencyConfig[]) { - for (const currency of currencies) { - if (currency.invoiceExpiry) { - this.invoiceExpiry.set(currency.symbol, currency.invoiceExpiry); + constructor(pairs: PairConfig[], timeoutDeltaProvider: TimeoutDeltaProvider) { + for (const pair of pairs) { + const pairId = getPairId(pair); + + if (pair.invoiceExpiry) { + this.invoiceExpiry.set(pairId, pair.invoiceExpiry); + continue; } + + const delta = timeoutDeltaProvider.timeoutDeltas.get(getPairId(pair))! + .quote.reverse; + + // Convert to seconds and divide by 2 + const expiry = Math.ceil( + (delta * TimeoutDeltaProvider.blockTimes.get(pair.quote)! * 60) / 2, + ); + + this.invoiceExpiry.set(pairId, expiry); } } - public getExpiry = (symbol: string): number => { + public getExpiry = (pair: string): number => { return ( - this.invoiceExpiry.get(symbol) || InvoiceExpiryHelper.defaultInvoiceExpiry + this.invoiceExpiry.get(pair) || InvoiceExpiryHelper.defaultInvoiceExpiry ); }; @@ -35,7 +50,7 @@ class InvoiceExpiryHelper { if (timeExpireDate) { invoiceExpiry = timeExpireDate; } else { - invoiceExpiry += 3600; + invoiceExpiry += InvoiceExpiryHelper.defaultInvoiceExpiry; } return invoiceExpiry; diff --git a/lib/service/Service.ts b/lib/service/Service.ts index aef4404b..ea6e3370 100644 --- a/lib/service/Service.ts +++ b/lib/service/Service.ts @@ -17,7 +17,6 @@ import LndClient from '../lightning/LndClient'; import ElementsService from './ElementsService'; import SwapOutputType from '../swap/SwapOutputType'; import ElementsClient from '../chain/ElementsClient'; -import InvoiceExpiryHelper from './InvoiceExpiryHelper'; import PaymentRequestUtils from './PaymentRequestUtils'; import PairRepository from '../db/repositories/PairRepository'; import SwapRepository from '../db/repositories/SwapRepository'; @@ -145,7 +144,6 @@ class Service { this.nodeSwitch, this.rateProvider, this.timeoutDeltaProvider, - new InvoiceExpiryHelper(config.currencies), new SwapOutputType( config.swapwitnessaddress ? OutputType.Bech32 diff --git a/lib/swap/SwapManager.ts b/lib/swap/SwapManager.ts index 8bd62e02..9604c17f 100644 --- a/lib/swap/SwapManager.ts +++ b/lib/swap/SwapManager.ts @@ -8,6 +8,7 @@ import Swap from '../db/models/Swap'; import NodeSwitch from './NodeSwitch'; import SwapNursery from './SwapNursery'; import NodeFallback from './NodeFallback'; +import { PairConfig } from '../consts/Types'; import SwapOutputType from './SwapOutputType'; import RateProvider from '../rates/RateProvider'; import RoutingHints from './routing/RoutingHints'; @@ -61,15 +62,15 @@ class SwapManager { public routingHints!: RoutingHints; private nodeFallback!: NodeFallback; + private invoiceExpiryHelper!: InvoiceExpiryHelper; constructor( - private logger: Logger, - private walletManager: WalletManager, - private nodeSwitch: NodeSwitch, + private readonly logger: Logger, + private readonly walletManager: WalletManager, + private readonly nodeSwitch: NodeSwitch, rateProvider: RateProvider, - timeoutDeltaProvider: TimeoutDeltaProvider, - private invoiceExpiryHelper: InvoiceExpiryHelper, - private swapOutputType: SwapOutputType, + private readonly timeoutDeltaProvider: TimeoutDeltaProvider, + private readonly swapOutputType: SwapOutputType, retryInterval: number, ) { this.nursery = new SwapNursery( @@ -83,7 +84,10 @@ class SwapManager { ); } - public init = async (currencies: Currency[]): Promise => { + public init = async ( + currencies: Currency[], + pairs: PairConfig[], + ): Promise => { currencies.forEach((currency) => { this.currencies.set(currency.symbol, currency); }); @@ -128,6 +132,11 @@ class SwapManager { this.nodeSwitch, this.routingHints, ); + + this.invoiceExpiryHelper = new InvoiceExpiryHelper( + pairs, + this.timeoutDeltaProvider, + ); }; /** @@ -517,6 +526,11 @@ class SwapManager { ); } + const pair = getPairId({ + base: args.baseCurrency, + quote: args.quoteCurrency, + }); + const { nodeType, lightningClient, paymentRequest, routingHints } = await this.nodeFallback.getReverseSwapInvoice( id, @@ -526,7 +540,7 @@ class SwapManager { args.holdInvoiceAmount, args.preimageHash, args.lightningTimeoutBlockDelta, - this.invoiceExpiryHelper.getExpiry(receivingCurrency.symbol), + this.invoiceExpiryHelper.getExpiry(pair), getSwapMemo(sendingCurrency.symbol, true), ); @@ -545,7 +559,7 @@ class SwapManager { args.prepayMinerFeeInvoiceAmount, minerFeeInvoicePreimageHash, undefined, - this.invoiceExpiryHelper.getExpiry(receivingCurrency.symbol), + this.invoiceExpiryHelper.getExpiry(pair), getPrepayMinerFeeInvoiceMemo(sendingCurrency.symbol), routingHints, ); @@ -554,16 +568,15 @@ class SwapManager { if (args.prepayMinerFeeOnchainAmount) { this.logger.debug( - `Sending ${args.prepayMinerFeeOnchainAmount} Ether as prepay miner fee for Reverse Swap: ${id}`, + `Sending ${args.prepayMinerFeeOnchainAmount} ${ + this.walletManager.ethereumManagers.find((manager) => + manager.hasSymbol(sendingCurrency.symbol), + )!.networkDetails.name + } as prepay miner fee for Reverse Swap: ${id}`, ); } } - const pair = getPairId({ - base: args.baseCurrency, - quote: args.quoteCurrency, - }); - let lockupAddress: string; let timeoutBlockHeight: number; diff --git a/test/integration/lightning/ClnClient.spec.ts b/test/integration/lightning/ClnClient.spec.ts index 228467b4..da351e85 100644 --- a/test/integration/lightning/ClnClient.spec.ts +++ b/test/integration/lightning/ClnClient.spec.ts @@ -1,8 +1,10 @@ import { randomBytes } from 'crypto'; import { crypto } from 'bitcoinjs-lib'; import Errors from '../../../lib/lightning/Errors'; +import { decodeInvoice, getUnixTime } from '../../../lib/Utils'; import { bitcoinClient, bitcoinLndClient, clnClient } from '../Nodes'; import { InvoiceFeature } from '../../../lib/lightning/LightningClient'; +import InvoiceExpiryHelper from '../../../lib/service/InvoiceExpiryHelper'; describe('ClnClient', () => { beforeAll(async () => { @@ -27,6 +29,27 @@ describe('ClnClient', () => { expect(invoice.startsWith('lnbcrt')).toBeTruthy(); }); + test.each` + expiry + ${60} + ${1200} + ${3600} + ${43200} + `('should create invoices with expiry $expiry', async ({ expiry }) => { + const invoice = await clnClient.addHoldInvoice( + 10_000, + randomBytes(32), + undefined, + expiry, + ); + const { timestamp, timeExpireDate } = decodeInvoice(invoice); + expect( + getUnixTime() + + expiry - + InvoiceExpiryHelper.getInvoiceExpiry(timestamp, timeExpireDate), + ).toBeLessThanOrEqual(5); + }); + test('should fail settle for invalid states', async () => { await expect(clnClient.settleHoldInvoice(randomBytes(32))).rejects.toEqual( expect.anything(), diff --git a/test/integration/lightning/LndClient.spec.ts b/test/integration/lightning/LndClient.spec.ts index 41b133d9..e18c7b44 100644 --- a/test/integration/lightning/LndClient.spec.ts +++ b/test/integration/lightning/LndClient.spec.ts @@ -1,9 +1,12 @@ import * as grpc from '@grpc/grpc-js'; import { readFileSync } from 'fs'; +import { randomBytes } from 'crypto'; import { getPort } from '../../Utils'; import Logger from '../../../lib/Logger'; import LndClient from '../../../lib/lightning/LndClient'; -import { lndDataPath } from '../Nodes'; +import { decodeInvoice, getUnixTime } from '../../../lib/Utils'; +import { bitcoinClient, bitcoinLndClient, lndDataPath } from '../Nodes'; +import InvoiceExpiryHelper from '../../../lib/service/InvoiceExpiryHelper'; import { LightningClient, LightningService, @@ -15,6 +18,38 @@ import { } from '../../../lib/proto/lnd/rpc_pb'; describe('LndClient', () => { + beforeAll(async () => { + await bitcoinClient.generate(1); + await bitcoinLndClient.connect(false); + }); + + afterAll(() => { + bitcoinLndClient.removeAllListeners(); + bitcoinLndClient.disconnect(); + bitcoinLndClient.disconnect(); + }); + + test.each` + expiry + ${60} + ${1200} + ${3600} + ${43200} + `('should create invoices with expiry $expiry', async ({ expiry }) => { + const invoice = await bitcoinLndClient.addHoldInvoice( + 10_000, + randomBytes(32), + undefined, + expiry, + ); + const { timestamp, timeExpireDate } = decodeInvoice(invoice); + expect( + getUnixTime() + + expiry - + InvoiceExpiryHelper.getInvoiceExpiry(timestamp, timeExpireDate), + ).toBeLessThanOrEqual(5); + }); + test('should handle messages longer than the default gRPC limit', async () => { // 4 MB is the default gRPC limit const defaultGrpcLimit = 1024 * 1024 * 4; @@ -31,13 +66,19 @@ describe('LndClient', () => { // Define all needed methods of the LightningClient to work around gRPC throwing an error for (const method of Object.keys(LightningClient['service'])) { - serviceImplementation[method] = async (_, callback) => { + serviceImplementation[method] = async ( + _: any | null, + callback: (error: any, res: GetInfoResponse) => void, + ) => { // "GetInfo" is the only call the LndClient is using on startup callback(null, new GetInfoResponse()); }; } - serviceImplementation['getTransactions'] = async (_, callback) => { + serviceImplementation['getTransactions'] = async ( + _: any | null, + callback: (error: any, res: TransactionDetails) => void, + ) => { const response = new TransactionDetails(); const randomTransaction = new Transaction(); diff --git a/test/unit/service/InvoiceExpiryHelper.spec.ts b/test/unit/service/InvoiceExpiryHelper.spec.ts index a595b51f..4187c92e 100644 --- a/test/unit/service/InvoiceExpiryHelper.spec.ts +++ b/test/unit/service/InvoiceExpiryHelper.spec.ts @@ -1,41 +1,89 @@ -import { CurrencyConfig } from '../../../lib/Config'; +import { getPairId } from '../../../lib/Utils'; +import { PairConfig } from '../../../lib/consts/Types'; import InvoiceExpiryHelper from '../../../lib/service/InvoiceExpiryHelper'; +import TimeoutDeltaProvider from '../../../lib/service/TimeoutDeltaProvider'; + +jest.mock('../../../lib/service/TimeoutDeltaProvider', () => { + return jest.fn().mockImplementation(() => ({ + timeoutDeltas: new Map([ + [ + 'RBTC/BTC', + { + quote: { + swapMaximal: 0, + swapMinimal: 0, + reverse: 144, + }, + }, + ], + ]), + })); +}); + +const MockedTimeoutDeltaProvider = >( + (TimeoutDeltaProvider) +); + +(MockedTimeoutDeltaProvider as any).blockTimes = new Map([ + ['BTC', 10], +]); describe('InvoiceExpiryHelper', () => { - const currencies = [ + const pairs = [ { - symbol: 'BTC', + base: 'BTC', + quote: 'BTC', invoiceExpiry: 123, }, { - symbol: 'LTC', + base: 'LTC', + quote: 'BTC', invoiceExpiry: 210, }, - ] as any as CurrencyConfig[]; - - const helper = new InvoiceExpiryHelper(currencies); + { + base: 'RBTC', + quote: 'BTC', + }, + ] as any as PairConfig[]; - test('should get expiry of invoices', () => { - // Defined in the currency array - expect(helper.getExpiry(currencies[0].symbol)).toEqual( - currencies[0].invoiceExpiry, - ); - expect(helper.getExpiry(currencies[1].symbol)).toEqual( - currencies[1].invoiceExpiry, - ); + const timeoutDeltaProvider = MockedTimeoutDeltaProvider(); - // Default value - expect(helper.getExpiry('DOGE')).toEqual(3600); - }); + const helper = new InvoiceExpiryHelper(pairs, timeoutDeltaProvider); - test('should calculate expiry of invoices', () => { - // Should use expiry date when defined - expect(InvoiceExpiryHelper.getInvoiceExpiry(120, 360)).toEqual(360); + test.each` + pair | expected + ${getPairId(pairs[0])} | ${123} + ${getPairId(pairs[1])} | ${210} + `( + 'should get expiry of invoices with set invoiceExpiry', + ({ pair, expected }) => { + expect(helper.getExpiry(pair)).toEqual(expected); + }, + ); - // Should add default expiry to timestamp when expiry is not defined - expect(InvoiceExpiryHelper.getInvoiceExpiry(120)).toEqual(3720); + test('should use 50% of swap timeout for invoice expiry', () => { + expect(helper.getExpiry(getPairId(pairs[2]))).toEqual((144 * 10 * 60) / 2); + }); - // should use 0 as timestamp when not defined - expect(InvoiceExpiryHelper.getInvoiceExpiry()).toEqual(3600); + test('should default when pair cannot be found', () => { + expect(helper.getExpiry('DOGE')).toEqual(3600); + expect(helper.getExpiry('DOGE/BTC')).toEqual(3600); }); + + test.each` + timestamp | timeExpiryDate | expected + ${120} | ${360} | ${360} + ${400} | ${360} | ${360} + ${400} | ${1200} | ${1200} + ${120} | ${undefined} | ${3720} + ${400} | ${undefined} | ${4000} + ${undefined} | ${undefined} | ${3600} + `( + 'should calculate expiry of invoice (timestamp $timestamp, timeExpiryDate $timeExpiryDate)', + ({ timestamp, timeExpiryDate, expected }) => { + expect( + InvoiceExpiryHelper.getInvoiceExpiry(timestamp, timeExpiryDate), + ).toEqual(expected); + }, + ); }); diff --git a/test/unit/swap/SwapManager.spec.ts b/test/unit/swap/SwapManager.spec.ts index 28eed9cc..1527a08e 100644 --- a/test/unit/swap/SwapManager.spec.ts +++ b/test/unit/swap/SwapManager.spec.ts @@ -14,8 +14,8 @@ import ChainClient from '../../../lib/chain/ChainClient'; import LndClient from '../../../lib/lightning/LndClient'; import RateProvider from '../../../lib/rates/RateProvider'; import SwapOutputType from '../../../lib/swap/SwapOutputType'; +import { Ethereum } from '../../../lib/wallet/ethereum/EvmNetworks'; import SwapRepository from '../../../lib/db/repositories/SwapRepository'; -import InvoiceExpiryHelper from '../../../lib/service/InvoiceExpiryHelper'; import ReverseSwap, { NodeType } from '../../../lib/db/models/ReverseSwap'; import WalletManager, { Currency } from '../../../lib/wallet/WalletManager'; import TimeoutDeltaProvider from '../../../lib/service/TimeoutDeltaProvider'; @@ -25,16 +25,17 @@ import SwapManager, { ChannelCreationInfo, } from '../../../lib/swap/SwapManager'; import { - OrderSide, + ChannelCreationType, CurrencyType, + OrderSide, SwapUpdateEvent, - ChannelCreationType, } from '../../../lib/consts/Enums'; import { - getUnixTime, + decodeInvoice, getHexBuffer, getHexString, - decodeInvoice, + getPairId, + getUnixTime, reverseBuffer, } from '../../../lib/Utils'; @@ -125,6 +126,12 @@ jest.mock('../../../lib/wallet/WalletManager', () => { return jest.fn().mockImplementation(() => { return { wallets: mockWallets, + ethereumManagers: [ + { + networkDetails: Ethereum, + hasSymbol: jest.fn().mockReturnValue(true), + }, + ], }; }); }); @@ -293,10 +300,6 @@ jest.mock('../../../lib/service/InvoiceExpiryHelper', () => { return mockedImplementation; }); -const MockedInvoiceExpiryHelper = >( - (InvoiceExpiryHelper) -); - jest.mock('../../../lib/swap/SwapNursery', () => { return jest.fn().mockImplementation(() => ({ init: jest.fn().mockImplementation(async () => {}), @@ -353,7 +356,6 @@ describe('SwapManager', () => { new NodeSwitch(Logger.disabledLogger), new MockedRateProvider(), new MockedTimeoutDeltaProvider(), - new MockedInvoiceExpiryHelper(), new SwapOutputType(OutputType.Compatibility), 0, ); @@ -392,7 +394,7 @@ describe('SwapManager', () => { }, ]; - await manager.init([btcCurrency, ltcCurrency]); + await manager.init([btcCurrency, ltcCurrency], []); expect(manager.currencies.size).toEqual(2); @@ -889,7 +891,7 @@ describe('SwapManager', () => { test('should create Reverse Swaps', async () => { manager['recreateFilters'] = jest.fn().mockImplementation(); - await manager.init([btcCurrency, ltcCurrency]); + await manager.init([btcCurrency, ltcCurrency], []); const preimageHash = getHexBuffer( '6b0d0275c597a18cfcc23261a62e095e2ba12ac5c866823d2926912806a5b10a', @@ -933,7 +935,9 @@ describe('SwapManager', () => { }); expect(mockGetExpiry).toHaveBeenCalledTimes(1); - expect(mockGetExpiry).toHaveBeenCalledWith(quoteCurrency); + expect(mockGetExpiry).toHaveBeenCalledWith( + getPairId({ base: baseCurrency, quote: quoteCurrency }), + ); expect(mockAddHoldInvoice).toHaveBeenCalledTimes(1); expect(mockAddHoldInvoice).toHaveBeenCalledWith(