diff --git a/packages/wasm-miniscript/Cargo.lock b/packages/wasm-miniscript/Cargo.lock index 8f05bfa..54485a6 100644 --- a/packages/wasm-miniscript/Cargo.lock +++ b/packages/wasm-miniscript/Cargo.lock @@ -18,6 +18,12 @@ dependencies = [ "bitcoin_hashes", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bech32" version = "0.11.0" @@ -268,6 +274,7 @@ dependencies = [ name = "wasm-miniscript" version = "0.1.0" dependencies = [ + "base64", "js-sys", "miniscript", "wasm-bindgen", diff --git a/packages/wasm-miniscript/Cargo.toml b/packages/wasm-miniscript/Cargo.toml index 844ed6c..af7b3ce 100644 --- a/packages/wasm-miniscript/Cargo.toml +++ b/packages/wasm-miniscript/Cargo.toml @@ -11,6 +11,9 @@ wasm-bindgen = "0.2" js-sys = "0.3" miniscript = { version = "12.3.0" } +[dev-dependencies] +base64 = "0.22.1" + [profile.release] # this is required to make webpack happy # https://github.com/webpack/webpack/issues/15566#issuecomment-2558347645 diff --git a/packages/wasm-miniscript/src/miniscript.rs b/packages/wasm-miniscript/src/miniscript.rs index d58e8eb..c173f1a 100644 --- a/packages/wasm-miniscript/src/miniscript.rs +++ b/packages/wasm-miniscript/src/miniscript.rs @@ -103,17 +103,3 @@ impl From> for WrapMiniscript { WrapMiniscript(WrapMiniscriptEnum::Legacy(miniscript)) } } - -#[test] -pub fn panic_xprv() { - use miniscript::bitcoin::secp256k1::Secp256k1; - use miniscript::Descriptor; - let (d,m) = Descriptor::parse_descriptor( - &Secp256k1::new(), - "wsh(multi(2,xprv9s21ZrQH143K31xYSDQpPDxsXRTUcvj2iNHm5NUtrGiGG5e2DtALGdso3pGz6ssrdK4PFmM8NSpSBHNqPqm55Qn3LqFtT2emdEXVYsCzC2U/2147483647'/0,xprv9vHkqa6EV4sPZHYqZznhT2NPtPCjKuDKGY38FBWLvgaDx45zo9WQRUT3dKYnjwih2yJD9mkrocEZXo1ex8G81dwSM1fwqWpWkeS3v86pgKt/1/2/*,xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi/10/20/30/40/*'))", - ).unwrap(); - - let dd = d.at_derivation_index(0).unwrap(); - - let _ = dd.explicit_script().unwrap(); -} diff --git a/packages/wasm-miniscript/src/psbt.rs b/packages/wasm-miniscript/src/psbt.rs index 778b8bc..0c845b5 100644 --- a/packages/wasm-miniscript/src/psbt.rs +++ b/packages/wasm-miniscript/src/psbt.rs @@ -1,15 +1,78 @@ use crate::descriptor::WrapDescriptorEnum; use crate::try_into_js_value::TryIntoJsValue; use crate::WrapDescriptor; -use miniscript::bitcoin::secp256k1::Secp256k1; -use miniscript::bitcoin::Psbt; +use miniscript::bitcoin::bip32::Fingerprint; +use miniscript::bitcoin::secp256k1::{Context, Secp256k1, Signing}; +use miniscript::bitcoin::{bip32, psbt, secp256k1, PublicKey, XOnlyPublicKey}; +use miniscript::bitcoin::{PrivateKey, Psbt}; +use miniscript::descriptor::{SinglePub, SinglePubKey}; use miniscript::psbt::PsbtExt; -use miniscript::ToPublicKey; +use miniscript::{DescriptorPublicKey, ToPublicKey}; use std::collections::HashMap; use std::str::FromStr; use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::{JsError, JsValue}; +#[derive(Debug)] +struct SingleKeySigner { + privkey: PrivateKey, + pubkey: PublicKey, + pubkey_xonly: XOnlyPublicKey, + fingerprint: Fingerprint, + fingerprint_xonly: Fingerprint, +} + +impl SingleKeySigner { + fn fingerprint(key: SinglePubKey) -> Fingerprint { + DescriptorPublicKey::Single(SinglePub { origin: None, key }).master_fingerprint() + } + + fn from_privkey(privkey: PrivateKey, secp: &Secp256k1) -> SingleKeySigner { + let pubkey = privkey.public_key(secp); + let pubkey_xonly = pubkey.to_x_only_pubkey(); + SingleKeySigner { + privkey, + pubkey, + pubkey_xonly, + fingerprint: SingleKeySigner::fingerprint(SinglePubKey::FullKey(pubkey)), + fingerprint_xonly: SingleKeySigner::fingerprint(SinglePubKey::XOnly(pubkey_xonly)), + } + } +} + +impl psbt::GetKey for SingleKeySigner { + type Error = String; + + fn get_key( + &self, + key_request: psbt::KeyRequest, + secp: &Secp256k1, + ) -> Result, Self::Error> { + match key_request { + // NOTE: this KeyRequest does not occur for taproot signatures + // even if the descriptor keys are definite, we will receive a bip32 request + // instead based on `DescriptorPublicKey::Single(SinglePub { origin: None, key, })` + psbt::KeyRequest::Pubkey(req_pubkey) => { + if req_pubkey == self.pubkey { + Ok(Some(self.privkey.clone())) + } else { + Ok(None) + } + } + + psbt::KeyRequest::Bip32((fingerprint, path)) => { + if fingerprint.eq(&self.fingerprint) || fingerprint.eq(&self.fingerprint_xonly) { + Ok(Some(self.privkey.clone())) + } else { + Ok(None) + } + } + + _ => Ok(None), + } + } +} + #[wasm_bindgen] pub struct WrapPsbt(Psbt); @@ -69,8 +132,7 @@ impl WrapPsbt { #[wasm_bindgen(js_name = signWithXprv)] pub fn sign_with_xprv(&mut self, xprv: String) -> Result { - let key = miniscript::bitcoin::bip32::Xpriv::from_str(&xprv) - .map_err(|_| JsError::new("Invalid xprv"))?; + let key = bip32::Xpriv::from_str(&xprv).map_err(|_| JsError::new("Invalid xprv"))?; self.0 .sign(&key, &Secp256k1::new()) .map_err(|(_, errors)| JsError::new(&format!("{} errors: {:?}", errors.len(), errors))) @@ -79,24 +141,12 @@ impl WrapPsbt { #[wasm_bindgen(js_name = signWithPrv)] pub fn sign_with_prv(&mut self, prv: Vec) -> Result { - let privkey = miniscript::bitcoin::PrivateKey::from_slice( - &prv, - miniscript::bitcoin::network::Network::Bitcoin, - ) - .map_err(|_| JsError::new("Invalid private key"))?; - let mut map: HashMap = - std::collections::HashMap::new(); - map.insert(privkey.public_key(&Secp256k1::new()), privkey); - map.insert( - privkey - .public_key(&Secp256k1::new()) - .to_x_only_pubkey() - .to_public_key(), - privkey, - ); + let privkey = PrivateKey::from_slice(&prv, miniscript::bitcoin::network::Network::Bitcoin) + .map_err(|_| JsError::new("Invalid private key"))?; + let secp = Secp256k1::new(); self.0 - .sign(&map, &Secp256k1::new()) - .map_err(|(_, errors)| JsError::new(&format!("{} errors: {:?}", errors.len(), errors))) + .sign(&SingleKeySigner::from_privkey(privkey, &secp), &secp) + .map_err(|(r, errors)| JsError::new(&format!("{} errors: {:?}", errors.len(), errors))) .and_then(|r| r.try_to_js_value()) } @@ -107,3 +157,61 @@ impl WrapPsbt { .map_err(|vec_err| JsError::new(&format!("{} errors: {:?}", vec_err.len(), vec_err))) } } + +#[cfg(test)] +mod tests { + use crate::psbt::SingleKeySigner; + use base64::prelude::*; + use miniscript::bitcoin::bip32::{DerivationPath, Fingerprint, KeySource}; + use miniscript::bitcoin::psbt::{SigningKeys, SigningKeysMap}; + use miniscript::bitcoin::secp256k1::Secp256k1; + use miniscript::bitcoin::{PrivateKey, Psbt}; + use miniscript::psbt::PsbtExt; + use miniscript::{DefiniteDescriptorKey, Descriptor, DescriptorPublicKey, ToPublicKey}; + use std::str::FromStr; + + fn psbt_from_base64(s: &str) -> Psbt { + let psbt = BASE64_STANDARD.decode(s.as_bytes()).unwrap(); + Psbt::deserialize(&psbt).unwrap() + } + + #[test] + pub fn test_wrap_privkey() { + let desc = "tr(039ab0771c5f88913208a26f81ab8223e98d25176e4648a5a2bb8ff79cf1c5198b,pk(039ab0771c5f88913208a26f81ab8223e98d25176e4648a5a2bb8ff79cf1c5198b))"; + let desc = Descriptor::::from_str(desc).unwrap(); + let psbt = "cHNidP8BAKYCAAAAAgEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAAAAAAD9////AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAAAAAP3///8CgBoGAAAAAAAWABRTtvjcap+5t7odMosMnHl97YJClYAaBgAAAAAAIlEg1S2GuUvFU+Ve4XFLV65ffhuYsGeDkpaER6lQFjONAmEAAAAAAAEBK0BCDwAAAAAAIlEg1S2GuUvFU+Ve4XFLV65ffhuYsGeDkpaER6lQFjONAmEAAQErQEIPAAAAAAAiUSDVLYa5S8VT5V7hcUtXrl9+G5iwZ4OSloRHqVAWM40CYQAAAA=="; + let mut psbt = psbt_from_base64(psbt); + psbt.update_input_with_descriptor(0, &desc).unwrap(); + println!("{:?}", psbt.inputs[0].tap_key_origins); + let prv = + PrivateKey::from_str("KzEGYtKcbhYwUWcZygbsqmF31f3iV7HC3iUQug7MBecwCz9hm1Tv").unwrap(); + let pk = prv.public_key(&Secp256k1::new()).to_x_only_pubkey(); + let secp = Secp256k1::new(); + let sks = SingleKeySigner::from_privkey(prv, &secp); + psbt.inputs[0] + .tap_key_origins + .values() + .for_each(|key_source| { + let key_source_ref: KeySource = ( + Fingerprint::from_hex(&"aeee1e6a").unwrap(), + DerivationPath::from(vec![]), + ); + assert_eq!(key_source.1, key_source_ref); + assert_eq!(sks.fingerprint, key_source.1 .0,); + }); + let mut expected_keys = SigningKeysMap::new(); + expected_keys.insert(0, SigningKeys::Schnorr(vec![pk])); + expected_keys.insert(1, SigningKeys::Schnorr(vec![])); + assert_eq!(psbt.sign(&sks, &secp).unwrap(), expected_keys); + } + + #[test] + fn test_tr_xpub() { + let d = "tr(xpub661MyMwAqRbcEv1i36otFUwWZRcQBJHjdCoQvqykteW4sMHP3m4h9TzvPhK9q7rtkkWMMTJB4jFxCgVki9GwB9GvfHf366dpXDAaHHHdad2/*,{pk(xpub661MyMwAqRbcFod8uqcC3G2jub4McRVKZsZrvWZXAUFBjeuyMT2UqDFkw3TAUebQRAE7XQKFFhvLRW2mWvmKC2KzNuCkzVkFucWapGqnkXj/*),pk(xpub661MyMwAqRbcFVAMsxk7PkfGh66U9K9qWh2dvS5s4kL4JaDHdZdBbb4CbzQxZMC2MAUcKZudSk86RxeaTQctKa6tpSCPEkKGYfMEFDKWJu9/*)})"; + let desc = Descriptor::::from_str(d).unwrap(); + let psbt = "cHNidP8BAKYCAAAAAgEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAAAAAAD9////AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAAAAAP3///8CgBoGAAAAAAAWABRTtvjcap+5t7odMosMnHl97YJClYAaBgAAAAAAIlEgBBlsh6bt3RStSy0egEjFHML8bVhqFYO8knG5OLcA/zcAAAAAAAEBK0BCDwAAAAAAIlEgBBlsh6bt3RStSy0egEjFHML8bVhqFYO8knG5OLcA/zcAAQErQEIPAAAAAAAiUSDFpFC16pT0pXIHKzV7teFiXul3DtlyYj9DdCpF1CHVQAAAAA=="; + let mut psbt = psbt_from_base64(psbt); + psbt.update_input_with_descriptor(0, &desc.at_derivation_index(0).unwrap()) + .unwrap(); + } +} diff --git a/packages/wasm-miniscript/test/psbtFromDescriptor.ts b/packages/wasm-miniscript/test/psbtFromDescriptor.ts index 2c058c4..e7485ea 100644 --- a/packages/wasm-miniscript/test/psbtFromDescriptor.ts +++ b/packages/wasm-miniscript/test/psbtFromDescriptor.ts @@ -1,5 +1,5 @@ import assert from "node:assert"; -import { BIP32Interface } from "@bitgo/utxo-lib"; +import { BIP32Interface, ECPair, ECPairInterface } from "@bitgo/utxo-lib"; import { getKey } from "@bitgo/utxo-lib/dist/src/testutil"; import { DescriptorNode, formatNode } from "../js/ast"; @@ -8,40 +8,58 @@ import { Descriptor } from "../js"; import { toWrappedPsbt } from "./psbt.util"; function toKeyWithPath(k: BIP32Interface, path = "*"): string { - return k.toBase58() + "/" + path; + return k.neutered().toBase58() + "/" + path; } function toKeyPlain(k: Buffer): string { return k.toString("hex"); } +function toECPair(k: BIP32Interface): ECPairInterface { + assert(k.privateKey); + return ECPair.fromPrivateKey(k.privateKey); +} + +function toKeyPlainXOnly(k: Buffer): string { + return k.subarray(1).toString("hex"); +} + const external = getKey("external"); const a = getKey("a"); const b = getKey("b"); const c = getKey("c"); const keys = { external, a, b, c }; -function getKeyName(bipKey: BIP32Interface) { - return Object.keys(keys).find( - (k) => keys[k as keyof typeof keys] === bipKey, - ) as keyof typeof keys; +function getKeyName(k: BIP32Interface | ECPairInterface) { + const objKeys = Object.keys(keys) as (keyof typeof keys)[]; + const keyName = objKeys.find( + (key) => keys[key] === k || toECPair(keys[key]).publicKey.equals(k.publicKey), + ); } +type SigningKey = BIP32Interface | ECPairInterface; + function describeSignDescriptor( name: string, - descriptor: DescriptorNode, - signSeqs: BIP32Interface[][], + descriptor: Descriptor, + { + signBip32 = [], + signECPair = [], + }: { + signBip32?: BIP32Interface[][]; + signECPair?: ECPairInterface[][]; + }, ) { describe(`psbt with descriptor ${name}`, function () { - const isTaproot = Object.keys(descriptor)[0] === "tr"; + const isTaproot = Object.keys(descriptor.node())[0] === "Tr"; const psbt = mockPsbtDefault({ - descriptorSelf: Descriptor.fromString(formatNode(descriptor), "derivable"), + descriptorSelf: descriptor, descriptorOther: Descriptor.fromString( formatNode({ wpkh: toKeyWithPath(external) }), "derivable", ), }); - function getSigResult(keys: BIP32Interface[]) { + function getSigResult(keys: (BIP32Interface | ECPairInterface)[]) { return { [isTaproot ? "Schnorr" : "Ecdsa"]: keys.map((key) => key.publicKey.subarray(isTaproot ? 1 : 0).toString("hex"), @@ -49,7 +67,7 @@ function describeSignDescriptor( }; } - signSeqs.forEach((signSeq, i) => { + signBip32.forEach((signSeq, i) => { it(`should sign ${signSeq.map((k) => getKeyName(k))} xprv`, function () { const wrappedPsbt = toWrappedPsbt(psbt); signSeq.forEach((key) => { @@ -62,52 +80,83 @@ function describeSignDescriptor( }); it(`should sign ${signSeq.map((k) => getKeyName(k))} prv buffer`, function () { - if (isTaproot) { - // signing with non-bip32 taproot keys is not supported apparently - this.skip(); - } const wrappedPsbt = toWrappedPsbt(psbt); signSeq.forEach((key) => { assert.deepStrictEqual(wrappedPsbt.signWithPrv(key.derive(0).privateKey), { - 0: getSigResult([key.derive(0)]), + // NOTE: signing with a plain derived key does not work for taproot + // see SingleKeySigner implementation in psbt.rs for details + 0: getSigResult(isTaproot ? [] : [key.derive(0)]), 1: getSigResult([]), }); }); }); }); + + signECPair.forEach((signSeq, i) => { + it(`should sign ${signSeq.map((k) => getKeyName(k))} ec pair`, function () { + const wrappedPsbt = toWrappedPsbt(psbt); + signSeq.forEach((key) => { + assert(key.privateKey); + assert.deepStrictEqual(wrappedPsbt.signWithPrv(key.privateKey), { + 0: getSigResult([key]), + 1: getSigResult([key]), + }); + }); + wrappedPsbt.finalize(); + }); + }); }); } +function fromNodes(node: DescriptorNode, type: "definite" | "derivable") { + return Descriptor.fromString(formatNode(node), type); +} + describeSignDescriptor( "Wsh2Of3", + fromNodes( + { + wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] }, + }, + "derivable", + ), { - wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] }, + signBip32: [ + [a, b], + [b, a], + ], }, - [ - [a, b], - [b, a], - ], ); describeSignDescriptor( "Tr1Of3", - { - tr: [toKeyWithPath(a), [{ pk: toKeyWithPath(b) }, { pk: toKeyWithPath(c) }]], - }, - [[a], [b], [c]], + fromNodes( + { + tr: [toKeyWithPath(a), [{ pk: toKeyWithPath(b) }, { pk: toKeyWithPath(c) }]], + }, + "derivable", + ), + { signBip32: [[a], [b], [c]] }, ); -// while we cannot sign with a derived plain xonly key, we can sign with an xprv describeSignDescriptor( "TrWithExternalPlain", - { - tr: [ - toKeyPlain(external.publicKey), - [ - { pk: toKeyPlain(external.publicKey) }, - { or_b: [{ pk: toKeyPlain(external.publicKey) }, { "s:pk": toKeyWithPath(a) }] }, + fromNodes( + { + tr: [ + toKeyPlainXOnly(external.publicKey), + [ + { pk: toKeyPlainXOnly(external.publicKey) }, + { + or_b: [ + { pk: toKeyPlainXOnly(external.publicKey) }, + { "s:pk": toKeyPlainXOnly(a.publicKey) }, + ], + }, + ], ], - ], - }, - [[a]], + }, + "definite", + ), + { signECPair: [[toECPair(a)]] }, ); diff --git a/packages/wasm-miniscript/test/psbtFromDescriptor.util.ts b/packages/wasm-miniscript/test/psbtFromDescriptor.util.ts index a37af07..f2465c4 100644 --- a/packages/wasm-miniscript/test/psbtFromDescriptor.util.ts +++ b/packages/wasm-miniscript/test/psbtFromDescriptor.util.ts @@ -156,6 +156,10 @@ type MockOutput = { external?: boolean; }; +function deriveIfWildcard(descriptor: Descriptor, index: number): Descriptor { + return descriptor.hasWildcard() ? descriptor.atDerivationIndex(index) : descriptor; +} + export function mockPsbt( inputs: MockInput[], outputs: MockOutput[], @@ -164,10 +168,10 @@ export function mockPsbt( return createPsbt( { ...params, network: params.network ?? utxolib.networks.bitcoin }, inputs.map((i) => - mockDerivedDescriptorWalletOutput(i.descriptor.atDerivationIndex(i.index), i), + mockDerivedDescriptorWalletOutput(deriveIfWildcard(i.descriptor, i.index), i), ), outputs.map((o) => { - const derivedDescriptor = o.descriptor.atDerivationIndex(o.index); + const derivedDescriptor = deriveIfWildcard(o.descriptor, o.index); return { script: createScriptPubKeyFromDescriptor(derivedDescriptor), value: o.value,