Skip to content

Commit 7fa29d4

Browse files
Merge pull request #6027 from BitGo/BTC-2079.force-ecdsa
feat(utxo-staking): implement ECDSA support for Babylon BTC staking
2 parents dd6759f + c40cd02 commit 7fa29d4

File tree

4 files changed

+143
-17
lines changed

4 files changed

+143
-17
lines changed

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,10 @@ interface InclusionProof {
9393
}
9494

9595
export class BabylonBtcStakingManager {
96-
private stakingParams: VersionedStakingParams[];
97-
private btcProvider: BtcProvider;
98-
private network: networks.Network;
99-
private babylonProvider: BabylonProvider;
96+
protected stakingParams: VersionedStakingParams[];
97+
protected btcProvider: BtcProvider;
98+
protected network: networks.Network;
99+
protected babylonProvider: BabylonProvider;
100100

101101
constructor(
102102
network: networks.Network,
@@ -624,7 +624,8 @@ export class BabylonBtcStakingManager {
624624

625625
/**
626626
* Creates a proof of possession for the staker based on ECDSA signature.
627-
* @param bech32Address - The staker's bech32 address.
627+
* @param bech32Address - The staker's bech32 address on the babylon chain
628+
* @param stakerBtcAddress - The staker's BTC address.
628629
* @returns The proof of possession.
629630
*/
630631
async createProofOfPossession(

modules/utxo-staking/src/babylon/delegationMessage.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,38 @@ import { BabylonNetworkLike, toBitcoinJsNetwork } from './network';
1919

2020
export type ValueWithTypeUrl<T> = { typeUrl: string; value: T };
2121

22+
/**
23+
* Decode a hex or base64 encoded string and check if the length is valid.
24+
* @param v
25+
* @param encoding
26+
*/
27+
function decodeCheck(v: string, encoding: 'hex' | 'base64') {
28+
const result = Buffer.from(v, encoding);
29+
if (result.toString(encoding).length !== v.length) {
30+
throw new Error(`Invalid ${encoding} encoding`);
31+
}
32+
return result;
33+
}
34+
35+
/**
36+
* Convert a Buffer or string to a base64 encoded string.
37+
* @param v
38+
*/
39+
function toBase64(v: Buffer | string) {
40+
if (typeof v === 'string') {
41+
for (const encoding of ['base64', 'hex'] as const) {
42+
try {
43+
return toBase64(decodeCheck(v, encoding));
44+
} catch (e) {
45+
// try next
46+
}
47+
}
48+
throw new Error(`Invalid base64 or hex encoding: ${v}`);
49+
}
50+
51+
return v.toString('base64');
52+
}
53+
2254
export function getSignedPsbt(
2355
psbt: bitcoinjslib.Psbt,
2456
descriptor: Descriptor,
@@ -106,6 +138,12 @@ export function getBtcProviderForECKey(
106138
}
107139

108140
return {
141+
/**
142+
* @param signingStep
143+
* @param message
144+
* @param type
145+
* @returns Base64 encoded string
146+
*/
109147
async signMessage(
110148
signingStep: vendor.SigningStep,
111149
message: string,
@@ -114,9 +152,9 @@ export function getBtcProviderForECKey(
114152
assert(signingStep === 'proof-of-possession');
115153
switch (type) {
116154
case 'ecdsa':
117-
return stakerKey.sign(Buffer.from(message, 'hex')).toString('hex');
155+
return toBase64(stakerKey.sign(Buffer.from(message, 'hex')));
118156
case 'bip322-simple':
119-
return signBip322Simple(message);
157+
return toBase64(signBip322Simple(message));
120158
default:
121159
throw new Error(`unexpected signing step: ${signingStep}`);
122160
}

modules/utxo-staking/src/babylon/stakingManager.ts

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,81 @@
11
import * as bitcoinjslib from 'bitcoinjs-lib';
22
import * as utxolib from '@bitgo/utxo-lib';
33
import * as vendor from '@bitgo/babylonlabs-io-btc-staking-ts';
4+
import {
5+
BIP322Sig,
6+
BTCSigType,
7+
ProofOfPossessionBTC,
8+
} from '@babylonlabs-io/babylon-proto-ts/dist/generated/babylon/btcstaking/v1/pop';
49

510
import { getStakingParams } from './stakingParams';
611

12+
/**
13+
* Subclass of BabylonBtcStakingManager with the sole purpose of forcing
14+
* a ECDSA signature.
15+
*/
16+
class BitGoStakingManager extends vendor.BabylonBtcStakingManager {
17+
constructor(
18+
network: bitcoinjslib.Network,
19+
stakingParams: vendor.VersionedStakingParams[],
20+
btcProvider: vendor.BtcProvider,
21+
babylonProvider: vendor.BabylonProvider
22+
) {
23+
super(network, stakingParams, btcProvider, babylonProvider);
24+
}
25+
26+
/**
27+
* Creates a proof of possession for the staker based on ECDSA signature.
28+
*
29+
* This is a parameterized version of the superclass method which infers
30+
* the signature type from the stakerBtcAddress.
31+
*
32+
* @param bech32Address - The staker's bech32 address on the babylon network.
33+
* @param stakerBtcAddress - The staker's BTC address.
34+
* @param sigType - The signature type (BIP322 or ECDSA).
35+
* @returns The proof of possession.
36+
*/
37+
async createProofOfPossessionWithSigType(
38+
bech32Address: string,
39+
stakerBtcAddress: string,
40+
sigType: BTCSigType
41+
): Promise<ProofOfPossessionBTC> {
42+
const signedBabylonAddress = await this.btcProvider.signMessage(
43+
vendor.SigningStep.PROOF_OF_POSSESSION,
44+
bech32Address,
45+
sigType === BTCSigType.BIP322 ? 'bip322-simple' : 'ecdsa'
46+
);
47+
48+
let btcSig: Uint8Array;
49+
if (sigType === BTCSigType.BIP322) {
50+
const bip322Sig = BIP322Sig.fromPartial({
51+
address: stakerBtcAddress,
52+
sig: Buffer.from(signedBabylonAddress, 'base64'),
53+
});
54+
// Encode the BIP322 protobuf message to a Uint8Array
55+
btcSig = BIP322Sig.encode(bip322Sig).finish();
56+
} else {
57+
// Encode the ECDSA signature to a Uint8Array
58+
btcSig = Buffer.from(signedBabylonAddress, 'base64');
59+
}
60+
61+
return {
62+
btcSigType: sigType,
63+
btcSig,
64+
};
65+
}
66+
67+
/**
68+
* Creates a proof of possession for the staker based on ECDSA signature.
69+
* @param bech32Address - The staker's bech32 address on the babylon network.
70+
* @param stakerBtcAddress
71+
* @returns The proof of possession.
72+
*/
73+
async createProofOfPossession(bech32Address: string, stakerBtcAddress: string): Promise<ProofOfPossessionBTC> {
74+
// force the ECDSA signature type
75+
return this.createProofOfPossessionWithSigType(bech32Address, stakerBtcAddress, BTCSigType.ECDSA);
76+
}
77+
}
78+
779
export const mockBabylonProvider: vendor.BabylonProvider = {
880
signTransaction(): Promise<Uint8Array> {
981
throw new Error('Function not implemented.');
@@ -31,10 +103,5 @@ export function createStakingManager(
31103
throw new Error('Unsupported network');
32104
}
33105
}
34-
return new vendor.BabylonBtcStakingManager(
35-
network,
36-
stakingParams ?? getStakingParams(network),
37-
btcProvider,
38-
babylonProvider
39-
);
106+
return new BitGoStakingManager(network, stakingParams ?? getStakingParams(network), btcProvider, babylonProvider);
40107
}

modules/utxo-staking/test/unit/babylon/transactions.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,16 @@ function parseScripts(scripts: unknown) {
178178
return Object.fromEntries(Object.entries(scripts).map(([key, value]) => [key, parseScript(key, value)]));
179179
}
180180

181-
async function assertEqualsFixture(fixtureName: string, value: unknown): Promise<void> {
182-
value = normalize(value);
183-
assert.deepStrictEqual(await getFixture(fixtureName, value), value);
181+
type EqualsAssertion = typeof assert.deepStrictEqual;
182+
183+
async function assertEqualsFixture(
184+
fixtureName: string,
185+
value: unknown,
186+
n = normalize,
187+
eq: EqualsAssertion = assert.deepStrictEqual
188+
): Promise<void> {
189+
value = n(value);
190+
eq(await getFixture(fixtureName, value), value);
184191
}
185192

186193
async function assertScriptsEqualFixture(
@@ -379,7 +386,20 @@ function describeWithKeys(
379386
utxo,
380387
feeRateSatB,
381388
800_000
382-
)
389+
),
390+
normalize,
391+
(a, b) => {
392+
// The vendor library serializes the signature as BIP322, while
393+
// our implementation serializes it as ECDSA.
394+
// Strip the pop field from the MsgCreateBTCDelegation.
395+
function stripPop(v: unknown) {
396+
const vAny = v as any;
397+
delete vAny['unsignedDelegationMsg']['value']['pop'];
398+
}
399+
stripPop(a);
400+
stripPop(b);
401+
assert.deepStrictEqual(a, b);
402+
}
383403
);
384404
}
385405
});

0 commit comments

Comments
 (0)