Skip to content

Commit 8a94827

Browse files
Merge pull request #6455 from BitGo/BTC-2143.delegation-message-sample
feat(utxo-staking): add basic Babylon unbonding support
2 parents 3cb970d + 6bebe13 commit 8a94827

20 files changed

+1703
-117
lines changed

modules/utxo-core/src/descriptor/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export * from './derive';
55
export * from './Output';
66
export * from './VirtualSize';
77
export * from './fromFixedScriptWallet';
8+
export * from './parse/PatternMatcher';
89

910
/** @deprecated - import from @bitgo/utxo-core directly instead */
1011
export * from '../Output';
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Pattern matching types
2+
export type PatternVar = { $var: string };
3+
export type Pattern = PatternVar | string | number | { [key: string]: Pattern | Pattern[] } | Pattern[];
4+
5+
export type ExtractedVars = Record<string, unknown>;
6+
7+
export class PatternMatcher {
8+
match(node: unknown, pattern: Pattern): ExtractedVars | null {
9+
const vars: ExtractedVars = {};
10+
return this.matchNode(node, pattern, vars) ? vars : null;
11+
}
12+
13+
private matchNode(node: unknown, pattern: Pattern, vars: ExtractedVars): boolean {
14+
// Variable placeholder
15+
if (this.isPatternVar(pattern)) {
16+
const varName = pattern.$var;
17+
if (varName in vars) {
18+
return this.deepEqual(vars[varName], node);
19+
}
20+
vars[varName] = node;
21+
return true;
22+
}
23+
24+
// Primitive values
25+
if (typeof node !== typeof pattern) return false;
26+
if (typeof node === 'string' || typeof node === 'number') {
27+
return node === pattern;
28+
}
29+
30+
// Arrays
31+
if (Array.isArray(node) && Array.isArray(pattern)) {
32+
return node.length === pattern.length && node.every((item, i) => this.matchNode(item, pattern[i], vars));
33+
}
34+
35+
// Objects
36+
if (typeof node === 'object' && typeof pattern === 'object' && node !== null && pattern !== null) {
37+
const nodeKeys = Object.keys(node);
38+
const patternKeys = Object.keys(pattern);
39+
40+
return (
41+
nodeKeys.length === patternKeys.length &&
42+
nodeKeys.every(
43+
(key) =>
44+
patternKeys.includes(key) &&
45+
this.matchNode((node as Record<string, unknown>)[key], (pattern as Record<string, Pattern>)[key], vars)
46+
)
47+
);
48+
}
49+
50+
return false;
51+
}
52+
53+
private isPatternVar(value: unknown): value is PatternVar {
54+
return value !== null && typeof value === 'object' && '$var' in value;
55+
}
56+
57+
private deepEqual(a: unknown, b: unknown): boolean {
58+
if (a === b) return true;
59+
if (typeof a !== typeof b) return false;
60+
if (Array.isArray(a) && Array.isArray(b)) {
61+
return a.length === b.length && a.every((item, i) => this.deepEqual(item, b[i]));
62+
}
63+
if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
64+
const keysA = Object.keys(a);
65+
const keysB = Object.keys(b);
66+
return (
67+
keysA.length === keysB.length &&
68+
keysA.every((key) => this.deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]))
69+
);
70+
}
71+
return false;
72+
}
73+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import assert from 'assert';
2+
3+
import { Descriptor, ast } from '@bitgo/wasm-miniscript';
4+
5+
import { getKey } from '../../../src/testutil';
6+
import { toXOnlyPublicKey } from '../../../src';
7+
import { PatternMatcher, Pattern } from '../../../src/descriptor/parse/PatternMatcher';
8+
9+
function key32(seed: string): Buffer {
10+
// return x-only public key from seed
11+
return toXOnlyPublicKey(getKey(seed).publicKey);
12+
}
13+
14+
function getUnspendableKey(): string {
15+
return '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0';
16+
}
17+
18+
function pk(b: Buffer): ast.MiniscriptNode {
19+
return { 'v:pk': b.toString('hex') };
20+
}
21+
22+
function sortedKeys(keys: Buffer[]): Buffer[] {
23+
return [...keys].sort((a, b) => a.compare(b));
24+
}
25+
26+
function multiArgs(threshold: number, keys: Buffer[]): [number, ...string[]] {
27+
return [threshold, ...sortedKeys(keys).map((k) => k.toString('hex'))];
28+
}
29+
30+
function taprootScriptOnlyFromAst(n: ast.TapTreeNode): Descriptor {
31+
return Descriptor.fromString(ast.formatNode({ tr: [getUnspendableKey(), n] }), 'definite');
32+
}
33+
34+
class StakingDescriptorBuilder {
35+
constructor(
36+
public userKey: Buffer,
37+
public providerKeys: Buffer[],
38+
public guardianKeys: Buffer[],
39+
public guardianThreshold: number,
40+
public stakingTimeLock: number
41+
) {}
42+
43+
getTimelockMiniscriptNode(): ast.MiniscriptNode {
44+
return { and_v: [pk(this.userKey), { older: this.stakingTimeLock }] };
45+
}
46+
47+
getWithdrawalMiniscriptNode(): ast.MiniscriptNode {
48+
return { and_v: [pk(this.userKey), { multi_a: multiArgs(this.guardianThreshold, this.guardianKeys) }] };
49+
}
50+
51+
getPenaltyMiniscriptNode(): ast.MiniscriptNode {
52+
return {
53+
and_v: [
54+
{
55+
and_v: [
56+
pk(this.userKey),
57+
this.providerKeys.length === 1
58+
? { 'v:pk': this.providerKeys[0].toString('hex') }
59+
: { 'v:multi_a': multiArgs(1, this.providerKeys) },
60+
],
61+
},
62+
{ multi_a: multiArgs(this.guardianThreshold, this.guardianKeys) },
63+
],
64+
};
65+
}
66+
67+
getStakingDescriptor(): Descriptor {
68+
return taprootScriptOnlyFromAst([
69+
this.getPenaltyMiniscriptNode(),
70+
[this.getWithdrawalMiniscriptNode(), this.getTimelockMiniscriptNode()],
71+
]);
72+
}
73+
}
74+
75+
// Inverse function to parse the descriptor
76+
function parseStakingDescriptor(descriptor: Descriptor): {
77+
penaltyNode: ast.MiniscriptNode;
78+
withdrawalNode: ast.MiniscriptNode;
79+
timelockNode: ast.MiniscriptNode;
80+
} | null {
81+
const pattern: Pattern = {
82+
tr: [getUnspendableKey(), [{ $var: 'penaltyNode' }, [{ $var: 'withdrawalNode' }, { $var: 'timelockNode' }]]],
83+
};
84+
85+
const matcher = new PatternMatcher();
86+
const descriptorNode = ast.fromDescriptor(descriptor);
87+
const result = matcher.match(descriptorNode, pattern);
88+
89+
if (!result) {
90+
return null;
91+
}
92+
93+
return {
94+
penaltyNode: result.penaltyNode as ast.MiniscriptNode,
95+
withdrawalNode: result.withdrawalNode as ast.MiniscriptNode,
96+
timelockNode: result.timelockNode as ast.MiniscriptNode,
97+
};
98+
}
99+
100+
describe('PatternMatcher', function () {
101+
it('should match basic object', function () {
102+
const pattern = { a: { $var: 'x' }, b: 'hello' };
103+
const node = { a: 123, b: 'hello' };
104+
const vars = new PatternMatcher().match(node, pattern);
105+
assert.deepStrictEqual(vars, { x: 123 });
106+
});
107+
108+
it('should fail on non-matching object', function () {
109+
const pattern = { a: { $var: 'x' }, b: 'world' };
110+
const node = { a: 123, b: 'hello' };
111+
const vars = new PatternMatcher().match(node, pattern);
112+
assert.strictEqual(vars, null);
113+
});
114+
115+
it('should match with repeated var', function () {
116+
const pattern = { a: { $var: 'x' }, b: { $var: 'x' } };
117+
const node = { a: 123, b: 123 };
118+
const vars = new PatternMatcher().match(node, pattern);
119+
assert.deepStrictEqual(vars, { x: 123 });
120+
});
121+
122+
it('should fail with non-matching repeated var', function () {
123+
const pattern = { a: { $var: 'x' }, b: { $var: 'x' } };
124+
const node = { a: 123, b: 456 };
125+
const vars = new PatternMatcher().match(node, pattern);
126+
assert.strictEqual(vars, null);
127+
});
128+
129+
it('should parse staking descriptor', function () {
130+
const builder = new StakingDescriptorBuilder(
131+
key32('user'),
132+
[key32('provider1')],
133+
[key32('guardian1'), key32('guardian2')],
134+
2,
135+
100
136+
);
137+
138+
const descriptor = builder.getStakingDescriptor();
139+
const parsed = parseStakingDescriptor(descriptor);
140+
141+
assert.notStrictEqual(parsed, null);
142+
if (parsed) {
143+
assert.deepStrictEqual(parsed.penaltyNode, builder.getPenaltyMiniscriptNode());
144+
assert.deepStrictEqual(parsed.withdrawalNode, builder.getWithdrawalMiniscriptNode());
145+
assert.deepStrictEqual(parsed.timelockNode, builder.getTimelockMiniscriptNode());
146+
}
147+
});
148+
});

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/scripts/babylon-params.ts

Lines changed: 0 additions & 58 deletions
This file was deleted.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import assert from 'node:assert';
2+
import * as fs from 'fs/promises';
3+
4+
import yargs from 'yargs';
5+
import { hideBin } from 'yargs/helpers';
6+
7+
function getBaseUrl(network: 'mainnet' | 'testnet') {
8+
if (network === 'mainnet') {
9+
return 'https://babylon.nodes.guru/api';
10+
}
11+
return 'https://babylon-testnet-api.nodes.guru';
12+
}
13+
14+
function unwrapJson(r: Response): Promise<unknown> {
15+
if (r.ok) {
16+
return r.json();
17+
}
18+
return r.text().then((text) => {
19+
throw new Error(`Fetch failed: ${r.status} ${r.statusText} - ${text}`);
20+
});
21+
}
22+
23+
type BabylonNetwork = 'mainnet' | 'testnet';
24+
25+
async function getAllParams(network: BabylonNetwork): Promise<unknown[]> {
26+
const url = `${getBaseUrl(network)}/babylon/btcstaking/v1/params_versions`;
27+
const result = await unwrapJson(await fetch(url));
28+
assert(result && typeof result === 'object', `Invalid response from ${url}`);
29+
assert('params' in result, `Response from ${url} does not contain 'params'`);
30+
assert(Array.isArray(result.params), `Response from ${url} 'params' is not an array`);
31+
return result.params;
32+
}
33+
34+
async function syncParams(network: BabylonNetwork | undefined): Promise<void> {
35+
if (network === undefined) {
36+
await syncParams('testnet');
37+
await syncParams('mainnet');
38+
return;
39+
}
40+
41+
const allParams = await getAllParams(network);
42+
const filename = __dirname + `/../src/babylon/params.${network}.json`;
43+
await fs.writeFile(filename, JSON.stringify(allParams, null, 2) + '\n');
44+
console.log(`Wrote ${allParams.length} params to ${filename}`);
45+
}
46+
47+
async function syncDelegationResponse(network: BabylonNetwork | undefined, txid: string | undefined): Promise<void> {
48+
if (network === undefined && txid === undefined) {
49+
console.log('Syncing delegation with default params');
50+
return syncDelegationResponse('testnet', '5d277e1b29e5589074aea95ac8c8230fd911c2ec3c58774aafdef915619b772c');
51+
}
52+
53+
if (network === undefined || txid === undefined) {
54+
throw new Error('Network must be specified when syncing delegation response');
55+
}
56+
57+
const url = `${getBaseUrl(network)}/babylon/btcstaking/v1/btc_delegation/${txid}`;
58+
const result = await unwrapJson(await fetch(url));
59+
assert(result && typeof result === 'object', `Invalid response from ${url}`);
60+
61+
const filename = __dirname + `/../test/fixtures/babylon/rpc/btc_delegation/${network}.${txid}.json`;
62+
await fs.writeFile(filename, JSON.stringify(result, null, 2) + '\n');
63+
console.log(`Wrote delegation response to ${filename}`);
64+
}
65+
66+
yargs(hideBin(process.argv))
67+
.command({
68+
command: 'sync-babylon-params',
69+
describe: 'Sync Babylon params',
70+
builder(b) {
71+
return b.option('network', {
72+
choices: ['mainnet', 'testnet'] as const,
73+
description: 'Network',
74+
});
75+
},
76+
async handler(argv) {
77+
await syncParams(argv.network);
78+
},
79+
})
80+
.command({
81+
command: 'sync-btc-delegation',
82+
describe: 'Sync BTC delegation messages',
83+
builder(b) {
84+
return b
85+
.option('network', {
86+
choices: ['mainnet', 'testnet'] as const,
87+
description: 'Network',
88+
})
89+
.option('txid', {
90+
type: 'string',
91+
description: 'Transaction ID of the delegation message to sync',
92+
});
93+
},
94+
async handler(argv) {
95+
await syncDelegationResponse(argv.network, argv.txid);
96+
},
97+
})
98+
.demandCommand()
99+
.help()
100+
.strict().argv;

0 commit comments

Comments
 (0)