diff --git a/modules/babylonlabs-io-btc-staking-ts/.gitignore b/modules/babylonlabs-io-btc-staking-ts/.gitignore index 6b0417d7d6..f59c3e6ee6 100644 --- a/modules/babylonlabs-io-btc-staking-ts/.gitignore +++ b/modules/babylonlabs-io-btc-staking-ts/.gitignore @@ -207,4 +207,4 @@ $RECYCLE.BIN/ *.swp *.swo -build/ \ No newline at end of file +build/ diff --git a/modules/babylonlabs-io-btc-staking-ts/jest.setup.js b/modules/babylonlabs-io-btc-staking-ts/jest.setup.js index eab17e50a6..1f446f4b85 100644 --- a/modules/babylonlabs-io-btc-staking-ts/jest.setup.js +++ b/modules/babylonlabs-io-btc-staking-ts/jest.setup.js @@ -1,8 +1,7 @@ const { initBTCCurve } = require("./src"); const originalTest = global.test; -const NUM_ITERATIONS = 3; -; +const NUM_ITERATIONS = parseInt(process.env.TEST_REPEAT_TIMES) || 1; initBTCCurve(); diff --git a/modules/babylonlabs-io-btc-staking-ts/package.json b/modules/babylonlabs-io-btc-staking-ts/package.json index af54fd71a8..b22a7dfcbc 100644 --- a/modules/babylonlabs-io-btc-staking-ts/package.json +++ b/modules/babylonlabs-io-btc-staking-ts/package.json @@ -1,6 +1,6 @@ { "name": "@bitgo/babylonlabs-io-btc-staking-ts", - "version": "2.4.1", + "version": "3.0.0", "description": "Library exposing methods for the creation and consumption of Bitcoin transactions pertaining to Babylon's Bitcoin Staking protocol.", "module": "dist/index.js", "main": "dist/index.cjs", @@ -27,7 +27,7 @@ "btc-staking" ], "engines": { - "node": ">=20 < 23" + "node": ">=18 < 23" }, "author": "Babylon Labs Ltd.", "license": "SEE LICENSE IN LICENSE", @@ -37,7 +37,7 @@ "nanoevents": "^9.1.0" }, "dependencies": { - "@babylonlabs-io/babylon-proto-ts": "1.0.0", + "@babylonlabs-io/babylon-proto-ts": "1.7.2", "@bitcoin-js/tiny-secp256k1-asmjs": "2.2.3", "@cosmjs/encoding": "^0.33.0", "bip174": "=2.1.1", diff --git a/modules/babylonlabs-io-btc-staking-ts/src/constants/fee.ts b/modules/babylonlabs-io-btc-staking-ts/src/constants/fee.ts index f68caf697a..1e89916cab 100644 --- a/modules/babylonlabs-io-btc-staking-ts/src/constants/fee.ts +++ b/modules/babylonlabs-io-btc-staking-ts/src/constants/fee.ts @@ -2,8 +2,12 @@ export const DEFAULT_INPUT_SIZE = 180; // Estimated size of a P2WPKH input in bytes export const P2WPKH_INPUT_SIZE = 68; -// Estimated size of a P2TR input in bytes +// Estimated size of a P2TR input in bytes. 42vb inputs + 16vb witness export const P2TR_INPUT_SIZE = 58; +// Estimated size of a P2TR input in bytes for staking expansion transactions. +// This value accounts for the witness size including covenant signatures +// and is calibrated for a typical covenant quorum of 6 signatures. +export const P2TR_STAKING_EXPANSION_INPUT_SIZE = 268; // Estimated size of a transaction buffer in bytes export const TX_BUFFER_SIZE_OVERHEAD = 11; // Buffer for estimation accuracy when fee rate <= 2 sat/byte diff --git a/modules/babylonlabs-io-btc-staking-ts/src/constants/registry.ts b/modules/babylonlabs-io-btc-staking-ts/src/constants/registry.ts index ecea14e494..85eedf87eb 100644 --- a/modules/babylonlabs-io-btc-staking-ts/src/constants/registry.ts +++ b/modules/babylonlabs-io-btc-staking-ts/src/constants/registry.ts @@ -1,3 +1,4 @@ export const BABYLON_REGISTRY_TYPE_URLS = { MsgCreateBTCDelegation: "/babylon.btcstaking.v1.MsgCreateBTCDelegation", + MsgBtcStakeExpand: "/babylon.btcstaking.v1.MsgBtcStakeExpand", }; diff --git a/modules/babylonlabs-io-btc-staking-ts/src/constants/staking.ts b/modules/babylonlabs-io-btc-staking-ts/src/constants/staking.ts new file mode 100644 index 0000000000..3cd2476e80 --- /dev/null +++ b/modules/babylonlabs-io-btc-staking-ts/src/constants/staking.ts @@ -0,0 +1,5 @@ +/** + * Staking module address for the Babylon Genesis chain. + * This address is derived deterministically from the module name and is the same across all environments. + */ +export const STAKING_MODULE_ADDRESS = "bbn13837feaxn8t0zvwcjwhw7lhpgdcx4s36eqteah"; \ No newline at end of file diff --git a/modules/babylonlabs-io-btc-staking-ts/src/staking/index.ts b/modules/babylonlabs-io-btc-staking-ts/src/staking/index.ts index e3f547be95..d3bec931d3 100644 --- a/modules/babylonlabs-io-btc-staking-ts/src/staking/index.ts +++ b/modules/babylonlabs-io-btc-staking-ts/src/staking/index.ts @@ -13,13 +13,11 @@ import { deriveStakingOutputInfo, findMatchingTxOutputIndex, toBuffers, - validateParams, - validateStakingTimelock, - validateStakingTxInputData, } from "../utils/staking"; -import { stakingPsbt, unbondingPsbt } from "./psbt"; +import { stakingExpansionPsbt, stakingPsbt, unbondingPsbt } from "./psbt"; import { StakingScriptData, StakingScripts } from "./stakingScript"; import { + stakingExpansionTransaction, slashEarlyUnbondedTransaction, slashTimelockUnbondedTransaction, stakingTransaction, @@ -28,6 +26,7 @@ import { withdrawSlashingTransaction, withdrawTimelockUnbondedTransaction, } from "./transactions"; +import { validateParams, validateStakingExpansionCovenantQuorum, validateStakingTimelock, validateStakingTxInputData } from "../utils/staking/validation"; export * from "./stakingScript"; export interface StakerInfo { @@ -171,6 +170,106 @@ export class Staking { } } + /** + * Creates a staking expansion transaction that extends an existing BTC stake + * to new finality providers or renews the timelock. + * + * This method implements RFC 037 BTC Stake Expansion, + * allowing existing active BTC staking transactions + * to extend their delegation to new finality providers without going through + * the full unbonding process. + * + * The expansion transaction: + * 1. Spends the previous staking transaction output as the first input + * 2. Uses funding UTXO as additional input to cover transaction fees or + * to increase the staking amount + * 3. Creates a new staking output with expanded finality provider coverage or + * renews the timelock + * 4. Has an output returning the remaining funds as change (if any) to the + * staker BTC address + * + * @param {number} stakingAmountSat - The total staking amount in satoshis + * (The amount had to be equal to the previous staking amount for now, this + * lib does not yet support increasing the staking amount at this stage) + * @param {UTXO[]} inputUTXOs - Available UTXOs to use for funding the + * expansion transaction fees. Only one will be selected for the expansion + * @param {number} feeRate - Fee rate in satoshis per byte for the + * expansion transaction + * @param {StakingParams} paramsForPreviousStakingTx - Staking parameters + * used in the previous staking transaction + * @param {Object} previousStakingTxInfo - Necessary information to spend the + * previous staking transaction. + * @returns {TransactionResult & { fundingUTXO: UTXO }} - An object containing + * the unsigned expansion transaction and calculated fee, and the funding UTXO + * @throws {StakingError} - If the transaction cannot be built or validation + * fails + */ + public createStakingExpansionTransaction( + stakingAmountSat: number, + inputUTXOs: UTXO[], + feeRate: number, + paramsForPreviousStakingTx: StakingParams, + previousStakingTxInfo: { + stakingTx: Transaction, + stakingInput: { + finalityProviderPksNoCoordHex: string[], + stakingTimelock: number, + }, + }, + ): TransactionResult & { + fundingUTXO: UTXO; + } { + validateStakingTxInputData( + stakingAmountSat, + this.stakingTimelock, + this.params, + inputUTXOs, + feeRate, + ); + validateStakingExpansionCovenantQuorum( + paramsForPreviousStakingTx, + this.params, + ); + + // Create a Staking instance for the previous staking transaction + // This allows us to build the scripts needed to spend the previous + // staking output + const previousStaking = new Staking( + this.network, + this.stakerInfo, + paramsForPreviousStakingTx, + previousStakingTxInfo.stakingInput.finalityProviderPksNoCoordHex, + previousStakingTxInfo.stakingInput.stakingTimelock, + ); + + // Build the expansion transaction using the stakingExpansionTransaction + // utility function. + // This creates a transaction that spends the previous staking output and + // creates new staking outputs + const { + transaction: stakingExpansionTx, + fee: stakingExpansionTxFee, + fundingUTXO, + } = stakingExpansionTransaction( + this.network, + this.buildScripts(), + stakingAmountSat, + this.stakerInfo.address, + feeRate, + inputUTXOs, + { + stakingTx: previousStakingTxInfo.stakingTx, + scripts: previousStaking.buildScripts(), + }, + ) + + return { + transaction: stakingExpansionTx, + fee: stakingExpansionTxFee, + fundingUTXO, + }; + } + /** * Create a staking psbt based on the existing staking transaction. * @@ -200,6 +299,76 @@ export class Staking { ); } + /** + * Convert a staking expansion transaction to a PSBT. + * + * @param {Transaction} stakingExpansionTx - The staking expansion + * transaction to convert + * @param {UTXO[]} inputUTXOs - Available UTXOs for the + * funding input (second input) + * @param {StakingParams} paramsForPreviousStakingTx - Staking parameters + * used for the previous staking transaction + * @param {Object} previousStakingTxInfo - Information about the previous + * staking transaction + * @returns {Psbt} The PSBT for the staking expansion transaction + * @throws {Error} If the previous staking output cannot be found or + * validation fails + */ + public toStakingExpansionPsbt( + stakingExpansionTx: Transaction, + inputUTXOs: UTXO[], + paramsForPreviousStakingTx: StakingParams, + previousStakingTxInfo: { + stakingTx: Transaction, + stakingInput: { + finalityProviderPksNoCoordHex: string[], + stakingTimelock: number, + }, + }, + ): Psbt { + // Reconstruct the previous staking instance to access its scripts and + // parameters. This is necessary because we need to identify which output + // in the previous staking transaction is the staking output (it could be + // at any output index) + const previousStaking = new Staking( + this.network, + this.stakerInfo, + paramsForPreviousStakingTx, + previousStakingTxInfo.stakingInput.finalityProviderPksNoCoordHex, + previousStakingTxInfo.stakingInput.stakingTimelock, + ); + + // Find the staking output address in the previous staking transaction + const previousScripts = previousStaking.buildScripts(); + const { outputAddress } = deriveStakingOutputInfo(previousScripts, this.network); + + // Find the output index in the previous staking transaction that matches + // the staking output address. + const previousStakingOutputIndex = findMatchingTxOutputIndex( + previousStakingTxInfo.stakingTx, + outputAddress, + this.network, + ); + + // Create and return the PSBT for the staking expansion transaction + // The PSBT will have two inputs: + // 1. The previous staking output + // 2. A funding UTXO from inputUTXOs (for additional funds) + return stakingExpansionPsbt( + this.network, + stakingExpansionTx, + { + stakingTx: previousStakingTxInfo.stakingTx, + outputIndex: previousStakingOutputIndex, + }, + inputUTXOs, + previousScripts, + isTaproot(this.stakerInfo.address, this.network) + ? Buffer.from(this.stakerInfo.publicKeyNoCoordHex, "hex") + : undefined, + ); + } + /** * Create an unbonding transaction for staking. * diff --git a/modules/babylonlabs-io-btc-staking-ts/src/staking/manager.ts b/modules/babylonlabs-io-btc-staking-ts/src/staking/manager.ts index 390d376998..7eee366df6 100644 --- a/modules/babylonlabs-io-btc-staking-ts/src/staking/manager.ts +++ b/modules/babylonlabs-io-btc-staking-ts/src/staking/manager.ts @@ -1,57 +1,56 @@ -import { - btccheckpoint, - btcstaking, - btcstakingtx, -} from "@babylonlabs-io/babylon-proto-ts"; +import { btccheckpoint, btcstaking, btcstakingtx } from '@babylonlabs-io/babylon-proto-ts'; import { BIP322Sig, BTCSigType, ProofOfPossessionBTC, -} from "@babylonlabs-io/babylon-proto-ts/dist/generated/babylon/btcstaking/v1/pop"; -import { Psbt, Transaction, networks } from "bitcoinjs-lib"; -import type { Emitter } from "nanoevents"; - -import { StakerInfo, Staking } from "."; -import { BABYLON_REGISTRY_TYPE_URLS } from "../constants/registry"; -import { StakingError, StakingErrorCode } from "../error"; -import { TransactionResult, UTXO } from "../types"; -import { ActionName } from "../types/action"; -import { Contract, ContractId } from "../types/contract"; -import { ManagerEvents } from "../types/events"; -import { - BabylonProvider, - BtcProvider, - InclusionProof, - StakingInputs, -} from "../types/manager"; -import { StakingParams, VersionedStakingParams } from "../types/params"; -import { reverseBuffer } from "../utils"; -import { isValidBabylonAddress } from "../utils/babylon"; -import { isNativeSegwit, isTaproot } from "../utils/btc"; +} from '@babylonlabs-io/babylon-proto-ts/dist/generated/babylon/btcstaking/v1/pop'; +import { Psbt, Transaction, networks } from 'bitcoinjs-lib'; +import type { Emitter } from 'nanoevents'; + +import { StakerInfo, Staking } from '.'; +import { BABYLON_REGISTRY_TYPE_URLS } from '../constants/registry'; +import { StakingError, StakingErrorCode } from '../error'; +import { TransactionResult, UTXO } from '../types'; +import { ActionName } from '../types/action'; +import { Contract, ContractId } from '../types/contract'; +import { ManagerEvents } from '../types/events'; +import { BabylonProvider, BtcProvider, InclusionProof, StakingInputs, UpgradeConfig } from '../types/manager'; +import { StakingParams, VersionedStakingParams } from '../types/params'; +import { reverseBuffer } from '../utils'; +import { isValidBabylonAddress } from '../utils/babylon'; +import { isNativeSegwit, isTaproot } from '../utils/btc'; +import { buildPopMessage } from '../utils/pop'; import { + clearTxSignatures, + deriveMerkleProof, deriveStakingOutputInfo, + extractFirstSchnorrSignatureFromTransaction, findMatchingTxOutputIndex, -} from "../utils/staking"; -import { - getBabylonParamByBtcHeight, - getBabylonParamByVersion, -} from "../utils/staking/param"; -import { createCovenantWitness } from "./transactions"; +} from '../utils/staking'; +import { getBabylonParamByBtcHeight, getBabylonParamByVersion } from '../utils/staking/param'; + +import { createCovenantWitness } from './transactions'; +import { validateStakingExpansionInputs } from '../utils/staking/validation'; export class BabylonBtcStakingManager { + private upgradeConfig?: UpgradeConfig; + constructor( protected network: networks.Network, protected stakingParams: VersionedStakingParams[], protected btcProvider: BtcProvider, protected babylonProvider: BabylonProvider, - protected ee?: Emitter + protected ee?: Emitter, + upgradeConfig?: UpgradeConfig ) { this.network = network; if (stakingParams.length === 0) { - throw new Error("No staking parameters provided"); + throw new Error('No staking parameters provided'); } this.stakingParams = stakingParams; + + this.upgradeConfig = upgradeConfig; } /** @@ -76,63 +75,212 @@ export class BabylonBtcStakingManager { babylonBtcTipHeight: number, inputUTXOs: UTXO[], feeRate: number, - babylonAddress: string, + babylonAddress: string ): Promise<{ signedBabylonTx: Uint8Array; stakingTx: Transaction; }> { if (babylonBtcTipHeight === 0) { - throw new Error("Babylon BTC tip height cannot be 0"); + throw new Error('Babylon BTC tip height cannot be 0'); } if (inputUTXOs.length === 0) { - throw new Error("No input UTXOs provided"); + throw new Error('No input UTXOs provided'); } if (!isValidBabylonAddress(babylonAddress)) { - throw new Error("Invalid Babylon address"); + throw new Error('Invalid Babylon address'); } // Get the Babylon params based on the BTC tip height from Babylon chain - const params = getBabylonParamByBtcHeight( - babylonBtcTipHeight, - this.stakingParams, - ); + const params = getBabylonParamByBtcHeight(babylonBtcTipHeight, this.stakingParams); const staking = new Staking( this.network, stakerBtcInfo, params, stakingInput.finalityProviderPksNoCoordHex, - stakingInput.stakingTimelock, + stakingInput.stakingTimelock ); // Create unsigned staking transaction - const { transaction } = staking.createStakingTransaction( + const { transaction } = staking.createStakingTransaction(stakingInput.stakingAmountSat, inputUTXOs, feeRate); + + // Create delegation message without including inclusion proof + const msg = await this.createBtcDelegationMsg( + 'delegation:create', + staking, + stakingInput, + transaction, + babylonAddress, + stakerBtcInfo, + params + ); + + this.ee?.emit('delegation:create', { + type: 'create-btc-delegation-msg', + }); + + return { + signedBabylonTx: await this.babylonProvider.signTransaction(msg), + stakingTx: transaction, + }; + } + + /** + * Create a signed staking expansion transaction that is ready to be sent to + * the Babylon chain. + */ + async stakingExpansionRegistrationBabylonTransaction( + stakerBtcInfo: StakerInfo, + stakingInput: StakingInputs, + babylonBtcTipHeight: number, + inputUTXOs: UTXO[], + feeRate: number, + babylonAddress: string, + // Previous staking transaction info + previousStakingTxInfo: { + stakingTx: Transaction; + paramVersion: number; + stakingInput: StakingInputs; + } + ): Promise<{ + signedBabylonTx: Uint8Array; + stakingTx: Transaction; + }> { + // Perform validation for the staking expansion inputs + validateStakingExpansionInputs({ + babylonBtcTipHeight, + inputUTXOs, + stakingInput, + previousStakingInput: previousStakingTxInfo.stakingInput, + babylonAddress, + }); + // Param for the expandsion staking transaction + const params = getBabylonParamByBtcHeight(babylonBtcTipHeight, this.stakingParams); + + const paramsForPreviousStakingTx = getBabylonParamByVersion(previousStakingTxInfo.paramVersion, this.stakingParams); + + const stakingInstance = new Staking( + this.network, + stakerBtcInfo, + params, + stakingInput.finalityProviderPksNoCoordHex, + stakingInput.stakingTimelock + ); + + const { transaction: stakingExpansionTx, fundingUTXO } = stakingInstance.createStakingExpansionTransaction( stakingInput.stakingAmountSat, inputUTXOs, feeRate, + paramsForPreviousStakingTx, + previousStakingTxInfo ); + let fundingTx; + try { + fundingTx = await this.btcProvider.getTransactionHex(fundingUTXO.txid); + } catch (error) { + throw StakingError.fromUnknown( + error, + StakingErrorCode.INVALID_INPUT, + 'Failed to retrieve funding transaction hex' + ); + } // Create delegation message without including inclusion proof const msg = await this.createBtcDelegationMsg( - "delegation:create", - staking, + 'delegation:expand', + stakingInstance, stakingInput, - transaction, + stakingExpansionTx, babylonAddress, stakerBtcInfo, params, + { + delegationExpansionInfo: { + previousStakingTx: previousStakingTxInfo.stakingTx, + fundingTx: Transaction.fromHex(fundingTx), + }, + } ); - this.ee?.emit("delegation:create", { - type: "create-btc-delegation-msg", + this.ee?.emit('delegation:expand', { + type: 'create-btc-delegation-msg', }); return { signedBabylonTx: await this.babylonProvider.signTransaction(msg), - stakingTx: transaction, + stakingTx: stakingExpansionTx, }; } + /** + * Estimates the transaction fee for a BTC staking expansion transaction. + * + * @param {StakerInfo} stakerBtcInfo - The staker's Bitcoin information + * including address and public key + * @param {number} babylonBtcTipHeight - The current Babylon BTC tip height + * used to determine staking parameters + * @param {StakingInputs} stakingInput - The new staking input parameters for + * the expansion + * @param {UTXO[]} inputUTXOs - Available UTXOs that can be used for funding + * the expansion transaction + * @param {number} feeRate - Fee rate in satoshis per byte for the expansion + * transaction + * @param {Object} previousStakingTxInfo - Information about the previous + * staking transaction being expanded + * @returns {number} - The estimated transaction fee in satoshis + * @throws {Error} - If validation fails or the fee cannot be calculated + */ + estimateBtcStakingExpansionFee( + stakerBtcInfo: StakerInfo, + babylonBtcTipHeight: number, + stakingInput: StakingInputs, + inputUTXOs: UTXO[], + feeRate: number, + previousStakingTxInfo: { + stakingTx: Transaction; + paramVersion: number; + stakingInput: StakingInputs; + } + ): number { + // Validate all input parameters before fee calculation + validateStakingExpansionInputs({ + babylonBtcTipHeight, + inputUTXOs, + stakingInput, + previousStakingInput: previousStakingTxInfo.stakingInput, + }); + + // Get the appropriate staking parameters based on the current Babylon BTC + // tip height. This ensures we use the correct parameters for the current + // network state + const params = getBabylonParamByBtcHeight(babylonBtcTipHeight, this.stakingParams); + + // Get the staking parameters that were used in the previous staking + // transaction. This is needed to properly reconstruct the previous staking + // scripts + const paramsForPreviousStakingTx = getBabylonParamByVersion(previousStakingTxInfo.paramVersion, this.stakingParams); + + // Create a Staking instance for the new expansion with current parameters + // This will be used to build the new staking scripts and calculate the + // transaction + const stakingInstance = new Staking( + this.network, + stakerBtcInfo, + params, + stakingInput.finalityProviderPksNoCoordHex, + stakingInput.stakingTimelock + ); + const { fee } = stakingInstance.createStakingExpansionTransaction( + stakingInput.stakingAmountSat, + inputUTXOs, + feeRate, + paramsForPreviousStakingTx, + previousStakingTxInfo + ); + + return fee; + } + /** * Creates a signed post-staking registration transaction that is ready to be * sent to the Babylon chain. This is used when a staking transaction is @@ -155,18 +303,15 @@ export class BabylonBtcStakingManager { stakingTxHeight: number, stakingInput: StakingInputs, inclusionProof: InclusionProof, - babylonAddress: string, + babylonAddress: string ): Promise<{ signedBabylonTx: Uint8Array; }> { // Get the Babylon params at the time of the staking transaction - const params = getBabylonParamByBtcHeight( - stakingTxHeight, - this.stakingParams, - ); + const params = getBabylonParamByBtcHeight(stakingTxHeight, this.stakingParams); if (!isValidBabylonAddress(babylonAddress)) { - throw new Error("Invalid Babylon address"); + throw new Error('Invalid Babylon address'); } const stakingInstance = new Staking( @@ -174,7 +319,7 @@ export class BabylonBtcStakingManager { stakerBtcInfo, params, stakingInput.finalityProviderPksNoCoordHex, - stakingInput.stakingTimelock, + stakingInput.stakingTimelock ); // Validate if the stakingTx is valid based on the retrieved Babylon param @@ -182,31 +327,28 @@ export class BabylonBtcStakingManager { const stakingOutputInfo = deriveStakingOutputInfo(scripts, this.network); // Error will be thrown if the expected staking output address is not found // in the stakingTx - findMatchingTxOutputIndex( - stakingTx, - stakingOutputInfo.outputAddress, - this.network, - ); + findMatchingTxOutputIndex(stakingTx, stakingOutputInfo.outputAddress, this.network); // Create delegation message const delegationMsg = await this.createBtcDelegationMsg( - "delegation:register", + 'delegation:register', stakingInstance, stakingInput, stakingTx, babylonAddress, stakerBtcInfo, params, - this.getInclusionProof(inclusionProof), + { + inclusionProof: this.getInclusionProof(inclusionProof), + } ); - this.ee?.emit("delegation:register", { - type: "create-btc-delegation-msg", + this.ee?.emit('delegation:register', { + type: 'create-btc-delegation-msg', }); return { - signedBabylonTx: - await this.babylonProvider.signTransaction(delegationMsg), + signedBabylonTx: await this.babylonProvider.signTransaction(delegationMsg), }; } @@ -229,30 +371,23 @@ export class BabylonBtcStakingManager { babylonBtcTipHeight: number, stakingInput: StakingInputs, inputUTXOs: UTXO[], - feeRate: number, + feeRate: number ): number { if (babylonBtcTipHeight === 0) { - throw new Error("Babylon BTC tip height cannot be 0"); + throw new Error('Babylon BTC tip height cannot be 0'); } // Get the param based on the tip height - const params = getBabylonParamByBtcHeight( - babylonBtcTipHeight, - this.stakingParams, - ); + const params = getBabylonParamByBtcHeight(babylonBtcTipHeight, this.stakingParams); const staking = new Staking( this.network, stakerBtcInfo, params, stakingInput.finalityProviderPksNoCoordHex, - stakingInput.stakingTimelock, + stakingInput.stakingTimelock ); - const { fee: stakingFee } = staking.createStakingTransaction( - stakingInput.stakingAmountSat, - inputUTXOs, - feeRate, - ); + const { fee: stakingFee } = staking.createStakingTransaction(stakingInput.stakingAmountSat, inputUTXOs, feeRate); return stakingFee; } @@ -275,15 +410,12 @@ export class BabylonBtcStakingManager { stakingInput: StakingInputs, unsignedStakingTx: Transaction, inputUTXOs: UTXO[], - stakingParamsVersion: number, + stakingParamsVersion: number ): Promise { - const params = getBabylonParamByVersion( - stakingParamsVersion, - this.stakingParams, - ); + const params = getBabylonParamByVersion(stakingParamsVersion, this.stakingParams); if (inputUTXOs.length === 0) { - throw new Error("No input UTXOs provided"); + throw new Error('No input UTXOs provided'); } const staking = new Staking( @@ -291,7 +423,7 @@ export class BabylonBtcStakingManager { stakerBtcInfo, params, stakingInput.finalityProviderPksNoCoordHex, - stakingInput.stakingTimelock, + stakingInput.stakingTimelock ); const stakingPsbt = staking.toStakingPsbt(unsignedStakingTx, inputUTXOs); @@ -310,27 +442,172 @@ export class BabylonBtcStakingManager { }, ]; - this.ee?.emit("delegation:stake", { + this.ee?.emit('delegation:stake', { stakerPk: stakerBtcInfo.publicKeyNoCoordHex, finalityProviders: stakingInput.finalityProviderPksNoCoordHex, covenantPks: params.covenantNoCoordPks, covenantThreshold: params.covenantQuorum, unbondingTimeBlocks: params.unbondingTime, stakingDuration: stakingInput.stakingTimelock, - type: "staking", + type: 'staking', }); - const signedStakingPsbtHex = await this.btcProvider.signPsbt( - stakingPsbt.toHex(), + const signedStakingPsbtHex = await this.btcProvider.signPsbt(stakingPsbt.toHex(), { + contracts, + action: { + name: ActionName.SIGN_BTC_STAKING_TRANSACTION, + }, + }); + + return Psbt.fromHex(signedStakingPsbtHex).extractTransaction(); + } + + /** + * Creates a signed staking expansion transaction that is ready to be sent to + * the BTC network. + * + * @param {StakerInfo} stakerBtcInfo - The staker's BTC information including + * address and public key + * @param {StakingInputs} stakingInput - The staking inputs for the expansion + * @param {Transaction} unsignedStakingExpansionTx - The unsigned staking + * expansion transaction + * @param {UTXO[]} inputUTXOs - Available UTXOs for the funding input + * @param {number} stakingParamsVersion - The version of staking parameters + * that was used when registering the staking expansion delegation. + * @param {Object} previousStakingTxInfo - Information about the previous + * staking transaction + * @param {Array} covenantStakingExpansionSignatures - Covenant committee + * signatures for the expansion + * @returns {Promise} The fully signed staking expansion + * transaction + * @throws {Error} If signing fails, validation fails, or required data is + * missing + */ + async createSignedBtcStakingExpansionTransaction( + stakerBtcInfo: StakerInfo, + stakingInput: StakingInputs, + unsignedStakingExpansionTx: Transaction, + inputUTXOs: UTXO[], + stakingParamsVersion: number, + previousStakingTxInfo: { + stakingTx: Transaction; + paramVersion: number; + stakingInput: StakingInputs; + }, + covenantStakingExpansionSignatures: { + btcPkHex: string; + sigHex: string; + }[] + ): Promise { + validateStakingExpansionInputs({ + inputUTXOs, + stakingInput, + previousStakingInput: previousStakingTxInfo.stakingInput, + }); + + // Get the staking parameters for the current version + // These parameters define the covenant committee and other staking rules + const params = getBabylonParamByVersion(stakingParamsVersion, this.stakingParams); + + // Validate that input UTXOs are provided for the funding input + if (inputUTXOs.length === 0) { + throw new Error('No input UTXOs provided'); + } + + // Create a new staking instance with the current parameters + // This will be used to build the PSBT for the expansion transaction + const staking = new Staking( + this.network, + stakerBtcInfo, + params, + stakingInput.finalityProviderPksNoCoordHex, + stakingInput.stakingTimelock + ); + + const previousParams = getBabylonParamByVersion(previousStakingTxInfo.paramVersion, this.stakingParams); + + // Create the PSBT for the staking expansion transaction + // This PSBT will have two inputs: the previous staking output and a + // funding UTXO + const stakingExpansionPsbt = staking.toStakingExpansionPsbt( + unsignedStakingExpansionTx, + inputUTXOs, + previousParams, + previousStakingTxInfo + ); + + // Define the contract information for the PSBT signing + const contracts: Contract[] = [ { - contracts, - action: { - name: ActionName.SIGN_BTC_STAKING_TRANSACTION, + id: ContractId.STAKING, + params: { + stakerPk: stakerBtcInfo.publicKeyNoCoordHex, + finalityProviders: stakingInput.finalityProviderPksNoCoordHex, + covenantPks: params.covenantNoCoordPks, + covenantThreshold: params.covenantQuorum, + minUnbondingTime: params.unbondingTime, + stakingDuration: stakingInput.stakingTimelock, }, }, + ]; + + // Emit an event to notify listeners about the staking expansion + // This can be used for logging, monitoring, or UI updates + this.ee?.emit('delegation:stake', { + stakerPk: stakerBtcInfo.publicKeyNoCoordHex, + finalityProviders: stakingInput.finalityProviderPksNoCoordHex, + covenantPks: params.covenantNoCoordPks, + covenantThreshold: params.covenantQuorum, + unbondingTimeBlocks: params.unbondingTime, + stakingDuration: stakingInput.stakingTimelock, + type: 'staking', + }); + + // Sign the PSBT using the BTC provider (wallet) + // The wallet will sign the transaction based on the contract information + // provided + const signedStakingPsbtHex = await this.btcProvider.signPsbt(stakingExpansionPsbt.toHex(), { + contracts, + action: { + name: ActionName.SIGN_BTC_STAKING_TRANSACTION, + }, + }); + + // Extract the signed transaction from the PSBT + const signedStakingExpansionTx = Psbt.fromHex(signedStakingPsbtHex).extractTransaction(); + + // Validate that the signed transaction hash matches the unsigned + // transaction hash + // This ensures that the signing process didn't change the transaction + // structure + if (signedStakingExpansionTx.getId() !== unsignedStakingExpansionTx.getId()) { + throw new Error('Staking expansion transaction hash does not match the computed hash'); + } + + // Add covenant committee signatures to the transaction + // Convert covenant public keys from hex strings to buffers + // The covenants committee is based on the params at the time of the previous + // staking transaction. Hence using the previous params here. + const covenantBuffers = previousParams.covenantNoCoordPks.map((covenant) => Buffer.from(covenant, 'hex')); + + // Create the witness that includes both the staker's signature and covenant + // signatures + // The witness is the data that proves the transaction is authorized + const witness = createCovenantWitness( + // The first input of the staking expansion transaction is the previous + // staking output. We will attach the covenant signatures to this input + // to unbond the previousstaking output. + signedStakingExpansionTx.ins[0].witness, + covenantBuffers, + covenantStakingExpansionSignatures, + previousParams.covenantQuorum ); - return Psbt.fromHex(signedStakingPsbtHex).extractTransaction(); + // Overwrite the witness to include the covenant staking expansion signatures + // This makes the transaction valid for submission to the Bitcoin network + signedStakingExpansionTx.ins[0].witness = witness; + + return signedStakingExpansionTx; } /** @@ -351,24 +628,20 @@ export class BabylonBtcStakingManager { stakerBtcInfo: StakerInfo, stakingInput: StakingInputs, stakingParamsVersion: number, - stakingTx: Transaction, + stakingTx: Transaction ): Promise { // Get the staking params at the time of the staking transaction - const params = getBabylonParamByVersion( - stakingParamsVersion, - this.stakingParams, - ); + const params = getBabylonParamByVersion(stakingParamsVersion, this.stakingParams); const staking = new Staking( this.network, stakerBtcInfo, params, stakingInput.finalityProviderPksNoCoordHex, - stakingInput.stakingTimelock, + stakingInput.stakingTimelock ); - const { transaction: unbondingTx, fee } = - staking.createUnbondingTransaction(stakingTx); + const { transaction: unbondingTx, fee } = staking.createUnbondingTransaction(stakingTx); const psbt = staking.toUnbondingPsbt(unbondingTx, stakingTx); @@ -397,7 +670,7 @@ export class BabylonBtcStakingManager { }, ]; - this.ee?.emit("delegation:unbond", { + this.ee?.emit('delegation:unbond', { stakerPk: stakerBtcInfo.publicKeyNoCoordHex, finalityProviders: stakingInput.finalityProviderPksNoCoordHex, covenantPks: params.covenantNoCoordPks, @@ -405,22 +678,17 @@ export class BabylonBtcStakingManager { stakingDuration: stakingInput.stakingTimelock, unbondingTimeBlocks: params.unbondingTime, unbondingFeeSat: params.unbondingFeeSat, - type: "unbonding", + type: 'unbonding', }); - const signedUnbondingPsbtHex = await this.btcProvider.signPsbt( - psbt.toHex(), - { - contracts, - action: { - name: ActionName.SIGN_BTC_UNBONDING_TRANSACTION, - }, + const signedUnbondingPsbtHex = await this.btcProvider.signPsbt(psbt.toHex(), { + contracts, + action: { + name: ActionName.SIGN_BTC_UNBONDING_TRANSACTION, }, - ); + }); - const signedUnbondingTx = Psbt.fromHex( - signedUnbondingPsbtHex, - ).extractTransaction(); + const signedUnbondingTx = Psbt.fromHex(signedUnbondingPsbtHex).extractTransaction(); return { transaction: signedUnbondingTx, @@ -451,35 +719,27 @@ export class BabylonBtcStakingManager { covenantUnbondingSignatures: { btcPkHex: string; sigHex: string; - }[], + }[] ): Promise { // Get the staking params at the time of the staking transaction - const params = getBabylonParamByVersion( + const params = getBabylonParamByVersion(stakingParamsVersion, this.stakingParams); + + const { transaction: signedUnbondingTx, fee } = await this.createPartialSignedBtcUnbondingTransaction( + stakerBtcInfo, + stakingInput, stakingParamsVersion, - this.stakingParams, + stakingTx ); - const { transaction: signedUnbondingTx, fee } = - await this.createPartialSignedBtcUnbondingTransaction( - stakerBtcInfo, - stakingInput, - stakingParamsVersion, - stakingTx, - ); - // Check the computed txid of the signed unbonding transaction is the same as // the txid of the unsigned unbonding transaction if (signedUnbondingTx.getId() !== unsignedUnbondingTx.getId()) { - throw new Error( - "Unbonding transaction hash does not match the computed hash", - ); + throw new Error('Unbonding transaction hash does not match the computed hash'); } // Add covenant unbonding signatures // Convert the params of covenants to buffer - const covenantBuffers = params.covenantNoCoordPks.map((covenant) => - Buffer.from(covenant, "hex"), - ); + const covenantBuffers = params.covenantNoCoordPks.map((covenant) => Buffer.from(covenant, 'hex')); const witness = createCovenantWitness( // Since unbonding transactions always have a single input and output, // we expect exactly one signature in TaprootScriptSpendSig when the @@ -487,7 +747,7 @@ export class BabylonBtcStakingManager { signedUnbondingTx.ins[0].witness, covenantBuffers, covenantUnbondingSignatures, - params.covenantQuorum, + params.covenantQuorum ); // Overwrite the witness to include the covenant unbonding signatures signedUnbondingTx.ins[0].witness = witness; @@ -515,23 +775,19 @@ export class BabylonBtcStakingManager { stakingInput: StakingInputs, stakingParamsVersion: number, earlyUnbondingTx: Transaction, - feeRate: number, + feeRate: number ): Promise { - const params = getBabylonParamByVersion( - stakingParamsVersion, - this.stakingParams, - ); + const params = getBabylonParamByVersion(stakingParamsVersion, this.stakingParams); const staking = new Staking( this.network, stakerBtcInfo, params, stakingInput.finalityProviderPksNoCoordHex, - stakingInput.stakingTimelock, + stakingInput.stakingTimelock ); - const { psbt: unbondingPsbt, fee } = - staking.createWithdrawEarlyUnbondedTransaction(earlyUnbondingTx, feeRate); + const { psbt: unbondingPsbt, fee } = staking.createWithdrawEarlyUnbondedTransaction(earlyUnbondingTx, feeRate); const contracts: Contract[] = [ { @@ -543,21 +799,18 @@ export class BabylonBtcStakingManager { }, ]; - this.ee?.emit("delegation:withdraw", { + this.ee?.emit('delegation:withdraw', { stakerPk: stakerBtcInfo.publicKeyNoCoordHex, timelockBlocks: params.unbondingTime, - type: "early-unbonded", + type: 'early-unbonded', }); - const signedWithdrawalPsbtHex = await this.btcProvider.signPsbt( - unbondingPsbt.toHex(), - { - contracts, - action: { - name: ActionName.SIGN_BTC_WITHDRAW_TRANSACTION, - }, + const signedWithdrawalPsbtHex = await this.btcProvider.signPsbt(unbondingPsbt.toHex(), { + contracts, + action: { + name: ActionName.SIGN_BTC_WITHDRAW_TRANSACTION, }, - ); + }); return { transaction: Psbt.fromHex(signedWithdrawalPsbtHex).extractTransaction(), @@ -584,25 +837,19 @@ export class BabylonBtcStakingManager { stakingInput: StakingInputs, stakingParamsVersion: number, stakingTx: Transaction, - feeRate: number, + feeRate: number ): Promise { - const params = getBabylonParamByVersion( - stakingParamsVersion, - this.stakingParams, - ); + const params = getBabylonParamByVersion(stakingParamsVersion, this.stakingParams); const staking = new Staking( this.network, stakerBtcInfo, params, stakingInput.finalityProviderPksNoCoordHex, - stakingInput.stakingTimelock, + stakingInput.stakingTimelock ); - const { psbt, fee } = staking.createWithdrawStakingExpiredPsbt( - stakingTx, - feeRate, - ); + const { psbt, fee } = staking.createWithdrawStakingExpiredPsbt(stakingTx, feeRate); const contracts: Contract[] = [ { @@ -614,21 +861,18 @@ export class BabylonBtcStakingManager { }, ]; - this.ee?.emit("delegation:withdraw", { + this.ee?.emit('delegation:withdraw', { stakerPk: stakerBtcInfo.publicKeyNoCoordHex, timelockBlocks: stakingInput.stakingTimelock, - type: "staking-expired", + type: 'staking-expired', }); - const signedWithdrawalPsbtHex = await this.btcProvider.signPsbt( - psbt.toHex(), - { - contracts, - action: { - name: ActionName.SIGN_BTC_WITHDRAW_TRANSACTION, - }, + const signedWithdrawalPsbtHex = await this.btcProvider.signPsbt(psbt.toHex(), { + contracts, + action: { + name: ActionName.SIGN_BTC_WITHDRAW_TRANSACTION, }, - ); + }); return { transaction: Psbt.fromHex(signedWithdrawalPsbtHex).extractTransaction(), @@ -655,25 +899,19 @@ export class BabylonBtcStakingManager { stakingInput: StakingInputs, stakingParamsVersion: number, slashingTx: Transaction, - feeRate: number, + feeRate: number ): Promise { - const params = getBabylonParamByVersion( - stakingParamsVersion, - this.stakingParams, - ); + const params = getBabylonParamByVersion(stakingParamsVersion, this.stakingParams); const staking = new Staking( this.network, stakerBtcInfo, params, stakingInput.finalityProviderPksNoCoordHex, - stakingInput.stakingTimelock, + stakingInput.stakingTimelock ); - const { psbt, fee } = staking.createWithdrawSlashingPsbt( - slashingTx, - feeRate, - ); + const { psbt, fee } = staking.createWithdrawSlashingPsbt(slashingTx, feeRate); const contracts: Contract[] = [ { @@ -685,26 +923,21 @@ export class BabylonBtcStakingManager { }, ]; - this.ee?.emit("delegation:withdraw", { + this.ee?.emit('delegation:withdraw', { stakerPk: stakerBtcInfo.publicKeyNoCoordHex, timelockBlocks: params.unbondingTime, - type: "slashing", + type: 'slashing', }); - const signedWithrawSlashingPsbtHex = await this.btcProvider.signPsbt( - psbt.toHex(), - { - contracts, - action: { - name: ActionName.SIGN_BTC_WITHDRAW_TRANSACTION, - }, + const signedWithrawSlashingPsbtHex = await this.btcProvider.signPsbt(psbt.toHex(), { + contracts, + action: { + name: ActionName.SIGN_BTC_WITHDRAW_TRANSACTION, }, - ); + }); return { - transaction: Psbt.fromHex( - signedWithrawSlashingPsbtHex, - ).extractTransaction(), + transaction: Psbt.fromHex(signedWithrawSlashingPsbtHex).extractTransaction(), fee, }; } @@ -715,43 +948,58 @@ export class BabylonBtcStakingManager { * @returns The proof of possession. */ async createProofOfPossession( - channel: "delegation:create" | "delegation:register", + channel: 'delegation:create' | 'delegation:register' | 'delegation:expand', bech32Address: string, - stakerBtcAddress: string, + stakerBtcAddress: string ): Promise { let sigType: BTCSigType = BTCSigType.ECDSA; // For Taproot or Native SegWit addresses, use the BIP322 signature scheme // in the proof of possession as it uses the same signature type as the regular // input UTXO spend. For legacy addresses, use the ECDSA signature scheme. - if ( - isTaproot(stakerBtcAddress, this.network) || - isNativeSegwit(stakerBtcAddress, this.network) - ) { + if (isTaproot(stakerBtcAddress, this.network) || isNativeSegwit(stakerBtcAddress, this.network)) { sigType = BTCSigType.BIP322; } - this.ee?.emit(channel, { + const [chainId, babyTipHeight] = await Promise.all([ + this.babylonProvider.getChainId?.(), + this.babylonProvider.getCurrentHeight?.(), + ]); + + const upgradeConfig = this.upgradeConfig?.pop; + + // Get the message to sign for the proof of possession + const messageToSign = buildPopMessage( bech32Address, - type: "proof-of-possession", + babyTipHeight, + chainId, + upgradeConfig && { + upgradeHeight: upgradeConfig.upgradeHeight, + version: upgradeConfig.version, + } + ); + + this.ee?.emit(channel, { + messageToSign, + type: 'proof-of-possession', }); const signedBabylonAddress = await this.btcProvider.signMessage( - bech32Address, - sigType === BTCSigType.BIP322 ? "bip322-simple" : "ecdsa", + messageToSign, + sigType === BTCSigType.BIP322 ? 'bip322-simple' : 'ecdsa' ); let btcSig: Uint8Array; if (sigType === BTCSigType.BIP322) { const bip322Sig = BIP322Sig.fromPartial({ address: stakerBtcAddress, - sig: Buffer.from(signedBabylonAddress, "base64"), + sig: Buffer.from(signedBabylonAddress, 'base64'), }); // Encode the BIP322 protobuf message to a Uint8Array btcSig = BIP322Sig.encode(bip322Sig).finish(); } else { // Encode the ECDSA signature to a Uint8Array - btcSig = Buffer.from(signedBabylonAddress, "base64"); + btcSig = Buffer.from(signedBabylonAddress, 'base64'); } return { @@ -768,19 +1016,13 @@ export class BabylonBtcStakingManager { * @returns The unbonding, slashing, and unbonding slashing transactions and * PSBTs. */ - private async createDelegationTransactionsAndPsbts( - stakingInstance: Staking, - stakingTx: Transaction, - ) { - const { transaction: unbondingTx } = - stakingInstance.createUnbondingTransaction(stakingTx); + private async createDelegationTransactionsAndPsbts(stakingInstance: Staking, stakingTx: Transaction) { + const { transaction: unbondingTx } = stakingInstance.createUnbondingTransaction(stakingTx); // Create slashing transactions and extract signatures - const { psbt: slashingPsbt } = - stakingInstance.createStakingOutputSlashingPsbt(stakingTx); + const { psbt: slashingPsbt } = stakingInstance.createStakingOutputSlashingPsbt(stakingTx); - const { psbt: unbondingSlashingPsbt } = - stakingInstance.createUnbondingOutputSlashingPsbt(unbondingTx); + const { psbt: unbondingSlashingPsbt } = stakingInstance.createUnbondingOutputSlashingPsbt(unbondingTx); return { unbondingTx, @@ -799,31 +1041,43 @@ export class BabylonBtcStakingManager { * @param stakerBtcInfo - The staker's BTC information such as address and * public key * @param params - The staking parameters. - * @param inclusionProof - The inclusion proof of the staking transaction. + * @param options - The options for the BTC delegation. + * @param options.inclusionProof - The inclusion proof of the staking + * transaction. + * @param options.delegationExpansionInfo - The information for the BTC + * delegation expansion. * @returns The protobuf message. */ public async createBtcDelegationMsg( - channel: "delegation:create" | "delegation:register", + channel: 'delegation:create' | 'delegation:register' | 'delegation:expand', stakingInstance: Staking, stakingInput: StakingInputs, stakingTx: Transaction, bech32Address: string, stakerBtcInfo: StakerInfo, params: StakingParams, - inclusionProof?: btcstaking.InclusionProof, - ) { + options?: { + inclusionProof?: btcstaking.InclusionProof; + delegationExpansionInfo?: { + previousStakingTx: Transaction; + fundingTx: Transaction; + }; + } + ): Promise<{ + typeUrl: string; + value: btcstakingtx.MsgCreateBTCDelegation | btcstakingtx.MsgBtcStakeExpand; + }> { if (!params.slashing) { throw new StakingError( StakingErrorCode.INVALID_PARAMS, - "Slashing parameters are required for creating delegation message", + 'Slashing parameters are required for creating delegation message' ); } - const { unbondingTx, slashingPsbt, unbondingSlashingPsbt } = - await this.createDelegationTransactionsAndPsbts( - stakingInstance, - stakingTx, - ); + const { unbondingTx, slashingPsbt, unbondingSlashingPsbt } = await this.createDelegationTransactionsAndPsbts( + stakingInstance, + stakingTx + ); const slashingContracts: Contract[] = [ { @@ -864,26 +1118,20 @@ export class BabylonBtcStakingManager { stakingDuration: stakingInput.stakingTimelock, slashingFeeSat: params.slashing.minSlashingTxFeeSat, slashingPkScriptHex: params.slashing.slashingPkScriptHex, - type: "staking-slashing", + type: 'staking-slashing', }); - const signedSlashingPsbtHex = await this.btcProvider.signPsbt( - slashingPsbt.toHex(), - { - contracts: slashingContracts, - action: { - name: ActionName.SIGN_BTC_SLASHING_TRANSACTION, - }, + const signedSlashingPsbtHex = await this.btcProvider.signPsbt(slashingPsbt.toHex(), { + contracts: slashingContracts, + action: { + name: ActionName.SIGN_BTC_SLASHING_TRANSACTION, }, - ); + }); - const signedSlashingTx = Psbt.fromHex( - signedSlashingPsbtHex, - ).extractTransaction(); - const slashingSig = - extractFirstSchnorrSignatureFromTransaction(signedSlashingTx); + const signedSlashingTx = Psbt.fromHex(signedSlashingPsbtHex).extractTransaction(); + const slashingSig = extractFirstSchnorrSignatureFromTransaction(signedSlashingTx); if (!slashingSig) { - throw new Error("No signature found in the staking output slashing PSBT"); + throw new Error('No signature found in the staking output slashing PSBT'); } const unbondingSlashingContracts: Contract[] = [ @@ -925,68 +1173,61 @@ export class BabylonBtcStakingManager { unbondingFeeSat: params.unbondingFeeSat, slashingFeeSat: params.slashing.minSlashingTxFeeSat, slashingPkScriptHex: params.slashing.slashingPkScriptHex, - type: "unbonding-slashing", + type: 'unbonding-slashing', }); - const signedUnbondingSlashingPsbtHex = await this.btcProvider.signPsbt( - unbondingSlashingPsbt.toHex(), - { - contracts: unbondingSlashingContracts, - action: { - name: ActionName.SIGN_BTC_UNBONDING_SLASHING_TRANSACTION, - }, + const signedUnbondingSlashingPsbtHex = await this.btcProvider.signPsbt(unbondingSlashingPsbt.toHex(), { + contracts: unbondingSlashingContracts, + action: { + name: ActionName.SIGN_BTC_UNBONDING_SLASHING_TRANSACTION, }, - ); + }); - const signedUnbondingSlashingTx = Psbt.fromHex( - signedUnbondingSlashingPsbtHex, - ).extractTransaction(); - const unbondingSignatures = extractFirstSchnorrSignatureFromTransaction( - signedUnbondingSlashingTx, - ); + const signedUnbondingSlashingTx = Psbt.fromHex(signedUnbondingSlashingPsbtHex).extractTransaction(); + const unbondingSignatures = extractFirstSchnorrSignatureFromTransaction(signedUnbondingSlashingTx); if (!unbondingSignatures) { - throw new Error( - "No signature found in the unbonding output slashing PSBT", - ); + throw new Error('No signature found in the unbonding output slashing PSBT'); } // Create proof of possession - const proofOfPossession = await this.createProofOfPossession( - channel, - bech32Address, - stakerBtcInfo.address, - ); + const proofOfPossession = await this.createProofOfPossession(channel, bech32Address, stakerBtcInfo.address); + + const commonMsg = { + stakerAddr: bech32Address, + pop: proofOfPossession, + btcPk: Uint8Array.from(Buffer.from(stakerBtcInfo.publicKeyNoCoordHex, 'hex')), + fpBtcPkList: stakingInput.finalityProviderPksNoCoordHex.map((pk) => Uint8Array.from(Buffer.from(pk, 'hex'))), + stakingTime: stakingInput.stakingTimelock, + stakingValue: stakingInput.stakingAmountSat, + stakingTx: Uint8Array.from(stakingTx.toBuffer()), + slashingTx: Uint8Array.from(Buffer.from(clearTxSignatures(signedSlashingTx).toHex(), 'hex')), + delegatorSlashingSig: Uint8Array.from(slashingSig), + unbondingTime: params.unbondingTime, + unbondingTx: Uint8Array.from(unbondingTx.toBuffer()), + unbondingValue: stakingInput.stakingAmountSat - params.unbondingFeeSat, + unbondingSlashingTx: Uint8Array.from(Buffer.from(clearTxSignatures(signedUnbondingSlashingTx).toHex(), 'hex')), + delegatorUnbondingSlashingSig: Uint8Array.from(unbondingSignatures), + }; - // Prepare the final protobuf message - const msg: btcstakingtx.MsgCreateBTCDelegation = - btcstakingtx.MsgCreateBTCDelegation.fromPartial({ - stakerAddr: bech32Address, - pop: proofOfPossession, - btcPk: Uint8Array.from( - Buffer.from(stakerBtcInfo.publicKeyNoCoordHex, "hex"), - ), - fpBtcPkList: stakingInput.finalityProviderPksNoCoordHex.map((pk) => - Uint8Array.from(Buffer.from(pk, "hex")), - ), - stakingTime: stakingInput.stakingTimelock, - stakingValue: stakingInput.stakingAmountSat, - stakingTx: Uint8Array.from(stakingTx.toBuffer()), - slashingTx: Uint8Array.from( - Buffer.from(clearTxSignatures(signedSlashingTx).toHex(), "hex"), - ), - delegatorSlashingSig: Uint8Array.from(slashingSig), - unbondingTime: params.unbondingTime, - unbondingTx: Uint8Array.from(unbondingTx.toBuffer()), - unbondingValue: stakingInput.stakingAmountSat - params.unbondingFeeSat, - unbondingSlashingTx: Uint8Array.from( - Buffer.from( - clearTxSignatures(signedUnbondingSlashingTx).toHex(), - "hex", - ), - ), - delegatorUnbondingSlashingSig: Uint8Array.from(unbondingSignatures), - stakingTxInclusionProof: inclusionProof, + // If the delegation is an expansion, we use the MsgBtcStakeExpand message + if (options?.delegationExpansionInfo) { + const fundingTx = Uint8Array.from(options.delegationExpansionInfo.fundingTx.toBuffer()); + const msg = btcstakingtx.MsgBtcStakeExpand.fromPartial({ + ...commonMsg, + previousStakingTxHash: options.delegationExpansionInfo.previousStakingTx.getId(), + fundingTx, }); + return { + typeUrl: BABYLON_REGISTRY_TYPE_URLS.MsgBtcStakeExpand, + value: msg, + }; + } + + // Otherwise, it's a new staking delegation + const msg: btcstakingtx.MsgCreateBTCDelegation = btcstakingtx.MsgCreateBTCDelegation.fromPartial({ + ...commonMsg, + stakingTxInclusionProof: options?.inclusionProof, + }); return { typeUrl: BABYLON_REGISTRY_TYPE_URLS.MsgCreateBTCDelegation, @@ -1000,101 +1241,33 @@ export class BabylonBtcStakingManager { * @param inclusionProof - The inclusion proof. * @returns The inclusion proof. */ - private getInclusionProof( - inclusionProof: InclusionProof, - ): btcstaking.InclusionProof { + private getInclusionProof(inclusionProof: InclusionProof): btcstaking.InclusionProof { const { pos, merkle, blockHashHex } = inclusionProof; const proofHex = deriveMerkleProof(merkle); - const hash = reverseBuffer( - Uint8Array.from(Buffer.from(blockHashHex, "hex")), - ); - const inclusionProofKey: btccheckpoint.TransactionKey = - btccheckpoint.TransactionKey.fromPartial({ - index: pos, - hash, - }); + const hash = reverseBuffer(Uint8Array.from(Buffer.from(blockHashHex, 'hex'))); + const inclusionProofKey: btccheckpoint.TransactionKey = btccheckpoint.TransactionKey.fromPartial({ + index: pos, + hash, + }); return btcstaking.InclusionProof.fromPartial({ key: inclusionProofKey, - proof: Uint8Array.from(Buffer.from(proofHex, "hex")), + proof: Uint8Array.from(Buffer.from(proofHex, 'hex')), }); } } -/** - * Extracts the first valid Schnorr signature from a signed transaction. - * - * Since we only handle transactions with a single input and request a signature - * for one public key, there can be at most one signature from the Bitcoin node. - * A valid Schnorr signature is exactly 64 bytes in length. - * - * @param singedTransaction - The signed Bitcoin transaction to extract the signature from - * @returns The first valid 64-byte Schnorr signature found in the transaction witness data, - * or undefined if no valid signature exists - */ -const extractFirstSchnorrSignatureFromTransaction = ( - singedTransaction: Transaction, -): Buffer | undefined => { - // Loop through each input to extract the witness signature - for (const input of singedTransaction.ins) { - if (input.witness && input.witness.length > 0) { - const schnorrSignature = input.witness[0]; - - // Check that it's a 64-byte Schnorr signature - if (schnorrSignature.length === 64) { - return schnorrSignature; // Return the first valid signature found - } - } - } - return undefined; -}; - -/** - * Strips all signatures from a transaction by clearing both the script and - * witness data. This is due to the fact that we only need the raw unsigned - * transaction structure. The signatures are sent in a separate protobuf field - * when creating the delegation message in the Babylon. - * @param tx - The transaction to strip signatures from - * @returns A copy of the transaction with all signatures removed - */ -const clearTxSignatures = (tx: Transaction): Transaction => { - tx.ins.forEach((input) => { - input.script = Buffer.alloc(0); - input.witness = []; - }); - return tx; -}; - -/** - * Derives the merkle proof from the list of hex strings. Note the - * sibling hashes are reversed from hex before concatenation. - * @param merkle - The merkle proof hex strings. - * @returns The merkle proof in hex string format. - */ -const deriveMerkleProof = (merkle: string[]) => { - const proofHex = merkle.reduce((acc: string, m: string) => { - return acc + Buffer.from(m, "hex").reverse().toString("hex"); - }, ""); - return proofHex; -}; - /** * Get the staker signature from the unbonding transaction * This is used mostly for unbonding transactions from phase-1(Observable) * @param unbondingTx - The unbonding transaction * @returns The staker signature */ -export const getUnbondingTxStakerSignature = ( - unbondingTx: Transaction, -): string => { +export const getUnbondingTxStakerSignature = (unbondingTx: Transaction): string => { try { // There is only one input and one output in the unbonding transaction - return unbondingTx.ins[0].witness[0].toString("hex"); + return unbondingTx.ins[0].witness[0].toString('hex'); } catch (error) { - throw StakingError.fromUnknown( - error, - StakingErrorCode.INVALID_INPUT, - "Failed to get staker signature", - ); + throw StakingError.fromUnknown(error, StakingErrorCode.INVALID_INPUT, 'Failed to get staker signature'); } }; diff --git a/modules/babylonlabs-io-btc-staking-ts/src/staking/observable/index.ts b/modules/babylonlabs-io-btc-staking-ts/src/staking/observable/index.ts index c4cba94a6e..e84d05e220 100644 --- a/modules/babylonlabs-io-btc-staking-ts/src/staking/observable/index.ts +++ b/modules/babylonlabs-io-btc-staking-ts/src/staking/observable/index.ts @@ -3,7 +3,8 @@ import { UTXO } from "../../types/UTXO"; import { StakingError, StakingErrorCode } from "../../error"; import { stakingTransaction } from "../transactions"; import { isTaproot } from "../../utils/btc"; -import { toBuffers, validateStakingTxInputData } from "../../utils/staking"; +import { toBuffers } from "../../utils/staking"; +import { validateStakingTxInputData } from "../../utils/staking/validation"; import { TransactionResult } from "../../types/transaction"; import { ObservableStakingScriptData, ObservableStakingScripts } from "./observableStakingScript"; import { StakerInfo, Staking } from ".."; diff --git a/modules/babylonlabs-io-btc-staking-ts/src/staking/psbt.ts b/modules/babylonlabs-io-btc-staking-ts/src/staking/psbt.ts index 9e6caf4b05..03d18ee7d1 100644 --- a/modules/babylonlabs-io-btc-staking-ts/src/staking/psbt.ts +++ b/modules/babylonlabs-io-btc-staking-ts/src/staking/psbt.ts @@ -7,6 +7,8 @@ import { UTXO } from "../types/UTXO"; import { deriveUnbondingOutputInfo } from "../utils/staking"; import { findInputUTXO } from "../utils/utxo/findInputUTXO"; import { getPsbtInputFields } from "../utils/utxo/getPsbtInputFields"; +import { BitcoinScriptType, getScriptType } from "../utils/utxo/getScriptType"; +import { StakingScripts } from "./stakingScript"; /** * Convert a staking transaction to a PSBT. @@ -53,6 +55,145 @@ export const stakingPsbt = ( return psbt; }; +/** + * Convert a staking expansion transaction to a PSBT. + * + * @param {networks.Network} network - The Bitcoin network to use for the PSBT + * @param {Transaction} stakingTx - The staking expansion transaction to convert + * @param {Object} previousStakingTxInfo - Information about the previous staking transaction + * @param {Transaction} previousStakingTxInfo.stakingTx - The previous staking transaction + * @param {number} previousStakingTxInfo.outputIndex - The index of the staking output in the previous transaction + * @param {UTXO[]} inputUTXOs - Available UTXOs for the funding input + * @param {Buffer} [publicKeyNoCoord] - The staker's public key without coordinate (for Taproot) + * @returns {Psbt} The PSBT for the staking expansion transaction + * @throws {Error} If validation fails or required data is missing + */ +export const stakingExpansionPsbt = ( + network: networks.Network, + stakingTx: Transaction, + previousStakingTxInfo: { + stakingTx: Transaction, + outputIndex: number, + }, + inputUTXOs: UTXO[], + previousScripts: StakingScripts, + publicKeyNoCoord?: Buffer, +): Psbt => { + // Initialize PSBT with the specified network + const psbt = new Psbt({ network }); + + // Set transaction version and locktime if provided + if (stakingTx.version !== undefined) psbt.setVersion(stakingTx.version); + if (stakingTx.locktime !== undefined) psbt.setLocktime(stakingTx.locktime); + + // Validate the public key format if provided + if ( + publicKeyNoCoord && publicKeyNoCoord.length !== NO_COORD_PK_BYTE_LENGTH + ) { + throw new Error("Invalid public key"); + } + + // Extract the previous staking output from the previous staking transaction + const previousStakingOutput = previousStakingTxInfo.stakingTx.outs[ + previousStakingTxInfo.outputIndex + ]; + if (!previousStakingOutput) { + throw new Error("Previous staking output not found"); + }; + + // Validate that the previous staking output is a Taproot (P2TR) script + if ( + getScriptType(previousStakingOutput.script) !== BitcoinScriptType.P2TR + ) { + throw new Error("Previous staking output script type is not P2TR"); + } + + // Validate that the staking expansion transaction has exactly 2 inputs + // Input 0: Previous staking output (existing stake) + // Input 1: Funding UTXO (additional funds for fees or staking amount) + if (stakingTx.ins.length !== 2) { + throw new Error( + "Staking expansion transaction must have exactly 2 inputs", + ); + } + + // Validate the first input matches the previous staking transaction + const txInputs = stakingTx.ins; + + // Check that the first input references the correct previous staking + // transaction + if ( + Buffer.from(txInputs[0].hash).reverse().toString("hex") !== previousStakingTxInfo.stakingTx.getId() + ) { + throw new Error("Previous staking input hash does not match"); + } + // Check that the first input references the correct output index + else if (txInputs[0].index !== previousStakingTxInfo.outputIndex) { + throw new Error("Previous staking input index does not match"); + } + + // Build input tapleaf script that spends the previous staking output + const inputScriptTree: Taptree = [ + { output: previousScripts.slashingScript }, + [{ output: previousScripts.unbondingScript }, { output: previousScripts.timelockScript }], + ]; + const inputRedeem = { + output: previousScripts.unbondingScript, + redeemVersion: REDEEM_VERSION, + }; + const p2tr = payments.p2tr({ + internalPubkey, + scriptTree: inputScriptTree, + redeem: inputRedeem, + network, + }); + + if (!p2tr.witness || p2tr.witness.length === 0) { + throw new Error( + "Failed to create P2TR witness for expansion transaction input" + ); + } + + const inputTapLeafScript = { + leafVersion: inputRedeem.redeemVersion, + script: inputRedeem.output, + controlBlock: p2tr.witness[p2tr.witness.length - 1], + }; + + // Add the previous staking input to the PSBT + // This input spends the existing staking output + psbt.addInput({ + hash: txInputs[0].hash, + index: txInputs[0].index, + sequence: txInputs[0].sequence, + witnessUtxo: { + script: previousStakingOutput.script, + value: previousStakingOutput.value, + }, + tapInternalKey: internalPubkey, + tapLeafScript: [inputTapLeafScript], + }); + + // Add the second input (funding UTXO) to the PSBT + // This input provides additional funds for fees or staking amount + const inputUTXO = findInputUTXO(inputUTXOs, txInputs[1]); + const psbtInputData = getPsbtInputFields(inputUTXO, publicKeyNoCoord); + + psbt.addInput({ + hash: txInputs[1].hash, + index: txInputs[1].index, + sequence: txInputs[1].sequence, + ...psbtInputData, + }); + + // Add all outputs from the staking expansion transaction to the PSBT + stakingTx.outs.forEach((o) => { + psbt.addOutput({ script: o.script, value: o.value }); + }); + + return psbt; +}; + export const unbondingPsbt = ( scripts: { unbondingScript: Buffer; diff --git a/modules/babylonlabs-io-btc-staking-ts/src/staking/transactions.ts b/modules/babylonlabs-io-btc-staking-ts/src/staking/transactions.ts index 2b70a95418..4031bcac1d 100644 --- a/modules/babylonlabs-io-btc-staking-ts/src/staking/transactions.ts +++ b/modules/babylonlabs-io-btc-staking-ts/src/staking/transactions.ts @@ -1,4 +1,6 @@ -import { Psbt, Transaction, networks, payments, script, address, opcodes } from "bitcoinjs-lib"; +import { + Psbt, Transaction, networks, payments, script, address, opcodes +} from "bitcoinjs-lib"; import { Taptree } from "bitcoinjs-lib/src/types"; import { BTC_DUST_SAT } from "../constants/dustSat"; @@ -6,9 +8,18 @@ import { internalPubkey } from "../constants/internalPubkey"; import { UTXO } from "../types/UTXO"; import { PsbtResult, TransactionResult } from "../types/transaction"; import { isValidBitcoinAddress, transactionIdToHash } from "../utils/btc"; -import { getStakingTxInputUTXOsAndFees, getWithdrawTxFee } from "../utils/fee"; +import { + getStakingExpansionTxFundingUTXOAndFees, + getStakingTxInputUTXOsAndFees, + getWithdrawTxFee, +} from "../utils/fee"; import { inputValueSum } from "../utils/fee/utils"; -import { buildStakingTransactionOutputs, deriveUnbondingOutputInfo } from "../utils/staking"; +import { + buildStakingTransactionOutputs, + deriveStakingOutputInfo, + deriveUnbondingOutputInfo, + findMatchingTxOutputIndex, +} from "../utils/staking"; import { NON_RBF_SEQUENCE, TRANSACTION_VERSION } from "../constants/psbt"; import { CovenantSignature } from "../types/covenantSignatures"; import { REDEEM_VERSION } from "../constants/transaction"; @@ -125,6 +136,144 @@ export function stakingTransaction( }; } +/** + * Expand an existing staking transaction with additional finality providers + * or renew timelock. + * + * This function builds a Bitcoin transaction that: + * 1. Spends the previous staking transaction output as the first input + * 2. Uses a funding UTXO as the second input to cover transaction fees + * 3. Creates new staking outputs where the timelock is renewed or FPs added + * 4. Returns any remaining funds as change + * + * @param network - Bitcoin network (mainnet, testnet, etc.) + * @param scripts - Scripts for the new staking outputs + * @param amount - Total staking amount (must equal previous staking amount, + * we only support equal amounts for now) + * @param changeAddress - Bitcoin address to receive change from funding UTXO + * @param feeRate - Fee rate in satoshis per byte + * @param inputUTXOs - Available UTXOs to use for funding the expansion + * @param previousStakingTxInfo - Details of the previous staking transaction + * being expanded + * @returns {TransactionResult & { fundingUTXO: UTXO }} containing the built + * transaction and calculated fee, and the funding UTXO + */ +export function stakingExpansionTransaction( + network: networks.Network, + scripts: { + timelockScript: Buffer; + unbondingScript: Buffer; + slashingScript: Buffer; + }, + amount: number, + changeAddress: string, + feeRate: number, + inputUTXOs: UTXO[], + previousStakingTxInfo: { + stakingTx: Transaction, + scripts: { + timelockScript: Buffer; + unbondingScript: Buffer; + slashingScript: Buffer; + }, + }, +): TransactionResult & { + fundingUTXO: UTXO; +} { + // Validate input parameters + if (amount <= 0 || feeRate <= 0) { + throw new Error("Amount and fee rate must be bigger than 0"); + } else if (!isValidBitcoinAddress(changeAddress, network)) { + throw new Error("Invalid BTC change address"); + } + + // Derive the output address and amount from the previous staking transaction + // scripts. This helps us locate the specific output in the previous + // transaction + const previousStakingOutputInfo = deriveStakingOutputInfo( + previousStakingTxInfo.scripts, network + ); + + // Find the output index of the previous staking transaction in the + // transaction outputs. This method will throw an error if the output + // is not found. + const previousStakingOutputIndex = findMatchingTxOutputIndex( + previousStakingTxInfo.stakingTx, + previousStakingOutputInfo.outputAddress, + network + ); + + // Extract the actual staking amount from the previous transaction output + const previousStakingAmount = previousStakingTxInfo.stakingTx.outs[ + previousStakingOutputIndex + ].value; + + // Validate that the expansion amount matches the previous staking amount + // According to Babylon protocol, expansion amount must be >= previous amount + // Currently, this library only supports equal amounts (no stake increase) + if (amount !== previousStakingAmount) { + throw new Error( + "Expansion staking transaction amount must be equal to the previous " + + "staking amount. Increase of the staking amount is not supported yet.", + ); + } + + // Build the staking outputs for the expansion transaction + // These outputs will contain the new scripts with expanded timelock or FPs + const stakingOutputs = buildStakingTransactionOutputs( + scripts, network, amount, + ); + + // Select a single funding UTXO and calculate the required fee + // The funding UTXO will be used as the second input to cover transaction fees + const { selectedUTXO, fee } = getStakingExpansionTxFundingUTXOAndFees( + inputUTXOs, + feeRate, + stakingOutputs, + ); + + // Initialize the transaction with the standard version + const tx = new Transaction(); + tx.version = TRANSACTION_VERSION; + + // Add the first input: previous staking transaction output + // This is the existing stake that we're expanding + tx.addInput( + previousStakingTxInfo.stakingTx.getHash(), + previousStakingOutputIndex, + NON_RBF_SEQUENCE, + ); + + // Add the second input: selected funding UTXO + // This provides the funds to cover transaction fees + tx.addInput( + transactionIdToHash(selectedUTXO.txid), + selectedUTXO.vout, + NON_RBF_SEQUENCE, + ); + + // Add all staking outputs to the transaction + // These represent the expanded stake with new finality provider coverage + stakingOutputs.forEach((o) => { + tx.addOutput(o.scriptPubKey, o.value); + }); + + // Add a change output if there are remaining funds from the funding UTXO + // Only create change if the remaining amount is above the dust threshold + if (selectedUTXO.value - fee > BTC_DUST_SAT) { + tx.addOutput( + address.toOutputScript(changeAddress, network), + selectedUTXO.value - fee, + ); + } + + return { + transaction: tx, + fee, + fundingUTXO: selectedUTXO, + }; +} + /** * Constructs a withdrawal transaction for manually unbonded delegation. * @@ -703,15 +852,19 @@ export const createCovenantWitness = ( + `got: ${covenantSigs.length}` ); } - // Verify all btcPkHex from covenantSigs exist in paramsCovenants - for (const sig of covenantSigs) { + // Filter out the signatures that are not in the params covenants + const filteredCovenantSigs = covenantSigs.filter((sig) => { const btcPkHexBuf = Buffer.from(sig.btcPkHex, "hex"); - if (!paramsCovenants.some(covenant => covenant.equals(btcPkHexBuf))) { - throw new Error( - `Covenant signature public key ${sig.btcPkHex} not found in params covenants` - ); - } + return paramsCovenants.some(covenant => covenant.equals(btcPkHexBuf)); + }); + + if (filteredCovenantSigs.length < covenantQuorum) { + throw new Error( + `Not enough valid covenant signatures. Required: ${covenantQuorum}, ` + + `got: ${filteredCovenantSigs.length}` + ); } + // We only take exactly covenantQuorum number of signatures, even if more are provided. // Including extra signatures will cause the unbonding transaction to fail validation. // This is because the witness script expects exactly covenantQuorum number of signatures diff --git a/modules/babylonlabs-io-btc-staking-ts/src/types/events.ts b/modules/babylonlabs-io-btc-staking-ts/src/types/events.ts index af3ddf25b5..81c1a643af 100644 --- a/modules/babylonlabs-io-btc-staking-ts/src/types/events.ts +++ b/modules/babylonlabs-io-btc-staking-ts/src/types/events.ts @@ -15,6 +15,7 @@ export interface ManagerEvents { "delegation:stake": (data?: EventData) => void; "delegation:unbond": (data?: EventData) => void; "delegation:withdraw": (data?: EventData) => void; + "delegation:expand": (data?: EventData) => void; } export type DelegationEvent = keyof ManagerEvents; diff --git a/modules/babylonlabs-io-btc-staking-ts/src/types/manager.ts b/modules/babylonlabs-io-btc-staking-ts/src/types/manager.ts index cf83ccbc03..bf3aeae7a5 100644 --- a/modules/babylonlabs-io-btc-staking-ts/src/types/manager.ts +++ b/modules/babylonlabs-io-btc-staking-ts/src/types/manager.ts @@ -22,6 +22,9 @@ export interface BtcProvider { message: string, type: "ecdsa" | "bip322-simple", ) => Promise; + + // Get the transaction hex from the transaction ID + getTransactionHex(txid: string): Promise; } export interface BabylonProvider { @@ -39,6 +42,20 @@ export interface BabylonProvider { typeUrl: string; value: T; }) => Promise; + + /** + * Gets the current height of the Babylon Genesis chain. + * + * @returns {Promise} The current Babylon chain height + */ + getCurrentHeight?: () => Promise; + + /** + * Gets the chain ID of the Babylon Genesis chain. + * + * @returns {Promise} The Babylon chain ID + */ + getChainId?: () => Promise; } export interface StakingInputs { @@ -61,3 +78,31 @@ export interface InclusionProof { // The block hash of the block that contains the transaction blockHashHex: string; } + +/** + * Upgrade configuration for Babylon POP (Proof of Possession) context. + * This is used to determine when to switch to the new POP context format + * based on the Babylon chain height and version. + */ +export interface UpgradeConfig { + /** + * POP context upgrade configuration. + */ + pop?: PopUpgradeConfig; +} + +/** + * Configuration for POP context upgrade. + * - upgradeHeight: The Babylon chain height at which the POP context upgrade is activated. + * - version: The version of the POP context to use after the upgrade. + */ +export interface PopUpgradeConfig { + /** + * The Babylon chain height at which the POP context upgrade is activated. + */ + upgradeHeight: number; + /** + * The version of the POP context to use after the upgrade. + */ + version: number; +} \ No newline at end of file diff --git a/modules/babylonlabs-io-btc-staking-ts/src/utils/fee/index.ts b/modules/babylonlabs-io-btc-staking-ts/src/utils/fee/index.ts index 4f53a73785..30a6c160f8 100644 --- a/modules/babylonlabs-io-btc-staking-ts/src/utils/fee/index.ts +++ b/modules/babylonlabs-io-btc-staking-ts/src/utils/fee/index.ts @@ -6,6 +6,7 @@ import { OP_RETURN_OUTPUT_VALUE_SIZE, OP_RETURN_VALUE_SERIALIZE_SIZE, P2TR_INPUT_SIZE, + P2TR_STAKING_EXPANSION_INPUT_SIZE, TX_BUFFER_SIZE_OVERHEAD, WALLET_RELAY_FEE_RATE_THRESHOLD, WITHDRAW_TX_BUFFER_SIZE, @@ -94,6 +95,99 @@ export const getStakingTxInputUTXOsAndFees = ( }; }; +/** + * Calculates the required funding UTXO and fees for a staking expansion transaction. + * + * This function selects a single UTXO from available UTXOs to cover: + * 1. Transaction fees for the expansion + * 2. Any additional staking amount beyond the previous stake + * + * @param availableUTXOs - List of available UTXOs to choose from for funding + * @param previousStakingTx - Details of the previous staking transaction being expanded + * @param stakingAmount - Total staking amount for the expansion (includes previous + additional) + * @param feeRate - Fee rate in satoshis per byte + * @param outputs - Transaction outputs for the expansion + * @returns Object containing the selected funding UTXO and calculated fee + */ +export const getStakingExpansionTxFundingUTXOAndFees = ( + availableUTXOs: UTXO[], + feeRate: number, + outputs: TransactionOutput[], +): { + selectedUTXO: UTXO; + fee: number; +} => { + // Validate that we have UTXOs to work with + if (availableUTXOs.length === 0) { + throw new Error("Insufficient funds"); + } + + // Filter out invalid UTXOs by checking if their script can be decompiled + // This ensures we only work with properly formatted Bitcoin scripts + const validUTXOs = availableUTXOs.filter((utxo) => { + const script = Buffer.from(utxo.scriptPubKey, "hex"); + const decompiledScript = bitcoinScript.decompile(script); + return decompiledScript && decompiledScript.length > 0; + }); + + if (validUTXOs.length === 0) { + throw new Error("Insufficient funds: no valid UTXOs available for staking"); + } + + // Sort available UTXOs from lowest to highest value for optimal selection + // This helps us avoid selecting large UTXOs which can be used + // for other activities. + const sortedUTXOs = validUTXOs.sort((a, b) => a.value - b.value); + + // Iterate through UTXOs to find one that can cover the required fees + for (const utxo of sortedUTXOs) { + // Calculate the estimated transaction size including: + // - Base transaction size (additional UTXOs + Outputs) + // - Previous staking transaction output as the input for the expansion tx + // Note: Staking transactions use P2TR (Taproot) format, + // hence P2TR_STAKING_EXPANSION_INPUT_SIZE accounts for the witness size + // including covenant signatures and is calibrated for a typical covenant + // quorum of 6 signatures. + const estimatedSize = getEstimatedSize( + [utxo], + outputs, + ) + P2TR_STAKING_EXPANSION_INPUT_SIZE; + + // Calculate base fee: size * rate + buffer fee for network congestion + let estimatedFee = estimatedSize * feeRate + rateBasedTxBufferFee(feeRate); + + // Check if this UTXO has enough value to cover the estimated fee + // We are selecting a UTXO that can only cover the fee as + // in the case of stake expansion we only want the additional UTXO to cover + // the staking fee. + // TODO: In the future, we will want to support selecting a UTXO for an increased + // staking amount. + if (utxo.value >= estimatedFee) { + // Check if there will be change left after paying the fee + // If change amount is above dust threshold, we need to add a change output + // which increases the transaction size and fee + if (utxo.value - estimatedFee > BTC_DUST_SAT) { + // Add fee for the change output + estimatedFee += getEstimatedChangeOutputSize() * feeRate; + } + // Finally, ensure the estimated fee is not greater than the UTXO value + if (utxo.value >= estimatedFee) { + return { + selectedUTXO: utxo, + fee: estimatedFee, + }; + } + // If the UTXO value is less than the estimated fee, we need to continue + // searching for a UTXO that can cover the fees. + } + } + + // If no UTXO can cover the fees, throw an error + throw new Error( + "Insufficient funds: unable to find a UTXO to cover the fees for the staking expansion transaction.", + ); +}; + /** * Calculates the estimated fee for a withdrawal transaction. diff --git a/modules/babylonlabs-io-btc-staking-ts/src/utils/pop.ts b/modules/babylonlabs-io-btc-staking-ts/src/utils/pop.ts new file mode 100644 index 0000000000..0bfc789eb0 --- /dev/null +++ b/modules/babylonlabs-io-btc-staking-ts/src/utils/pop.ts @@ -0,0 +1,59 @@ +import { sha256 } from "bitcoinjs-lib/src/crypto"; + +import { STAKING_MODULE_ADDRESS } from "../constants/staking"; + +/** + * Creates the context string for the staker POP following RFC-036. + * See: https://github.com/babylonlabs-io/pm/blob/main/rfc/rfc-036-replay-attack-protection.md + * @param chainId - The Babylon chain ID + * @param popContextVersion - The POP context version (defaults to 0) + * @returns The hex encoded SHA-256 hash of the context string. + */ +export function createStakerPopContext( + chainId: string, + popContextVersion: number = 0, +): string { + // Context string format following RFC-036: + // Format: btcstaking/{version}/{operation_type}/{chain_id}/{module_address} + // + // Fields: + // - btcstaking: Protocol identifier for Bitcoin staking operations + // - version: POP context version (integer, defaults to 0) + // - operation_type: Type of operation ("staker_pop" for staker proof of possession) + // - chain_id: The Babylon chain ID for domain separation + // - module_address: The staking module address for additional context + const contextString = `btcstaking/${popContextVersion}/staker_pop/${chainId}/${STAKING_MODULE_ADDRESS}`; + return sha256(Buffer.from(contextString, "utf8")).toString("hex"); +} + +/** + * Creates the POP message to sign based on upgrade configuration and current height. + * RFC-036: If the Babylon tip height is greater than or equal to the POP context + * upgrade height, use the new context format, otherwise use legacy format. + * @param currentHeight - The current Babylon tip height + * @param bech32Address - The staker's bech32 address + * @param chainId - The Babylon chain ID + * @param upgradeConfig - Optional upgrade configuration with height and version + * @returns The message to sign (either just the address or context hash + address) + */ +export function buildPopMessage( + bech32Address: string, + currentHeight?: number, + chainId?: string, + upgradeConfig?: { upgradeHeight: number; version: number }, +): string { + // RFC-036: If upgrade is configured and current height >= upgrade height, use new context format + // https://github.com/babylonlabs-io/pm/blob/main/rfc/rfc-036-replay-attack-protection.md + if ( + chainId !== undefined && + upgradeConfig?.upgradeHeight !== undefined && + upgradeConfig.version !== undefined && + currentHeight !== undefined && + currentHeight >= upgradeConfig.upgradeHeight + ) { + const contextHash = createStakerPopContext(chainId, upgradeConfig.version); + return contextHash + bech32Address; + } + + return bech32Address; +} \ No newline at end of file diff --git a/modules/babylonlabs-io-btc-staking-ts/src/utils/staking/index.ts b/modules/babylonlabs-io-btc-staking-ts/src/utils/staking/index.ts index d4e0034110..e86d3740d2 100644 --- a/modules/babylonlabs-io-btc-staking-ts/src/utils/staking/index.ts +++ b/modules/babylonlabs-io-btc-staking-ts/src/utils/staking/index.ts @@ -1,13 +1,8 @@ import { address, networks, payments, Transaction } from "bitcoinjs-lib"; import { Taptree } from "bitcoinjs-lib/src/types"; import { internalPubkey } from "../../constants/internalPubkey"; -import { MIN_UNBONDING_OUTPUT_VALUE } from "../../constants/unbonding"; import { StakingError, StakingErrorCode } from "../../error"; -import { StakingParams } from "../../types/params"; import { TransactionOutput } from "../../types/psbtOutputs"; -import { UTXO } from "../../types/UTXO"; -import { isValidNoCoordPublicKey } from "../btc"; - export interface OutputInfo { scriptPubKey: Buffer; outputAddress: string; @@ -204,190 +199,78 @@ export const findMatchingTxOutputIndex = ( }; /** - * Validate the staking transaction input data. + * toBuffers converts an array of strings to an array of buffers. * - * @param {number} stakingAmountSat - The staking amount in satoshis. - * @param {number} timelock - The staking time in blocks. - * @param {StakingParams} params - The staking parameters. - * @param {UTXO[]} inputUTXOs - The input UTXOs. - * @param {number} feeRate - The Bitcoin fee rate in sat/vbyte - * @throws {StakingError} - If the input data is invalid. + * @param {string[]} inputs - The input strings. + * @returns {Buffer[]} - The buffers. + * @throws {StakingError} - If the values cannot be converted to buffers. */ -export const validateStakingTxInputData = ( - stakingAmountSat: number, - timelock: number, - params: StakingParams, - inputUTXOs: UTXO[], - feeRate: number, -) => { - if ( - stakingAmountSat < params.minStakingAmountSat || - stakingAmountSat > params.maxStakingAmountSat - ) { - throw new StakingError( - StakingErrorCode.INVALID_INPUT, - "Invalid staking amount", - ); - } - - if ( - timelock < params.minStakingTimeBlocks || - timelock > params.maxStakingTimeBlocks - ) { - throw new StakingError(StakingErrorCode.INVALID_INPUT, "Invalid timelock"); - } - - if (inputUTXOs.length == 0) { - throw new StakingError( +export const toBuffers = (inputs: string[]): Buffer[] => { + try { + return inputs.map((i) => Buffer.from(i, "hex")); + } catch (error) { + throw StakingError.fromUnknown( + error, StakingErrorCode.INVALID_INPUT, - "No input UTXOs provided", + "Cannot convert values to buffers", ); } - if (feeRate <= 0) { - throw new StakingError(StakingErrorCode.INVALID_INPUT, "Invalid fee rate"); - } }; + /** - * Validate the staking parameters. - * Extend this method to add additional validation for staking parameters based - * on the staking type. - * @param {StakingParams} params - The staking parameters. - * @throws {StakingError} - If the parameters are invalid. + * Strips all signatures from a transaction by clearing both the script and + * witness data. This is due to the fact that we only need the raw unsigned + * transaction structure. The signatures are sent in a separate protobuf field + * when creating the delegation message in the Babylon. + * @param tx - The transaction to strip signatures from + * @returns A copy of the transaction with all signatures removed */ -export const validateParams = (params: StakingParams) => { - // Check covenant public keys - if (params.covenantNoCoordPks.length == 0) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Could not find any covenant public keys", - ); - } - if (params.covenantNoCoordPks.length < params.covenantQuorum) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Covenant public keys must be greater than or equal to the quorum", - ); - } - params.covenantNoCoordPks.forEach((pk) => { - if (!isValidNoCoordPublicKey(pk)) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Covenant public key should contains no coordinate", - ); - } +export const clearTxSignatures = (tx: Transaction): Transaction => { + tx.ins.forEach((input) => { + input.script = Buffer.alloc(0); + input.witness = []; }); - // Check other parameters - if (params.unbondingTime <= 0) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Unbonding time must be greater than 0", - ); - } - if (params.unbondingFeeSat <= 0) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Unbonding fee must be greater than 0", - ); - } - if (params.maxStakingAmountSat < params.minStakingAmountSat) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Max staking amount must be greater or equal to min staking amount", - ); - } - if ( - params.minStakingAmountSat < - params.unbondingFeeSat + MIN_UNBONDING_OUTPUT_VALUE - ) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - `Min staking amount must be greater than unbonding fee plus ${MIN_UNBONDING_OUTPUT_VALUE}`, - ); - } - if (params.maxStakingTimeBlocks < params.minStakingTimeBlocks) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Max staking time must be greater or equal to min staking time", - ); - } - if (params.minStakingTimeBlocks <= 0) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Min staking time must be greater than 0", - ); - } - if (params.covenantQuorum <= 0) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Covenant quorum must be greater than 0", - ); - } - if (params.slashing) { - if (params.slashing.slashingRate <= 0) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Slashing rate must be greater than 0", - ); - } - if (params.slashing.slashingRate > 1) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Slashing rate must be less or equal to 1", - ); - } - if (params.slashing.slashingPkScriptHex.length == 0) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Slashing public key script is missing", - ); - } - if (params.slashing.minSlashingTxFeeSat <= 0) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Minimum slashing transaction fee must be greater than 0", - ); - } - } + return tx; }; /** - * Validate the staking timelock. - * - * @param {number} stakingTimelock - The staking timelock. - * @param {StakingParams} params - The staking parameters. - * @throws {StakingError} - If the staking timelock is invalid. + * Derives the merkle proof from the list of hex strings. Note the + * sibling hashes are reversed from hex before concatenation. + * @param merkle - The merkle proof hex strings. + * @returns The merkle proof in hex string format. */ -export const validateStakingTimelock = ( - stakingTimelock: number, - params: StakingParams, -) => { - if ( - stakingTimelock < params.minStakingTimeBlocks || - stakingTimelock > params.maxStakingTimeBlocks - ) { - throw new StakingError( - StakingErrorCode.INVALID_INPUT, - "Staking transaction timelock is out of range", - ); - } +export const deriveMerkleProof = (merkle: string[]) => { + const proofHex = merkle.reduce((acc: string, m: string) => { + return acc + Buffer.from(m, "hex").reverse().toString("hex"); + }, ""); + return proofHex; }; /** - * toBuffers converts an array of strings to an array of buffers. + * Extracts the first valid Schnorr signature from a signed transaction. * - * @param {string[]} inputs - The input strings. - * @returns {Buffer[]} - The buffers. - * @throws {StakingError} - If the values cannot be converted to buffers. + * Since we only handle transactions with a single input and request a signature + * for one public key, there can be at most one signature from the Bitcoin node. + * A valid Schnorr signature is exactly 64 bytes in length. + * + * @param singedTransaction - The signed Bitcoin transaction to extract the signature from + * @returns The first valid 64-byte Schnorr signature found in the transaction witness data, + * or undefined if no valid signature exists */ -export const toBuffers = (inputs: string[]): Buffer[] => { - try { - return inputs.map((i) => Buffer.from(i, "hex")); - } catch (error) { - throw StakingError.fromUnknown( - error, - StakingErrorCode.INVALID_INPUT, - "Cannot convert values to buffers", - ); +export const extractFirstSchnorrSignatureFromTransaction = ( + singedTransaction: Transaction, +): Buffer | undefined => { + // Loop through each input to extract the witness signature + for (const input of singedTransaction.ins) { + if (input.witness && input.witness.length > 0) { + const schnorrSignature = input.witness[0]; + + // Check that it's a 64-byte Schnorr signature + if (schnorrSignature.length === 64) { + return schnorrSignature; // Return the first valid signature found + } + } } + return undefined; }; diff --git a/modules/babylonlabs-io-btc-staking-ts/src/utils/staking/validation.ts b/modules/babylonlabs-io-btc-staking-ts/src/utils/staking/validation.ts new file mode 100644 index 0000000000..73d0dcc061 --- /dev/null +++ b/modules/babylonlabs-io-btc-staking-ts/src/utils/staking/validation.ts @@ -0,0 +1,280 @@ +import { MIN_UNBONDING_OUTPUT_VALUE } from "../../constants/unbonding"; +import { StakingError, StakingErrorCode } from "../../error"; +import { StakingInputs, StakingParams, UTXO } from "../../types"; +import { isValidBabylonAddress } from "../babylon"; +import { isValidNoCoordPublicKey } from "../btc"; + +/** + * Validates the staking expansion input + * @param babylonBtcTipHeight - The Babylon BTC tip height + * @param inputUTXOs - The input UTXOs + * @param stakingInput - The staking input + * @param previousStakingInput - The previous staking input + * @param babylonAddress - The Babylon address + * @returns true if validation passes, throws error if validation fails + */ +export const validateStakingExpansionInputs = ( + { + babylonBtcTipHeight, + inputUTXOs, + stakingInput, + previousStakingInput, + babylonAddress, + }: { + babylonBtcTipHeight?: number, + inputUTXOs: UTXO[], + stakingInput: StakingInputs, + previousStakingInput: StakingInputs, + babylonAddress?: string, + } +) => { + if (babylonBtcTipHeight === 0) { + throw new StakingError( + StakingErrorCode.INVALID_INPUT, + "Babylon BTC tip height cannot be 0", + ); + } + if (!inputUTXOs || inputUTXOs.length === 0) { + throw new StakingError( + StakingErrorCode.INVALID_INPUT, + "No input UTXOs provided", + ); + } + if (babylonAddress && !isValidBabylonAddress(babylonAddress)) { + throw new StakingError( + StakingErrorCode.INVALID_INPUT, + "Invalid Babylon address", + ); + } + + // TODO: We currently don't support increasing the staking amount + if (stakingInput.stakingAmountSat !== previousStakingInput.stakingAmountSat) { + throw new StakingError( + StakingErrorCode.INVALID_INPUT, + "Staking expansion amount must equal the previous staking amount", + ); + } + // Check the previous staking transaction's finality providers + // are a subset of the new staking input's finality providers + const currentFPs = stakingInput.finalityProviderPksNoCoordHex; + const previousFPs = previousStakingInput.finalityProviderPksNoCoordHex; + + // Check if all previous finality providers are included in the current + // staking + const missingPreviousFPs = previousFPs.filter(prevFp => !currentFPs.includes(prevFp)); + + if (missingPreviousFPs.length > 0) { + throw new StakingError( + StakingErrorCode.INVALID_INPUT, + `Invalid staking expansion: all finality providers from the previous + staking must be included. Missing: ${missingPreviousFPs.join(", ")}`, + ); + } +} + +/** + * Validate the staking transaction input data. + * + * @param {number} stakingAmountSat - The staking amount in satoshis. + * @param {number} timelock - The staking time in blocks. + * @param {StakingParams} params - The staking parameters. + * @param {UTXO[]} inputUTXOs - The input UTXOs. + * @param {number} feeRate - The Bitcoin fee rate in sat/vbyte + * @throws {StakingError} - If the input data is invalid. + */ +export const validateStakingTxInputData = ( + stakingAmountSat: number, + timelock: number, + params: StakingParams, + inputUTXOs: UTXO[], + feeRate: number, +) => { + if ( + stakingAmountSat < params.minStakingAmountSat || + stakingAmountSat > params.maxStakingAmountSat + ) { + throw new StakingError( + StakingErrorCode.INVALID_INPUT, + "Invalid staking amount", + ); + } + + if ( + timelock < params.minStakingTimeBlocks || + timelock > params.maxStakingTimeBlocks + ) { + throw new StakingError(StakingErrorCode.INVALID_INPUT, "Invalid timelock"); + } + + if (inputUTXOs.length == 0) { + throw new StakingError( + StakingErrorCode.INVALID_INPUT, + "No input UTXOs provided", + ); + } + if (feeRate <= 0) { + throw new StakingError(StakingErrorCode.INVALID_INPUT, "Invalid fee rate"); + } +}; + +/** + * Validate the staking parameters. + * Extend this method to add additional validation for staking parameters based + * on the staking type. + * @param {StakingParams} params - The staking parameters. + * @throws {StakingError} - If the parameters are invalid. + */ +export const validateParams = (params: StakingParams) => { + // Check covenant public keys + if (params.covenantNoCoordPks.length == 0) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Could not find any covenant public keys", + ); + } + if (params.covenantNoCoordPks.length < params.covenantQuorum) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Covenant public keys must be greater than or equal to the quorum", + ); + } + params.covenantNoCoordPks.forEach((pk) => { + if (!isValidNoCoordPublicKey(pk)) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Covenant public key should contains no coordinate", + ); + } + }); + // Check other parameters + if (params.unbondingTime <= 0) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Unbonding time must be greater than 0", + ); + } + if (params.unbondingFeeSat <= 0) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Unbonding fee must be greater than 0", + ); + } + if (params.maxStakingAmountSat < params.minStakingAmountSat) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Max staking amount must be greater or equal to min staking amount", + ); + } + if ( + params.minStakingAmountSat < + params.unbondingFeeSat + MIN_UNBONDING_OUTPUT_VALUE + ) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + `Min staking amount must be greater than unbonding fee plus ${MIN_UNBONDING_OUTPUT_VALUE}`, + ); + } + if (params.maxStakingTimeBlocks < params.minStakingTimeBlocks) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Max staking time must be greater or equal to min staking time", + ); + } + if (params.minStakingTimeBlocks <= 0) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Min staking time must be greater than 0", + ); + } + if (params.covenantQuorum <= 0) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Covenant quorum must be greater than 0", + ); + } + if (params.slashing) { + if (params.slashing.slashingRate <= 0) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Slashing rate must be greater than 0", + ); + } + if (params.slashing.slashingRate > 1) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Slashing rate must be less or equal to 1", + ); + } + if (params.slashing.slashingPkScriptHex.length == 0) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Slashing public key script is missing", + ); + } + if (params.slashing.minSlashingTxFeeSat <= 0) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Minimum slashing transaction fee must be greater than 0", + ); + } + } +}; + +/** + * Validate the staking timelock. + * + * @param {number} stakingTimelock - The staking timelock. + * @param {StakingParams} params - The staking parameters. + * @throws {StakingError} - If the staking timelock is invalid. + */ +export const validateStakingTimelock = ( + stakingTimelock: number, + params: StakingParams, +) => { + if ( + stakingTimelock < params.minStakingTimeBlocks || + stakingTimelock > params.maxStakingTimeBlocks + ) { + throw new StakingError( + StakingErrorCode.INVALID_INPUT, + "Staking transaction timelock is out of range", + ); + } +}; + +/** + * Validate the staking expansion covenant quorum. + * + * The quorum is the number of covenant members that must be active in the + * previous staking transaction in order to expand the staking. + * + * If the quorum is not met, the staking expansion will fail. + * + * @param {StakingParams} paramsForPreviousStakingTx - The staking parameters + * for the previous staking transaction. + * @param {StakingParams} paramsForCurrentStakingTx - The staking parameters + * for the current staking transaction. + * @throws {StakingError} - If the staking expansion covenant quorum is invalid. + */ +export const validateStakingExpansionCovenantQuorum = ( + paramsForPreviousStakingTx: StakingParams, + paramsForCurrentStakingTx: StakingParams, +) => { + const previousCovenantMembers = paramsForPreviousStakingTx.covenantNoCoordPks; + const currentCovenantMembers = paramsForCurrentStakingTx.covenantNoCoordPks; + const requiredQuorum = paramsForPreviousStakingTx.covenantQuorum; + + // Count how many previous covenant members are still active + const activePreviousMembers = previousCovenantMembers.filter( + prevMember => currentCovenantMembers.includes(prevMember) + ).length; + + if (activePreviousMembers < requiredQuorum) { + throw new StakingError( + StakingErrorCode.INVALID_INPUT, + `Staking expansion failed: insufficient covenant quorum. ` + + `Required: ${requiredQuorum}, Available: ${activePreviousMembers}. ` + + `Too many covenant members have rotated out.` + ); + } +} \ No newline at end of file diff --git a/modules/utxo-staking/package.json b/modules/utxo-staking/package.json index 514c0437bc..cfa91bc577 100644 --- a/modules/utxo-staking/package.json +++ b/modules/utxo-staking/package.json @@ -43,8 +43,8 @@ }, "type": "commonjs", "dependencies": { - "@babylonlabs-io/babylon-proto-ts": "1.0.0", - "@bitgo/babylonlabs-io-btc-staking-ts": "^2.4.1", + "@babylonlabs-io/babylon-proto-ts": "1.7.2", + "@bitgo/babylonlabs-io-btc-staking-ts": "^3.0.0", "@bitgo/utxo-core": "^1.20.3", "@bitgo/utxo-lib": "^11.11.0", "@bitgo/wasm-miniscript": "2.0.0-beta.7", diff --git a/modules/utxo-staking/src/babylon/delegationMessage.ts b/modules/utxo-staking/src/babylon/delegationMessage.ts index fd3fc40e1c..89368d5388 100644 --- a/modules/utxo-staking/src/babylon/delegationMessage.ts +++ b/modules/utxo-staking/src/babylon/delegationMessage.ts @@ -174,6 +174,14 @@ export function getBtcProviderForECKey( throw new Error(`unexpected signing step: ${options.action.name}`); } }, + + /** + * This function is only used by btc-staking-ts to create a staking expansion registration + * transaction, which we do not currently support. + */ + async getTransactionHex(txid: string): Promise { + throw new Error(`Unsupported operation getTransactionHex (txid=${txid})`); + }, }; } type Result = { @@ -252,6 +260,22 @@ export function toStakingTransaction(tx: TransactionLike): bitcoinjslib.Transact return bitcoinjslib.Transaction.fromHex(tx.toHex()); } +/** + * As of babylonlabs-io/btc-staking-ts v1.5.7, the BTC delegation message creation functions support two message types: + * - MsgCreateBTCDelegation + * - MsgBtcStakeExpand + * + * BitGo still only supports MsgCreateBTCDelegation, so we need to check the message type here. + * + * @param msg - the message to check + * @return `true` if the message is of type MsgCreateBTCDelegation + */ +function isMsgBtcStakeExpand( + msg: babylonProtobuf.btcstakingtx.MsgCreateBTCDelegation | babylonProtobuf.btcstakingtx.MsgBtcStakeExpand +) { + return 'previousStakingTxHash' in msg; +} + /* * This is mostly lifted from * https://github.com/babylonlabs-io/btc-staking-ts/blob/v0.4.0-rc.2/src/staking/manager.ts#L100-L172 @@ -270,7 +294,7 @@ export async function createDelegationMessageWithTransaction( throw new Error('Invalid Babylon address'); } // Create delegation message without including inclusion proof - return manager.createBtcDelegationMsg( + const msg = await manager.createBtcDelegationMsg( channel, staking, { @@ -283,6 +307,14 @@ export async function createDelegationMessageWithTransaction( staking.stakerInfo, staking.params ); + + // It shouldn't be possible for us to create a MsgBtcStakeExpand here because that only gets created when + // we pass channel = delegation:expand into createBtcDelegationMsg, which we cannot do. + if (isMsgBtcStakeExpand(msg.value)) { + throw new Error('MsgBtcStakeExpand is not supported'); + } + + return { ...msg, value: msg.value as babylonProtobuf.btcstakingtx.MsgCreateBTCDelegation }; } export async function createUnsignedPreStakeRegistrationBabylonTransactionWithBtcProvider( diff --git a/scripts/vendor-github-repo.ts b/scripts/vendor-github-repo.ts index b82754de94..e31b1e23e6 100644 --- a/scripts/vendor-github-repo.ts +++ b/scripts/vendor-github-repo.ts @@ -1,4 +1,4 @@ -import { execa, ResultPromise } from 'execa'; +import execa, { ExecaChildProcess } from 'execa'; import fs from 'fs/promises'; import tmp from 'tmp'; import yargs from 'yargs'; @@ -91,7 +91,7 @@ async function fetchArchive(lib: GithubSource, outfile: string): Promise { await fs.writeFile(outfile, Buffer.from(await result.arrayBuffer())); } -function pipe(cmd: ResultPromise): ResultPromise { +function pipe(cmd: ExecaChildProcess): ExecaChildProcess { cmd.stdout?.pipe(process.stdout); cmd.stderr?.pipe(process.stderr); return cmd; @@ -191,6 +191,30 @@ const vendorConfigs: VendorConfig[] = [ end: '8d84d9b02af73d7c216d87aceca3dec0baabfecf', }, }, + { + org: 'babylonlabs-io', + repo: 'btc-staking-ts', + tag: 'v2.5.7', + targetDir: 'modules/babylonlabs-io-btc-staking-ts', + removeFiles: [ + '.eslintrc.json', + '.github/', + '.husky/', + '.npmrc', + '.nvmrc', + '.prettierignore', + '.prettierrc.json', + 'docs/', + 'tests/', + '.releaserc.json', + '.commitlint.config.cjs', + 'README.md', + ], + cherryPick: { + start: '161a937c4303d8273922ebfd04640d2391aca246', + end: '8d84d9b02af73d7c216d87aceca3dec0baabfecf', + }, + }, ]; function getMatches(name: string, version: string | undefined): VendorConfig[] { diff --git a/yarn.lock b/yarn.lock index 3248be8426..c77c6fc1a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -909,6 +909,16 @@ dependencies: "@bufbuild/protobuf" "^2.2.0" +"@babylonlabs-io/babylon-proto-ts@1.7.2": + version "1.7.2" + resolved "https://registry.npmjs.org/@babylonlabs-io/babylon-proto-ts/-/babylon-proto-ts-1.7.2.tgz#7acbd0b38c7512216a10dc754d9354187f23de51" + integrity sha512-10WVqrXA9nIE8Pipmg0Y+ywT3ODonNoM1h3Hmu2oxc8F7IQBokx76WyLvVoY1Y7NxVuLm0uV9iEKZbp+YWwf9Q== + dependencies: + "@bufbuild/protobuf" "^2.2.0" + "@cosmjs/proto-signing" "^0.33.1" + "@cosmjs/stargate" "^0.33.1" + "@cosmjs/tendermint-rpc" "^0.33.1" + "@bitcoin-js/tiny-secp256k1-asmjs@2.2.3": version "2.2.3" resolved "https://registry.npmjs.org/@bitcoin-js/tiny-secp256k1-asmjs/-/tiny-secp256k1-asmjs-2.2.3.tgz" @@ -1249,6 +1259,16 @@ "@cosmjs/math" "^0.29.5" "@cosmjs/utils" "^0.29.5" +"@cosmjs/amino@^0.33.1": + version "0.33.1" + resolved "https://registry.npmjs.org/@cosmjs/amino/-/amino-0.33.1.tgz#0d4957b2e755af8392627c0c0f72bee129dcdcf3" + integrity sha512-WfWiBf2EbIWpwKG9AOcsIIkR717SY+JdlXM/SL/bI66BdrhniAF+/ZNis9Vo9HF6lP2UU5XrSmFA4snAvEgdrg== + dependencies: + "@cosmjs/crypto" "^0.33.1" + "@cosmjs/encoding" "^0.33.1" + "@cosmjs/math" "^0.33.1" + "@cosmjs/utils" "^0.33.1" + "@cosmjs/crypto@^0.29.5": version "0.29.5" resolved "https://registry.npmjs.org/@cosmjs/crypto/-/crypto-0.29.5.tgz" @@ -1275,6 +1295,19 @@ elliptic "^6.5.4" libsodium-wrappers "^0.7.6" +"@cosmjs/crypto@^0.33.1": + version "0.33.1" + resolved "https://registry.npmjs.org/@cosmjs/crypto/-/crypto-0.33.1.tgz#761b1623e4abe8af4cbf7ca92639561314f04c3b" + integrity sha512-U4kGIj/SNBzlb2FGgA0sMR0MapVgJUg8N+oIAiN5+vl4GZ3aefmoL1RDyTrFS/7HrB+M+MtHsxC0tvEu4ic/zA== + dependencies: + "@cosmjs/encoding" "^0.33.1" + "@cosmjs/math" "^0.33.1" + "@cosmjs/utils" "^0.33.1" + "@noble/hashes" "^1" + bn.js "^5.2.0" + elliptic "^6.6.1" + libsodium-wrappers-sumo "^0.7.11" + "@cosmjs/encoding@^0.29.5": version "0.29.5" resolved "https://registry.npmjs.org/@cosmjs/encoding/-/encoding-0.29.5.tgz" @@ -1293,7 +1326,7 @@ bech32 "^1.1.4" readonly-date "^1.0.0" -"@cosmjs/encoding@^0.33.0": +"@cosmjs/encoding@^0.33.0", "@cosmjs/encoding@^0.33.1": version "0.33.1" resolved "https://registry.npmjs.org/@cosmjs/encoding/-/encoding-0.33.1.tgz" integrity sha512-nuNxf29fUcQE14+1p//VVQDwd1iau5lhaW/7uMz7V2AH3GJbFJoJVaKvVyZvdFk+Cnu+s3wCqgq4gJkhRCJfKw== @@ -1310,6 +1343,14 @@ "@cosmjs/stream" "^0.29.5" xstream "^11.14.0" +"@cosmjs/json-rpc@^0.33.1": + version "0.33.1" + resolved "https://registry.npmjs.org/@cosmjs/json-rpc/-/json-rpc-0.33.1.tgz#a5b8459605750fa7d38c05aa6009a92010c0d042" + integrity sha512-T6VtWzecpmuTuMRGZWuBYHsMF/aznWCYUt/cGMWNSz7DBPipVd0w774PKpxXzpEbyt5sr61NiuLXc+Az15S/Cw== + dependencies: + "@cosmjs/stream" "^0.33.1" + xstream "^11.14.0" + "@cosmjs/math@^0.29.5": version "0.29.5" resolved "https://registry.npmjs.org/@cosmjs/math/-/math-0.29.5.tgz" @@ -1324,6 +1365,13 @@ dependencies: bn.js "^5.2.0" +"@cosmjs/math@^0.33.1": + version "0.33.1" + resolved "https://registry.npmjs.org/@cosmjs/math/-/math-0.33.1.tgz#04ae4cfdb05f04f1b13e908f9551ca85b13ba4d4" + integrity sha512-ytGkWdKFCPiiBU5eqjHNd59djPpIsOjbr2CkNjlnI1Zmdj+HDkSoD9MUGpz9/RJvRir5IvsXqdE05x8EtoQkJA== + dependencies: + bn.js "^5.2.0" + "@cosmjs/proto-signing@^0.29.5": version "0.29.5" resolved "https://registry.npmjs.org/@cosmjs/proto-signing/-/proto-signing-0.29.5.tgz" @@ -1337,6 +1385,18 @@ cosmjs-types "^0.5.2" long "^4.0.0" +"@cosmjs/proto-signing@^0.33.1": + version "0.33.1" + resolved "https://registry.npmjs.org/@cosmjs/proto-signing/-/proto-signing-0.33.1.tgz#b084eb86410486cff30da7de34a636421db90ca8" + integrity sha512-Sv4W+MxX+0LVnd+2rU4Fw1HRsmMwSVSYULj7pRkij3wnPwUlTVoJjmKFgKz13ooIlfzPrz/dnNjGp/xnmXChFQ== + dependencies: + "@cosmjs/amino" "^0.33.1" + "@cosmjs/crypto" "^0.33.1" + "@cosmjs/encoding" "^0.33.1" + "@cosmjs/math" "^0.33.1" + "@cosmjs/utils" "^0.33.1" + cosmjs-types "^0.9.0" + "@cosmjs/socket@^0.29.5": version "0.29.5" resolved "https://registry.npmjs.org/@cosmjs/socket/-/socket-0.29.5.tgz" @@ -1347,6 +1407,16 @@ ws "^7" xstream "^11.14.0" +"@cosmjs/socket@^0.33.1": + version "0.33.1" + resolved "https://registry.npmjs.org/@cosmjs/socket/-/socket-0.33.1.tgz#2402487e7c70c8a5c801bd3189a58a09da786513" + integrity sha512-KzAeorten6Vn20sMiM6NNWfgc7jbyVo4Zmxev1FXa5EaoLCZy48cmT3hJxUJQvJP/lAy8wPGEjZ/u4rmF11x9A== + dependencies: + "@cosmjs/stream" "^0.33.1" + isomorphic-ws "^4.0.1" + ws "^7" + xstream "^11.14.0" + "@cosmjs/stargate@^0.29.5": version "0.29.5" resolved "https://registry.npmjs.org/@cosmjs/stargate/-/stargate-0.29.5.tgz" @@ -1365,6 +1435,20 @@ protobufjs "~6.11.3" xstream "^11.14.0" +"@cosmjs/stargate@^0.33.1": + version "0.33.1" + resolved "https://registry.npmjs.org/@cosmjs/stargate/-/stargate-0.33.1.tgz#13972f710942ac728474051be4f9754814ccfb52" + integrity sha512-CnJ1zpSiaZgkvhk+9aTp5IPmgWn2uo+cNEBN8VuD9sD6BA0V4DMjqe251cNFLiMhkGtiE5I/WXFERbLPww3k8g== + dependencies: + "@cosmjs/amino" "^0.33.1" + "@cosmjs/encoding" "^0.33.1" + "@cosmjs/math" "^0.33.1" + "@cosmjs/proto-signing" "^0.33.1" + "@cosmjs/stream" "^0.33.1" + "@cosmjs/tendermint-rpc" "^0.33.1" + "@cosmjs/utils" "^0.33.1" + cosmjs-types "^0.9.0" + "@cosmjs/stream@^0.29.5": version "0.29.5" resolved "https://registry.npmjs.org/@cosmjs/stream/-/stream-0.29.5.tgz" @@ -1372,6 +1456,13 @@ dependencies: xstream "^11.14.0" +"@cosmjs/stream@^0.33.1": + version "0.33.1" + resolved "https://registry.npmjs.org/@cosmjs/stream/-/stream-0.33.1.tgz#2e928eb68c52253e64ab56a3047cd8039b66abde" + integrity sha512-bMUvEENjeQPSTx+YRzVsWT1uFIdHRcf4brsc14SOoRQ/j5rOJM/aHfsf/BmdSAnYbdOQ3CMKj/8nGAQ7xUdn7w== + dependencies: + xstream "^11.14.0" + "@cosmjs/tendermint-rpc@^0.29.5": version "0.29.5" resolved "https://registry.npmjs.org/@cosmjs/tendermint-rpc/-/tendermint-rpc-0.29.5.tgz" @@ -1388,6 +1479,22 @@ readonly-date "^1.0.0" xstream "^11.14.0" +"@cosmjs/tendermint-rpc@^0.33.1": + version "0.33.1" + resolved "https://registry.npmjs.org/@cosmjs/tendermint-rpc/-/tendermint-rpc-0.33.1.tgz#5ab5b0b63e585badaa5827aef7e9e3d18695630a" + integrity sha512-22klDFq2MWnf//C8+rZ5/dYatr6jeGT+BmVbutXYfAK9fmODbtFcumyvB6uWaEORWfNukl8YK1OLuaWezoQvxA== + dependencies: + "@cosmjs/crypto" "^0.33.1" + "@cosmjs/encoding" "^0.33.1" + "@cosmjs/json-rpc" "^0.33.1" + "@cosmjs/math" "^0.33.1" + "@cosmjs/socket" "^0.33.1" + "@cosmjs/stream" "^0.33.1" + "@cosmjs/utils" "^0.33.1" + axios "^1.6.0" + readonly-date "^1.0.0" + xstream "^11.14.0" + "@cosmjs/utils@^0.29.5": version "0.29.5" resolved "https://registry.npmjs.org/@cosmjs/utils/-/utils-0.29.5.tgz" @@ -1398,6 +1505,11 @@ resolved "https://registry.npmjs.org/@cosmjs/utils/-/utils-0.30.1.tgz" integrity sha512-KvvX58MGMWh7xA+N+deCfunkA/ZNDvFLw4YbOmX3f/XBIkqrVY7qlotfy2aNb1kgp6h4B6Yc8YawJPDTfvWX7g== +"@cosmjs/utils@^0.33.1": + version "0.33.1" + resolved "https://registry.npmjs.org/@cosmjs/utils/-/utils-0.33.1.tgz#8882cd26172cb5b0b692c179407d6c3904493fed" + integrity sha512-UnLHDY6KMmC+UXf3Ufyh+onE19xzEXjT4VZ504Acmk4PXxqyvG4cCPprlKUFnGUX7f0z8Or9MAOHXBx41uHBcg== + "@csstools/postcss-cascade-layers@^1.1.1": version "1.1.1" resolved "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-1.1.1.tgz" @@ -7337,7 +7449,7 @@ aws4@^1.8.0: resolved "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz" integrity sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw== -axios@0.25.0, axios@0.27.2, axios@1.7.4, axios@^0.21.2, axios@^0.26.1, axios@^1.12.0, axios@^1.8.3: +axios@0.25.0, axios@0.27.2, axios@1.7.4, axios@^0.21.2, axios@^0.26.1, axios@^1.12.0, axios@^1.6.0, axios@^1.8.3: version "1.12.1" resolved "https://registry.npmjs.org/axios/-/axios-1.12.1.tgz#0747b39c5b615f81f93f2c138e6d82a71426937f" integrity sha512-Kn4kbSXpkFHCGE6rBFNwIv0GQs4AvDT80jlveJDKFxjbTYMUeB4QtsdPCv6H8Cm19Je7IU6VFtRl2zWZI0rudQ== @@ -9159,6 +9271,11 @@ cosmjs-types@^0.6.1: long "^4.0.0" protobufjs "~6.11.2" +cosmjs-types@^0.9.0: + version "0.9.0" + resolved "https://registry.npmjs.org/cosmjs-types/-/cosmjs-types-0.9.0.tgz#c3bc482d28c7dfa25d1445093fdb2d9da1f6cfcc" + integrity sha512-MN/yUe6mkJwHnCFfsNPeCfXVhyxHYW6c/xDUzrSbBycYzw++XvWDMJArXp2pLdgD6FQ8DW79vkPjeNKVrXaHeQ== + crc-32@^1.2.0: version "1.2.2" resolved "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz" @@ -14330,7 +14447,7 @@ libsodium-sumo@^0.7.15: resolved "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.7.15.tgz" integrity sha512-5tPmqPmq8T8Nikpm1Nqj0hBHvsLFCXvdhBFV7SGOitQPZAA6jso8XoL0r4L7vmfKXr486fiQInvErHtEvizFMw== -libsodium-wrappers-sumo@^0.7.9: +libsodium-wrappers-sumo@^0.7.11, libsodium-wrappers-sumo@^0.7.9: version "0.7.15" resolved "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.15.tgz" integrity sha512-aSWY8wKDZh5TC7rMvEdTHoyppVq/1dTSAeAR7H6pzd6QRT3vQWcT5pGwCotLcpPEOLXX6VvqihSPkpEhYAjANA==