diff --git a/lib/api/v2/routers/SwapRouter.ts b/lib/api/v2/routers/SwapRouter.ts index b4b7e9a2..6d600671 100644 --- a/lib/api/v2/routers/SwapRouter.ts +++ b/lib/api/v2/routers/SwapRouter.ts @@ -7,6 +7,7 @@ import SwapRepository from '../../../db/repositories/SwapRepository'; import RateProviderTaproot from '../../../rates/providers/RateProviderTaproot'; import CountryCodes from '../../../service/CountryCodes'; import Errors from '../../../service/Errors'; +import InvoiceExpiryHelper from '../../../service/InvoiceExpiryHelper'; import Service, { WebHookData } from '../../../service/Service'; import ChainSwapSigner from '../../../service/cooperative/ChainSwapSigner'; import MusigSigner, { @@ -768,6 +769,9 @@ class SwapRouter extends RouterBase { * descriptionHash: * type: string * description: Description hash for the invoice. Takes precedence over "description" if both are specified + * invoiceExpiry: + * type: number + * description: Expiry of the invoice in seconds * webhook: * $ref: '#/components/schemas/WebhookData' */ @@ -1814,6 +1818,7 @@ class SwapRouter extends RouterBase { routingNode, preimageHash, claimAddress, + invoiceExpiry, invoiceAmount, onchainAmount, claimCovenant, @@ -1831,6 +1836,7 @@ class SwapRouter extends RouterBase { { name: 'routingNode', type: 'string', optional: true }, { name: 'claimAddress', type: 'string', optional: true }, { name: 'invoiceAmount', type: 'number', optional: true }, + { name: 'invoiceExpiry', type: 'number', optional: true }, { name: 'onchainAmount', type: 'number', optional: true }, { name: 'claimCovenant', type: 'boolean', optional: true }, { name: 'descriptionHash', type: 'string', hex: true, optional: true }, @@ -1841,6 +1847,13 @@ class SwapRouter extends RouterBase { checkPreimageHashLength(preimageHash); + if ( + invoiceExpiry !== undefined && + !InvoiceExpiryHelper.isValidExpiry(invoiceExpiry) + ) { + throw 'invalid invoice expiry'; + } + const { pairId, orderSide } = this.service.convertToPairAndSide(from, to); const webHookData = this.parseWebHook(webhook); @@ -1856,6 +1869,7 @@ class SwapRouter extends RouterBase { invoiceAmount, onchainAmount, claimCovenant, + invoiceExpiry, claimPublicKey, descriptionHash, userAddress: address, diff --git a/lib/service/InvoiceExpiryHelper.ts b/lib/service/InvoiceExpiryHelper.ts index e5aab48d..57546cbc 100644 --- a/lib/service/InvoiceExpiryHelper.ts +++ b/lib/service/InvoiceExpiryHelper.ts @@ -28,11 +28,9 @@ class InvoiceExpiryHelper { } } - public getExpiry = (pair: string): number => { - return ( - this.invoiceExpiry.get(pair) || InvoiceExpiryHelper.defaultInvoiceExpiry - ); - }; + // 43 200 seconds = 12 hours + public static isValidExpiry = (expiry: number) => + expiry >= 60 && expiry <= 43_200; /** * Calculates the expiry of an invoice @@ -55,6 +53,14 @@ class InvoiceExpiryHelper { return invoiceExpiry; }; + + public getExpiry = (pair: string, customExpiry?: number): number => { + return ( + customExpiry || + this.invoiceExpiry.get(pair) || + InvoiceExpiryHelper.defaultInvoiceExpiry + ); + }; } export default InvoiceExpiryHelper; diff --git a/lib/service/Service.ts b/lib/service/Service.ts index 32d9645e..b75cad52 100644 --- a/lib/service/Service.ts +++ b/lib/service/Service.ts @@ -1527,6 +1527,8 @@ class Service { descriptionHash?: Buffer; webHook?: WebHookData; + + invoiceExpiry?: number; }): Promise<{ id: string; invoice: string; @@ -1782,6 +1784,7 @@ class Service { userAddress: args.userAddress, claimAddress: args.claimAddress, preimageHash: args.preimageHash, + invoiceExpiry: args.invoiceExpiry, claimPublicKey: args.claimPublicKey, descriptionHash: args.descriptionHash, claimCovenant: args.claimCovenant || false, diff --git a/lib/swap/SwapManager.ts b/lib/swap/SwapManager.ts index 356c066e..88f939a0 100644 --- a/lib/swap/SwapManager.ts +++ b/lib/swap/SwapManager.ts @@ -682,6 +682,8 @@ class SwapManager { memo?: string; descriptionHash?: Buffer; + + invoiceExpiry?: number; }): Promise => { const { sendingCurrency, receivingCurrency } = this.getCurrencies( args.baseCurrency, @@ -730,7 +732,7 @@ class SwapManager { args.holdInvoiceAmount, args.preimageHash, args.lightningTimeoutBlockDelta, - this.invoiceExpiryHelper.getExpiry(pair), + this.invoiceExpiryHelper.getExpiry(pair, args.invoiceExpiry), hints.invoiceMemo, hints.invoiceDescriptionHash, hints.routingHint, diff --git a/swagger-spec.json b/swagger-spec.json index 7c2c57f1..30265c6a 100644 --- a/swagger-spec.json +++ b/swagger-spec.json @@ -2364,6 +2364,10 @@ "type": "string", "description": "Description hash for the invoice. Takes precedence over \"description\" if both are specified" }, + "invoiceExpiry": { + "type": "number", + "description": "Expiry of the invoice in seconds" + }, "webhook": { "$ref": "#/components/schemas/WebhookData" } diff --git a/test/unit/api/v2/routers/SwapRouter.spec.ts b/test/unit/api/v2/routers/SwapRouter.spec.ts index dac4f22f..35603c3f 100644 --- a/test/unit/api/v2/routers/SwapRouter.spec.ts +++ b/test/unit/api/v2/routers/SwapRouter.spec.ts @@ -890,6 +890,7 @@ describe('SwapRouter', () => { ${'could not parse hex string: preimageHash'} | ${{ to: 'L-BTC', from: 'BTC', preimageHash: 'notHex' }} ${'could not parse hex string: claimPublicKey'} | ${{ to: 'L-BTC', from: 'BTC', preimageHash: '00', claimPublicKey: 'notHex' }} ${'could not parse hex string: addressSignature'} | ${{ to: 'L-BTC', from: 'BTC', preimageHash: '00', claimPublicKey: '0011', addressSignature: 'notHex' }} + ${'invalid parameter: invoiceExpiry'} | ${{ to: 'L-BTC', from: 'BTC', preimageHash: '00', claimPublicKey: '0011', invoiceExpiry: '123' }} ${'invalid parameter: description'} | ${{ to: 'L-BTC', from: 'BTC', preimageHash: '00', claimPublicKey: '0011', description: 123 }} ${'invalid parameter: claimCovenant'} | ${{ to: 'L-BTC', from: 'BTC', preimageHash: '00', claimPublicKey: '0011', claimCovenant: 123 }} ${'invalid parameter: claimCovenant'} | ${{ to: 'L-BTC', from: 'BTC', preimageHash: '00', claimPublicKey: '0011', claimCovenant: 'notBool' }} @@ -905,7 +906,7 @@ describe('SwapRouter', () => { ); test.each([1, 2, 3, 21, 31, 33, 64])( - 'should not create reverse swaps preimage hash length != 32', + 'should not create reverse swaps with preimage hash length != 32', async (length) => { await expect( swapRouter['createReverse']( @@ -921,6 +922,24 @@ describe('SwapRouter', () => { }, ); + test.each([0, 1, 2, 60 * 60 * 24])( + 'should not create reverse swaps with invalid invoice expiry', + async (invoiceExpiry) => { + await expect( + swapRouter['createReverse']( + mockRequest({ + to: 'L-BTC', + from: 'BTC', + invoiceExpiry, + claimPublicKey: '21', + preimageHash: getHexString(randomBytes(32)), + }), + mockResponse(), + ), + ).rejects.toEqual('invalid invoice expiry'); + }, + ); + test('should create reverse swaps', async () => { const reqBody = { to: 'L-BTC', diff --git a/test/unit/service/InvoiceExpiryHelper.spec.ts b/test/unit/service/InvoiceExpiryHelper.spec.ts index 4187c92e..d204d3e7 100644 --- a/test/unit/service/InvoiceExpiryHelper.spec.ts +++ b/test/unit/service/InvoiceExpiryHelper.spec.ts @@ -61,6 +61,11 @@ describe('InvoiceExpiryHelper', () => { }, ); + test('should coalesce set invoice expiry', () => { + const expiry = 6_111; + expect(helper.getExpiry(getPairId(pairs[0]), expiry)).toEqual(expiry); + }); + test('should use 50% of swap timeout for invoice expiry', () => { expect(helper.getExpiry(getPairId(pairs[2]))).toEqual((144 * 10 * 60) / 2); }); @@ -86,4 +91,20 @@ describe('InvoiceExpiryHelper', () => { ).toEqual(expected); }, ); + + test.each` + expiry | valid + ${-1} | ${false} + ${0} | ${false} + ${1} | ${false} + ${59} | ${false} + ${60} | ${true} + ${61} | ${true} + ${3_600} | ${true} + ${60 * 60 * 12 - 1} | ${true} + ${60 * 60 * 12} | ${true} + ${60 * 60 * 12 + 1} | ${false} + `('should determine if expiry $expiry is valid', ({ expiry, valid }) => { + expect(InvoiceExpiryHelper.isValidExpiry(expiry)).toEqual(valid); + }); }); diff --git a/test/unit/swap/SwapManager.spec.ts b/test/unit/swap/SwapManager.spec.ts index 91439f98..8159ed70 100644 --- a/test/unit/swap/SwapManager.spec.ts +++ b/test/unit/swap/SwapManager.spec.ts @@ -434,6 +434,8 @@ describe('SwapManager', () => { deferredClaimSymbols: [], } as any, {} as any, + {} as any, + {} as any, ); manager['currencies'].set(btcCurrency.symbol, btcCurrency); @@ -1019,6 +1021,7 @@ describe('SwapManager', () => { const onchainTimeoutBlockDelta = 140; const lightningTimeoutBlockDelta = 143; const percentageFee = 1; + const invoiceExpiry = 6_111; const reverseSwap = await manager.createReverseSwap({ orderSide, @@ -1027,6 +1030,7 @@ describe('SwapManager', () => { quoteCurrency, onchainAmount, percentageFee, + invoiceExpiry, holdInvoiceAmount, onchainTimeoutBlockDelta, lightningTimeoutBlockDelta, @@ -1050,6 +1054,7 @@ describe('SwapManager', () => { expect(mockGetExpiry).toHaveBeenCalledTimes(1); expect(mockGetExpiry).toHaveBeenCalledWith( getPairId({ base: baseCurrency, quote: quoteCurrency }), + invoiceExpiry, ); expect(mockAddHoldInvoice).toHaveBeenCalledTimes(1);