From f64d6aebfff2b7876fd6d83ebb07acf715d33f48 Mon Sep 17 00:00:00 2001 From: michael1011 Date: Tue, 2 Jan 2024 09:51:13 +0100 Subject: [PATCH] feat: Taproot reverse swaps --- lib/Core.ts | 8 ++ lib/Utils.ts | 5 +- lib/api/v2/routers/SwapRouter.ts | 82 ++++++++++++++- lib/cli/BoltzApiClient.ts | 53 ++++++++-- lib/cli/BuilderComponents.ts | 4 + lib/cli/TaprootHelper.ts | 138 ++++++++++++++++++++++++ lib/cli/commands/Claim.ts | 5 +- lib/cli/commands/CooperativeClaim.ts | 76 ++++++++++++++ lib/cli/commands/CooperativeRefund.ts | 117 ++++++--------------- lib/db/Migration.ts | 16 +++ lib/db/models/ReverseSwap.ts | 14 +++ lib/service/Errors.ts | 4 + lib/service/MusigSigner.ts | 104 +++++++++++++++--- lib/service/Service.ts | 28 +++-- lib/swap/SwapManager.ts | 145 +++++++++++++++----------- lib/swap/SwapNursery.ts | 132 +++++++++++++---------- lib/wallet/WalletLiquid.ts | 33 +++++- 17 files changed, 722 insertions(+), 242 deletions(-) create mode 100644 lib/cli/TaprootHelper.ts create mode 100644 lib/cli/commands/CooperativeClaim.ts diff --git a/lib/Core.ts b/lib/Core.ts index 7fc548e7..c684ce50 100644 --- a/lib/Core.ts +++ b/lib/Core.ts @@ -250,6 +250,14 @@ export const extractClaimPublicKeyFromSwapTree = ( swapTree: Types.SwapTree, ): Buffer => script.decompile(swapTree.claimLeaf.output)![3] as Buffer; +export const extractClaimPublicKeyFromReverseSwapTree = ( + swapTree: Types.SwapTree, +): Buffer => script.decompile(swapTree.claimLeaf.output)![6] as Buffer; + +export const extractRefundPublicKeyFromReverseSwapTree = ( + swapTree: Types.SwapTree, +): Buffer => extractRefundPublicKeyFromSwapTree(swapTree); + export const createMusig = ( ourKeys: ECPairInterface | BIP32Interface, refundPublicKey: Buffer, diff --git a/lib/Utils.ts b/lib/Utils.ts index 92385530..bff04004 100644 --- a/lib/Utils.ts +++ b/lib/Utils.ts @@ -8,9 +8,9 @@ import { OutputType, Scripts } from 'boltz-core'; import { Transaction as LiquidTransaction, confidential } from 'liquidjs-lib'; import commitHash from './Version'; import packageJson from '../package.json'; -import { OrderSide } from './consts/Enums'; import ChainClient from './chain/ChainClient'; import { etherDecimals } from './consts/Consts'; +import { OrderSide, SwapVersion } from './consts/Enums'; const { p2trOutput, @@ -42,6 +42,9 @@ export const generateId = (length = 6): string => { return id; }; +export const generateSwapId = (version: SwapVersion): string => + generateId(version === SwapVersion.Legacy ? undefined : 12); + /** * Stringify any object or array */ diff --git a/lib/api/v2/routers/SwapRouter.ts b/lib/api/v2/routers/SwapRouter.ts index 0dfdd8f7..1a2057d5 100644 --- a/lib/api/v2/routers/SwapRouter.ts +++ b/lib/api/v2/routers/SwapRouter.ts @@ -1,15 +1,15 @@ import { Request, Response, Router } from 'express'; import Logger from '../../../Logger'; import RouterBase from './RouterBase'; -import { getHexString, stringify } from '../../../Utils'; +import { SwapVersion } from '../../../consts/Enums'; import Service from '../../../service/Service'; +import { getHexString, stringify } from '../../../Utils'; import { checkPreimageHashLength, createdResponse, successResponse, validateRequest, } from '../../Utils'; -import { SwapVersion } from '../../../consts/Enums'; class SwapRouter extends RouterBase { constructor( @@ -88,6 +88,10 @@ class SwapRouter extends RouterBase { router.post('/submarine/refund', this.handleError(this.refundSubmarine)); + router.post('/reverse', this.handleError(this.createReverse)); + + router.post('/reverse/claim', this.handleError(this.claimReverse)); + return router; }; @@ -169,6 +173,80 @@ class SwapRouter extends RouterBase { partialSignature: getHexString(sig.signature), }); }; + + private createReverse = async (req: Request, res: Response) => { + const { + pairId, + pairHash, + orderSide, + referralId, + routingNode, + claimAddress, + preimageHash, + invoiceAmount, + onchainAmount, + claimPublicKey, + } = validateRequest(req.body, [ + { name: 'pairId', type: 'string' }, + { name: 'orderSide', type: 'string' }, + { name: 'preimageHash', type: 'string', hex: true }, + { name: 'claimPublicKey', type: 'string', hex: true }, + { name: 'pairHash', type: 'string', optional: true }, + { name: 'referralId', type: 'string', optional: true }, + { name: 'routingNode', type: 'string', optional: true }, + { name: 'claimAddress', type: 'string', optional: true }, + { name: 'invoiceAmount', type: 'number', optional: true }, + { name: 'onchainAmount', type: 'number', optional: true }, + ]); + + checkPreimageHashLength(preimageHash); + + const response = await this.service.createReverseSwap({ + pairId, + pairHash, + orderSide, + referralId, + routingNode, + claimAddress, + preimageHash, + invoiceAmount, + onchainAmount, + claimPublicKey, + prepayMinerFee: false, + version: SwapVersion.Taproot, + }); + + this.logger.verbose(`Created Reverse Swap with id: ${response.id}`); + this.logger.silly(`Reverse swap ${response.id}: ${stringify(response)}`); + + createdResponse(res, response); + }; + + private claimReverse = async (req: Request, res: Response) => { + const { id, preimage, pubNonce, index, transaction } = validateRequest( + req.body, + [ + { name: 'id', type: 'string' }, + { name: 'index', type: 'number' }, + { name: 'preimage', type: 'string', hex: true }, + { name: 'pubNonce', type: 'string', hex: true }, + { name: 'transaction', type: 'string', hex: true }, + ], + ); + + const sig = await this.service.musigSigner.signReverseSwapClaim( + id, + preimage, + pubNonce, + transaction, + index, + ); + + successResponse(res, { + pubNonce: getHexString(sig.pubNonce), + partialSignature: getHexString(sig.signature), + }); + }; } export default SwapRouter; diff --git a/lib/cli/BoltzApiClient.ts b/lib/cli/BoltzApiClient.ts index 698b0f0d..08d6331b 100644 --- a/lib/cli/BoltzApiClient.ts +++ b/lib/cli/BoltzApiClient.ts @@ -1,32 +1,48 @@ import axios from 'axios'; +import { SwapUpdateEvent } from '../consts/Enums'; + +type PartialSignature = { + pubNonce: string; + partialSignature: string; +}; class BoltzApiClient { public static readonly regtestEndpoint = 'http://127.0.0.1:9001'; constructor(private readonly endpoint = BoltzApiClient.regtestEndpoint) {} + public getStatus = async ( + swapId: string, + ): Promise<{ + status: SwapUpdateEvent; + transaction?: { + hex: string; + }; + }> => + ( + await axios.post(`${this.endpoint}/swapstatus`, { + id: swapId, + }) + ).data; + public getSwapTransaction = async ( swapId: string, ): Promise<{ transactionHex: string; - }> => { - return ( + }> => + ( await axios.post(`${this.endpoint}/getswaptransaction`, { id: swapId, }) ).data; - }; - public refundSwap = async ( + public getSwapRefundPartialSignature = async ( swapId: string, transaction: string, vin: number, pubNonce: string, - ): Promise<{ - pubNonce: string; - partialSignature: string; - }> => { - return ( + ): Promise => + ( await axios.post(`${this.endpoint}/v2/swap/submarine/refund`, { pubNonce, transaction, @@ -34,7 +50,24 @@ class BoltzApiClient { index: vin, }) ).data; - }; + + public getReverseClaimPartialSignature = async ( + swapId: string, + preimage: string, + transaction: string, + vin: number, + pubNonce: string, + ): Promise => + ( + await axios.post(`${this.endpoint}/v2/swap/reverse/claim`, { + pubNonce, + preimage, + transaction, + id: swapId, + index: vin, + }) + ).data; } export default BoltzApiClient; +export { PartialSignature }; diff --git a/lib/cli/BuilderComponents.ts b/lib/cli/BuilderComponents.ts index cfa291b4..c7edc9e6 100644 --- a/lib/cli/BuilderComponents.ts +++ b/lib/cli/BuilderComponents.ts @@ -50,4 +50,8 @@ export default { describe: 'SwapTree of the Swap', type: 'string', }, + preimage: { + describe: 'preimage of the swap', + type: 'string', + }, }; diff --git a/lib/cli/TaprootHelper.ts b/lib/cli/TaprootHelper.ts new file mode 100644 index 00000000..3cfba303 --- /dev/null +++ b/lib/cli/TaprootHelper.ts @@ -0,0 +1,138 @@ +import { Arguments } from 'yargs'; +import { randomBytes } from 'crypto'; +import { ECPairInterface } from 'ecpair'; +import { Network, Transaction } from 'bitcoinjs-lib'; +import { LiquidRefundDetails } from 'boltz-core/lib/liquid'; +import { + detectSwap, + Musig, + OutputType, + RefundDetails, + SwapTreeSerializer, + TaprootUtils, + Types, +} from 'boltz-core'; +import { Network as LiquidNetwork } from 'liquidjs-lib/src/networks'; +import { Transaction as LiquidTransaction } from 'liquidjs-lib/src/transaction'; +import { TaprootUtils as LiquidTaprootDetails } from 'boltz-core/dist/lib/liquid'; +import { getHexBuffer } from '../Utils'; +import { ECPair } from '../ECPairHelper'; +import { CurrencyType } from '../consts/Enums'; +import { PartialSignature } from './BoltzApiClient'; +import { + constructClaimTransaction, + setup, + tweakMusig, + zkpMusig, +} from '../Core'; +import { + currencyTypeFromNetwork, + getWalletStub, + parseNetwork, +} from './Command'; + +export const setupCooperativeTransaction = async ( + argv: Arguments, + keyExtractionFunc: (tree: Types.SwapTree) => Buffer, +) => { + await setup(); + + const network = parseNetwork(argv.network); + const currencyType = currencyTypeFromNetwork(argv.network); + + const swapTree = SwapTreeSerializer.deserializeSwapTree(argv.swapTree); + const keys = ECPair.fromPrivateKey(getHexBuffer(argv.privateKey)); + const theirPublicKey = keyExtractionFunc(swapTree); + + const musig = new Musig(zkpMusig, keys, randomBytes(32), [ + theirPublicKey, + keys.publicKey, + ]); + const tweakedKey = tweakMusig(currencyType, musig, swapTree); + + return { + keys, + musig, + network, + tweakedKey, + currencyType, + theirPublicKey, + }; +}; + +export const prepareCooperativeTransaction = < + T extends Transaction | LiquidTransaction, +>( + argv: Arguments, + network: Network | LiquidNetwork, + currencyType: CurrencyType, + keys: ECPairInterface, + tweakedKey: Buffer, + lockupTx: T, +): { tx: T; details: RefundDetails | LiquidRefundDetails } => { + const swapOutput = detectSwap(tweakedKey, lockupTx); + if (swapOutput === undefined) { + throw 'could not find swap output'; + } + + const details = { + ...swapOutput, + keys, + txHash: lockupTx.getHash(), + type: OutputType.Taproot, + cooperative: true, + } as any; + const tx = constructClaimTransaction( + getWalletStub( + currencyType, + network, + argv.destinationAddress, + argv.blindingKey, + ), + [details], + argv.destinationAddress, + argv.feePerVbyte, + ); + + return { + details, + tx: tx as T, + }; +}; + +export const finalizeCooperativeTransaction = < + T extends Transaction | LiquidTransaction, +>( + tx: T, + musig: Musig, + network: Network | LiquidNetwork, + currencyType: CurrencyType, + otherPublicKey: Buffer, + details: RefundDetails | LiquidRefundDetails, + partialSig: PartialSignature, +): T => { + musig.aggregateNonces([[otherPublicKey, getHexBuffer(partialSig.pubNonce)]]); + + let hash: Buffer; + if (currencyType === CurrencyType.BitcoinLike) { + hash = TaprootUtils.hashForWitnessV1( + [details] as RefundDetails[], + tx as Transaction, + 0, + ); + } else { + hash = LiquidTaprootDetails.hashForWitnessV1( + network as LiquidNetwork, + [details] as LiquidRefundDetails[], + tx as LiquidTransaction, + 0, + ); + } + + musig.initializeSession(hash); + musig.signPartial(); + musig.addPartial(otherPublicKey, getHexBuffer(partialSig.partialSignature)); + tx.setWitness(0, [musig.aggregatePartials()]); + + return tx; +}; diff --git a/lib/cli/commands/Claim.ts b/lib/cli/commands/Claim.ts index 6dd97922..3cef1d68 100644 --- a/lib/cli/commands/Claim.ts +++ b/lib/cli/commands/Claim.ts @@ -11,14 +11,11 @@ export const describe = 'claims reverse submarine or chain to chain swaps'; export const builder = { network: BuilderComponents.network, + preimage: BuilderComponents.preimage, privateKey: BuilderComponents.privateKey, redeemScript: BuilderComponents.redeemScript, rawTransaction: BuilderComponents.rawTransaction, destinationAddress: BuilderComponents.destinationAddress, - preimage: { - describe: 'preimage of the swap', - type: 'string', - }, feePerVbyte: BuilderComponents.feePerVbyte, blindingKey: BuilderComponents.blindingKey, }; diff --git a/lib/cli/commands/CooperativeClaim.ts b/lib/cli/commands/CooperativeClaim.ts new file mode 100644 index 00000000..c2076102 --- /dev/null +++ b/lib/cli/commands/CooperativeClaim.ts @@ -0,0 +1,76 @@ +import { Arguments } from 'yargs'; +import BoltzApiClient from '../BoltzApiClient'; +import BuilderComponents from '../BuilderComponents'; +import { getHexString, stringify } from '../../Utils'; +import { + extractRefundPublicKeyFromReverseSwapTree, + parseTransaction, +} from '../../Core'; +import { + finalizeCooperativeTransaction, + prepareCooperativeTransaction, + setupCooperativeTransaction, +} from '../TaprootHelper'; + +export const command = + 'claim-cooperative [feePerVbyte] [blindingKey]'; + +export const describe = 'claims a Taproot Reverse Submarine Swap cooperatively'; + +export const builder = { + network: BuilderComponents.network, + preimage: BuilderComponents.preimage, + privateKey: BuilderComponents.privateKey, + swapId: BuilderComponents.swapId, + swapTree: BuilderComponents.swapTree, + destinationAddress: BuilderComponents.destinationAddress, + feePerVbyte: BuilderComponents.feePerVbyte, + blindingKey: BuilderComponents.blindingKey, +}; + +export const handler = async (argv: Arguments): Promise => { + const { network, keys, tweakedKey, theirPublicKey, musig, currencyType } = + await setupCooperativeTransaction( + argv, + extractRefundPublicKeyFromReverseSwapTree, + ); + + const boltzClient = new BoltzApiClient(); + const swapStatus = await boltzClient.getStatus(argv.swapId); + + if (swapStatus.transaction === undefined) { + throw 'no transaction in swap status'; + } + + const lockupTx = parseTransaction(currencyType, swapStatus.transaction.hex); + + const { details, tx } = prepareCooperativeTransaction( + argv, + network, + currencyType, + keys, + tweakedKey, + lockupTx, + ); + + const partialSig = await boltzClient.getReverseClaimPartialSignature( + argv.swapId, + argv.preimage, + tx.toHex(), + 0, + getHexString(Buffer.from(musig.getPublicNonce())), + ); + console.log( + stringify({ + refundTransaction: finalizeCooperativeTransaction( + tx, + musig, + network, + currencyType, + theirPublicKey, + details, + partialSig, + ).toHex(), + }), + ); +}; diff --git a/lib/cli/commands/CooperativeRefund.ts b/lib/cli/commands/CooperativeRefund.ts index 55d6740d..46a60e38 100644 --- a/lib/cli/commands/CooperativeRefund.ts +++ b/lib/cli/commands/CooperativeRefund.ts @@ -1,33 +1,16 @@ import { Arguments } from 'yargs'; -import { randomBytes } from 'crypto'; -import { Transaction } from 'bitcoinjs-lib'; -import { Transaction as LiquidTransaction } from 'liquidjs-lib'; -import { TaprootUtils as LiquidTaprootDetails } from 'boltz-core/dist/lib/liquid'; -import { - detectSwap, - Musig, - OutputType, - SwapTreeSerializer, - TaprootUtils, -} from 'boltz-core'; -import { ECPair } from '../../ECPairHelper'; import BoltzApiClient from '../BoltzApiClient'; -import { CurrencyType } from '../../consts/Enums'; import BuilderComponents from '../BuilderComponents'; -import { getHexBuffer, getHexString, stringify } from '../../Utils'; +import { getHexString, stringify } from '../../Utils'; +import { + finalizeCooperativeTransaction, + prepareCooperativeTransaction, + setupCooperativeTransaction, +} from '../TaprootHelper'; import { - constructClaimTransaction, extractClaimPublicKeyFromSwapTree, parseTransaction, - setup, - tweakMusig, - zkpMusig, } from '../../Core'; -import { - currencyTypeFromNetwork, - getWalletStub, - parseNetwork, -} from '../Command'; export const command = 'refund-cooperative [feePerVbyte] [blindingKey]'; @@ -45,82 +28,42 @@ export const builder = { }; export const handler = async (argv: Arguments): Promise => { - await setup(); - - const network = parseNetwork(argv.network); - const currencyType = currencyTypeFromNetwork(argv.network); - - const swapTree = SwapTreeSerializer.deserializeSwapTree(argv.swapTree); - const keys = ECPair.fromPrivateKey(getHexBuffer(argv.privateKey)); - const claimKey = extractClaimPublicKeyFromSwapTree(swapTree); - - const musig = new Musig(zkpMusig, keys, randomBytes(32), [ - claimKey, - keys.publicKey, - ]); - const tweakedKey = tweakMusig(currencyType, musig, swapTree); + const { keys, network, currencyType, musig, tweakedKey, theirPublicKey } = + await setupCooperativeTransaction(argv, extractClaimPublicKeyFromSwapTree); const boltzClient = new BoltzApiClient(); const lockupTx = parseTransaction( currencyType, (await boltzClient.getSwapTransaction(argv.swapId)).transactionHex, ); - const swapOutput = detectSwap(tweakedKey, lockupTx); - if (swapOutput === undefined) { - throw 'could not find swap output'; - } - const refundDetails = [ - { - ...swapOutput, - keys, - txHash: lockupTx.getHash(), - type: OutputType.Taproot, - cooperative: true, - } as any, - ]; - const refundTx = constructClaimTransaction( - getWalletStub( - currencyType, - network, - argv.destinationAddress, - argv.blindingKey, - ), - refundDetails, - argv.destinationAddress, - argv.feePerVbyte, + const { details, tx } = prepareCooperativeTransaction( + argv, + network, + currencyType, + keys, + tweakedKey, + lockupTx, ); - const partialSig = await boltzClient.refundSwap( + const partialSig = await boltzClient.getSwapRefundPartialSignature( argv.swapId, - refundTx.toHex(), + tx.toHex(), 0, getHexString(Buffer.from(musig.getPublicNonce())), ); - musig.aggregateNonces( - new Map([[claimKey, getHexBuffer(partialSig.pubNonce)]]), - ); - - let hash: Buffer; - if (currencyType === CurrencyType.BitcoinLike) { - hash = TaprootUtils.hashForWitnessV1( - refundDetails, - refundTx as Transaction, - 0, - ); - } else { - hash = LiquidTaprootDetails.hashForWitnessV1( - network, - refundDetails, - refundTx as LiquidTransaction, - 0, - ); - } - musig.initializeSession(hash); - musig.signPartial(); - musig.addPartial(claimKey, getHexBuffer(partialSig.partialSignature)); - refundTx.setWitness(0, [musig.aggregatePartials()]); - - console.log(stringify({ refundTransaction: refundTx.toHex() })); + console.log( + stringify({ + refundTransaction: finalizeCooperativeTransaction( + tx, + musig, + network, + currencyType, + theirPublicKey, + details, + partialSig, + ).toHex(), + }), + ); }; diff --git a/lib/db/Migration.ts b/lib/db/Migration.ts index c112c45a..ee9783cc 100644 --- a/lib/db/Migration.ts +++ b/lib/db/Migration.ts @@ -393,6 +393,22 @@ class Migration { .getQueryInterface() .changeColumn('swaps', 'version', attrs); + this.logUpdatingTable('reverseSwaps'); + + attrs.allowNull = true; + await this.sequelize + .getQueryInterface() + .addColumn('reverseSwaps', 'version', attrs); + + await this.sequelize + .getQueryInterface() + .bulkUpdate('reverseSwaps', { version: SwapVersion.Legacy }, {}); + + attrs.allowNull = false; + await this.sequelize + .getQueryInterface() + .changeColumn('reverseSwaps', 'version', attrs); + await this.finishMigration(versionRow.version, currencies); break; } diff --git a/lib/db/models/ReverseSwap.ts b/lib/db/models/ReverseSwap.ts index 8301b267..31669c84 100644 --- a/lib/db/models/ReverseSwap.ts +++ b/lib/db/models/ReverseSwap.ts @@ -1,5 +1,6 @@ import { Model, Sequelize, DataTypes } from 'sequelize'; import Pair from './Pair'; +import { SwapVersion } from '../../consts/Enums'; enum NodeType { LND = 0, @@ -8,6 +9,7 @@ enum NodeType { type ReverseSwapType = { id: string; + version: SwapVersion; lockupAddress: string; @@ -48,6 +50,7 @@ type ReverseSwapType = { class ReverseSwap extends Model implements ReverseSwapType { public id!: string; + public version!: SwapVersion; public lockupAddress!: string; @@ -97,6 +100,17 @@ class ReverseSwap extends Model implements ReverseSwapType { primaryKey: true, allowNull: false, }, + version: { + type: new DataTypes.INTEGER(), + allowNull: false, + validate: { + isIn: [ + Object.values(SwapVersion).filter( + (val) => typeof val === 'number', + ), + ], + }, + }, lockupAddress: { type: new DataTypes.STRING(255), allowNull: false }, keyIndex: { type: new DataTypes.INTEGER(), allowNull: true }, redeemScript: { type: new DataTypes.TEXT(), allowNull: true }, diff --git a/lib/service/Errors.ts b/lib/service/Errors.ts index bb4e8d65..ee7dae74 100644 --- a/lib/service/Errors.ts +++ b/lib/service/Errors.ts @@ -137,4 +137,8 @@ export default { message: 'swap not eligible for a cooperative refund', code: concatErrorCode(ErrorCodePrefix.Service, 35), }), + INCORRECT_PREIMAGE: (): Error => ({ + message: 'incorrect preimage', + code: concatErrorCode(ErrorCodePrefix.Service, 36), + }), }; diff --git a/lib/service/MusigSigner.ts b/lib/service/MusigSigner.ts index 5deb7605..25140efd 100644 --- a/lib/service/MusigSigner.ts +++ b/lib/service/MusigSigner.ts @@ -1,19 +1,28 @@ -import { SwapTreeSerializer } from 'boltz-core'; +import { SwapTreeSerializer, Types } from 'boltz-core'; import Errors from './Errors'; import Logger from '../Logger'; import Swap from '../db/models/Swap'; +import SwapNursery from '../swap/SwapNursery'; import { Payment } from '../proto/lnd/rpc_pb'; import SwapRepository from '../db/repositories/SwapRepository'; import WalletManager, { Currency } from '../wallet/WalletManager'; -import { getChainCurrency, getHexBuffer, splitPairId } from '../Utils'; +import { + getChainCurrency, + getHexBuffer, + getHexString, + splitPairId, +} from '../Utils'; import { FailedSwapUpdateEvents, SwapUpdateEvent } from '../consts/Enums'; +import ReverseSwapRepository from '../db/repositories/ReverseSwapRepository'; import { createMusig, + extractClaimPublicKeyFromReverseSwapTree, extractRefundPublicKeyFromSwapTree, hashForWitnessV1, parseTransaction, tweakMusig, } from '../Core'; +import { crypto } from 'bitcoinjs-lib'; type PartialSignature = { pubNonce: Buffer; @@ -27,6 +36,7 @@ class MusigSigner { private readonly logger: Logger, private readonly currencies: Map, private readonly walletManager: WalletManager, + private readonly nursery: SwapNursery, ) {} public signSwapRefund = async ( @@ -50,7 +60,7 @@ class MusigSigner { (await this.hasPendingLightningPayment(currency, swap)) ) { this.logger.verbose( - `Not creating partial signature for refund of Swap ${swap.id} because it is not eligible`, + `Not creating partial signature for refund of Swap ${swap.id}: it is not eligible`, ); throw Errors.NOT_ELIGIBLE_FOR_COOPERATIVE_REFUND(); } @@ -59,22 +69,92 @@ class MusigSigner { `Creating partial signature for refund of Swap ${swap.id}`, ); - const wallet = this.walletManager.wallets.get(currency.symbol)!; + const swapTree = SwapTreeSerializer.deserializeSwapTree(swap.redeemScript!); + return this.createPartialSignature( + currency, + swapTree, + swap.keyIndex!, + extractRefundPublicKeyFromSwapTree(swapTree), + theirNonce, + rawTransaction, + index, + ); + }; + + public signReverseSwapClaim = async ( + swapId: string, + preimage: Buffer, + theirNonce: Buffer, + rawTransaction: Buffer, + index: number, + ): Promise => { + const swap = await ReverseSwapRepository.getReverseSwap({ id: swapId }); + if (!swap) { + throw Errors.SWAP_NOT_FOUND(swapId); + } + + if ( + ![ + SwapUpdateEvent.TransactionMempool, + SwapUpdateEvent.TransactionConfirmed, + SwapUpdateEvent.InvoiceSettled, + ].includes(swap.status as SwapUpdateEvent) + ) { + this.logger.verbose( + `Not creating partial signature for claim of Reverse Swap ${swap.id}: it is not eligible`, + ); + throw Errors.NOT_ELIGIBLE_FOR_COOPERATIVE_REFUND(); + } + if (getHexString(crypto.sha256(preimage)) !== swap.preimageHash) { + this.logger.verbose( + `Not creating partial signature for claim of Reverse Swap ${swap.id}: preimage is incorrect`, + ); + throw Errors.INCORRECT_PREIMAGE(); + } + + this.logger.debug( + `Creating partial signature for claim of Reverse Swap ${swap.id}`, + ); + + await this.nursery.lock.acquire(SwapNursery.reverseSwapLock, async () => { + await this.nursery.settleReverseSwapInvoice(swap, preimage); + }); + + const { base, quote } = splitPairId(swap.pair); const swapTree = SwapTreeSerializer.deserializeSwapTree(swap.redeemScript!); - const claimKeys = wallet.getKeysByIndex(swap.keyIndex!); - const refundKeys = extractRefundPublicKeyFromSwapTree(swapTree); - const tx = parseTransaction(currency.type, rawTransaction); + return this.createPartialSignature( + this.currencies.get(getChainCurrency(base, quote, swap.orderSide, true))!, + swapTree, + swap.keyIndex!, + extractClaimPublicKeyFromReverseSwapTree(swapTree), + theirNonce, + rawTransaction, + index, + ); + }; + + private createPartialSignature = async ( + currency: Currency, + swapTree: Types.SwapTree, + keyIndex: number, + theirPublicKey: Buffer, + theirNonce: Buffer, + rawTransaction: Buffer | string, + vin: number, + ): Promise => { + const wallet = this.walletManager.wallets.get(currency.symbol)!; + + const ourKeys = wallet.getKeysByIndex(keyIndex); - const musig = createMusig(claimKeys, refundKeys); + const musig = createMusig(ourKeys, theirPublicKey); tweakMusig(currency.type, musig, swapTree); - musig.aggregateNonces( - new Map([[refundKeys, theirNonce]]), - ); + musig.aggregateNonces([[theirPublicKey, theirNonce]]); - const hash = await hashForWitnessV1(currency, tx, index); + const tx = parseTransaction(currency.type, rawTransaction); + const hash = await hashForWitnessV1(currency, tx, vin); musig.initializeSession(hash); return { diff --git a/lib/service/Service.ts b/lib/service/Service.ts index 784d833c..692a447e 100644 --- a/lib/service/Service.ts +++ b/lib/service/Service.ts @@ -1,5 +1,5 @@ import bolt11 from 'bolt11'; -import { OutputType } from 'boltz-core'; +import { OutputType, SwapTreeSerializer } from 'boltz-core'; import { getAddress, Provider } from 'ethers'; import Errors from './Errors'; import Logger from '../Logger'; @@ -24,14 +24,11 @@ import SwapRepository from '../db/repositories/SwapRepository'; import RateProvider, { PairType } from '../rates/RateProvider'; import EthereumManager from '../wallet/ethereum/EthereumManager'; import WalletManager, { Currency } from '../wallet/WalletManager'; +import SwapManager, { ChannelCreationInfo } from '../swap/SwapManager'; import ReferralRepository from '../db/repositories/ReferralRepository'; import ReverseSwapRepository from '../db/repositories/ReverseSwapRepository'; import { InvoiceFeature, PaymentResponse } from '../lightning/LightningClient'; import ChannelCreationRepository from '../db/repositories/ChannelCreationRepository'; -import SwapManager, { - ChannelCreationInfo, - SerializedSwapTree, -} from '../swap/SwapManager'; import TimeoutDeltaProvider, { PairTimeoutBlocksDelta, } from './TimeoutDeltaProvider'; @@ -174,6 +171,7 @@ class Service { this.logger, this.currencies, this.walletManager, + this.swapManager.nursery, ); } @@ -742,7 +740,7 @@ class Service { // Only set for Taproot swaps claimPublicKey?: string; - swapTree?: SerializedSwapTree; + swapTree?: SwapTreeSerializer.SerializedTree; // Is undefined when Bitcoin or Litecoin is swapped to Lightning claimAddress?: string; @@ -764,6 +762,7 @@ class Service { this.getCurrency(getChainCurrency(base, quote, orderSide, false)).type ) { case CurrencyType.BitcoinLike: + case CurrencyType.Liquid: if (args.refundPublicKey === undefined) { throw ApiErrors.UNDEFINED_PARAMETER('refundPublicKey'); } @@ -1102,7 +1101,7 @@ class Service { // Only set for Taproot swaps claimPublicKey?: string; - swapTree?: SerializedSwapTree; + swapTree?: SwapTreeSerializer.SerializedTree; // Is undefined when Bitcoin or Litecoin is swapped to Lightning claimAddress?: string; @@ -1210,13 +1209,23 @@ class Service { }): Promise<{ id: string; invoice: string; + blindingKey?: string; lockupAddress: string; + redeemScript?: string; + + // Only set for Taproot swaps + refundPublicKey?: string; + swapTree?: SwapTreeSerializer.SerializedTree; + refundAddress?: string; + onchainAmount?: number; minerFeeInvoice?: string; + timeoutBlockHeight: number; + prepayMinerFeeAmount?: number; }> => { if (!this.allowReverseSwaps) { @@ -1409,10 +1418,12 @@ class Service { const { id, invoice, + swapTree, blindingKey, redeemScript, refundAddress, lockupAddress, + refundPublicKey, minerFeeInvoice, timeoutBlockHeight, } = await this.swapManager.createReverseSwap({ @@ -1428,6 +1439,7 @@ class Service { orderSide: side, baseCurrency: base, quoteCurrency: quote, + version: args.version, routingNode: args.routingNode, claimAddress: args.claimAddress, preimageHash: args.preimageHash, @@ -1439,10 +1451,12 @@ class Service { const response: any = { id, invoice, + swapTree, blindingKey, redeemScript, refundAddress, lockupAddress, + refundPublicKey, timeoutBlockHeight, }; diff --git a/lib/swap/SwapManager.ts b/lib/swap/SwapManager.ts index ece5c8cd..001563e3 100644 --- a/lib/swap/SwapManager.ts +++ b/lib/swap/SwapManager.ts @@ -3,6 +3,7 @@ import { randomBytes } from 'crypto'; import { crypto } from 'bitcoinjs-lib'; import { reverseSwapScript, + reverseSwapTree, Scripts, swapScript, swapTree, @@ -40,7 +41,7 @@ import { import { decodeInvoice, formatError, - generateId, + generateSwapId, getChainCurrency, getHexBuffer, getHexString, @@ -65,16 +66,6 @@ type SetSwapInvoiceResponse = { channelCreationError?: string; }; -type SerializedSwapTreeLeaf = { - version: number; - output: string; -}; - -type SerializedSwapTree = { - claimLeaf: SerializedSwapTreeLeaf; - refundLeaf: SerializedSwapTreeLeaf; -}; - class SwapManager { public currencies = new Map(); @@ -191,7 +182,7 @@ class SwapManager { // Only set for Taproot swaps claimPublicKey?: string; - swapTree?: SerializedSwapTree; + swapTree?: SwapTreeSerializer.SerializedTree; // Specified when either Ether or ERC20 tokens or swapped to Lightning // So that the user can specify the claim address (Boltz) in the lockup transaction to the contract @@ -210,7 +201,7 @@ class SwapManager { throw Errors.NO_LIGHTNING_SUPPORT(sendingCurrency.symbol); } - const id = generateId(args.version === SwapVersion.Legacy ? undefined : 12); + const id = generateSwapId(args.version); this.logger.verbose( `Creating new ${swapVersionToString(args.version)} Swap from ${ @@ -255,9 +246,7 @@ class SwapManager { args.refundPublicKey!, result.timeoutBlockHeight, ); - result.swapTree = JSON.parse( - SwapTreeSerializer.serializeSwapTree(tree), - ); + result.swapTree = SwapTreeSerializer.serializeSwapTree(tree); const musig = createMusig(keys, args.refundPublicKey!); const tweakedKey = tweakMusig(receivingCurrency.type, musig, tree); @@ -310,7 +299,7 @@ class SwapManager { redeemScript: args.version === SwapVersion.Legacy ? result.redeemScript - : SwapTreeSerializer.serializeSwapTree(tree!), + : JSON.stringify(SwapTreeSerializer.serializeSwapTree(tree!)), }); } else { result.address = await this.getLockupContractAddress( @@ -521,6 +510,8 @@ class SwapManager { * Creates a new reverse Swap from Lightning to the chain */ public createReverseSwap = async (args: { + version: number; + baseCurrency: string; quoteCurrency: string; orderSide: OrderSide; @@ -556,6 +547,10 @@ class SwapManager { // Only set for Bitcoin like, UTXO based, chains redeemScript: string | undefined; + // Only set for Taproot swaps + refundPublicKey?: string; + swapTree?: SwapTreeSerializer.SerializedTree; + // Only set for Ethereum like chains refundAddress: string | undefined; @@ -576,10 +571,12 @@ class SwapManager { throw Errors.NO_LIGHTNING_SUPPORT(receivingCurrency.symbol); } - const id = generateId(); + const id = generateSwapId(args.version); this.logger.verbose( - `Creating new Reverse Swap from ${receivingCurrency.symbol} to ${sendingCurrency.symbol}: ${id}`, + `Creating new ${swapVersionToString(args.version)} Reverse Swap from ${ + receivingCurrency.symbol + } to ${sendingCurrency.symbol}: ${id}`, ); if (args.referralId) { this.logger.silly( @@ -638,13 +635,11 @@ class SwapManager { } } - let lockupAddress: string; - let timeoutBlockHeight: number; - - let blindingKey: Buffer | undefined; - let redeemScript: Buffer | undefined; - - let refundAddress: string | undefined; + const result: any = { + id, + minerFeeInvoice, + invoice: paymentRequest, + }; if ( sendingCurrency.type === CurrencyType.BitcoinLike || @@ -652,91 +647,119 @@ class SwapManager { ) { const { keys, index } = sendingCurrency.wallet.getNewKeys(); const { blocks } = await sendingCurrency.chainClient!.getBlockchainInfo(); - timeoutBlockHeight = blocks + args.onchainTimeoutBlockDelta; + result.timeoutBlockHeight = blocks + args.onchainTimeoutBlockDelta; - redeemScript = reverseSwapScript( - args.preimageHash, - args.claimPublicKey!, - keys.publicKey, - timeoutBlockHeight, - ); + let outputScript: Buffer; + let tree: Types.SwapTree | undefined; - const outputScript = getScriptHashFunction(ReverseSwapOutputType)( - redeemScript, - ); - lockupAddress = sendingCurrency.wallet.encodeAddress(outputScript); + switch (args.version) { + case SwapVersion.Taproot: { + result.refundPublicKey = getHexString(keys.publicKey); + + 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!); + const tweakedKey = tweakMusig(sendingCurrency.type, musig, tree); + outputScript = Scripts.p2trOutput(tweakedKey); + + break; + } + + default: { + const redeemScript = reverseSwapScript( + args.preimageHash, + args.claimPublicKey!, + keys.publicKey, + result.timeoutBlockHeight, + ); + result.redeemScript = getHexString(redeemScript); + + outputScript = getScriptHashFunction(ReverseSwapOutputType)( + result.redeemScript, + ); + + break; + } + } + + result.lockupAddress = sendingCurrency.wallet.encodeAddress(outputScript); if (sendingCurrency.type === CurrencyType.Liquid) { - blindingKey = ( - sendingCurrency.wallet as WalletLiquid - ).deriveBlindingKeyFromScript(outputScript).privateKey; + result.blindingKey = getHexString( + (sendingCurrency.wallet as WalletLiquid).deriveBlindingKeyFromScript( + outputScript, + ).privateKey!, + ); } await ReverseSwapRepository.addReverseSwap({ id, pair, - lockupAddress, minerFeeInvoice, - timeoutBlockHeight, - node: nodeType, keyIndex: index, + + version: args.version, fee: args.percentageFee, invoice: paymentRequest, referral: args.referralId, orderSide: args.orderSide, onchainAmount: args.onchainAmount, + lockupAddress: result.lockupAddress, status: SwapUpdateEvent.SwapCreated, invoiceAmount: args.holdInvoiceAmount, - redeemScript: getHexString(redeemScript), + timeoutBlockHeight: result.timeoutBlockHeight, preimageHash: getHexString(args.preimageHash), minerFeeInvoicePreimage: minerFeeInvoicePreimage, minerFeeOnchainAmount: args.prepayMinerFeeOnchainAmount, + redeemScript: + args.version === SwapVersion.Legacy + ? result.redeemScript + : JSON.stringify(SwapTreeSerializer.serializeSwapTree(tree!)), }); } else { const blockNumber = await sendingCurrency.provider!.getBlockNumber(); - timeoutBlockHeight = blockNumber + args.onchainTimeoutBlockDelta; + result.timeoutBlockHeight = blockNumber + args.onchainTimeoutBlockDelta; - lockupAddress = await this.getLockupContractAddress( + result.lockupAddress = await this.getLockupContractAddress( sendingCurrency.symbol, sendingCurrency.type, ); - refundAddress = await this.walletManager.wallets + result.refundAddress = await this.walletManager.wallets .get(sendingCurrency.symbol)! .getAddress(); await ReverseSwapRepository.addReverseSwap({ id, pair, - lockupAddress, minerFeeInvoice, - timeoutBlockHeight, - node: nodeType, fee: args.percentageFee, + invoice: paymentRequest, orderSide: args.orderSide, referral: args.referralId, + version: SwapVersion.Legacy, claimAddress: args.claimAddress!, + lockupAddress: result.lockupAddress, onchainAmount: args.onchainAmount, status: SwapUpdateEvent.SwapCreated, invoiceAmount: args.holdInvoiceAmount, + timeoutBlockHeight: result.timeoutBlockHeight, preimageHash: getHexString(args.preimageHash), minerFeeInvoicePreimage: minerFeeInvoicePreimage, minerFeeOnchainAmount: args.prepayMinerFeeOnchainAmount, }); } - return { - id, - lockupAddress, - refundAddress, - minerFeeInvoice, - timeoutBlockHeight, - invoice: paymentRequest, - blindingKey: blindingKey ? getHexString(blindingKey) : undefined, - redeemScript: redeemScript ? getHexString(redeemScript) : undefined, - }; + return result; }; // TODO: check current status of invoices or do the streams handle that already? @@ -865,4 +888,4 @@ class SwapManager { } export default SwapManager; -export { ChannelCreationInfo, SerializedSwapTree }; +export { ChannelCreationInfo }; diff --git a/lib/swap/SwapNursery.ts b/lib/swap/SwapNursery.ts index a92a862b..ecd27346 100644 --- a/lib/swap/SwapNursery.ts +++ b/lib/swap/SwapNursery.ts @@ -19,6 +19,7 @@ import FeeProvider from '../rates/FeeProvider'; import ChainClient from '../chain/ChainClient'; import EthereumNursery from './EthereumNursery'; import RateProvider from '../rates/RateProvider'; +import { etherDecimals } from '../consts/Consts'; import LightningNursery from './LightningNursery'; import ReverseSwap from '../db/models/ReverseSwap'; import ChannelCreation from '../db/models/ChannelCreation'; @@ -28,7 +29,6 @@ import EthereumManager from '../wallet/ethereum/EthereumManager'; import WalletManager, { Currency } from '../wallet/WalletManager'; import TimeoutDeltaProvider from '../service/TimeoutDeltaProvider'; import { ERC20SwapValues, EtherSwapValues } from '../consts/Types'; -import { etherDecimals, ReverseSwapOutputType } from '../consts/Consts'; import ERC20WalletProvider from '../wallet/providers/ERC20WalletProvider'; import ReverseSwapRepository from '../db/repositories/ReverseSwapRepository'; import { CurrencyType, SwapUpdateEvent, SwapVersion } from '../consts/Enums'; @@ -59,6 +59,7 @@ import { constructClaimTransaction, constructRefundTransaction, createMusig, + extractClaimPublicKeyFromReverseSwapTree, extractRefundPublicKeyFromSwapTree, LiquidClaimDetails, LiquidRefundDetails, @@ -186,10 +187,10 @@ class SwapNursery extends EventEmitter implements ISwapNursery { // Locks public readonly lock = new AsyncLock(); - public static swapLock = 'swap'; + public static readonly swapLock = 'swap'; + public static readonly reverseSwapLock = 'reverseSwap'; private static retryLock = 'retry'; - private static reverseSwapLock = 'reverseSwap'; constructor( private logger: Logger, @@ -575,6 +576,45 @@ class SwapNursery extends EventEmitter implements ISwapNursery { } }; + public settleReverseSwapInvoice = async ( + reverseSwap: ReverseSwap, + preimage: Buffer, + ) => { + const { base, quote } = splitPairId(reverseSwap.pair); + const lightningCurrency = getLightningCurrency( + base, + quote, + reverseSwap.orderSide, + true, + ); + + const lightningClient = NodeSwitch.getReverseSwapNode( + this.currencies.get(lightningCurrency)!, + reverseSwap, + ); + try { + await lightningClient.raceCall( + lightningClient.settleHoldInvoice(preimage), + (reject) => { + reject('invoice settlement timed out'); + }, + LightningNursery.lightningClientCallTimeout, + ); + + this.logger.info(`Settled Reverse Swap ${reverseSwap.id}`); + + this.emit( + 'invoice.settled', + await ReverseSwapRepository.setInvoiceSettled( + reverseSwap, + getHexString(preimage), + ), + ); + } catch (e) { + this.logger.error(`Could not settle invoice: ${formatError(e)}`); + } + }; + private listenEthereumNursery = async (ethereumNursery: EthereumNursery) => { const contractHandler = ethereumNursery.ethereumManager.contractHandler; @@ -888,8 +928,6 @@ class SwapNursery extends EventEmitter implements ISwapNursery { return; } - const destinationAddress = await wallet.getAddress(); - // Compatibility mode with database schema version 0 in which this column didn't exist if (swap.lockupTransactionVout === undefined) { swap.lockupTransactionVout = detectSwap( @@ -918,19 +956,22 @@ class SwapNursery extends EventEmitter implements ISwapNursery { claimDetails.keys, extractRefundPublicKeyFromSwapTree(claimDetails.swapTree), ).getAggregatedPublicKey(); + break; } default: { claimDetails.type = this.swapOutputType.get(wallet.type); claimDetails.redeemScript = getHexBuffer(swap.redeemScript!); + + break; } } const claimTransaction = constructClaimTransaction( wallet, [claimDetails] as ClaimDetails[] | LiquidClaimDetails[], - destinationAddress, + await wallet.getAddress(), await chainClient.estimateFee(), ); @@ -1038,45 +1079,6 @@ class SwapNursery extends EventEmitter implements ISwapNursery { ); }; - private settleReverseSwapInvoice = async ( - reverseSwap: ReverseSwap, - preimage: Buffer, - ) => { - const { base, quote } = splitPairId(reverseSwap.pair); - const lightningCurrency = getLightningCurrency( - base, - quote, - reverseSwap.orderSide, - true, - ); - - const lightningClient = NodeSwitch.getReverseSwapNode( - this.currencies.get(lightningCurrency)!, - reverseSwap, - ); - try { - await lightningClient.raceCall( - lightningClient.settleHoldInvoice(preimage), - (reject) => { - reject('invoice settlement timed out'); - }, - LightningNursery.lightningClientCallTimeout, - ); - - this.logger.info(`Settled Reverse Swap ${reverseSwap.id}`); - - this.emit( - 'invoice.settled', - await ReverseSwapRepository.setInvoiceSettled( - reverseSwap, - getHexString(preimage), - ), - ); - } catch (e) { - this.logger.error(`Could not settle invoice: ${formatError(e)}`); - } - }; - private handleReverseSwapSendFailed = async ( reverseSwap: ReverseSwap, chainSymbol: string, @@ -1236,18 +1238,38 @@ class SwapNursery extends EventEmitter implements ISwapNursery { ); const lockupOutput = lockupTransaction.outs[reverseSwap.transactionVout!]; + const refundDetails = { + ...lockupOutput, + vout: reverseSwap.transactionVout, + txHash: lockupTransaction.getHash(), + keys: wallet.getKeysByIndex(reverseSwap.keyIndex!), + } as RefundDetails | LiquidRefundDetails; + + switch (reverseSwap.version) { + case SwapVersion.Taproot: { + refundDetails.type = OutputType.Taproot; + refundDetails.cooperative = false; + refundDetails.swapTree = SwapTreeSerializer.deserializeSwapTree( + reverseSwap.redeemScript!, + ); + refundDetails.internalKey = createMusig( + refundDetails.keys, + extractClaimPublicKeyFromReverseSwapTree(refundDetails.swapTree), + ).getAggregatedPublicKey(); + break; + } + + default: { + refundDetails.type = this.swapOutputType.get(wallet.type); + refundDetails.redeemScript = getHexBuffer(reverseSwap.redeemScript!); + + break; + } + } + const refundTransaction = constructRefundTransaction( wallet, - [ - { - ...lockupOutput, - type: ReverseSwapOutputType, - vout: reverseSwap.transactionVout!, - txHash: lockupTransaction.getHash(), - keys: wallet.getKeysByIndex(reverseSwap.keyIndex!), - redeemScript: getHexBuffer(reverseSwap.redeemScript!), - }, - ] as RefundDetails[] | LiquidRefundDetails[], + [refundDetails] as RefundDetails[] | LiquidRefundDetails[], await wallet.getAddress(), reverseSwap.timeoutBlockHeight, await chainCurrency.chainClient!.estimateFee(), diff --git a/lib/wallet/WalletLiquid.ts b/lib/wallet/WalletLiquid.ts index c855767b..c3e218f9 100644 --- a/lib/wallet/WalletLiquid.ts +++ b/lib/wallet/WalletLiquid.ts @@ -1,6 +1,6 @@ import { Slip77Interface } from 'slip77'; +import { Payment } from 'liquidjs-lib/src/payments'; import { address, networks, payments } from 'liquidjs-lib'; -import Errors from './Errors'; import Wallet from './Wallet'; import Logger from '../Logger'; import { CurrencyType } from '../consts/Enums'; @@ -54,10 +54,37 @@ class WalletLiquid extends Wallet { return payments.p2wpkh; case address.ScriptType.P2Wsh: return payments.p2wsh; - case address.ScriptType.P2Tr: - throw Errors.TAPROOT_BLINDING_NOT_SUPPORTED(); + default: + return WalletLiquid.blindP2tr; } }; + + private static blindP2tr = ( + payment: Payment, + ): { + address: string; + confidentialAddress: string | undefined; + } => { + const addr = address.fromOutputScript(payment.output!, payment.network); + + const dec = address.fromBech32(addr); + + let confidentialAddress: string | undefined; + + if (payment.blindkey) { + confidentialAddress = address.toBlech32( + Buffer.concat([Buffer.from([dec.version, dec.data.length]), dec.data]), + payment.blindkey, + payment.network!.blech32, + dec.version, + ); + } + + return { + confidentialAddress, + address: addr, + }; + }; } export default WalletLiquid;