Skip to content

Commit 0bb18bf

Browse files
Merge pull request #5973 from BitGo/BTC-2032.backport-babylon
feat(utxo-staking): add BIP-322 support for Babylon staking
2 parents 3d1eea1 + 0950a20 commit 0bb18bf

File tree

16 files changed

+410
-91
lines changed

16 files changed

+410
-91
lines changed

modules/babylonlabs-io-btc-staking-ts/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@bitgo/babylonlabs-io-btc-staking-ts",
3-
"version": "0.5.0",
3+
"version": "1.0.0",
44
"description": "Library exposing methods for the creation and consumption of Bitcoin transactions pertaining to Babylon's Bitcoin Staking protocol.",
55
"module": "dist/index.js",
66
"main": "dist/index.cjs",

modules/babylonlabs-io-btc-staking-ts/src/staking/manager.ts

Lines changed: 74 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,53 @@ import { networks, Psbt, Transaction } from "bitcoinjs-lib";
22
import { StakingParams, VersionedStakingParams } from "../types/params";
33
import { TransactionResult, UTXO } from "../types";
44
import { StakerInfo, Staking } from ".";
5-
import { fromBech32 } from "@cosmjs/encoding";
65
import {
76
btccheckpoint,
87
btcstaking,
98
btcstakingtx,
109
} from "@babylonlabs-io/babylon-proto-ts";
1110
import {
11+
BIP322Sig,
1212
BTCSigType,
1313
ProofOfPossessionBTC,
1414
} from "@babylonlabs-io/babylon-proto-ts/dist/generated/babylon/btcstaking/v1/pop";
1515
import { BABYLON_REGISTRY_TYPE_URLS } from "../constants/registry";
1616
import { createCovenantWitness } from "./transactions";
1717
import { getBabylonParamByBtcHeight, getBabylonParamByVersion } from "../utils/staking/param";
18-
import { reverseBuffer, uint8ArrayToHex } from "../utils";
18+
import { reverseBuffer } from "../utils";
1919
import { deriveStakingOutputInfo } from "../utils/staking";
2020
import { findMatchingTxOutputIndex } from "../utils/staking";
2121
import { isValidBabylonAddress } from "../utils/babylon";
2222
import { StakingError } from "../error";
2323
import { StakingErrorCode } from "../error";
24+
import { isNativeSegwit, isTaproot } from "../utils/btc";
2425

2526
export interface BtcProvider {
2627
// Sign a PSBT
28+
// Expecting the PSBT to be encoded in hex format.
2729
signPsbt(signingStep: SigningStep, psbtHex: string): Promise<string>;
28-
// Sign a message using the ECDSA type
29-
// This is optional and only required if you would like to use the
30-
// `createProofOfPossession` function
31-
signMessage?: (signingStep: SigningStep, message: string, type: "ecdsa") => Promise<string>;
30+
31+
// Signs a message using either ECDSA or BIP-322, depending on the address type.
32+
// - Taproot and Native Segwit addresses will use BIP-322.
33+
// - Legacy addresses will use ECDSA.
34+
// Expecting the message to be encoded in base64 format.
35+
signMessage: (
36+
signingStep: SigningStep, message: string, type: "ecdsa" | "bip322-simple"
37+
) => Promise<string>;
3238
}
3339

3440
export interface BabylonProvider {
41+
/**
42+
* Signs a Babylon chain transaction using the provided signing step.
43+
* This is primarily used for signing MsgCreateBTCDelegation transactions
44+
* which register the BTC delegation on the Babylon Genesis chain.
45+
*
46+
* @param {SigningStep} signingStep - The current signing step context
47+
* @param {object} msg - The Cosmos SDK transaction message to sign
48+
* @param {string} msg.typeUrl - The Protobuf type URL identifying the message type
49+
* @param {T} msg.value - The transaction message data matching the typeUrl
50+
* @returns {Promise<Uint8Array>} The signed transaction bytes
51+
*/
3552
signTransaction: <T extends object>(
3653
signingStep: SigningStep,
3754
msg: {
@@ -106,7 +123,9 @@ export class BabylonBtcStakingManager {
106123
* @param babylonBtcTipHeight - The Babylon BTC tip height.
107124
* @param inputUTXOs - The UTXOs that will be used to pay for the staking
108125
* transaction.
109-
* @param feeRate - The fee rate in satoshis per byte.
126+
* @param feeRate - The fee rate in satoshis per byte. Typical value for the
127+
* fee rate is above 1. If the fee rate is too low, the transaction will not
128+
* be included in a block.
110129
* @param babylonAddress - The Babylon bech32 encoded address of the staker.
111130
* @returns The signed babylon pre-staking registration transaction in base64
112131
* format.
@@ -182,7 +201,8 @@ export class BabylonBtcStakingManager {
182201
* @param stakingTxHeight - The BTC height in which the staking transaction
183202
* is included.
184203
* @param stakingInput - The staking inputs.
185-
* @param inclusionProof - The inclusion proof of the staking transaction.
204+
* @param inclusionProof - Merkle Proof of Inclusion: Verifies transaction
205+
* inclusion in a Bitcoin block that is k-deep.
186206
* @param babylonAddress - The Babylon bech32 encoded address of the staker.
187207
* @returns The signed babylon transaction in base64 format.
188208
*/
@@ -249,7 +269,9 @@ export class BabylonBtcStakingManager {
249269
* @param stakingInput - The staking inputs.
250270
* @param inputUTXOs - The UTXOs that will be used to pay for the staking
251271
* transaction.
252-
* @param feeRate - The fee rate in satoshis per byte.
272+
* @param feeRate - The fee rate in satoshis per byte. Typical value for the
273+
* fee rate is above 1. If the fee rate is too low, the transaction will not
274+
* be included in a block.
253275
* @returns The estimated BTC fee in satoshis.
254276
*/
255277
estimateBtcStakingFee(
@@ -462,7 +484,9 @@ export class BabylonBtcStakingManager {
462484
* @param stakingParamsVersion - The params version that was used to create the
463485
* delegation in Babylon chain
464486
* @param earlyUnbondingTx - The early unbonding transaction.
465-
* @param feeRate - The fee rate in satoshis per byte.
487+
* @param feeRate - The fee rate in satoshis per byte. Typical value for the
488+
* fee rate is above 1. If the fee rate is too low, the transaction will not
489+
* be included in a block.
466490
* @returns The signed withdrawal transaction and its fee.
467491
*/
468492
async createSignedBtcWithdrawEarlyUnbondedTransaction(
@@ -509,7 +533,9 @@ export class BabylonBtcStakingManager {
509533
* @param stakingParamsVersion - The params version that was used to create the
510534
* delegation in Babylon chain
511535
* @param stakingTx - The staking transaction.
512-
* @param feeRate - The fee rate in satoshis per byte.
536+
* @param feeRate - The fee rate in satoshis per byte. Typical value for the
537+
* fee rate is above 1. If the fee rate is too low, the transaction will not
538+
* be included in a block.
513539
* @returns The signed withdrawal transaction and its fee.
514540
*/
515541
async createSignedBtcWithdrawStakingExpiredTransaction(
@@ -556,7 +582,9 @@ export class BabylonBtcStakingManager {
556582
* @param stakingParamsVersion - The params version that was used to create the
557583
* delegation in Babylon chain
558584
* @param slashingTx - The slashing transaction.
559-
* @param feeRate - The fee rate in satoshis per byte.
585+
* @param feeRate - The fee rate in satoshis per byte. Typical value for the
586+
* fee rate is above 1. If the fee rate is too low, the transaction will not
587+
* be included in a block.
560588
* @returns The signed withdrawal transaction and its fee.
561589
*/
562590
async createSignedBtcWithdrawSlashingTransaction(
@@ -601,22 +629,42 @@ export class BabylonBtcStakingManager {
601629
*/
602630
async createProofOfPossession(
603631
bech32Address: string,
632+
stakerBtcAddress: string,
604633
): Promise<ProofOfPossessionBTC> {
605-
if (!this.btcProvider.signMessage) {
606-
throw new Error("Sign message function not found");
634+
let sigType: BTCSigType = BTCSigType.ECDSA;
635+
636+
// For Taproot or Native SegWit addresses, use the BIP322 signature scheme
637+
// in the proof of possession as it uses the same signature type as the regular
638+
// input UTXO spend. For legacy addresses, use the ECDSA signature scheme.
639+
if (
640+
isTaproot(stakerBtcAddress, this.network)
641+
|| isNativeSegwit(stakerBtcAddress, this.network)
642+
) {
643+
sigType = BTCSigType.BIP322;
607644
}
608-
// Create Proof of Possession
609-
const bech32AddressHex = uint8ArrayToHex(fromBech32(bech32Address).data);
610-
645+
611646
const signedBabylonAddress = await this.btcProvider.signMessage(
612647
SigningStep.PROOF_OF_POSSESSION,
613-
bech32AddressHex,
614-
"ecdsa",
648+
bech32Address,
649+
sigType === BTCSigType.BIP322 ? "bip322-simple" : "ecdsa",
615650
);
616-
const ecdsaSig = Uint8Array.from(Buffer.from(signedBabylonAddress, "base64"));
651+
652+
let btcSig: Uint8Array;
653+
if (sigType === BTCSigType.BIP322) {
654+
const bip322Sig = BIP322Sig.fromPartial({
655+
address: stakerBtcAddress,
656+
sig: Buffer.from(signedBabylonAddress, "base64"),
657+
});
658+
// Encode the BIP322 protobuf message to a Uint8Array
659+
btcSig = BIP322Sig.encode(bip322Sig).finish();
660+
} else {
661+
// Encode the ECDSA signature to a Uint8Array
662+
btcSig = Buffer.from(signedBabylonAddress, "base64");
663+
}
664+
617665
return {
618-
btcSigType: BTCSigType.ECDSA,
619-
btcSig: ecdsaSig,
666+
btcSigType: sigType,
667+
btcSig
620668
};
621669
}
622670

@@ -710,7 +758,10 @@ export class BabylonBtcStakingManager {
710758
}
711759

712760
// Create proof of possession
713-
const proofOfPossession = await this.createProofOfPossession(bech32Address);
761+
const proofOfPossession = await this.createProofOfPossession(
762+
bech32Address,
763+
stakerBtcInfo.address,
764+
);
714765

715766
// Prepare the final protobuf message
716767
const msg: btcstakingtx.MsgCreateBTCDelegation =

modules/babylonlabs-io-btc-staking-ts/src/staking/stakingScript.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,19 +104,19 @@ export class StakingScriptData {
104104
// check that the threshold is above 0 and less than or equal to
105105
// the size of the covenant emulators set
106106
if (
107-
this.covenantThreshold == 0 ||
107+
this.covenantThreshold <= 0 ||
108108
this.covenantThreshold > this.covenantKeys.length
109109
) {
110110
return false;
111111
}
112112

113113
// check that maximum value for staking time is not greater than uint16 and above 0
114-
if (this.stakingTimeLock == 0 || this.stakingTimeLock > 65535) {
114+
if (this.stakingTimeLock <= 0 || this.stakingTimeLock > 65535) {
115115
return false;
116116
}
117117

118118
// check that maximum value for unbonding time is not greater than uint16 and above 0
119-
if (this.unbondingTimeLock == 0 || this.unbondingTimeLock > 65535) {
119+
if (this.unbondingTimeLock <= 0 || this.unbondingTimeLock > 65535) {
120120
return false;
121121
}
122122

modules/babylonlabs-io-btc-staking-ts/src/staking/transactions.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Psbt, Transaction, networks, payments, script, address } from "bitcoinjs-lib";
1+
import { Psbt, Transaction, networks, payments, script, address, opcodes } from "bitcoinjs-lib";
22
import { Taptree } from "bitcoinjs-lib/src/types";
33

44
import { BTC_DUST_SAT } from "../constants/dustSat";
@@ -15,6 +15,7 @@ import { REDEEM_VERSION } from "../constants/transaction";
1515

1616
// https://bips.xyz/370
1717
const BTC_LOCKTIME_HEIGHT_TIME_CUTOFF = 500000000;
18+
const BTC_SLASHING_FRACTION_DIGITS = 4;
1819

1920
/**
2021
* Constructs an unsigned BTC Staking transaction in psbt format.
@@ -537,7 +538,7 @@ function slashingTransaction(
537538
throw new Error("Slashing rate must be between 0 and 1");
538539
}
539540
// Round the slashing rate to two decimal places
540-
slashingRate = parseFloat(slashingRate.toFixed(2));
541+
slashingRate = parseFloat(slashingRate.toFixed(BTC_SLASHING_FRACTION_DIGITS));
541542
// Minimum fee must be a postive integer
542543
if (minimumFee <= 0 || !Number.isInteger(minimumFee)) {
543544
throw new Error("Minimum fee must be a positve integer");
@@ -574,9 +575,17 @@ function slashingTransaction(
574575
const stakingAmount = transaction.outs[outputIndex].value;
575576
// Slashing rate is a percentage of the staking amount, rounded down to
576577
// the nearest integer to avoid sending decimal satoshis
577-
const slashingAmount = Math.floor(stakingAmount * slashingRate);
578-
if (slashingAmount <= BTC_DUST_SAT) {
579-
throw new Error("Slashing amount is less than dust limit");
578+
const slashingAmount = Math.round(stakingAmount * slashingRate);
579+
580+
// Compute the slashing output
581+
const slashingOutput = Buffer.from(slashingPkScriptHex, "hex");
582+
583+
// If OP_RETURN is not included, the slashing amount must be greater than the
584+
// dust limit.
585+
if (opcodes.OP_RETURN != slashingOutput[0]) {
586+
if (slashingAmount <= BTC_DUST_SAT) {
587+
throw new Error("Slashing amount is less than dust limit");
588+
}
580589
}
581590

582591
const userFunds = stakingAmount - slashingAmount - minimumFee;
@@ -602,7 +611,7 @@ function slashingTransaction(
602611

603612
// Add the slashing output
604613
psbt.addOutput({
605-
script: Buffer.from(slashingPkScriptHex, "hex"),
614+
script: slashingOutput,
606615
value: slashingAmount,
607616
});
608617

modules/babylonlabs-io-btc-staking-ts/src/utils/btc.ts

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,60 @@ export const isValidBitcoinAddress = (
3232
* @param {object} network - The Bitcoin network (e.g., bitcoin.networks.bitcoin).
3333
* @returns {boolean} - True if the address is a Taproot address, otherwise false.
3434
*/
35-
export const isTaproot = (taprootAddress: string, network: networks.Network): boolean => {
35+
export const isTaproot = (
36+
taprootAddress: string,
37+
network: networks.Network,
38+
): boolean => {
3639
try {
3740
const decoded = address.fromBech32(taprootAddress);
3841
if (decoded.version !== 1) {
3942
return false;
4043
}
41-
switch (network) {
42-
case networks.bitcoin:
43-
// Check if address statrts with "bc1p"
44-
return taprootAddress.startsWith("bc1p");
45-
case networks.testnet:
46-
// signet, regtest and testnet taproot addresses start with "tb1p" or "sb1p"
47-
return taprootAddress.startsWith("tb1p") || taprootAddress.startsWith("sb1p");
48-
default:
49-
return false;
50-
}
44+
45+
// Compare network properties instead of object reference
46+
// The bech32 is hardcoded in the bitcoinjs-lib library.
47+
// https://github.com/bitcoinjs/bitcoinjs-lib/blob/master/ts_src/networks.ts#L36
48+
if (network.bech32 === networks.bitcoin.bech32) {
49+
// Check if address starts with "bc1p"
50+
return taprootAddress.startsWith("bc1p");
51+
} else if (network.bech32 === networks.testnet.bech32) {
52+
// signet, regtest and testnet taproot addresses start with "tb1p" or "sb1p"
53+
return taprootAddress.startsWith("tb1p") || taprootAddress.startsWith("sb1p");
54+
}
55+
return false;
56+
} catch (error) {
57+
return false;
58+
}
59+
};
60+
61+
/**
62+
* Check whether the given address is a Native SegWit address.
63+
*
64+
* @param {string} segwitAddress - The Bitcoin bech32 encoded address to check.
65+
* @param {object} network - The Bitcoin network (e.g., bitcoin.networks.bitcoin).
66+
* @returns {boolean} - True if the address is a Native SegWit address, otherwise false.
67+
*/
68+
export const isNativeSegwit = (
69+
segwitAddress: string,
70+
network: networks.Network,
71+
): boolean => {
72+
try {
73+
const decoded = address.fromBech32(segwitAddress);
74+
if (decoded.version !== 0) {
75+
return false;
76+
}
77+
78+
// Compare network properties instead of object reference
79+
// The bech32 is hardcoded in the bitcoinjs-lib library.
80+
// https://github.com/bitcoinjs/bitcoinjs-lib/blob/master/ts_src/networks.ts#L36
81+
if (network.bech32 === networks.bitcoin.bech32) {
82+
// Check if address starts with "bc1q"
83+
return segwitAddress.startsWith("bc1q");
84+
} else if (network.bech32 === networks.testnet.bech32) {
85+
// testnet native segwit addresses start with "tb1q"
86+
return segwitAddress.startsWith("tb1q");
87+
}
88+
return false;
5189
} catch (error) {
5290
return false;
5391
}

modules/utxo-staking/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,12 @@
4343
},
4444
"dependencies": {
4545
"@babylonlabs-io/babylon-proto-ts": "1.0.0",
46-
"@bitgo/babylonlabs-io-btc-staking-ts": "^0.5.0",
46+
"@bitgo/babylonlabs-io-btc-staking-ts": "^1.0.0",
4747
"@bitgo/unspents": "^0.47.21",
4848
"@bitgo/utxo-core": "^1.8.0",
4949
"@bitgo/utxo-lib": "^11.3.0",
5050
"@bitgo/wasm-miniscript": "2.0.0-beta.7",
51+
"bip322-js": "^2.0.0",
5152
"bitcoinjs-lib": "^6.1.7",
5253
"fp-ts": "^2.16.2",
5354
"io-ts": "npm:@bitgo-forks/[email protected]",

modules/utxo-staking/scripts/babylon-params.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { hideBin } from 'yargs/helpers';
55

66
function getBaseUrl(network: 'mainnet' | 'testnet') {
77
if (network === 'mainnet') {
8-
throw new Error('Mainnet not supported');
8+
return 'https://babylon.nodes.guru/api';
99
}
1010
return 'https://babylon-testnet-api.nodes.guru';
1111
}
@@ -16,7 +16,7 @@ async function getParams(network: BabylonNetwork, version: number): Promise<unkn
1616
const url = `${getBaseUrl(network)}/babylon/btcstaking/v1/params/${version}`;
1717
const resp = await fetch(url);
1818
if (!resp.ok) {
19-
throw new Error(`Failed to fetch ${url}: ${resp.statusText}`);
19+
throw new Error(`Failed to fetch ${url}: ${resp.status} ${resp.statusText}`);
2020
}
2121
return await resp.json();
2222
}

0 commit comments

Comments
 (0)