From 436245d8def128f7b5a838f7d62d7a38873be17b Mon Sep 17 00:00:00 2001 From: michael1011 Date: Mon, 26 Feb 2024 22:16:58 +0100 Subject: [PATCH] feat: Liquid claim covenant (#488) --- lib/api/Utils.ts | 2 +- lib/api/v2/routers/SwapRouter.ts | 8 +- lib/service/Errors.ts | 4 + lib/service/Service.ts | 3 + lib/service/cooperative/DeferredClaimer.ts | 4 + lib/swap/ReverseRoutingHints.ts | 14 +- lib/swap/SwapManager.ts | 60 +++++- package-lock.json | 40 ++-- package.json | 2 +- swagger-spec.json | 5 + .../cooperative/DeferredClaimer.spec.ts | 20 ++ test/unit/api/v2/routers/SwapRouter.spec.ts | 32 ++++ test/unit/service/Service.spec.ts | 7 + test/unit/swap/ReverseRoutingHints.spec.ts | 2 + test/unit/swap/SwapManager.spec.ts | 176 +++++++++++++++++- .../__snapshots__/SwapManager.spec.ts.snap | 18 ++ 16 files changed, 356 insertions(+), 41 deletions(-) create mode 100644 test/unit/swap/__snapshots__/SwapManager.spec.ts.snap diff --git a/lib/api/Utils.ts b/lib/api/Utils.ts index dda14302..a2926a5c 100644 --- a/lib/api/Utils.ts +++ b/lib/api/Utils.ts @@ -8,7 +8,7 @@ import Errors from './Errors'; type ApiArgument = { name: string; - type: string; + type: 'string' | 'number' | 'boolean' | 'object'; hex?: boolean; optional?: boolean; }; diff --git a/lib/api/v2/routers/SwapRouter.ts b/lib/api/v2/routers/SwapRouter.ts index 24bda300..624ee434 100644 --- a/lib/api/v2/routers/SwapRouter.ts +++ b/lib/api/v2/routers/SwapRouter.ts @@ -722,8 +722,11 @@ class SwapRouter extends RouterBase { * addressSignature: * type: string * description: Signature of the claim public key of the SHA256 hash of the address for the direct payment + * claimCovenant: + * type: boolean + * default: false + * description: If the claim covenant should be added to the Taproot tree. Only possible when "address" is set */ - /** * @openapi * components: @@ -1227,6 +1230,7 @@ class SwapRouter extends RouterBase { claimAddress, invoiceAmount, onchainAmount, + claimCovenant, claimPublicKey, addressSignature, } = validateRequest(req.body, [ @@ -1240,6 +1244,7 @@ class SwapRouter extends RouterBase { { name: 'claimAddress', type: 'string', optional: true }, { name: 'invoiceAmount', type: 'number', optional: true }, { name: 'onchainAmount', type: 'number', optional: true }, + { name: 'claimCovenant', type: 'boolean', optional: true }, { name: 'claimPublicKey', type: 'string', hex: true, optional: true }, { name: 'addressSignature', type: 'string', hex: true, optional: true }, ]); @@ -1257,6 +1262,7 @@ class SwapRouter extends RouterBase { claimAddress, invoiceAmount, onchainAmount, + claimCovenant, claimPublicKey, userAddress: address, diff --git a/lib/service/Errors.ts b/lib/service/Errors.ts index d5b75c8c..ac7b9e46 100644 --- a/lib/service/Errors.ts +++ b/lib/service/Errors.ts @@ -157,4 +157,8 @@ export default { message: 'no chain for symbol', code: concatErrorCode(ErrorCodePrefix.Service, 40), }), + INVALID_PARTIAL_SIGNATURE: (): Error => ({ + message: 'invalid partial signature', + code: concatErrorCode(ErrorCodePrefix.Service, 41), + }), }; diff --git a/lib/service/Service.ts b/lib/service/Service.ts index cfe7397b..e153076c 100644 --- a/lib/service/Service.ts +++ b/lib/service/Service.ts @@ -1367,6 +1367,8 @@ class Service { // Address of the user to encode in the invoice memo userAddress?: string; userAddressSignature?: Buffer; + + claimCovenant?: boolean; }): Promise<{ id: string; invoice: string; @@ -1612,6 +1614,7 @@ class Service { claimAddress: args.claimAddress, preimageHash: args.preimageHash, claimPublicKey: args.claimPublicKey, + claimCovenant: args.claimCovenant || false, userAddressSignature: args.userAddressSignature, }); diff --git a/lib/service/cooperative/DeferredClaimer.ts b/lib/service/cooperative/DeferredClaimer.ts index 23ca5686..1cf451fa 100644 --- a/lib/service/cooperative/DeferredClaimer.ts +++ b/lib/service/cooperative/DeferredClaimer.ts @@ -240,6 +240,10 @@ class DeferredClaimer extends TypedEventEmitter<{ musig.initializeSession( await hashForWitnessV1(chainCurrency, toClaim.cooperative.transaction, 0), ); + if (!musig.verifyPartial(theirPublicKey, theirPartialSignature)) { + throw Errors.INVALID_PARTIAL_SIGNATURE(); + } + musig.addPartial(theirPublicKey, theirPartialSignature); musig.signPartial(); diff --git a/lib/swap/ReverseRoutingHints.ts b/lib/swap/ReverseRoutingHints.ts index b7a865af..d84be2b8 100644 --- a/lib/swap/ReverseRoutingHints.ts +++ b/lib/swap/ReverseRoutingHints.ts @@ -11,6 +11,7 @@ import Errors from './Errors'; type SwapHints = { invoiceMemo: string; + receivedAmount: number; bip21?: string; routingHint?: HopHint[][]; }; @@ -39,12 +40,17 @@ class ReverseRoutingHints { }, ): SwapHints => { const invoiceMemo = getSwapMemo(sendingCurrency.symbol, true); + const receivedAmount = + args.onchainAmount - + this.rateProvider.feeProvider.minerFees.get(sendingCurrency.symbol)![ + args.version + ].reverse.claim; if ( args.userAddress === undefined || args.userAddressSignature === undefined ) { - return { invoiceMemo }; + return { invoiceMemo, receivedAmount }; } try { @@ -58,10 +64,7 @@ class ReverseRoutingHints { const bip21 = this.paymentRequestUtils.encodeBip21( sendingCurrency.symbol, args.userAddress, - args.onchainAmount - - this.rateProvider.feeProvider.minerFees.get(sendingCurrency.symbol)![ - args.version - ].reverse.claim, + receivedAmount, ); const routingHint = this.encodeRoutingHint( @@ -74,6 +77,7 @@ class ReverseRoutingHints { bip21, routingHint, invoiceMemo, + receivedAmount, }; }; diff --git a/lib/swap/SwapManager.ts b/lib/swap/SwapManager.ts index 33ba6bf3..eeee5ed2 100644 --- a/lib/swap/SwapManager.ts +++ b/lib/swap/SwapManager.ts @@ -8,7 +8,12 @@ import { swapScript, swapTree, } from 'boltz-core'; +import { + Feature, + reverseSwapTree as reverseSwapTreeLiquid, +} from 'boltz-core/dist/lib/liquid'; import { randomBytes } from 'crypto'; +import { Network as LiquidNetwork } from 'liquidjs-lib/src/networks'; import { Op } from 'sequelize'; import { createMusig, tweakMusig } from '../Core'; import Logger from '../Logger'; @@ -597,6 +602,8 @@ class SwapManager { userAddress?: string; userAddressSignature?: Buffer; + + claimCovenant: boolean; }): Promise => { const { sendingCurrency, receivingCurrency } = this.getCurrencies( args.baseCurrency, @@ -692,6 +699,7 @@ class SwapManager { if (isBitcoinLike) { const { keys, index } = sendingCurrency.wallet.getNewKeys(); const { blocks } = await sendingCurrency.chainClient!.getBlockchainInfo(); + result.timeoutBlockHeight = blocks + args.onchainTimeoutBlockDelta; let outputScript: Buffer; @@ -701,13 +709,50 @@ class SwapManager { case SwapVersion.Taproot: { result.refundPublicKey = getHexString(keys.publicKey); - tree = reverseSwapTree( - sendingCurrency.type === CurrencyType.Liquid, - args.preimageHash, - args.claimPublicKey!, - keys.publicKey, - result.timeoutBlockHeight, - ); + if (args.claimCovenant) { + if (sendingCurrency.type !== CurrencyType.Liquid) { + throw 'claim covenant only supported on Liquid'; + } + + if (args.userAddress === undefined) { + throw 'userAddress for covenant not specified'; + } + + try { + sendingCurrency.wallet.decodeAddress(args.userAddress); + } catch (e) { + throw Errors.INVALID_ADDRESS(); + } + + tree = reverseSwapTreeLiquid( + args.preimageHash, + args.claimPublicKey!, + keys.publicKey, + result.timeoutBlockHeight, + [ + { + expectedAmount: hints.receivedAmount, + type: Feature.ClaimCovenant, + assetHash: ( + this.walletManager.wallets.get(sendingCurrency.symbol)! + .network as LiquidNetwork + ).assetHash, + outputScript: this.walletManager.wallets + .get(sendingCurrency.symbol)! + .decodeAddress(args.userAddress!), + }, + ], + ); + } else { + tree = reverseSwapTree( + sendingCurrency.type === CurrencyType.Liquid, + args.preimageHash, + args.claimPublicKey!, + keys.publicKey, + result.timeoutBlockHeight, + ); + } + result.swapTree = SwapTreeSerializer.serializeSwapTree(tree); const musig = createMusig(keys, args.claimPublicKey!); @@ -750,7 +795,6 @@ class SwapManager { minerFeeInvoice, node: nodeType, keyIndex: index, - version: args.version, fee: args.percentageFee, invoice: paymentRequest, diff --git a/package-lock.json b/package-lock.json index 43cf7168..3268174e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "cors": "^2.8.5", "cross-os": "^1.5.0", "csv-parse": "^5.5.3", - "discord.js": "^14.14.1", + "discord.js": "^14.12.1", "ecpair": "^2.1.0", "ethers": "^6.11.1", "express": "^4.18.2", @@ -4844,27 +4844,27 @@ "license": "MIT" }, "node_modules/discord.js": { - "version": "14.14.1", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.14.1.tgz", - "integrity": "sha512-/hUVzkIerxKHyRKopJy5xejp4MYKDPTszAnpYxzVVv4qJYf+Tkt+jnT2N29PIPschicaEEpXwF2ARrTYHYwQ5w==", - "dependencies": { - "@discordjs/builders": "^1.7.0", - "@discordjs/collection": "1.5.3", - "@discordjs/formatters": "^0.3.3", - "@discordjs/rest": "^2.1.0", - "@discordjs/util": "^1.0.2", - "@discordjs/ws": "^1.0.2", - "@sapphire/snowflake": "3.5.1", - "@types/ws": "8.5.9", - "discord-api-types": "0.37.61", - "fast-deep-equal": "3.1.3", - "lodash.snakecase": "4.1.1", - "tslib": "2.6.2", - "undici": "5.27.2", - "ws": "8.14.2" + "version": "14.12.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.12.1.tgz", + "integrity": "sha512-gGjhTkauIPgFXxpBl0UZgyehrKhDe90cIS8Hn1xFBYQ63EuUAkKoUqRNmc/pcla6DD16s4cUz5tAbdSpXivnxw==", + "dependencies": { + "@discordjs/builders": "^1.6.4", + "@discordjs/collection": "^1.5.2", + "@discordjs/formatters": "^0.3.1", + "@discordjs/rest": "^2.0.0", + "@discordjs/util": "^1.0.0", + "@discordjs/ws": "^1.0.0", + "@sapphire/snowflake": "^3.5.1", + "@types/ws": "^8.5.5", + "discord-api-types": "^0.37.50", + "fast-deep-equal": "^3.1.3", + "lodash.snakecase": "^4.1.1", + "tslib": "^2.6.1", + "undici": "^5.22.1", + "ws": "^8.13.0" }, "engines": { - "node": ">=16.11.0" + "node": ">=16.9.0" } }, "node_modules/doctrine": { diff --git a/package.json b/package.json index acf53c01..c611bba4 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "cors": "^2.8.5", "cross-os": "^1.5.0", "csv-parse": "^5.5.3", - "discord.js": "^14.14.1", + "discord.js": "^14.12.1", "ecpair": "^2.1.0", "ethers": "^6.11.1", "express": "^4.18.2", diff --git a/swagger-spec.json b/swagger-spec.json index 13e96086..16b4691a 100644 --- a/swagger-spec.json +++ b/swagger-spec.json @@ -1903,6 +1903,11 @@ "addressSignature": { "type": "string", "description": "Signature of the claim public key of the SHA256 hash of the address for the direct payment" + }, + "claimCovenant": { + "type": "boolean", + "default": false, + "description": "If the claim covenant should be added to the Taproot tree. Only possible when \"address\" is set" } } }, diff --git a/test/integration/service/cooperative/DeferredClaimer.spec.ts b/test/integration/service/cooperative/DeferredClaimer.spec.ts index bda28b75..8bd72f96 100644 --- a/test/integration/service/cooperative/DeferredClaimer.spec.ts +++ b/test/integration/service/cooperative/DeferredClaimer.spec.ts @@ -521,6 +521,26 @@ describe('DeferredClaimer', () => { expect(claimTx.outs).toHaveLength(1); }); + test('should throw when cooperatively broadcasting a submarine swap with invalid partial signature', async () => { + await bitcoinClient.generate(1); + const { swap, preimage, refundKeys } = await createClaimableOutput(); + + await expect(claimer.deferClaim(swap, preimage)).resolves.toEqual(true); + await claimer.getCooperativeDetails(swap); + + const musig = new Musig(secp, refundKeys, randomBytes(32), [ + btcWallet.getKeysByIndex(swap.keyIndex!).publicKey, + refundKeys.publicKey, + ]); + await expect( + claimer.broadcastCooperative( + swap, + Buffer.from(musig.getPublicNonce()), + randomBytes(32), + ), + ).rejects.toEqual(Errors.INVALID_PARTIAL_SIGNATURE()); + }); + test('should throw when cooperatively broadcasting a submarine swap that does not exist', async () => { await expect( claimer.broadcastCooperative( diff --git a/test/unit/api/v2/routers/SwapRouter.spec.ts b/test/unit/api/v2/routers/SwapRouter.spec.ts index 3725f4f3..25f27986 100644 --- a/test/unit/api/v2/routers/SwapRouter.spec.ts +++ b/test/unit/api/v2/routers/SwapRouter.spec.ts @@ -721,6 +721,8 @@ 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: claimCovenant'} | ${{ to: 'L-BTC', from: 'BTC', preimageHash: '00', claimPublicKey: '0011', addressSignature: '0011', claimCovenant: 123 }} + ${'invalid parameter: claimCovenant'} | ${{ to: 'L-BTC', from: 'BTC', preimageHash: '00', claimPublicKey: '0011', addressSignature: '0011', claimCovenant: 'notBool' }} `( 'should not create reverse swaps with invalid parameters ($error)', async ({ body, error }) => { @@ -928,6 +930,36 @@ describe('SwapRouter', () => { }); }); + test('should create reverse swaps with claimCovenant', async () => { + const reqBody = { + to: 'L-BTC', + from: 'BTC', + address: 'bc1', + onchainAmount: 123, + claimCovenant: true, + claimPublicKey: '21', + addressSignature: '0011', + preimageHash: getHexString(randomBytes(32)), + }; + const res = mockResponse(); + + await swapRouter['createReverse'](mockRequest(reqBody), res); + + expect(service.createReverseSwap).toHaveBeenCalledTimes(1); + expect(service.createReverseSwap).toHaveBeenCalledWith({ + pairId: 'L-BTC/BTC', + prepayMinerFee: false, + orderSide: OrderSide.BUY, + version: SwapVersion.Taproot, + userAddress: reqBody.address, + claimCovenant: reqBody.claimCovenant, + onchainAmount: reqBody.onchainAmount, + preimageHash: getHexBuffer(reqBody.preimageHash), + claimPublicKey: getHexBuffer(reqBody.claimPublicKey), + userAddressSignature: getHexBuffer(reqBody.addressSignature), + }); + }); + test('should get BIP-21 of reverse swaps', async () => { const invoice = 'bip21Swap'; diff --git a/test/unit/service/Service.spec.ts b/test/unit/service/Service.spec.ts index 0c3da23b..9b669736 100644 --- a/test/unit/service/Service.spec.ts +++ b/test/unit/service/Service.spec.ts @@ -1752,6 +1752,7 @@ describe('Service', () => { claimPublicKey, baseCurrency: 'BTC', quoteCurrency: 'BTC', + claimCovenant: false, orderSide: OrderSide.BUY, onchainTimeoutBlockDelta: 1, version: SwapVersion.Legacy, @@ -1781,6 +1782,7 @@ describe('Service', () => { claimPublicKey, baseCurrency: 'LTC', quoteCurrency: 'BTC', + claimCovenant: false, orderSide: OrderSide.BUY, version: SwapVersion.Legacy, onchainTimeoutBlockDelta: 160, @@ -1942,6 +1944,7 @@ describe('Service', () => { claimPublicKey, baseCurrency: 'BTC', quoteCurrency: 'BTC', + claimCovenant: false, orderSide: OrderSide.BUY, onchainTimeoutBlockDelta: 1, version: SwapVersion.Legacy, @@ -1980,6 +1983,7 @@ describe('Service', () => { claimPublicKey, baseCurrency: 'BTC', quoteCurrency: 'BTC', + claimCovenant: false, orderSide: OrderSide.BUY, version: SwapVersion.Legacy, holdInvoiceAmount: invoiceAmount, @@ -2025,6 +2029,7 @@ describe('Service', () => { claimPublicKey, baseCurrency: 'BTC', quoteCurrency: 'BTC', + claimCovenant: false, orderSide: OrderSide.BUY, onchainTimeoutBlockDelta: 1, version: SwapVersion.Legacy, @@ -2088,6 +2093,7 @@ describe('Service', () => { prepayMinerFeeInvoiceAmount, baseCurrency: 'ETH', quoteCurrency: 'BTC', + claimCovenant: false, orderSide: OrderSide.BUY, onchainTimeoutBlockDelta: 900, lightningTimeoutBlockDelta: 23, @@ -2137,6 +2143,7 @@ describe('Service', () => { prepayMinerFeeInvoiceAmount, baseCurrency: 'ETH', quoteCurrency: 'BTC', + claimCovenant: false, orderSide: OrderSide.BUY, claimAddress: args.claimAddress, preimageHash: args.preimageHash, diff --git a/test/unit/swap/ReverseRoutingHints.spec.ts b/test/unit/swap/ReverseRoutingHints.spec.ts index dceb6e6d..daeabb6a 100644 --- a/test/unit/swap/ReverseRoutingHints.spec.ts +++ b/test/unit/swap/ReverseRoutingHints.spec.ts @@ -62,6 +62,7 @@ describe('ReverseRoutingHints', () => { version: SwapVersion.Taproot, }), ).toEqual({ + receivedAmount: 99877, invoiceMemo: getSwapMemo(sendingCurrency.symbol, true), }); }); @@ -84,6 +85,7 @@ describe('ReverseRoutingHints', () => { userAddressSignature: signature, }), ).toEqual({ + receivedAmount: 99877, invoiceMemo: getSwapMemo(sendingCurrency.symbol, true), bip21: paymentRequestUtils.encodeBip21( sendingCurrency.symbol, diff --git a/test/unit/swap/SwapManager.spec.ts b/test/unit/swap/SwapManager.spec.ts index cb658a63..73f26e50 100644 --- a/test/unit/swap/SwapManager.spec.ts +++ b/test/unit/swap/SwapManager.spec.ts @@ -2,8 +2,11 @@ import bolt11 from '@boltz/bolt11'; import AsyncLock from 'async-lock'; import { address } from 'bitcoinjs-lib'; import { Networks, OutputType } from 'boltz-core'; +import { Networks as LiquidNetworks } from 'boltz-core/dist/lib/liquid'; import { randomBytes } from 'crypto'; +import { address as addressLiquid } from 'liquidjs-lib'; import { Op } from 'sequelize'; +import { setup } from '../../../lib/Core'; import { ECPair } from '../../../lib/ECPairHelper'; import Logger from '../../../lib/Logger'; import { @@ -83,7 +86,27 @@ jest.mock('../../../lib/db/repositories/ChannelCreationRepository'); jest.mock('../../../lib/rates/RateProvider', () => { return jest.fn().mockImplementation(() => { - return {}; + return { + feeProvider: { + minerFees: new Map([ + [ + 'BTC', + { + [SwapVersion.Legacy]: { + reverse: { + claim: 2, + }, + }, + [SwapVersion.Taproot]: { + reverse: { + claim: 1, + }, + }, + }, + ], + ]), + }, + }; }); }); @@ -124,6 +147,23 @@ const MockedWallet = >(Wallet); const mockWallets = new Map([ ['BTC', new MockedWallet()], ['LTC', new MockedWallet()], + [ + 'L-BTC', + { + ...new MockedWallet(), + network: LiquidNetworks.liquidRegtest, + decodeAddress: jest + .fn() + .mockImplementation((address: string) => + addressLiquid.toOutputScript(address, LiquidNetworks.liquidRegtest), + ), + deriveBlindingKeyFromScript: jest.fn().mockReturnValue({ + privateKey: getHexBuffer( + '4e09bc9895ccef1eab4e2e67adcff67be2af26110ffb35f26592688c0e88dc76', + ), + }), + } as any, + ], ]); jest.mock('../../../lib/wallet/WalletManager', () => { @@ -334,11 +374,22 @@ describe('SwapManager', () => { lndClient: new MockedLndClient(), } as any as Currency; + const lbtcCurrency = { + symbol: 'L-BTC', + type: CurrencyType.Liquid, + network: LiquidNetworks.liquidRegtest, + chainClient: new MockedChainClient(), + } as any as Currency; + const rbtcCurrency = { symbol: 'RBTC', type: CurrencyType.Ether, } as any as Currency; + beforeAll(async () => { + await setup(); + }); + beforeEach(() => { jest.clearAllMocks(); @@ -383,6 +434,25 @@ describe('SwapManager', () => { manager['currencies'].set(btcCurrency.symbol, btcCurrency); manager['currencies'].set(ltcCurrency.symbol, ltcCurrency); manager['currencies'].set(rbtcCurrency.symbol, rbtcCurrency); + manager['currencies'].set(lbtcCurrency.symbol, lbtcCurrency); + + manager['nodeFallback'] = { + getReverseSwapInvoice: jest.fn().mockResolvedValue({ + lightningClient: btcCurrency.lndClient, + }), + } as any; + manager['invoiceExpiryHelper'] = { + getExpiry: jest.fn().mockReturnValue(3600), + } as any; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + manager['reverseRoutingHints'] = { + getHints: jest.fn().mockReturnValue({ + invoiceMemo: 'mock', + receivedAmount: 420, + }), + } as any; }); afterAll(() => { @@ -417,7 +487,7 @@ describe('SwapManager', () => { await manager.init([btcCurrency, ltcCurrency], []); - expect(manager.currencies.size).toEqual(3); + expect(manager.currencies.size).toEqual(4); expect(manager.currencies.get('BTC')).toEqual(btcCurrency); expect(manager.currencies.get('LTC')).toEqual(ltcCurrency); @@ -955,6 +1025,7 @@ describe('SwapManager', () => { holdInvoiceAmount, onchainTimeoutBlockDelta, lightningTimeoutBlockDelta, + claimCovenant: false, claimPublicKey: claimKey, version: SwapVersion.Legacy, }); @@ -982,7 +1053,7 @@ describe('SwapManager', () => { preimageHash, lightningTimeoutBlockDelta, mockGetExpiryResult, - 'Send to BTC address', + 'mock', [], ); @@ -1033,6 +1104,7 @@ describe('SwapManager', () => { lightningTimeoutBlockDelta, prepayMinerFeeInvoiceAmount, prepayMinerFeeOnchainAmount, + claimCovenant: false, claimPublicKey: claimKey, version: SwapVersion.Legacy, }); @@ -1058,7 +1130,7 @@ describe('SwapManager', () => { preimageHash, lightningTimeoutBlockDelta, mockGetExpiryResult, - 'Send to BTC address', + 'mock', [], ); expect(mockAddHoldInvoice).toHaveBeenNthCalledWith( @@ -1128,6 +1200,7 @@ describe('SwapManager', () => { onchainTimeoutBlockDelta, lightningTimeoutBlockDelta, prepayMinerFeeInvoiceAmount, + claimCovenant: false, claimPublicKey: claimKey, routingNode: nodePublicKey, version: SwapVersion.Legacy, @@ -1149,7 +1222,7 @@ describe('SwapManager', () => { preimageHash, lightningTimeoutBlockDelta, mockGetExpiryResult, - 'Send to BTC address', + 'mock', mockGetRoutingHintsResult, ); expect(mockAddHoldInvoice).toHaveBeenNthCalledWith( @@ -1178,6 +1251,7 @@ describe('SwapManager', () => { holdInvoiceAmount, onchainTimeoutBlockDelta, lightningTimeoutBlockDelta, + claimCovenant: false, claimPublicKey: claimKey, version: SwapVersion.Legacy, quoteCurrency: notFoundSymbol, @@ -1203,6 +1277,98 @@ describe('SwapManager', () => { blocks.isBlocked = jest.fn().mockReturnValue(false); }); + test('should create reverse swap with covenant', async () => { + const params = { + version: SwapVersion.Taproot, + orderSide: OrderSide.SELL, + baseCurrency: btcCurrency.symbol, + quoteCurrency: lbtcCurrency.symbol, + percentageFee: 500, + onchainAmount: 9_500, + holdInvoiceAmount: 10_000, + onchainTimeoutBlockDelta: 123, + lightningTimeoutBlockDelta: 125, + + claimCovenant: true, + preimageHash: getHexBuffer( + 'e5b18d8d20cbdf72f595dccd22508a6f3acc570e7659ed1ec362b4ee1136eb70', + ), + claimPublicKey: getHexBuffer( + '0302804e7f86e9ca29f582f1fd2b91e6eee6be10b5e2b086dfa52a14aa8ca63fcb', + ), + userAddress: + 'el1qq0lcekdcnur4hcgk2ctyt7kj0yr5yjqjlvnsrq5hsxhyk5duc9d2jfsxgy4vpm4lrdmeeadsu5jhsv2mdgvay2re3lt8wwq25', + }; + + expect( + (await manager.createReverseSwap(params)).swapTree, + ).toMatchSnapshot(); + }); + + test('should throw when creating reverse swaps with covenant on chain that is not Liquid', async () => { + const params = { + version: SwapVersion.Taproot, + orderSide: OrderSide.SELL, + baseCurrency: btcCurrency.symbol, + quoteCurrency: btcCurrency.symbol, + percentageFee: 500, + onchainAmount: 9_500, + holdInvoiceAmount: 10_000, + onchainTimeoutBlockDelta: 123, + lightningTimeoutBlockDelta: 125, + + claimCovenant: true, + preimageHash: randomBytes(32), + }; + + await expect(manager.createReverseSwap(params)).rejects.toEqual( + 'claim covenant only supported on Liquid', + ); + }); + + test('should throw when creating reverse swaps with covenant without address', async () => { + const params = { + version: SwapVersion.Taproot, + orderSide: OrderSide.SELL, + baseCurrency: btcCurrency.symbol, + quoteCurrency: lbtcCurrency.symbol, + percentageFee: 500, + onchainAmount: 9_500, + holdInvoiceAmount: 10_000, + onchainTimeoutBlockDelta: 123, + lightningTimeoutBlockDelta: 125, + + claimCovenant: true, + preimageHash: randomBytes(32), + }; + + await expect(manager.createReverseSwap(params)).rejects.toEqual( + 'userAddress for covenant not specified', + ); + }); + + test('should throw when creating reverse swaps with covenant with invalid address', async () => { + const params = { + version: SwapVersion.Taproot, + orderSide: OrderSide.SELL, + baseCurrency: btcCurrency.symbol, + quoteCurrency: lbtcCurrency.symbol, + percentageFee: 500, + onchainAmount: 9_500, + holdInvoiceAmount: 10_000, + onchainTimeoutBlockDelta: 123, + lightningTimeoutBlockDelta: 125, + + claimCovenant: true, + preimageHash: randomBytes(32), + userAddress: 'not a liquid address', + }; + + await expect(manager.createReverseSwap(params)).rejects.toEqual( + Errors.INVALID_ADDRESS(), + ); + }); + test('should recreate filters', () => { const recreateFilters = manager['recreateFilters']; diff --git a/test/unit/swap/__snapshots__/SwapManager.spec.ts.snap b/test/unit/swap/__snapshots__/SwapManager.spec.ts.snap new file mode 100644 index 00000000..3068b4f5 --- /dev/null +++ b/test/unit/swap/__snapshots__/SwapManager.spec.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SwapManager should create reverse swap with covenant 1`] = ` +{ + "claimLeaf": { + "output": "82012088a9148265576ce381e50302d7b2afb7e571fda90e6dc4882002804e7f86e9ca29f582f1fd2b91e6eee6be10b5e2b086dfa52a14aa8ca63fcbac", + "version": 196, + }, + "covenantClaimLeaf": { + "output": "82012088a9148265576ce381e50302d7b2afb7e571fda90e6dc48800d10088142606412ac0eebf1b779cf5b0e52578315b6a19d28800ce51882025b251070e29ca19043cf33ccd7324e2ddab03ecc4ae0b5e77c4fc0e5cf6c95a8800cf7508a40100000000000087", + "version": 196, + }, + "refundLeaf": { + "output": "20c9c71ee3fee0c400ff64e51e955313e77ea499fc609973c71c5a4104a8d903bbad02f600b1", + "version": 196, + }, +} +`;