From bd35cafe8fe70bfe2e8b66d1428535b209c2a4da Mon Sep 17 00:00:00 2001 From: michael1011 Date: Thu, 7 Mar 2024 18:22:01 +0100 Subject: [PATCH] feat: Taproot swaps --- package-lock.json | 48 +++-- package.json | 3 +- schema.gql | 2 +- .../claimBoltzTransaction.generated.tsx | 4 + .../mutations/claimBoltzTransaction.ts | 2 + src/client/src/graphql/types.ts | 1 + src/client/src/views/swap/SwapClaim.tsx | 6 +- src/server/modules/api/boltz/boltz.helpers.ts | 49 ++++- .../modules/api/boltz/boltz.resolver.ts | 172 ++++++++++++++---- src/server/modules/api/boltz/boltz.service.ts | 61 +++++-- 10 files changed, 260 insertions(+), 88 deletions(-) diff --git a/package-lock.json b/package-lock.json index de08c2c3d..223ded989 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@nestjs/throttler": "^5.0.1", "@nestjs/websockets": "^10.2.10", "@tanstack/react-table": "^8.10.7", + "@vulpemventures/secp256k1-zkp": "^3.2.1", "apollo-server-express": "^3.13.0", "balanceofsatoshis": "^17.5.2", "bcryptjs": "^2.4.3", @@ -32,7 +33,7 @@ "bip32": "^4.0.0", "bip39": "^3.1.0", "bitcoinjs-lib": "^6.1.5", - "boltz-core": "^1.0.4", + "boltz-core": "^2.1.1", "cookie": "^0.6.0", "cross-env": "^7.0.3", "crypto-js": "^4.1.1", @@ -5251,9 +5252,9 @@ } }, "node_modules/@openzeppelin/contracts": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.0.0.tgz", - "integrity": "sha512-bv2sdS6LKqVVMLI5+zqnNrNU/CA+6z6CmwFXm/MzmOPBRSO5reEJN7z0Gbzvs0/bv/MZZXNklubpwy3v2+azsw==" + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.0.2.tgz", + "integrity": "sha512-ytPc6eLGcHHnapAZ9S+5qsdomhjo6QBHTDRRBFfTxXIpsicMhVPouPgmUPebZZZGX7vt9USA+Z+0M0dSVtSUEA==" }, "node_modules/@otplib/core": { "version": "12.0.1", @@ -6416,23 +6417,20 @@ "dev": true }, "node_modules/@vulpemventures/secp256k1-zkp": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@vulpemventures/secp256k1-zkp/-/secp256k1-zkp-3.1.0.tgz", - "integrity": "sha512-64Ic62HK/JkjMzKPWcvlw7st/elRrozNqnN6oTaM+M7p1jsJRkCvPWskO5lYxtufKI0Zk2vDLfVBrTTVewBEwg==", - "peer": true, + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@vulpemventures/secp256k1-zkp/-/secp256k1-zkp-3.2.1.tgz", + "integrity": "sha512-2U4nuNbXuUgMmxhuoILbRMoD2DE7KND3udk5cYilIS1MHvMtje9ywUm/zsI0g7d7x8g2A57xri+wvqCC/fCnJg==", "dependencies": { - "@types/node": "^13.9.2", - "long": "^4.0.0" + "long": "^5.2.3" }, "engines": { - "node": ">=12.0.0" + "node": ">=12" } }, - "node_modules/@vulpemventures/secp256k1-zkp/node_modules/@types/node": { - "version": "13.13.52", - "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.52.tgz", - "integrity": "sha512-s3nugnZumCC//n4moGGe6tkNMyYEdaDBitVjwPxXmR5lnMG5dHePinH2EdxkG3Rh1ghFHHixAG4NJhpJW1rthQ==", - "peer": true + "node_modules/@vulpemventures/secp256k1-zkp/node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" }, "node_modules/@webassemblyjs/ast": { "version": "1.11.6", @@ -8303,12 +8301,13 @@ } }, "node_modules/boltz-core": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/boltz-core/-/boltz-core-1.0.4.tgz", - "integrity": "sha512-fMaU5pFMkA26cab0J5ghoLpBVA9/BZtF/jprFUGwvHR+4b5lEtiUkEIy2WorfBEhyXJ+jIL7w8Cvqrrlimo0nQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/boltz-core/-/boltz-core-2.1.1.tgz", + "integrity": "sha512-nbbMQbWcpJKoPvf1KNKZOlcOoflIrcretVhdt4rQ+QXVRULj+lCbl8t3FCfYLNKurWxl7OC3j/f+ILFCU7tkIw==", "dependencies": { "@boltz/bitcoin-ops": "^2.0.0", - "@openzeppelin/contracts": "^5.0.0", + "@openzeppelin/contracts": "^5.0.1", + "@vulpemventures/secp256k1-zkp": "^3.2.1", "bip32": "^4.0.0", "bip65": "^1.0.3", "bip66": "^1.1.5", @@ -8321,8 +8320,7 @@ "node": ">=14" }, "peerDependencies": { - "@vulpemventures/secp256k1-zkp": "^3.1.0", - "liquidjs-lib": "^6.0.2-liquid.31" + "liquidjs-lib": "^6.0.2-liquid.34" } }, "node_modules/bplist-parser": { @@ -15519,9 +15517,9 @@ } }, "node_modules/liquidjs-lib": { - "version": "6.0.2-liquid.32", - "resolved": "https://registry.npmjs.org/liquidjs-lib/-/liquidjs-lib-6.0.2-liquid.32.tgz", - "integrity": "sha512-EHKulPNptqGyPZKbWCygdvRP6FIjHLoLRJ0YTAp58Ikm9/t9UjFgeWEwrAwORwKyc4BnWTVwJqNZEBBzjX+cAA==", + "version": "6.0.2-liquid.34", + "resolved": "https://registry.npmjs.org/liquidjs-lib/-/liquidjs-lib-6.0.2-liquid.34.tgz", + "integrity": "sha512-oGW7ianIcrSlK4HdKlhpShx5H4jRxzS/KZahozOb0Vfkz/3PrAXa6fIwuAxfnhOzchVKwqlXerCZvIBXzDQA5g==", "peer": true, "dependencies": { "@types/randombytes": "^2.0.0", diff --git a/package.json b/package.json index 3492f1de0..f6df38e3a 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@nestjs/throttler": "^5.0.1", "@nestjs/websockets": "^10.2.10", "@tanstack/react-table": "^8.10.7", + "@vulpemventures/secp256k1-zkp": "^3.2.1", "apollo-server-express": "^3.13.0", "balanceofsatoshis": "^17.5.2", "bcryptjs": "^2.4.3", @@ -57,7 +58,7 @@ "bip32": "^4.0.0", "bip39": "^3.1.0", "bitcoinjs-lib": "^6.1.5", - "boltz-core": "^1.0.4", + "boltz-core": "^2.1.1", "cookie": "^0.6.0", "cross-env": "^7.0.3", "crypto-js": "^4.1.1", diff --git a/schema.gql b/schema.gql index ca3802221..c79524e35 100644 --- a/schema.gql +++ b/schema.gql @@ -489,7 +489,7 @@ type MessageType { type Mutation { addPeer(isTemporary: Boolean, publicKey: String, socket: String, url: String): Boolean! bosRebalance(avoid: [String!], in_through: String, max_fee: Float, max_fee_rate: Float, max_rebalance: Float, node: String, out_inbound: Float, out_through: String, timeout_minutes: Float): BosRebalanceResult! - claimBoltzTransaction(destination: String!, fee: Float!, preimage: String!, privateKey: String!, redeem: String!, transaction: String!): String! + claimBoltzTransaction(destination: String!, fee: Float!, id: String!, preimage: String!, privateKey: String!, redeem: String!, transaction: String!): String! claimGhostAddress(address: String): ClaimGhostAddress! closeChannel(forceClose: Boolean, id: String!, targetConfirmations: Float, tokensPerVByte: Float): OpenOrCloseChannel! createAddress(type: String! = "p2tr"): String! diff --git a/src/client/src/graphql/mutations/__generated__/claimBoltzTransaction.generated.tsx b/src/client/src/graphql/mutations/__generated__/claimBoltzTransaction.generated.tsx index 668522818..3c33fe3ac 100644 --- a/src/client/src/graphql/mutations/__generated__/claimBoltzTransaction.generated.tsx +++ b/src/client/src/graphql/mutations/__generated__/claimBoltzTransaction.generated.tsx @@ -4,6 +4,7 @@ import { gql } from '@apollo/client'; import * as Apollo from '@apollo/client'; const defaultOptions = {} as const; export type ClaimBoltzTransactionMutationVariables = Types.Exact<{ + id: Types.Scalars['String']['input']; redeem: Types.Scalars['String']['input']; transaction: Types.Scalars['String']['input']; preimage: Types.Scalars['String']['input']; @@ -19,6 +20,7 @@ export type ClaimBoltzTransactionMutation = { export const ClaimBoltzTransactionDocument = gql` mutation ClaimBoltzTransaction( + $id: String! $redeem: String! $transaction: String! $preimage: String! @@ -27,6 +29,7 @@ export const ClaimBoltzTransactionDocument = gql` $fee: Float! ) { claimBoltzTransaction( + id: $id redeem: $redeem transaction: $transaction preimage: $preimage @@ -54,6 +57,7 @@ export type ClaimBoltzTransactionMutationFn = Apollo.MutationFunction< * @example * const [claimBoltzTransactionMutation, { data, loading, error }] = useClaimBoltzTransactionMutation({ * variables: { + * id: // value for 'id' * redeem: // value for 'redeem' * transaction: // value for 'transaction' * preimage: // value for 'preimage' diff --git a/src/client/src/graphql/mutations/claimBoltzTransaction.ts b/src/client/src/graphql/mutations/claimBoltzTransaction.ts index bae0e680a..4ed1e643e 100644 --- a/src/client/src/graphql/mutations/claimBoltzTransaction.ts +++ b/src/client/src/graphql/mutations/claimBoltzTransaction.ts @@ -2,6 +2,7 @@ import { gql } from '@apollo/client'; export const CLAIM_BOLTZ_TRANSACTION = gql` mutation ClaimBoltzTransaction( + $id: String! $redeem: String! $transaction: String! $preimage: String! @@ -10,6 +11,7 @@ export const CLAIM_BOLTZ_TRANSACTION = gql` $fee: Float! ) { claimBoltzTransaction( + id: $id redeem: $redeem transaction: $transaction preimage: $preimage diff --git a/src/client/src/graphql/types.ts b/src/client/src/graphql/types.ts index caa0d42aa..771a99252 100644 --- a/src/client/src/graphql/types.ts +++ b/src/client/src/graphql/types.ts @@ -628,6 +628,7 @@ export type MutationBosRebalanceArgs = { export type MutationClaimBoltzTransactionArgs = { destination: Scalars['String']['input']; fee: Scalars['Float']['input']; + id: Scalars['String']['input']; preimage: Scalars['String']['input']; privateKey: Scalars['String']['input']; redeem: Scalars['String']['input']; diff --git a/src/client/src/views/swap/SwapClaim.tsx b/src/client/src/views/swap/SwapClaim.tsx index 48e75140f..a48488cbe 100644 --- a/src/client/src/views/swap/SwapClaim.tsx +++ b/src/client/src/views/swap/SwapClaim.tsx @@ -77,7 +77,8 @@ export const SwapClaim = () => { } const claimingSwap = swaps[claim]; - const { redeemScript, preimage, receivingAddress, privateKey } = claimingSwap; + const { redeemScript, preimage, receivingAddress, privateKey, id } = + claimingSwap; if (!preimage || !transactionHex || !privateKey) { return ; @@ -129,7 +130,7 @@ export const SwapClaim = () => { )} - + {type !== 'none' && ( { onClick={() => claimTransaction({ variables: { + id, redeem: redeemScript, transaction: transactionHex, preimage, diff --git a/src/server/modules/api/boltz/boltz.helpers.ts b/src/server/modules/api/boltz/boltz.helpers.ts index 877df42be..2ab9f216d 100644 --- a/src/server/modules/api/boltz/boltz.helpers.ts +++ b/src/server/modules/api/boltz/boltz.helpers.ts @@ -1,5 +1,14 @@ -import { address, Network, networks } from 'bitcoinjs-lib'; -import { ECPairFactory, ECPairAPI } from 'ecpair'; +import { Secp256k1ZKP } from '@vulpemventures/secp256k1-zkp'; +import { address, Network, networks, Transaction } from 'bitcoinjs-lib'; +import { + detectSwap, + extractRefundPublicKeyFromReverseSwapTree, + Musig, + TaprootUtils, +} from 'boltz-core'; +import { SwapTree } from 'boltz-core/dist/lib/consts/Types'; +import { randomBytes } from 'crypto'; +import { ECPairFactory, ECPairAPI, ECPairInterface } from 'ecpair'; import * as ecc from 'tiny-secp256k1'; const ECPair: ECPairAPI = ECPairFactory(ecc); @@ -33,3 +42,39 @@ export const generateKeys = (network: Network = networks.bitcoin) => { privateKey: getHexString(keys.privateKey), }; }; + +export const findTaprootOutput = ( + zkp: Secp256k1ZKP, + transaction: Transaction, + tree: SwapTree, + keys: ECPairInterface +) => { + const theirPublicKey = extractRefundPublicKeyFromReverseSwapTree(tree); + + // "brute force" the tie breaker because it is not in the onchain script + // https://medium.com/blockstream/reducing-bitcoin-transaction-sizes-with-x-only-pubkeys-f86476af05d7 + for (const tieBreaker of ['02', '03']) { + const compressedKey = Buffer.concat([ + getHexBuffer(tieBreaker), + theirPublicKey, + ]); + + const musig = new Musig(zkp, keys, randomBytes(32), [ + compressedKey, + keys.publicKey, + ]); + const tweakedKey = TaprootUtils.tweakMusig(musig, tree.tree); + + const swapOutput = detectSwap(tweakedKey, transaction); + if (swapOutput !== undefined) { + return { + musig, + tweakedKey, + swapOutput, + theirPublicKey: compressedKey, + }; + } + } + + return undefined; +}; diff --git a/src/server/modules/api/boltz/boltz.resolver.ts b/src/server/modules/api/boltz/boltz.resolver.ts index 0a3af23ac..6188ccc3e 100644 --- a/src/server/modules/api/boltz/boltz.resolver.ts +++ b/src/server/modules/api/boltz/boltz.resolver.ts @@ -1,3 +1,4 @@ +import zkpInit from '@vulpemventures/secp256k1-zkp'; import { Inject } from '@nestjs/common'; import { Args, @@ -11,10 +12,22 @@ import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { Logger } from 'winston'; import { NodeService } from '../../node/node.service'; import { BoltzService } from './boltz.service'; -import { constructClaimTransaction, detectSwap, targetFee } from 'boltz-core'; -import { generateKeys, getHexBuffer, validateAddress } from './boltz.helpers'; +import { + ClaimDetails, + SwapTreeSerializer, + TaprootUtils, + constructClaimTransaction, + detectSwap, + targetFee, +} from 'boltz-core'; +import { + findTaprootOutput, + generateKeys, + getHexBuffer, + validateAddress, +} from './boltz.helpers'; import { GraphQLError } from 'graphql'; -import { address, networks, Transaction } from 'bitcoinjs-lib'; +import { address, initEccLib, networks, Transaction } from 'bitcoinjs-lib'; import { BoltzInfoType, BoltzSwap, @@ -106,7 +119,7 @@ export class BoltzResolver { throw new Error(info.error); } - const btcPair = info?.pairs?.['BTC/BTC']; + const btcPair = info?.BTC?.BTC; if (!btcPair) { this.logger.error('No BTC > LN BTC information received from Boltz'); @@ -129,6 +142,7 @@ export class BoltzResolver { @Mutation(() => String) async claimBoltzTransaction( + @Args('id') id: string, @Args('redeem') redeem: string, @Args('transaction') transaction: string, @Args('preimage') preimage: string, @@ -141,54 +155,105 @@ export class BoltzResolver { throw new GraphQLError('InvalidBitcoinAddress'); } - const redeemScript = getHexBuffer(redeem); - const lockupTransaction = Transaction.fromHex(transaction); - - const info = detectSwap(redeemScript, lockupTransaction); + initEccLib(ecc); - if (info?.vout === undefined || info?.type === undefined) { - this.logger.error('Cannot get vout or type from Boltz'); - this.logger.debug('Swap info', { - redeemScript, - lockupTransaction, - info, - }); - throw new Error('ErrorCreatingClaimTransaction'); - } + const checkOutput = (output: any | undefined) => { + if (output === undefined) { + this.logger.error('Cannot get vout or type from Boltz'); + this.logger.debug('Swap info', { + lockupTransaction, + output, + }); + throw new Error('ErrorCreatingClaimTransaction'); + } + }; - const utxos = [ - { - ...info, - redeemScript, - txHash: lockupTransaction.getHash(), - preimage: getHexBuffer(preimage), - keys: ECPair.fromPrivateKey(getHexBuffer(privateKey)), - }, - ]; + const lockupTransaction = Transaction.fromHex(transaction); + const keys = ECPair.fromPrivateKey(getHexBuffer(privateKey)); const destinationScript = address.toOutputScript( destination, networks.bitcoin ); - const finalTransaction = targetFee(fee, absoluteFee => - constructClaimTransaction(utxos, destinationScript, absoluteFee) - ); + const isTaproot = redeem.startsWith('{'); - this.logger.debug('Final transaction', { finalTransaction }); + if (isTaproot) { + const zkp = await zkpInit(); + const tree = SwapTreeSerializer.deserializeSwapTree(redeem); + const output = findTaprootOutput(zkp, lockupTransaction, tree, keys); + checkOutput(output); - const response = await this.boltzService.broadcastTransaction( - finalTransaction.toHex() - ); - - this.logger.debug('Response from Boltz', { response }); + const utxo: ClaimDetails = { + ...output.swapOutput, + keys, + swapTree: tree, + cooperative: true, + preimage: getHexBuffer(preimage), + txHash: lockupTransaction.getHash(), + internalKey: output.musig.getAggregatedPublicKey(), + }; + + // Try the cooperative key path spend first + try { + const claimTransaction = this.constructTransaction( + [utxo], + destinationScript, + fee + ); + const theirPartial = + await this.boltzService.getReverseSwapClaimSignature( + id, + preimage, + claimTransaction.toHex(), + 0, + Buffer.from(output.musig.getPublicNonce()).toString('hex') + ); + + output.musig.aggregateNonces([ + [output.theirPublicKey, getHexBuffer(theirPartial.pubNonce)], + ]); + output.musig.initializeSession( + TaprootUtils.hashForWitnessV1([utxo], claimTransaction, 0) + ); + output.musig.addPartial( + output.theirPublicKey, + getHexBuffer(theirPartial.partialSignature) + ); + output.musig.signPartial(); + claimTransaction.ins[0].witness = [output.musig.aggregatePartials()]; + + return this.broadcastTransaction(claimTransaction); + } catch (e) { + this.logger.warn(`Cooperative Swap claim failed`, e); + } - if (!response?.transactionId) { - this.logger.error('Did not receive a transaction id from Boltz'); - throw new Error('NoTransactionIdFromBoltz'); + // If cooperative fails, enforce the HTLC via a script path spend + utxo.cooperative = false; + return this.broadcastTransaction( + this.constructTransaction([utxo], destinationScript, fee) + ); + } else { + const redeemScript = getHexBuffer(redeem); + const output = detectSwap(redeemScript, lockupTransaction); + checkOutput(output); + + return this.broadcastTransaction( + this.constructTransaction( + [ + { + ...output, + keys, + redeemScript, + txHash: lockupTransaction.getHash(), + preimage: getHexBuffer(preimage), + }, + ], + destinationScript, + fee + ) + ); } - - return response.transactionId; } @Mutation(() => CreateBoltzReverseSwapType) @@ -239,6 +304,7 @@ export class BoltzResolver { ...info, receivingAddress: btcAddress, preimage: preimage.toString('hex'), + redeemScript: JSON.stringify(info.swapTree), preimageHash: hash, privateKey, publicKey, @@ -248,4 +314,30 @@ export class BoltzResolver { return finalInfo; } + + private constructTransaction = ( + utxos: ClaimDetails[], + destinationScript: Buffer, + fee: number + ) => + targetFee(fee, absoluteFee => + constructClaimTransaction(utxos, destinationScript, absoluteFee) + ); + + private broadcastTransaction = async (finalTransaction: Transaction) => { + this.logger.debug('Final transaction', { finalTransaction }); + + const response = await this.boltzService.broadcastTransaction( + finalTransaction.toHex() + ); + + this.logger.debug('Response from Boltz', { response }); + + if (!response?.id) { + this.logger.error('Did not receive a transaction id from Boltz'); + throw new Error('NoTransactionIdFromBoltz'); + } + + return response.id; + }; } diff --git a/src/server/modules/api/boltz/boltz.service.ts b/src/server/modules/api/boltz/boltz.service.ts index e0aea1221..02b983269 100644 --- a/src/server/modules/api/boltz/boltz.service.ts +++ b/src/server/modules/api/boltz/boltz.service.ts @@ -15,7 +15,7 @@ export class BoltzService { async getPairs() { try { const response = await this.fetchService.fetchWithProxy( - `${this.configService.get('urls.boltz')}/getpairs` + `${this.configService.get('urls.boltz')}/v2/swap/reverse` ); return response.json(); } catch (error: any) { @@ -24,10 +24,10 @@ export class BoltzService { } } - async getFeeEstimations() { + async getFeeEstimation() { try { const response = await this.fetchService.fetchWithProxy( - `${this.configService.get('urls.boltz')}/getfeeestimation` + `${this.configService.get('urls.boltz')}/v2/chain/BTC/fee` ); return response.json(); } catch (error: any) { @@ -38,14 +38,8 @@ export class BoltzService { async getSwapStatus(id: string) { try { - const body = { id }; const response = await this.fetchService.fetchWithProxy( - `${this.configService.get('urls.boltz')}/swapstatus`, - { - method: 'POST', - body: JSON.stringify(body), - headers: { 'Content-Type': 'application/json' }, - } + `${this.configService.get('urls.boltz')}/v2/swap/${id}` ); return response.json(); } catch (error: any) { @@ -61,16 +55,15 @@ export class BoltzService { ) { try { const body = { - type: 'reversesubmarine', - pairId: 'BTC/BTC', - orderSide: 'buy', + from: 'BTC', + to: 'BTC', referralId: 'thunderhub', invoiceAmount, preimageHash, claimPublicKey, }; const response = await this.fetchService.fetchWithProxy( - `${this.configService.get('urls.boltz')}/createswap`, + `${this.configService.get('urls.boltz')}/v2/swap/reverse`, { method: 'POST', body: JSON.stringify(body), @@ -84,14 +77,48 @@ export class BoltzService { } } + async getReverseSwapClaimSignature( + id: string, + preimage: string, + transaction: string, + index: number, + pubNonce: string + ): Promise<{ + pubNonce: string; + partialSignature: string; + }> { + try { + const body = { + id, + index, + preimage, + pubNonce, + transaction, + }; + const response = await this.fetchService.fetchWithProxy( + `${this.configService.get('urls.boltz')}/v2/swap/reverse/claim`, + { + method: 'POST', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + } + ); + return response.json(); + } catch (error: any) { + this.logger.error('Error getting partial claim signature from Boltz', { + error, + }); + throw new Error(error); + } + } + async broadcastTransaction(transactionHex: string) { try { const body = { - currency: 'BTC', - transactionHex, + hex: transactionHex, }; const response = await this.fetchService.fetchWithProxy( - `${this.configService.get('urls.boltz')}/broadcasttransaction`, + `${this.configService.get('urls.boltz')}/v2/chain/BTC/transaction`, { method: 'POST', body: JSON.stringify(body),