feat: Taproot swaps (#611)

This commit is contained in:
michael1011 2024-03-07 23:51:36 +01:00 committed by GitHub
parent 6099f178a3
commit 96b5b84dfa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 260 additions and 88 deletions

48
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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!

View File

@ -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'

View File

@ -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

View File

@ -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'];

View File

@ -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 <Missing />;
@ -129,7 +130,7 @@ export const SwapClaim = () => {
</MultiButton>
</InputWithDeco>
)}
<InputWithDeco title={'Fee Amount'} amount={fee * 223} noInput={true}>
<InputWithDeco title={'Fee Amount'} amount={fee * 111} noInput={true}>
{type !== 'none' && (
<Input
maxWidth={'240px'}
@ -176,6 +177,7 @@ export const SwapClaim = () => {
onClick={() =>
claimTransaction({
variables: {
id,
redeem: redeemScript,
transaction: transactionHex,
preimage,

View File

@ -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;
};

View File

@ -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);
initEccLib(ecc);
const info = detectSwap(redeemScript, lockupTransaction);
if (info?.vout === undefined || info?.type === undefined) {
const checkOutput = (output: any | undefined) => {
if (output === undefined) {
this.logger.error('Cannot get vout or type from Boltz');
this.logger.debug('Swap info', {
redeemScript,
lockupTransaction,
info,
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('{');
if (isTaproot) {
const zkp = await zkpInit();
const tree = SwapTreeSerializer.deserializeSwapTree(redeem);
const output = findTaprootOutput(zkp, lockupTransaction, tree, keys);
checkOutput(output);
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')
);
this.logger.debug('Final transaction', { finalTransaction });
const response = await this.boltzService.broadcastTransaction(
finalTransaction.toHex()
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()];
this.logger.debug('Response from Boltz', { response });
if (!response?.transactionId) {
this.logger.error('Did not receive a transaction id from Boltz');
throw new Error('NoTransactionIdFromBoltz');
return this.broadcastTransaction(claimTransaction);
} catch (e) {
this.logger.warn(`Cooperative Swap claim failed`, e);
}
return response.transactionId;
// 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
)
);
}
}
@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;
};
}

View File

@ -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),