Skip to content

Commit 3f87455

Browse files
Merge pull request #52 from BitGo/BTC-1829.formatNode
feat: add formatNode utility for descriptor and miniscript ASTs
2 parents 1cb85c1 + bf46a94 commit 3f87455

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

68 files changed

+855
-79
lines changed
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
3+
This file contains type definitions for building an Abstract Syntax Tree for
4+
Bitcoin Descriptors and Miniscript expressions.
5+
6+
Currently, the types do not encode any validity or soundness checks, so it is
7+
possible to construct invalid descriptors.
8+
9+
*/
10+
type Key = string;
11+
12+
// https://bitcoin.sipa.be/miniscript/
13+
// r is for custom bitgo extension OP_DROP
14+
type Identities = "a" | "s" | "c" | "t" | "d" | "v" | "j" | "n" | "l" | "u" | "r";
15+
16+
// Union of all possible prefixes: { f: T } => { 'a:f': T } | { 's:f': T } | ...
17+
type PrefixWith<T, P extends string> = {
18+
[K in keyof T & string as `${P}:${K}`]: T[K];
19+
};
20+
type PrefixIdUnion<T> = { [P in Identities]: PrefixWith<T, P> }[Identities];
21+
22+
// Wrap a type with a union of all possible prefixes
23+
type Wrap<T> = T | PrefixIdUnion<T>;
24+
25+
type Miniscript =
26+
| Wrap<{ pk: Key }>
27+
| Wrap<{ pkh: Key }>
28+
| Wrap<{ wpkh: Key }>
29+
| Wrap<{ multi: [number, ...Key[]] }>
30+
| Wrap<{ sortedmulti: [number, ...Key[]] }>
31+
| Wrap<{ multi_a: [number, ...Key[]] }>
32+
| Wrap<{ sortedmulti_a: [number, ...Key[]] }>
33+
| Wrap<{ tr: Key | [Key, Miniscript] }>
34+
| Wrap<{ sh: Miniscript }>
35+
| Wrap<{ wsh: Miniscript }>
36+
| Wrap<{ and_v: [Miniscript, Miniscript] }>
37+
| Wrap<{ and_b: [Miniscript, Miniscript] }>
38+
| Wrap<{ andor: [Miniscript, Miniscript, Miniscript] }>
39+
| Wrap<{ or_b: [Miniscript, Miniscript] }>
40+
| Wrap<{ or_c: [Miniscript, Miniscript] }>
41+
| Wrap<{ or_d: [Miniscript, Miniscript] }>
42+
| Wrap<{ or_i: [Miniscript, Miniscript] }>
43+
| Wrap<{ thresh: [number, ...Miniscript[]] }>
44+
| Wrap<{ sha256: string }>
45+
| Wrap<{ ripemd160: string }>
46+
| Wrap<{ hash256: string }>
47+
| Wrap<{ hash160: string }>
48+
| Wrap<{ older: number }>
49+
| Wrap<{ after: number }>;
50+
51+
// Top level descriptor expressions
52+
// https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md#reference
53+
type Descriptor =
54+
| { sh: Miniscript | { wsh: Miniscript } }
55+
| { wsh: Miniscript }
56+
| { pk: Key }
57+
| { pkh: Key }
58+
| { wpkh: Key }
59+
| { combo: Key }
60+
| { tr: [Key, Miniscript] }
61+
| { addr: string }
62+
| { raw: string }
63+
| { rawtr: string };
64+
65+
type Node = Miniscript | Descriptor | number | string;
66+
67+
function formatN(n: Node | Node[]): string {
68+
if (typeof n === "string") {
69+
return n;
70+
}
71+
if (typeof n === "number") {
72+
return String(n);
73+
}
74+
if (Array.isArray(n)) {
75+
return n.map(formatN).join(",");
76+
}
77+
if (n && typeof n === "object") {
78+
const entries = Object.entries(n);
79+
if (entries.length !== 1) {
80+
throw new Error(`Invalid node: ${n}`);
81+
}
82+
const [name, value] = entries[0];
83+
return `${name}(${formatN(value)})`;
84+
}
85+
throw new Error(`Invalid node: ${n}`);
86+
}
87+
88+
export type MiniscriptNode = Miniscript;
89+
export type DescriptorNode = Descriptor;
90+
91+
/** Format a Miniscript or Descriptor node as a descriptor string (without checksum) */
92+
export function formatNode(n: MiniscriptNode | DescriptorNode): string {
93+
return formatN(n);
94+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { DescriptorNode, MiniscriptNode } from "./formatNode";
2+
import { Descriptor, Miniscript } from "../index";
3+
4+
function getSingleEntry(v: unknown): [string, unknown] {
5+
if (typeof v === "object" && v) {
6+
const entries = Object.entries(v);
7+
if (entries.length === 1) {
8+
return entries[0];
9+
}
10+
}
11+
12+
throw new Error("Expected single entry object");
13+
}
14+
15+
function node(type: string, value: unknown): MiniscriptNode | DescriptorNode {
16+
return { [type]: fromUnknown(value) } as MiniscriptNode | DescriptorNode;
17+
}
18+
19+
function wrap(type: string, value: unknown): MiniscriptNode {
20+
const n = fromWasmNode(value);
21+
const [name, inner] = getSingleEntry(n);
22+
return { [`${type}:${name}`]: inner } as MiniscriptNode;
23+
}
24+
25+
type Node = DescriptorNode | MiniscriptNode | string | number;
26+
27+
function fromUnknown(v: unknown): Node | Node[] {
28+
if (typeof v === "number" || typeof v === "string") {
29+
return v;
30+
}
31+
if (Array.isArray(v)) {
32+
return v.map(fromUnknown) as Node[];
33+
}
34+
if (typeof v === "object" && v) {
35+
const [type, value] = getSingleEntry(v);
36+
switch (type) {
37+
case "Bare":
38+
case "Single":
39+
case "Ms":
40+
case "XPub":
41+
case "relLockTime":
42+
case "absLockTime":
43+
return fromUnknown(value);
44+
case "Sh":
45+
case "Wsh":
46+
case "Tr":
47+
case "Pk":
48+
case "Pkh":
49+
case "PkH":
50+
case "Wpkh":
51+
case "Combo":
52+
case "SortedMulti":
53+
case "Addr":
54+
case "Raw":
55+
case "RawTr":
56+
case "After":
57+
case "Older":
58+
case "Sha256":
59+
case "Hash256":
60+
case "Ripemd160":
61+
case "Hash160":
62+
return node(type.toLocaleLowerCase(), value);
63+
case "PkK":
64+
return node("pk", value);
65+
case "RawPkH":
66+
return node("raw_pkh", value);
67+
68+
// Wrappers
69+
case "Alt":
70+
return wrap("a", value);
71+
case "Swap":
72+
return wrap("s", value);
73+
case "Check":
74+
return fromUnknown(value);
75+
case "DupIf":
76+
return wrap("d", value);
77+
case "Verify":
78+
return wrap("v", value);
79+
case "ZeroNotEqual":
80+
return wrap("n", value);
81+
82+
// Conjunctions
83+
case "AndV":
84+
return node("and_v", value);
85+
case "AndB":
86+
return node("and_b", value);
87+
case "AndOr":
88+
if (!Array.isArray(value)) {
89+
throw new Error(`Invalid AndOr node: ${JSON.stringify(value)}`);
90+
}
91+
const [cond, success, failure] = value;
92+
if (failure === false) {
93+
return node("and_n", [cond, success]);
94+
}
95+
return node("andor", [cond, success, failure]);
96+
97+
// Disjunctions
98+
case "OrB":
99+
return node("or_b", value);
100+
case "OrD":
101+
return node("or_d", value);
102+
case "OrC":
103+
return node("or_c", value);
104+
case "OrI":
105+
return node("or_i", value);
106+
107+
// Thresholds
108+
case "Thresh":
109+
return node("thresh", value);
110+
case "Multi":
111+
return node("multi", value);
112+
case "MultiA":
113+
return node("multi_a", value);
114+
}
115+
}
116+
117+
throw new Error(`Unknown node ${JSON.stringify(v)}`);
118+
}
119+
120+
function fromWasmNode(v: unknown): DescriptorNode | MiniscriptNode {
121+
return fromUnknown(v) as DescriptorNode | MiniscriptNode;
122+
}
123+
124+
export function fromDescriptor(d: Descriptor): DescriptorNode {
125+
return fromWasmNode(d.node()) as DescriptorNode;
126+
}
127+
128+
export function fromMiniscript(m: Miniscript): MiniscriptNode {
129+
return fromWasmNode(m.node()) as MiniscriptNode;
130+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./formatNode";
2+
export * from "./fromWasmNode";

packages/wasm-miniscript/js/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type ScriptContext = "tap" | "segwitv0" | "legacy";
1010

1111
declare module "./wasm/wasm_miniscript" {
1212
interface WrapDescriptor {
13+
/** These are not the same types of nodes as in the ast module */
1314
node(): unknown;
1415
}
1516

@@ -18,6 +19,7 @@ declare module "./wasm/wasm_miniscript" {
1819
}
1920

2021
interface WrapMiniscript {
22+
/** These are not the same types of nodes as in the ast module */
2123
node(): unknown;
2224
}
2325

@@ -30,3 +32,5 @@ declare module "./wasm/wasm_miniscript" {
3032
export { WrapDescriptor as Descriptor } from "./wasm/wasm_miniscript";
3133
export { WrapMiniscript as Miniscript } from "./wasm/wasm_miniscript";
3234
export { WrapPsbt as Psbt } from "./wasm/wasm_miniscript";
35+
36+
export * as ast from "./ast";

packages/wasm-miniscript/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
"dist/*/js/wasm/wasm_miniscript_bg.js",
1313
"dist/*/js/wasm/wasm_miniscript_bg.wasm",
1414
"dist/*/js/wasm/wasm_miniscript_bg.wasm.d.ts",
15-
"dist/*/js/index.d.ts",
16-
"dist/*/js/index.js"
15+
"dist/*/js/ast/*",
16+
"dist/*/js/index.*"
1717
],
1818
"main": "dist/node/js/index.js",
1919
"types": "dist/node/js/index.d.ts",

packages/wasm-miniscript/src/try_into_js_value.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ impl TryIntoJsValue for AbsLockTime {
9292

9393
impl TryIntoJsValue for RelLockTime {
9494
fn try_to_js_value(&self) -> Result<JsValue, JsError> {
95-
Ok(JsValue::from_str(&self.to_string()))
95+
Ok(JsValue::from_f64(self.to_consensus_u32() as f64))
9696
}
9797
}
9898

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as assert from "assert";
2+
3+
import { formatNode } from "../../js/ast";
4+
5+
describe("formatNode", function () {
6+
it("formats simple nodes", function () {
7+
assert.strictEqual(formatNode({ pk: "lol" }), "pk(lol)");
8+
assert.strictEqual(formatNode({ after: 1 }), "after(1)");
9+
assert.strictEqual(
10+
formatNode({ and_v: [{ after: 1 }, { after: 1 }] }),
11+
"and_v(after(1),after(1))",
12+
);
13+
});
14+
});

packages/wasm-miniscript/test/descriptorUtil.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as fs from "fs/promises";
22
import * as utxolib from "@bitgo/utxo-lib";
33
import { Descriptor } from "../js";
44
import * as assert from "node:assert";
5+
import { DescriptorNode, MiniscriptNode } from "../js/ast";
56

67
async function assertEqualJSON(path: string, value: unknown): Promise<void> {
78
try {
@@ -16,8 +17,15 @@ async function assertEqualJSON(path: string, value: unknown): Promise<void> {
1617
}
1718
}
1819

19-
export async function assertEqualAst(path: string, descriptor: Descriptor): Promise<void> {
20-
await assertEqualJSON(path, { descriptor: descriptor.toString(), ast: descriptor.node() });
20+
export async function assertEqualFixture(
21+
path: string,
22+
content: {
23+
descriptor: string;
24+
wasmNode: unknown;
25+
ast: DescriptorNode | MiniscriptNode;
26+
},
27+
): Promise<void> {
28+
await assertEqualJSON(path, content);
2129
}
2230

2331
/** Expand a template with the given root wallet keys and chain code */
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
{
22
"descriptor": "pk(03a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd)#9pcxlpvx",
3-
"ast": {
3+
"wasmNode": {
44
"Bare": {
55
"Check": {
66
"PkK": {
77
"Single": "03a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd"
88
}
99
}
1010
}
11+
},
12+
"ast": {
13+
"pk": "03a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd"
1114
}
1215
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
{
22
"descriptor": "pk(03a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd)#9pcxlpvx",
3-
"ast": {
3+
"wasmNode": {
44
"Bare": {
55
"Check": {
66
"PkK": {
77
"Single": "03a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd"
88
}
99
}
1010
}
11+
},
12+
"ast": {
13+
"pk": "03a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd"
1114
}
1215
}

0 commit comments

Comments
 (0)