Skip to content

Commit ecdbc5b

Browse files
Merge pull request #6519 from BitGo/BTC-2319.unbondingWithdraw
feat(utxo-staking): implement unbonding functionality and descriptor parsing
2 parents 815d911 + b302afd commit ecdbc5b

File tree

4 files changed

+301
-84
lines changed

4 files changed

+301
-84
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ function pk(b: Buffer): ast.MiniscriptNode {
1717
return { 'v:pk': b.toString('hex') };
1818
}
1919

20-
function sortedKeys(keys: Buffer[]): Buffer[] {
20+
export function sortedKeys(keys: Buffer[]): Buffer[] {
2121
return [...keys].sort((a, b) => a.compare(b));
2222
}
2323

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

Lines changed: 167 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,103 @@ import { PatternMatcher, Pattern } from '@bitgo/utxo-core/descriptor';
33

44
import { getUnspendableKey } from './descriptor';
55

6-
type ParsedStakingDescriptor = {
6+
export type ParsedStakingDescriptor = {
7+
stakerKey: Buffer;
8+
finalityProviderKeys: Buffer[];
9+
covenantKeys: Buffer[];
10+
covenantThreshold: number;
11+
stakingTimeLock: number;
712
slashingMiniscriptNode: ast.MiniscriptNode;
813
unbondingMiniscriptNode: ast.MiniscriptNode;
914
timelockMiniscriptNode: ast.MiniscriptNode;
1015
};
1116

17+
function parseMulti(multi: unknown): [number, string[]] {
18+
if (!Array.isArray(multi) || multi.length < 1) {
19+
throw new Error('Invalid multi structure: not an array or empty');
20+
}
21+
const [threshold, ...keys] = multi;
22+
if (typeof threshold !== 'number') {
23+
throw new Error('Invalid multi structure: threshold is not a number');
24+
}
25+
if (!keys.every((k) => typeof k === 'string')) {
26+
throw new Error('Invalid multi structure: not all keys are strings');
27+
}
28+
return [threshold, keys];
29+
}
30+
31+
function parseUnilateralTimelock(
32+
node: ast.MiniscriptNode,
33+
matcher: PatternMatcher
34+
): { key: string; timelock: number } | null {
35+
const pattern: Pattern = {
36+
and_v: [{ 'v:pk': { $var: 'key' } }, { older: { $var: 'timelock' } }],
37+
};
38+
const match = matcher.match(node, pattern);
39+
if (!match) {
40+
return null;
41+
}
42+
if (typeof match.key !== 'string') {
43+
throw new Error('key must be a string');
44+
}
45+
if (typeof match.timelock !== 'number') {
46+
throw new Error('timelock must be a number');
47+
}
48+
return { key: match.key, timelock: match.timelock };
49+
}
50+
51+
function parseSlashingNode(
52+
slashingNode: ast.MiniscriptNode,
53+
matcher: PatternMatcher
54+
): {
55+
stakerKey: string;
56+
finalityProviderKeys: Buffer[];
57+
covenantKeys: Buffer[];
58+
covenantThreshold: number;
59+
} {
60+
const slashingPattern: Pattern = {
61+
and_v: [
62+
{
63+
and_v: [{ 'v:pk': { $var: 'stakerKey' } }, { $var: 'finalityProviderKeyOrMulti' }],
64+
},
65+
{ multi_a: { $var: 'covenantMulti' } },
66+
],
67+
};
68+
69+
const slashingMatch = matcher.match(slashingNode, slashingPattern);
70+
if (!slashingMatch) {
71+
throw new Error('Slashing node does not match expected pattern');
72+
}
73+
74+
if (typeof slashingMatch.stakerKey !== 'string') {
75+
throw new Error('stakerKey must be a string');
76+
}
77+
78+
const [covenantThreshold, covenantKeyStrings] = parseMulti(slashingMatch.covenantMulti);
79+
const covenantKeys = covenantKeyStrings.map((k) => Buffer.from(k, 'hex'));
80+
81+
let finalityProviderKeys: Buffer[];
82+
const fpKeyOrMulti = slashingMatch.finalityProviderKeyOrMulti as ast.MiniscriptNode;
83+
if ('v:pk' in fpKeyOrMulti) {
84+
finalityProviderKeys = [Buffer.from(fpKeyOrMulti['v:pk'], 'hex')];
85+
} else if ('v:multi_a' in fpKeyOrMulti) {
86+
const [threshold, keyStrings] = parseMulti(fpKeyOrMulti['v:multi_a']);
87+
if (threshold !== 1) {
88+
throw new Error('Finality provider multi threshold must be 1');
89+
}
90+
finalityProviderKeys = keyStrings.map((k) => Buffer.from(k, 'hex'));
91+
} else {
92+
throw new Error('Invalid finality provider key structure');
93+
}
94+
95+
return {
96+
stakerKey: slashingMatch.stakerKey,
97+
finalityProviderKeys,
98+
covenantKeys,
99+
covenantThreshold,
100+
};
101+
}
102+
12103
/**
13104
* @return parsed staking descriptor components or null if the descriptor does not match the expected staking pattern.
14105
*/
@@ -33,19 +124,12 @@ export function parseStakingDescriptor(descriptor: Descriptor | ast.DescriptorNo
33124
const timelockNode = result.timelockMiniscriptNode as ast.MiniscriptNode;
34125

35126
// Verify slashing node shape: and_v([and_v([pk, pk/multi_a]), multi_a])
36-
const slashingPattern: Pattern = {
37-
and_v: [
38-
{
39-
and_v: [{ 'v:pk': { $var: 'stakerKey1' } }, { $var: 'finalityProviderKeyOrMulti' }],
40-
},
41-
{ multi_a: { $var: 'covenantMulti' } },
42-
],
43-
};
44-
45-
const slashingMatch = matcher.match(slashingNode, slashingPattern);
46-
if (!slashingMatch) {
47-
throw new Error('Slashing node does not match expected pattern');
48-
}
127+
const {
128+
stakerKey: stakerKey1,
129+
finalityProviderKeys,
130+
covenantKeys,
131+
covenantThreshold,
132+
} = parseSlashingNode(slashingNode, matcher);
49133

50134
// Verify unbonding node shape: and_v([pk, multi_a])
51135
const unbondingPattern: Pattern = {
@@ -58,31 +142,85 @@ export function parseStakingDescriptor(descriptor: Descriptor | ast.DescriptorNo
58142
}
59143

60144
// Verify unbonding timelock node shape: and_v([pk, older])
61-
const timelockPattern: Pattern = {
62-
and_v: [{ 'v:pk': { $var: 'stakerKey3' } }, { older: { $var: 'unbondingTimeLockValue' } }],
63-
};
64-
65-
const timelockMatch = matcher.match(timelockNode, timelockPattern);
66-
if (!timelockMatch) {
67-
throw new Error('Unbonding timelock node does not match expected pattern');
145+
const unilateralTimelock = parseUnilateralTimelock(timelockNode, matcher);
146+
if (!unilateralTimelock) {
147+
return null;
68148
}
69149

150+
const { key: stakerKey3, timelock: stakingTimeLock } = unilateralTimelock;
151+
70152
// Verify all staker keys are the same
71-
if (
72-
slashingMatch.stakerKey1 !== unbondingMatch.stakerKey2 ||
73-
unbondingMatch.stakerKey2 !== timelockMatch.stakerKey3
74-
) {
153+
if (stakerKey1 !== unbondingMatch.stakerKey2 || unbondingMatch.stakerKey2 !== stakerKey3) {
75154
throw new Error('Staker keys must be identical across all nodes');
76155
}
77156

78-
// Verify timelock value is a number
79-
if (typeof timelockMatch.unbondingTimeLockValue !== 'number') {
80-
throw new Error('Unbonding timelock value must be a number');
81-
}
157+
const stakerKey = Buffer.from(stakerKey1, 'hex');
82158

83159
return {
160+
stakerKey,
161+
finalityProviderKeys,
162+
covenantKeys,
163+
covenantThreshold,
164+
stakingTimeLock,
84165
slashingMiniscriptNode: slashingNode,
85166
unbondingMiniscriptNode: unbondingNode,
86167
timelockMiniscriptNode: timelockNode,
87168
};
88169
}
170+
171+
export type ParsedUnbondingDescriptor = {
172+
stakerKey: Buffer;
173+
finalityProviderKeys: Buffer[];
174+
covenantKeys: Buffer[];
175+
covenantThreshold: number;
176+
unbondingTimeLock: number;
177+
slashingMiniscriptNode: ast.MiniscriptNode;
178+
unbondingTimelockMiniscriptNode: ast.MiniscriptNode;
179+
};
180+
181+
export function parseUnbondingDescriptor(
182+
descriptor: Descriptor | ast.DescriptorNode
183+
): ParsedUnbondingDescriptor | null {
184+
const pattern: Pattern = {
185+
tr: [getUnspendableKey(), [{ $var: 'slashingMiniscriptNode' }, { $var: 'unbondingTimelockMiniscriptNode' }]],
186+
};
187+
188+
const matcher = new PatternMatcher();
189+
const descriptorNode = descriptor instanceof Descriptor ? ast.fromDescriptor(descriptor) : descriptor;
190+
const result = matcher.match(descriptorNode, pattern);
191+
192+
if (!result) {
193+
return null;
194+
}
195+
196+
const slashingNode = result.slashingMiniscriptNode as ast.MiniscriptNode;
197+
const unbondingTimelockNode = result.unbondingTimelockMiniscriptNode as ast.MiniscriptNode;
198+
199+
const {
200+
stakerKey: stakerKey1,
201+
finalityProviderKeys,
202+
covenantKeys,
203+
covenantThreshold,
204+
} = parseSlashingNode(slashingNode, matcher);
205+
206+
const unilateralTimelock = parseUnilateralTimelock(unbondingTimelockNode, matcher);
207+
if (!unilateralTimelock) {
208+
return null;
209+
}
210+
211+
const { key: stakerKey2, timelock: unbondingTimeLock } = unilateralTimelock;
212+
213+
if (stakerKey1 !== stakerKey2) {
214+
throw new Error('Staker keys must be identical across all nodes');
215+
}
216+
217+
return {
218+
stakerKey: Buffer.from(stakerKey1, 'hex'),
219+
finalityProviderKeys,
220+
covenantKeys,
221+
covenantThreshold,
222+
unbondingTimeLock,
223+
slashingMiniscriptNode: slashingNode,
224+
unbondingTimelockMiniscriptNode: unbondingTimelockNode,
225+
};
226+
}

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ import {
2323
getStakingParams,
2424
toStakerInfo,
2525
forceFinalizePsbt,
26+
sortedKeys,
2627
} from '../../../src/babylon';
27-
import { parseStakingDescriptor } from '../../../src/babylon/parseDescriptor';
28+
import { parseStakingDescriptor, parseUnbondingDescriptor } from '../../../src/babylon/parseDescriptor';
2829
import {
2930
normalize,
3031
assertEqualsFixture,
@@ -310,6 +311,32 @@ function describeWithKeys(
310311
assert.deepStrictEqual(parsed.slashingMiniscriptNode, descriptorBuilder.getSlashingMiniscriptNode());
311312
assert.deepStrictEqual(parsed.unbondingMiniscriptNode, descriptorBuilder.getUnbondingMiniscriptNode());
312313
assert.deepStrictEqual(parsed.timelockMiniscriptNode, descriptorBuilder.getStakingTimelockMiniscriptNode());
314+
315+
assert.strictEqual(parseStakingDescriptor(descriptorBuilder.getUnbondingDescriptor()), null);
316+
assert.strictEqual(parseStakingDescriptor(descriptorBuilder.getUnbondingTimelockDescriptor()), null);
317+
});
318+
319+
it('round-trip parseUnbondingDescriptor', function () {
320+
const descriptor = descriptorBuilder.getUnbondingDescriptor();
321+
const parsed = parseUnbondingDescriptor(descriptor);
322+
323+
assert(parsed);
324+
assert.deepStrictEqual(parsed.slashingMiniscriptNode, descriptorBuilder.getSlashingMiniscriptNode());
325+
assert.deepStrictEqual(
326+
parsed.unbondingTimelockMiniscriptNode,
327+
descriptorBuilder.getUnbondingTimelockMiniscriptNode()
328+
);
329+
assert.deepStrictEqual(parsed.stakerKey, descriptorBuilder.stakerKey);
330+
assert.deepStrictEqual(
331+
sortedKeys(parsed.finalityProviderKeys),
332+
sortedKeys(descriptorBuilder.finalityProviderKeys)
333+
);
334+
assert.deepStrictEqual(sortedKeys(parsed.covenantKeys), sortedKeys(descriptorBuilder.covenantKeys));
335+
assert.strictEqual(parsed.covenantThreshold, descriptorBuilder.covenantThreshold);
336+
assert.strictEqual(parsed.unbondingTimeLock, descriptorBuilder.unbondingTimeLock);
337+
338+
assert.strictEqual(parseUnbondingDescriptor(descriptorBuilder.getStakingDescriptor()), null);
339+
assert.strictEqual(parseUnbondingDescriptor(descriptorBuilder.getUnbondingTimelockDescriptor()), null);
313340
});
314341

315342
describe('Transaction Sets', async function () {

0 commit comments

Comments
 (0)