Skip to content

Commit 2f5d6e1

Browse files
OttoAllmendingerllm-git
andcommitted
feat(utxo-staking): add unbonding transaction construction for staking
This PR adds support for converting staking undelegation responses to PSBTs. The implementation handles covenant signatures properly and verifies them against the unbonding spend path of a staking descriptor. Issue: BTC-2143 Co-authored-by: llm-git <[email protected]>
1 parent 1967e15 commit 2f5d6e1

File tree

9 files changed

+462
-13
lines changed

9 files changed

+462
-13
lines changed

modules/utxo-staking/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"@bitgo/utxo-core": "^1.12.0",
4848
"@bitgo/utxo-lib": "^11.6.2",
4949
"@bitgo/wasm-miniscript": "2.0.0-beta.7",
50+
"bip174": "npm:@bitgo-forks/[email protected]",
5051
"bip322-js": "^2.0.0",
5152
"bitcoinjs-lib": "^6.1.7",
5253
"fp-ts": "^2.16.2",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export * from './delegationMessage';
99
export * from './descriptor';
1010
export * from './stakingParams';
1111
export * from './stakingManager';
12+
export * from './undelegation';

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

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { getUnspendableKey } from './descriptor';
55

66
type ParsedStakingDescriptor = {
77
slashingMiniscriptNode: ast.MiniscriptNode;
8-
unbondingTimelockMiniscriptNode: ast.MiniscriptNode;
98
unbondingMiniscriptNode: ast.MiniscriptNode;
9+
timelockMiniscriptNode: ast.MiniscriptNode;
1010
};
1111

1212
/**
@@ -16,10 +16,7 @@ export function parseStakingDescriptor(descriptor: Descriptor | ast.DescriptorNo
1616
const pattern: Pattern = {
1717
tr: [
1818
getUnspendableKey(),
19-
[
20-
{ $var: 'slashingMiniscriptNode' },
21-
[{ $var: 'unbondingMiniscriptNode' }, { $var: 'unbondingTimelockMiniscriptNode' }],
22-
],
19+
[{ $var: 'slashingMiniscriptNode' }, [{ $var: 'unbondingMiniscriptNode' }, { $var: 'timelockMiniscriptNode' }]],
2320
],
2421
};
2522

@@ -33,7 +30,7 @@ export function parseStakingDescriptor(descriptor: Descriptor | ast.DescriptorNo
3330

3431
const slashingNode = result.slashingMiniscriptNode as ast.MiniscriptNode;
3532
const unbondingNode = result.unbondingMiniscriptNode as ast.MiniscriptNode;
36-
const unbondingTimelockNode = result.unbondingTimelockMiniscriptNode as ast.MiniscriptNode;
33+
const timelockNode = result.timelockMiniscriptNode as ast.MiniscriptNode;
3734

3835
// Verify slashing node shape: and_v([and_v([pk, pk/multi_a]), multi_a])
3936
const slashingPattern: Pattern = {
@@ -61,31 +58,31 @@ export function parseStakingDescriptor(descriptor: Descriptor | ast.DescriptorNo
6158
}
6259

6360
// Verify unbonding timelock node shape: and_v([pk, older])
64-
const unbondingTimelockPattern: Pattern = {
61+
const timelockPattern: Pattern = {
6562
and_v: [{ 'v:pk': { $var: 'stakerKey3' } }, { older: { $var: 'unbondingTimeLockValue' } }],
6663
};
6764

68-
const unbondingTimelockMatch = matcher.match(unbondingTimelockNode, unbondingTimelockPattern);
69-
if (!unbondingTimelockMatch) {
65+
const timelockMatch = matcher.match(timelockNode, timelockPattern);
66+
if (!timelockMatch) {
7067
throw new Error('Unbonding timelock node does not match expected pattern');
7168
}
7269

7370
// Verify all staker keys are the same
7471
if (
7572
slashingMatch.stakerKey1 !== unbondingMatch.stakerKey2 ||
76-
unbondingMatch.stakerKey2 !== unbondingTimelockMatch.stakerKey3
73+
unbondingMatch.stakerKey2 !== timelockMatch.stakerKey3
7774
) {
7875
throw new Error('Staker keys must be identical across all nodes');
7976
}
8077

8178
// Verify timelock value is a number
82-
if (typeof unbondingTimelockMatch.unbondingTimeLockValue !== 'number') {
79+
if (typeof timelockMatch.unbondingTimeLockValue !== 'number') {
8380
throw new Error('Unbonding timelock value must be a number');
8481
}
8582

8683
return {
8784
slashingMiniscriptNode: slashingNode,
8885
unbondingMiniscriptNode: unbondingNode,
89-
unbondingTimelockMiniscriptNode: unbondingTimelockNode,
86+
timelockMiniscriptNode: timelockNode,
9087
};
9188
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import * as t from 'io-ts';
2+
import { PartialSig } from 'bip174/src/lib/interfaces';
3+
4+
/** As returned by https://babylon.nodes.guru/api#/Query/BTCDelegation */
5+
export const Signature = t.type({ pk: t.string, sig: t.string }, 'Signature');
6+
7+
/** As returned by https://babylon.nodes.guru/api#/Query/BTCDelegation */
8+
export const UndelegationResponse = t.type(
9+
{
10+
/** Network-formatted transaction hex */
11+
unbonding_tx_hex: t.string,
12+
/** List of signatures for the unbonding covenant */
13+
covenant_unbonding_sig_list: t.array(Signature),
14+
},
15+
'UndelegationResponse'
16+
);
17+
18+
export type UndelegationResponse = t.TypeOf<typeof UndelegationResponse>;
19+
20+
/** Converts a gRPC signature to a PartialSig as used by bitcoinjs-lib and utxo-lib */
21+
export function toPartialSig(grpcSig: { pk: string; sig: string }): PartialSig {
22+
return {
23+
pubkey: Buffer.from(grpcSig.pk, 'hex'),
24+
signature: Buffer.from(grpcSig.sig, 'base64'),
25+
};
26+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './unbonding';
2+
export * from './UndelegationResponse';
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import assert from 'assert';
2+
3+
import * as utxolib from '@bitgo/utxo-lib';
4+
import { PartialSig, WitnessUtxo } from 'bip174/src/lib/interfaces';
5+
import { Descriptor, Miniscript, ast } from '@bitgo/wasm-miniscript';
6+
import { findTapLeafScript, toUtxoPsbt, toWrappedPsbt } from '@bitgo/utxo-core/descriptor';
7+
8+
import { parseStakingDescriptor } from '../parseDescriptor';
9+
10+
/**
11+
* Adds covenant signatures to a PSBT input.
12+
*/
13+
function addCovenantSignatures(psbt: utxolib.bitgo.UtxoPsbt, inputIndex: number, signatures: PartialSig[]): void {
14+
const input = psbt.data.inputs[inputIndex];
15+
assert(input.tapLeafScript, 'Input must have tapLeafScript');
16+
assert(input.tapLeafScript.length === 1, 'Input must have exactly one tapLeafScript');
17+
const [{ controlBlock, script }] = input.tapLeafScript;
18+
const leafHash = utxolib.taproot.getTapleafHash(utxolib.ecc, controlBlock, script);
19+
psbt.updateInput(inputIndex, { tapScriptSig: signatures.map((s) => ({ ...s, leafHash })) });
20+
}
21+
22+
/**
23+
* Asserts that the provided signatures are valid for the given PSBT input.
24+
*/
25+
export function assertValidSignatures(
26+
psbt: utxolib.bitgo.UtxoPsbt,
27+
inputIndex: number,
28+
signatures: PartialSig[]
29+
): void {
30+
signatures.forEach((s) => {
31+
assert(
32+
psbt.validateTaprootSignaturesOfInput(inputIndex, s.pubkey),
33+
`Signature validation failed for ${s.pubkey.toString('hex')}`
34+
);
35+
});
36+
}
37+
38+
function getUnbondingScript(stakingDescriptor: Descriptor): Miniscript {
39+
const parsedDescriptor = parseStakingDescriptor(stakingDescriptor);
40+
assert(parsedDescriptor, 'Invalid staking descriptor');
41+
return Miniscript.fromString(ast.formatNode(parsedDescriptor.unbondingMiniscriptNode), 'tap');
42+
}
43+
44+
/**
45+
* @return unsigned PSBT for an unbonding transaction
46+
*/
47+
export function toUnbondingPsbt(
48+
tx: utxolib.bitgo.UtxoTransaction<bigint>,
49+
witnessUtxo: WitnessUtxo,
50+
stakingDescriptor: Descriptor,
51+
network: utxolib.Network
52+
): utxolib.bitgo.UtxoPsbt {
53+
const unbondingScript = getUnbondingScript(stakingDescriptor);
54+
const psbt = new utxolib.Psbt({ network });
55+
psbt.setVersion(tx.version);
56+
psbt.setLocktime(tx.locktime);
57+
psbt.addOutputs(
58+
tx.outs.map((output) => ({
59+
script: output.script,
60+
value: BigInt(output.value),
61+
}))
62+
);
63+
assert(tx.ins.length === 1);
64+
const input = tx.ins[0];
65+
psbt.addInput({
66+
hash: input.hash,
67+
index: input.index,
68+
sequence: input.sequence,
69+
witnessUtxo,
70+
});
71+
const wrappedPsbt = toWrappedPsbt(psbt);
72+
wrappedPsbt.updateInputWithDescriptor(0, stakingDescriptor);
73+
const unwrapped = toUtxoPsbt(wrappedPsbt, network);
74+
assert(unwrapped.data.inputs.length === 1, 'Unbonding transaction must have exactly one input');
75+
const unwrappedInputData = unwrapped.data.inputs[0];
76+
assert(unwrappedInputData.tapLeafScript);
77+
const unwrappedUnbond = findTapLeafScript(unwrappedInputData.tapLeafScript, unbondingScript);
78+
unwrappedInputData.tapLeafScript = [unwrappedUnbond];
79+
return unwrapped;
80+
}
81+
82+
/**
83+
* @return PSBT for an unbonding transaction with signatures
84+
*/
85+
export function toUnbondingPsbtWithSignatures(
86+
tx: utxolib.bitgo.UtxoTransaction<bigint>,
87+
witnessUtxo: WitnessUtxo,
88+
stakingDescriptor: Descriptor,
89+
signatures: PartialSig[],
90+
network: utxolib.Network
91+
): utxolib.bitgo.UtxoPsbt {
92+
const psbt = toUnbondingPsbt(tx, witnessUtxo, stakingDescriptor, network);
93+
assert(psbt.data.inputs.length === 1, 'Unbonding transaction must have exactly one input');
94+
addCovenantSignatures(psbt, 0, signatures);
95+
assertValidSignatures(psbt, 0, signatures);
96+
return psbt;
97+
}

0 commit comments

Comments
 (0)