Skip to content

Commit b302afd

Browse files
OttoAllmendingerllm-git
andcommitted
feat(utxo-staking): add parsing for unbonding descriptor
Add support for parsing unbonding descriptors, which are needed to validate and interact with unbonding outputs. This includes: - Create dedicated parsing function for unbonding descriptors - Refactor common parsing logic into helper functions - Add tests for validating round-trip parsing - Add proper type definitions for parsed unbonding descriptor Issue: BTC-2319 Co-authored-by: llm-git <[email protected]>
1 parent bd62506 commit b302afd

File tree

2 files changed

+169
-48
lines changed

2 files changed

+169
-48
lines changed

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

Lines changed: 141 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,78 @@ function parseMulti(multi: unknown): [number, string[]] {
2828
return [threshold, keys];
2929
}
3030

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+
31103
/**
32104
* @return parsed staking descriptor components or null if the descriptor does not match the expected staking pattern.
33105
*/
@@ -52,19 +124,12 @@ export function parseStakingDescriptor(descriptor: Descriptor | ast.DescriptorNo
52124
const timelockNode = result.timelockMiniscriptNode as ast.MiniscriptNode;
53125

54126
// Verify slashing node shape: and_v([and_v([pk, pk/multi_a]), multi_a])
55-
const slashingPattern: Pattern = {
56-
and_v: [
57-
{
58-
and_v: [{ 'v:pk': { $var: 'stakerKey1' } }, { $var: 'finalityProviderKeyOrMulti' }],
59-
},
60-
{ multi_a: { $var: 'covenantMulti' } },
61-
],
62-
};
63-
64-
const slashingMatch = matcher.match(slashingNode, slashingPattern);
65-
if (!slashingMatch) {
66-
throw new Error('Slashing node does not match expected pattern');
67-
}
127+
const {
128+
stakerKey: stakerKey1,
129+
finalityProviderKeys,
130+
covenantKeys,
131+
covenantThreshold,
132+
} = parseSlashingNode(slashingNode, matcher);
68133

69134
// Verify unbonding node shape: and_v([pk, multi_a])
70135
const unbondingPattern: Pattern = {
@@ -77,56 +142,85 @@ export function parseStakingDescriptor(descriptor: Descriptor | ast.DescriptorNo
77142
}
78143

79144
// Verify unbonding timelock node shape: and_v([pk, older])
80-
const timelockPattern: Pattern = {
81-
and_v: [{ 'v:pk': { $var: 'stakerKey3' } }, { older: { $var: 'stakingTimeLock' } }],
82-
};
83-
84-
const timelockMatch = matcher.match(timelockNode, timelockPattern);
85-
if (!timelockMatch) {
86-
throw new Error('Unbonding timelock node does not match expected pattern');
145+
const unilateralTimelock = parseUnilateralTimelock(timelockNode, matcher);
146+
if (!unilateralTimelock) {
147+
return null;
87148
}
88149

150+
const { key: stakerKey3, timelock: stakingTimeLock } = unilateralTimelock;
151+
89152
// Verify all staker keys are the same
90-
if (
91-
slashingMatch.stakerKey1 !== unbondingMatch.stakerKey2 ||
92-
unbondingMatch.stakerKey2 !== timelockMatch.stakerKey3
93-
) {
153+
if (stakerKey1 !== unbondingMatch.stakerKey2 || unbondingMatch.stakerKey2 !== stakerKey3) {
94154
throw new Error('Staker keys must be identical across all nodes');
95155
}
96156

97-
// Verify timelock value is a number
98-
if (typeof timelockMatch.stakingTimeLock !== 'number') {
99-
throw new Error('Unbonding timelock value must be a number');
157+
const stakerKey = Buffer.from(stakerKey1, 'hex');
158+
159+
return {
160+
stakerKey,
161+
finalityProviderKeys,
162+
covenantKeys,
163+
covenantThreshold,
164+
stakingTimeLock,
165+
slashingMiniscriptNode: slashingNode,
166+
unbondingMiniscriptNode: unbondingNode,
167+
timelockMiniscriptNode: timelockNode,
168+
};
169+
}
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;
100194
}
101195

102-
const stakerKey = Buffer.from(slashingMatch.stakerKey1 as string, 'hex');
103-
const stakingTimeLock = timelockMatch.stakingTimeLock as number;
196+
const slashingNode = result.slashingMiniscriptNode as ast.MiniscriptNode;
197+
const unbondingTimelockNode = result.unbondingTimelockMiniscriptNode as ast.MiniscriptNode;
104198

105-
const [covenantThreshold, covenantKeyStrings] = parseMulti(slashingMatch.covenantMulti);
106-
const covenantKeys = covenantKeyStrings.map((k) => Buffer.from(k, 'hex'));
199+
const {
200+
stakerKey: stakerKey1,
201+
finalityProviderKeys,
202+
covenantKeys,
203+
covenantThreshold,
204+
} = parseSlashingNode(slashingNode, matcher);
107205

108-
let finalityProviderKeys: Buffer[];
109-
const fpKeyOrMulti = slashingMatch.finalityProviderKeyOrMulti as ast.MiniscriptNode;
110-
if ('v:pk' in fpKeyOrMulti) {
111-
finalityProviderKeys = [Buffer.from(fpKeyOrMulti['v:pk'], 'hex')];
112-
} else if ('v:multi_a' in fpKeyOrMulti) {
113-
const [threshold, keyStrings] = parseMulti(fpKeyOrMulti['v:multi_a']);
114-
if (threshold !== 1) {
115-
throw new Error('Finality provider multi threshold must be 1');
116-
}
117-
finalityProviderKeys = keyStrings.map((k) => Buffer.from(k, 'hex'));
118-
} else {
119-
throw new Error('Invalid finality provider key structure');
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');
120215
}
121216

122217
return {
123-
stakerKey,
218+
stakerKey: Buffer.from(stakerKey1, 'hex'),
124219
finalityProviderKeys,
125220
covenantKeys,
126221
covenantThreshold,
127-
stakingTimeLock,
222+
unbondingTimeLock,
128223
slashingMiniscriptNode: slashingNode,
129-
unbondingMiniscriptNode: unbondingNode,
130-
timelockMiniscriptNode: timelockNode,
224+
unbondingTimelockMiniscriptNode: unbondingTimelockNode,
131225
};
132226
}

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)