diff --git a/packages/wasm-miniscript/js/index.ts b/packages/wasm-miniscript/js/index.ts index b722514..8ff308b 100644 --- a/packages/wasm-miniscript/js/index.ts +++ b/packages/wasm-miniscript/js/index.ts @@ -34,6 +34,7 @@ declare module "./wasm/wasm_miniscript" { interface WrapPsbt { signWithXprv(this: WrapPsbt, xprv: string): SignPsbtResult; + signWithPrv(this: WrapPsbt, prv: Buffer): SignPsbtResult; } } diff --git a/packages/wasm-miniscript/src/psbt.rs b/packages/wasm-miniscript/src/psbt.rs index a2e9d9b..778b8bc 100644 --- a/packages/wasm-miniscript/src/psbt.rs +++ b/packages/wasm-miniscript/src/psbt.rs @@ -4,6 +4,8 @@ use crate::WrapDescriptor; use miniscript::bitcoin::secp256k1::Secp256k1; use miniscript::bitcoin::Psbt; use miniscript::psbt::PsbtExt; +use miniscript::ToPublicKey; +use std::collections::HashMap; use std::str::FromStr; use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::{JsError, JsValue}; @@ -75,6 +77,29 @@ impl WrapPsbt { .and_then(|r| r.try_to_js_value()) } + #[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, + ); + self.0 + .sign(&map, &Secp256k1::new()) + .map_err(|(_, errors)| JsError::new(&format!("{} errors: {:?}", errors.len(), errors))) + .and_then(|r| r.try_to_js_value()) + } + #[wasm_bindgen(js_name = finalize)] pub fn finalize_mut(&mut self) -> Result<(), JsError> { self.0 diff --git a/packages/wasm-miniscript/test/descriptorUtil.ts b/packages/wasm-miniscript/test/descriptorUtil.ts index d1ac99b..182dc6e 100644 --- a/packages/wasm-miniscript/test/descriptorUtil.ts +++ b/packages/wasm-miniscript/test/descriptorUtil.ts @@ -1,8 +1,7 @@ +import * as assert from "node:assert"; import * as fs from "fs/promises"; import * as utxolib from "@bitgo/utxo-lib"; -import { Descriptor } from "../js"; -import * as assert from "node:assert"; -import { DescriptorNode, MiniscriptNode } from "../js/ast"; +import { DescriptorNode, MiniscriptNode, formatNode } from "../js/ast"; async function assertEqualJSON(path: string, value: unknown): Promise { try { @@ -29,16 +28,13 @@ export async function assertEqualFixture( } /** Expand a template with the given root wallet keys and chain code */ -function expand(template: string, rootWalletKeys: utxolib.bitgo.RootWalletKeys, chainCode: number) { - return template.replace(/\$([0-9])/g, (_, i) => { - const keyIndex = parseInt(i, 10); - if (keyIndex !== 0 && keyIndex !== 1 && keyIndex !== 2) { - throw new Error("Invalid key index"); - } - const xpub = rootWalletKeys.triple[keyIndex].neutered().toBase58(); - const prefix = rootWalletKeys.derivationPrefixes[keyIndex]; - return xpub + "/" + prefix + "/" + chainCode + "/*"; - }); +function expand(rootWalletKeys: utxolib.bitgo.RootWalletKeys, keyIndex: number, chainCode: number) { + if (keyIndex !== 0 && keyIndex !== 1 && keyIndex !== 2) { + throw new Error("Invalid key index"); + } + const xpub = rootWalletKeys.triple[keyIndex].neutered().toBase58(); + const prefix = rootWalletKeys.derivationPrefixes[keyIndex]; + return xpub + "/" + prefix + "/" + chainCode + "/*"; } /** @@ -55,13 +51,16 @@ export function getDescriptorForScriptType( scope === "external" ? utxolib.bitgo.getExternalChainCode(scriptType) : utxolib.bitgo.getInternalChainCode(scriptType); + const multi: MiniscriptNode = { + multi: [2, ...rootWalletKeys.triple.map((_, i) => expand(rootWalletKeys, i, chain))], + }; switch (scriptType) { case "p2sh": - return expand("sh(multi(2,$0,$1,$2))", rootWalletKeys, chain); + return formatNode({ sh: multi }); case "p2shP2wsh": - return expand("sh(wsh(multi(2,$0,$1,$2)))", rootWalletKeys, chain); + return formatNode({ sh: { wsh: multi } }); case "p2wsh": - return expand("wsh(multi(2,$0,$1,$2))", rootWalletKeys, chain); + return formatNode({ wsh: multi }); default: throw new Error(`Unsupported script type ${scriptType}`); } diff --git a/packages/wasm-miniscript/test/psbt.ts b/packages/wasm-miniscript/test/psbtFixedScriptCompat.ts similarity index 98% rename from packages/wasm-miniscript/test/psbt.ts rename to packages/wasm-miniscript/test/psbtFixedScriptCompat.ts index 0191dae..da891a5 100644 --- a/packages/wasm-miniscript/test/psbt.ts +++ b/packages/wasm-miniscript/test/psbtFixedScriptCompat.ts @@ -1,6 +1,6 @@ import * as utxolib from "@bitgo/utxo-lib"; import * as assert from "node:assert"; -import { getPsbtFixtures, PsbtStage } from "./psbtFixtures"; +import { getPsbtFixtures, PsbtStage } from "./psbtFixedScriptCompatFixtures"; import { Descriptor, Psbt } from "../js"; import { getDescriptorForScriptType } from "./descriptorUtil"; diff --git a/packages/wasm-miniscript/test/psbtFixtures.ts b/packages/wasm-miniscript/test/psbtFixedScriptCompatFixtures.ts similarity index 100% rename from packages/wasm-miniscript/test/psbtFixtures.ts rename to packages/wasm-miniscript/test/psbtFixedScriptCompatFixtures.ts diff --git a/packages/wasm-miniscript/test/psbtFromDescriptor.ts b/packages/wasm-miniscript/test/psbtFromDescriptor.ts new file mode 100644 index 0000000..2c058c4 --- /dev/null +++ b/packages/wasm-miniscript/test/psbtFromDescriptor.ts @@ -0,0 +1,113 @@ +import assert from "node:assert"; +import { BIP32Interface } from "@bitgo/utxo-lib"; +import { getKey } from "@bitgo/utxo-lib/dist/src/testutil"; + +import { DescriptorNode, formatNode } from "../js/ast"; +import { mockPsbtDefault } from "./psbtFromDescriptor.util"; +import { Descriptor } from "../js"; +import { toWrappedPsbt } from "./psbt.util"; + +function toKeyWithPath(k: BIP32Interface, path = "*"): string { + return k.toBase58() + "/" + path; +} + +function toKeyPlain(k: Buffer): string { + return k.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 describeSignDescriptor( + name: string, + descriptor: DescriptorNode, + signSeqs: BIP32Interface[][], +) { + describe(`psbt with descriptor ${name}`, function () { + const isTaproot = Object.keys(descriptor)[0] === "tr"; + const psbt = mockPsbtDefault({ + descriptorSelf: Descriptor.fromString(formatNode(descriptor), "derivable"), + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + function getSigResult(keys: BIP32Interface[]) { + return { + [isTaproot ? "Schnorr" : "Ecdsa"]: keys.map((key) => + key.publicKey.subarray(isTaproot ? 1 : 0).toString("hex"), + ), + }; + } + + signSeqs.forEach((signSeq, i) => { + it(`should sign ${signSeq.map((k) => getKeyName(k))} xprv`, function () { + const wrappedPsbt = toWrappedPsbt(psbt); + signSeq.forEach((key) => { + assert.deepStrictEqual(wrappedPsbt.signWithXprv(key.toBase58()), { + 0: getSigResult([key.derive(0)]), + 1: getSigResult([key.derive(1)]), + }); + }); + wrappedPsbt.finalize(); + }); + + 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)]), + 1: getSigResult([]), + }); + }); + }); + }); + }); +} + +describeSignDescriptor( + "Wsh2Of3", + { + wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] }, + }, + [ + [a, b], + [b, a], + ], +); + +describeSignDescriptor( + "Tr1Of3", + { + tr: [toKeyWithPath(a), [{ pk: toKeyWithPath(b) }, { pk: toKeyWithPath(c) }]], + }, + [[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) }] }, + ], + ], + }, + [[a]], +); diff --git a/packages/wasm-miniscript/test/psbtFromDescriptor.util.ts b/packages/wasm-miniscript/test/psbtFromDescriptor.util.ts new file mode 100644 index 0000000..a37af07 --- /dev/null +++ b/packages/wasm-miniscript/test/psbtFromDescriptor.util.ts @@ -0,0 +1,207 @@ +import * as utxolib from "@bitgo/utxo-lib"; +import { toUtxoPsbt, toWrappedPsbt } from "./psbt.util"; +import { Descriptor } from "../js"; + +export function createScriptPubKeyFromDescriptor(descriptor: Descriptor, index?: number): Buffer { + if (index === undefined) { + return Buffer.from(descriptor.scriptPubkey()); + } + return createScriptPubKeyFromDescriptor(descriptor.atDerivationIndex(index)); +} + +export type Output = { + script: Buffer; + value: bigint; +}; + +export type WithDescriptor = T & { + descriptor: Descriptor; +}; + +export type PrevOutput = { + hash: string; + index: number; + witnessUtxo: Output; +}; + +export type DescriptorWalletOutput = PrevOutput & { + descriptorName: string; + descriptorIndex: number; +}; + +export type DerivedDescriptorWalletOutput = WithDescriptor; + +export function toDerivedDescriptorWalletOutput( + output: DescriptorWalletOutput, + descriptor: Descriptor, +): DerivedDescriptorWalletOutput { + const derivedDescriptor = descriptor.atDerivationIndex(output.descriptorIndex); + const script = createScriptPubKeyFromDescriptor(derivedDescriptor); + if (!script.equals(output.witnessUtxo.script)) { + throw new Error( + `Script mismatch: descriptor ${output.descriptorName} ${descriptor.toString()} script=${script}`, + ); + } + return { + hash: output.hash, + index: output.index, + witnessUtxo: output.witnessUtxo, + descriptor: descriptor.atDerivationIndex(output.descriptorIndex), + }; +} + +/** + * Non-Final (Replaceable) + * Reference: https://github.com/bitcoin/bitcoin/blob/v25.1/src/rpc/rawtransaction_util.cpp#L49 + * */ +export const MAX_BIP125_RBF_SEQUENCE = 0xffffffff - 2; + +function updateInputsWithDescriptors(psbt: utxolib.bitgo.UtxoPsbt, descriptors: Descriptor[]) { + if (psbt.txInputs.length !== descriptors.length) { + throw new Error( + `Input count mismatch (psbt=${psbt.txInputs.length}, descriptors=${descriptors.length})`, + ); + } + const wrappedPsbt = toWrappedPsbt(psbt); + for (const [inputIndex, descriptor] of descriptors.entries()) { + wrappedPsbt.updateInputWithDescriptor(inputIndex, descriptor); + } + const unwrappedPsbt = toUtxoPsbt(wrappedPsbt); + for (const inputIndex in psbt.txInputs) { + psbt.data.inputs[inputIndex] = unwrappedPsbt.data.inputs[inputIndex]; + } +} + +function updateOutputsWithDescriptors( + psbt: utxolib.bitgo.UtxoPsbt, + descriptors: WithOptDescriptor[], +) { + const wrappedPsbt = toWrappedPsbt(psbt); + for (const [outputIndex, { descriptor }] of descriptors.entries()) { + if (descriptor) { + wrappedPsbt.updateOutputWithDescriptor(outputIndex, descriptor); + } + } + const unwrappedPsbt = toUtxoPsbt(wrappedPsbt); + for (const outputIndex in psbt.txOutputs) { + psbt.data.outputs[outputIndex] = unwrappedPsbt.data.outputs[outputIndex]; + } +} + +type WithOptDescriptor = T & { descriptor?: Descriptor }; + +export function createPsbt( + params: PsbtParams, + inputs: DerivedDescriptorWalletOutput[], + outputs: WithOptDescriptor[], +): utxolib.bitgo.UtxoPsbt { + const psbt = utxolib.bitgo.UtxoPsbt.createPsbt({ network: params.network }); + psbt.setVersion(params.version ?? 2); + psbt.setLocktime(params.locktime ?? 0); + psbt.addInputs( + inputs.map((i) => ({ ...i, sequence: params.sequence ?? MAX_BIP125_RBF_SEQUENCE })), + ); + psbt.addOutputs(outputs); + updateInputsWithDescriptors( + psbt, + inputs.map((i) => i.descriptor), + ); + updateOutputsWithDescriptors(psbt, outputs); + return psbt; +} + +type MockOutputIdParams = { hash?: string; vout?: number }; + +type BaseMockDescriptorOutputParams = { + id?: MockOutputIdParams; + index?: number; + value?: bigint; +}; + +function mockOutputId(id?: MockOutputIdParams): { + hash: string; + vout: number; +} { + const hash = id?.hash ?? Buffer.alloc(32, 1).toString("hex"); + const vout = id?.vout ?? 0; + return { hash, vout }; +} + +export function mockDerivedDescriptorWalletOutput( + descriptor: Descriptor, + outputParams: BaseMockDescriptorOutputParams = {}, +): DerivedDescriptorWalletOutput { + const { value = BigInt(1e6) } = outputParams; + const { hash, vout } = mockOutputId(outputParams.id); + return { + hash, + index: vout, + witnessUtxo: { + script: createScriptPubKeyFromDescriptor(descriptor), + value, + }, + descriptor, + }; +} + +type MockInput = BaseMockDescriptorOutputParams & { + index: number; + descriptor: Descriptor; +}; + +type MockOutput = { + descriptor: Descriptor; + index: number; + value: bigint; + external?: boolean; +}; + +export function mockPsbt( + inputs: MockInput[], + outputs: MockOutput[], + params: Partial = {}, +): utxolib.bitgo.UtxoPsbt { + return createPsbt( + { ...params, network: params.network ?? utxolib.networks.bitcoin }, + inputs.map((i) => + mockDerivedDescriptorWalletOutput(i.descriptor.atDerivationIndex(i.index), i), + ), + outputs.map((o) => { + const derivedDescriptor = o.descriptor.atDerivationIndex(o.index); + return { + script: createScriptPubKeyFromDescriptor(derivedDescriptor), + value: o.value, + descriptor: o.external ? undefined : derivedDescriptor, + }; + }), + ); +} + +export type PsbtParams = { + network: utxolib.Network; + version?: number; + locktime?: number; + sequence?: number; +}; + +export function mockPsbtDefault({ + descriptorSelf, + descriptorOther, + params = {}, +}: { + descriptorSelf: Descriptor; + descriptorOther: Descriptor; + params?: Partial; +}): utxolib.bitgo.UtxoPsbt { + return mockPsbt( + [ + { descriptor: descriptorSelf, index: 0 }, + { descriptor: descriptorSelf, index: 1, id: { vout: 1 } }, + ], + [ + { descriptor: descriptorOther, index: 0, value: BigInt(4e5), external: true }, + { descriptor: descriptorSelf, index: 0, value: BigInt(4e5) }, + ], + params, + ); +}