Skip to content

Commit ea8a018

Browse files
Merge pull request #59 from BitGo/BTC-1845.add-p2tr
feat: add PSBT private key signing and test utils
2 parents 9f8f83f + 57ea243 commit ea8a018

File tree

7 files changed

+362
-17
lines changed

7 files changed

+362
-17
lines changed

packages/wasm-miniscript/js/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ declare module "./wasm/wasm_miniscript" {
3434

3535
interface WrapPsbt {
3636
signWithXprv(this: WrapPsbt, xprv: string): SignPsbtResult;
37+
signWithPrv(this: WrapPsbt, prv: Buffer): SignPsbtResult;
3738
}
3839
}
3940

packages/wasm-miniscript/src/psbt.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use crate::WrapDescriptor;
44
use miniscript::bitcoin::secp256k1::Secp256k1;
55
use miniscript::bitcoin::Psbt;
66
use miniscript::psbt::PsbtExt;
7+
use miniscript::ToPublicKey;
8+
use std::collections::HashMap;
79
use std::str::FromStr;
810
use wasm_bindgen::prelude::wasm_bindgen;
911
use wasm_bindgen::{JsError, JsValue};
@@ -75,6 +77,29 @@ impl WrapPsbt {
7577
.and_then(|r| r.try_to_js_value())
7678
}
7779

80+
#[wasm_bindgen(js_name = signWithPrv)]
81+
pub fn sign_with_prv(&mut self, prv: Vec<u8>) -> Result<JsValue, JsError> {
82+
let privkey = miniscript::bitcoin::PrivateKey::from_slice(
83+
&prv,
84+
miniscript::bitcoin::network::Network::Bitcoin,
85+
)
86+
.map_err(|_| JsError::new("Invalid private key"))?;
87+
let mut map: HashMap<miniscript::bitcoin::PublicKey, miniscript::bitcoin::PrivateKey> =
88+
std::collections::HashMap::new();
89+
map.insert(privkey.public_key(&Secp256k1::new()), privkey);
90+
map.insert(
91+
privkey
92+
.public_key(&Secp256k1::new())
93+
.to_x_only_pubkey()
94+
.to_public_key(),
95+
privkey,
96+
);
97+
self.0
98+
.sign(&map, &Secp256k1::new())
99+
.map_err(|(_, errors)| JsError::new(&format!("{} errors: {:?}", errors.len(), errors)))
100+
.and_then(|r| r.try_to_js_value())
101+
}
102+
78103
#[wasm_bindgen(js_name = finalize)]
79104
pub fn finalize_mut(&mut self) -> Result<(), JsError> {
80105
self.0

packages/wasm-miniscript/test/descriptorUtil.ts

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1+
import * as assert from "node:assert";
12
import * as fs from "fs/promises";
23
import * as utxolib from "@bitgo/utxo-lib";
3-
import { Descriptor } from "../js";
4-
import * as assert from "node:assert";
5-
import { DescriptorNode, MiniscriptNode } from "../js/ast";
4+
import { DescriptorNode, MiniscriptNode, formatNode } from "../js/ast";
65

76
async function assertEqualJSON(path: string, value: unknown): Promise<void> {
87
try {
@@ -29,16 +28,13 @@ export async function assertEqualFixture(
2928
}
3029

3130
/** Expand a template with the given root wallet keys and chain code */
32-
function expand(template: string, rootWalletKeys: utxolib.bitgo.RootWalletKeys, chainCode: number) {
33-
return template.replace(/\$([0-9])/g, (_, i) => {
34-
const keyIndex = parseInt(i, 10);
35-
if (keyIndex !== 0 && keyIndex !== 1 && keyIndex !== 2) {
36-
throw new Error("Invalid key index");
37-
}
38-
const xpub = rootWalletKeys.triple[keyIndex].neutered().toBase58();
39-
const prefix = rootWalletKeys.derivationPrefixes[keyIndex];
40-
return xpub + "/" + prefix + "/" + chainCode + "/*";
41-
});
31+
function expand(rootWalletKeys: utxolib.bitgo.RootWalletKeys, keyIndex: number, chainCode: number) {
32+
if (keyIndex !== 0 && keyIndex !== 1 && keyIndex !== 2) {
33+
throw new Error("Invalid key index");
34+
}
35+
const xpub = rootWalletKeys.triple[keyIndex].neutered().toBase58();
36+
const prefix = rootWalletKeys.derivationPrefixes[keyIndex];
37+
return xpub + "/" + prefix + "/" + chainCode + "/*";
4238
}
4339

4440
/**
@@ -55,13 +51,16 @@ export function getDescriptorForScriptType(
5551
scope === "external"
5652
? utxolib.bitgo.getExternalChainCode(scriptType)
5753
: utxolib.bitgo.getInternalChainCode(scriptType);
54+
const multi: MiniscriptNode = {
55+
multi: [2, ...rootWalletKeys.triple.map((_, i) => expand(rootWalletKeys, i, chain))],
56+
};
5857
switch (scriptType) {
5958
case "p2sh":
60-
return expand("sh(multi(2,$0,$1,$2))", rootWalletKeys, chain);
59+
return formatNode({ sh: multi });
6160
case "p2shP2wsh":
62-
return expand("sh(wsh(multi(2,$0,$1,$2)))", rootWalletKeys, chain);
61+
return formatNode({ sh: { wsh: multi } });
6362
case "p2wsh":
64-
return expand("wsh(multi(2,$0,$1,$2))", rootWalletKeys, chain);
63+
return formatNode({ wsh: multi });
6564
default:
6665
throw new Error(`Unsupported script type ${scriptType}`);
6766
}

packages/wasm-miniscript/test/psbt.ts renamed to packages/wasm-miniscript/test/psbtFixedScriptCompat.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as utxolib from "@bitgo/utxo-lib";
22
import * as assert from "node:assert";
3-
import { getPsbtFixtures, PsbtStage } from "./psbtFixtures";
3+
import { getPsbtFixtures, PsbtStage } from "./psbtFixedScriptCompatFixtures";
44
import { Descriptor, Psbt } from "../js";
55

66
import { getDescriptorForScriptType } from "./descriptorUtil";
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import assert from "node:assert";
2+
import { BIP32Interface } from "@bitgo/utxo-lib";
3+
import { getKey } from "@bitgo/utxo-lib/dist/src/testutil";
4+
5+
import { DescriptorNode, formatNode } from "../js/ast";
6+
import { mockPsbtDefault } from "./psbtFromDescriptor.util";
7+
import { Descriptor } from "../js";
8+
import { toWrappedPsbt } from "./psbt.util";
9+
10+
function toKeyWithPath(k: BIP32Interface, path = "*"): string {
11+
return k.toBase58() + "/" + path;
12+
}
13+
14+
function toKeyPlain(k: Buffer): string {
15+
return k.toString("hex");
16+
}
17+
18+
const external = getKey("external");
19+
const a = getKey("a");
20+
const b = getKey("b");
21+
const c = getKey("c");
22+
const keys = { external, a, b, c };
23+
function getKeyName(bipKey: BIP32Interface) {
24+
return Object.keys(keys).find(
25+
(k) => keys[k as keyof typeof keys] === bipKey,
26+
) as keyof typeof keys;
27+
}
28+
29+
function describeSignDescriptor(
30+
name: string,
31+
descriptor: DescriptorNode,
32+
signSeqs: BIP32Interface[][],
33+
) {
34+
describe(`psbt with descriptor ${name}`, function () {
35+
const isTaproot = Object.keys(descriptor)[0] === "tr";
36+
const psbt = mockPsbtDefault({
37+
descriptorSelf: Descriptor.fromString(formatNode(descriptor), "derivable"),
38+
descriptorOther: Descriptor.fromString(
39+
formatNode({ wpkh: toKeyWithPath(external) }),
40+
"derivable",
41+
),
42+
});
43+
44+
function getSigResult(keys: BIP32Interface[]) {
45+
return {
46+
[isTaproot ? "Schnorr" : "Ecdsa"]: keys.map((key) =>
47+
key.publicKey.subarray(isTaproot ? 1 : 0).toString("hex"),
48+
),
49+
};
50+
}
51+
52+
signSeqs.forEach((signSeq, i) => {
53+
it(`should sign ${signSeq.map((k) => getKeyName(k))} xprv`, function () {
54+
const wrappedPsbt = toWrappedPsbt(psbt);
55+
signSeq.forEach((key) => {
56+
assert.deepStrictEqual(wrappedPsbt.signWithXprv(key.toBase58()), {
57+
0: getSigResult([key.derive(0)]),
58+
1: getSigResult([key.derive(1)]),
59+
});
60+
});
61+
wrappedPsbt.finalize();
62+
});
63+
64+
it(`should sign ${signSeq.map((k) => getKeyName(k))} prv buffer`, function () {
65+
if (isTaproot) {
66+
// signing with non-bip32 taproot keys is not supported apparently
67+
this.skip();
68+
}
69+
const wrappedPsbt = toWrappedPsbt(psbt);
70+
signSeq.forEach((key) => {
71+
assert.deepStrictEqual(wrappedPsbt.signWithPrv(key.derive(0).privateKey), {
72+
0: getSigResult([key.derive(0)]),
73+
1: getSigResult([]),
74+
});
75+
});
76+
});
77+
});
78+
});
79+
}
80+
81+
describeSignDescriptor(
82+
"Wsh2Of3",
83+
{
84+
wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] },
85+
},
86+
[
87+
[a, b],
88+
[b, a],
89+
],
90+
);
91+
92+
describeSignDescriptor(
93+
"Tr1Of3",
94+
{
95+
tr: [toKeyWithPath(a), [{ pk: toKeyWithPath(b) }, { pk: toKeyWithPath(c) }]],
96+
},
97+
[[a], [b], [c]],
98+
);
99+
100+
// while we cannot sign with a derived plain xonly key, we can sign with an xprv
101+
describeSignDescriptor(
102+
"TrWithExternalPlain",
103+
{
104+
tr: [
105+
toKeyPlain(external.publicKey),
106+
[
107+
{ pk: toKeyPlain(external.publicKey) },
108+
{ or_b: [{ pk: toKeyPlain(external.publicKey) }, { "s:pk": toKeyWithPath(a) }] },
109+
],
110+
],
111+
},
112+
[[a]],
113+
);

0 commit comments

Comments
 (0)