diff --git a/packages/wasm-miniscript/src/psbt.rs b/packages/wasm-miniscript/src/psbt.rs index 3c9a655..9fb32d4 100644 --- a/packages/wasm-miniscript/src/psbt.rs +++ b/packages/wasm-miniscript/src/psbt.rs @@ -43,6 +43,26 @@ impl WrapPsbt { } } + #[wasm_bindgen(js_name = updateOutputWithDescriptor)] + pub fn update_output_with_descriptor( + &mut self, + output_index: usize, + descriptor: WrapDescriptor, + ) -> Result<(), JsError> { + match descriptor.0 { + WrapDescriptorEnum::Definite(d) => self + .0 + .update_output_with_descriptor(output_index, &d) + .map_err(JsError::from), + WrapDescriptorEnum::Derivable(_, _) => Err(JsError::new( + "Cannot update output with a derivable descriptor", + )), + WrapDescriptorEnum::String(_) => Err(JsError::new( + "Cannot update output with a string descriptor", + )), + } + } + #[wasm_bindgen(js_name = finalize)] pub fn finalize_mut(&mut self) -> Result<(), JsError> { self.0 diff --git a/packages/wasm-miniscript/test/psbt.ts b/packages/wasm-miniscript/test/psbt.ts index 5632551..5aee0f7 100644 --- a/packages/wasm-miniscript/test/psbt.ts +++ b/packages/wasm-miniscript/test/psbt.ts @@ -1,59 +1,52 @@ import * as utxolib from "@bitgo/utxo-lib"; import * as assert from "node:assert"; -import { getPsbtFixtures, toPsbtWithPrevOutOnly } from "./psbtFixtures"; +import { getPsbtFixtures, PsbtStage } from "./psbtFixtures"; import { Descriptor, Psbt } from "../js"; import { getDescriptorForScriptType } from "./descriptorUtil"; +import { assertEqualPsbt, toUtxoPsbt, toWrappedPsbt, updateInputWithDescriptor } from "./psbt.util"; const rootWalletKeys = new utxolib.bitgo.RootWalletKeys(utxolib.testutil.getKeyTriple("wasm")); -function toWrappedPsbt(psbt: utxolib.bitgo.UtxoPsbt | Buffer | Uint8Array) { - if (psbt instanceof utxolib.bitgo.UtxoPsbt) { - psbt = psbt.toBuffer(); - } - if (psbt instanceof Buffer || psbt instanceof Uint8Array) { - return Psbt.deserialize(psbt); - } - throw new Error("Invalid input"); -} - -function toUtxoPsbt(psbt: Psbt | Buffer | Uint8Array) { - if (psbt instanceof Psbt) { - psbt = psbt.serialize(); - } - if (psbt instanceof Buffer || psbt instanceof Uint8Array) { - return utxolib.bitgo.UtxoPsbt.fromBuffer(Buffer.from(psbt), { - network: utxolib.networks.bitcoin, - }); - } - throw new Error("Invalid input"); -} - function assertEqualBuffer(a: Buffer | Uint8Array, b: Buffer | Uint8Array, message?: string) { assert.strictEqual(Buffer.from(a).toString("hex"), Buffer.from(b).toString("hex"), message); } const fixtures = getPsbtFixtures(rootWalletKeys); +function getWasmDescriptor( + scriptType: utxolib.bitgo.outputScripts.ScriptType2Of3, + scope: "internal" | "external", +) { + return Descriptor.fromString( + getDescriptorForScriptType(rootWalletKeys, scriptType, scope), + "derivable", + ); +} + function describeUpdateInputWithDescriptor( psbt: utxolib.bitgo.UtxoPsbt, scriptType: utxolib.bitgo.outputScripts.ScriptType2Of3, ) { - const fullSignedFixture = fixtures.find( - (f) => f.scriptType === scriptType && f.stage === "fullsigned", - ); - if (!fullSignedFixture) { - throw new Error("Could not find fullsigned fixture"); + function getFixtureAtStage(stage: PsbtStage) { + const f = fixtures.find((f) => f.scriptType === scriptType && f.stage === stage); + if (!f) { + throw new Error(`Could not find fixture for scriptType ${scriptType} and stage ${stage}`); + } + return f; } - describe("updateInputWithDescriptor", function () { + const descriptorStr = getDescriptorForScriptType(rootWalletKeys, scriptType, "internal"); + const index = 0; + const descriptor = Descriptor.fromString(descriptorStr, "derivable"); + + describe("Wrapped PSBT updateInputWithDescriptor", function () { it("should update the input with the descriptor", function () { - const descriptorStr = getDescriptorForScriptType(rootWalletKeys, scriptType, "internal"); - const index = 0; - const descriptor = Descriptor.fromString(descriptorStr, "derivable"); const wrappedPsbt = toWrappedPsbt(psbt); wrappedPsbt.updateInputWithDescriptor(0, descriptor.atDerivationIndex(index)); + wrappedPsbt.updateOutputWithDescriptor(0, descriptor.atDerivationIndex(index)); const updatedPsbt = toUtxoPsbt(wrappedPsbt); + assertEqualPsbt(updatedPsbt, getFixtureAtStage("unsigned").psbt); updatedPsbt.signAllInputsHD(rootWalletKeys.triple[0]); updatedPsbt.signAllInputsHD(rootWalletKeys.triple[2]); const wrappedSignedPsbt = toWrappedPsbt(updatedPsbt); @@ -63,11 +56,34 @@ function describeUpdateInputWithDescriptor( assertEqualBuffer(updatedPsbt.toBuffer(), wrappedSignedPsbt.serialize()); assertEqualBuffer( - fullSignedFixture.psbt.clone().finalizeAllInputs().extractTransaction().toBuffer(), + getFixtureAtStage("fullsigned") + .psbt.clone() + .finalizeAllInputs() + .extractTransaction() + .toBuffer(), updatedPsbt.extractTransaction().toBuffer(), ); }); }); + + describe("updateInputWithDescriptor util", function () { + it("should update the input with the descriptor", function () { + const cloned = psbt.clone(); + updateInputWithDescriptor(cloned, 0, descriptor.atDerivationIndex(index)); + cloned.signAllInputsHD(rootWalletKeys.triple[0]); + cloned.signAllInputsHD(rootWalletKeys.triple[2]); + cloned.finalizeAllInputs(); + + assertEqualBuffer( + getFixtureAtStage("fullsigned") + .psbt.clone() + .finalizeAllInputs() + .extractTransaction() + .toBuffer(), + cloned.extractTransaction().toBuffer(), + ); + }); + }); } fixtures.forEach(({ psbt, scriptType, stage }) => { diff --git a/packages/wasm-miniscript/test/psbt.util.ts b/packages/wasm-miniscript/test/psbt.util.ts new file mode 100644 index 0000000..e5cf18b --- /dev/null +++ b/packages/wasm-miniscript/test/psbt.util.ts @@ -0,0 +1,145 @@ +import * as assert from "node:assert"; +import * as utxolib from "@bitgo/utxo-lib"; +import { Descriptor, Psbt } from "../js"; + +function toAddress(descriptor: Descriptor, network: utxolib.Network) { + utxolib.address.fromOutputScript(Buffer.from(descriptor.scriptPubkey()), network); +} + +export function toWrappedPsbt(psbt: utxolib.bitgo.UtxoPsbt | utxolib.Psbt | Buffer | Uint8Array) { + if (psbt instanceof utxolib.bitgo.UtxoPsbt || psbt instanceof utxolib.Psbt) { + psbt = psbt.toBuffer(); + } + if (psbt instanceof Buffer || psbt instanceof Uint8Array) { + return Psbt.deserialize(psbt); + } + throw new Error("Invalid input"); +} + +export function toUtxoPsbt(psbt: Psbt | Buffer | Uint8Array) { + if (psbt instanceof Psbt) { + psbt = psbt.serialize(); + } + if (psbt instanceof Buffer || psbt instanceof Uint8Array) { + return utxolib.bitgo.UtxoPsbt.fromBuffer(Buffer.from(psbt), { + network: utxolib.networks.bitcoin, + }); + } + throw new Error("Invalid input"); +} + +export function updateInputWithDescriptor( + psbt: utxolib.Psbt, + inputIndex: number, + descriptor: Descriptor, +) { + const wrappedPsbt = toWrappedPsbt(psbt); + wrappedPsbt.updateInputWithDescriptor(inputIndex, descriptor); + psbt.data.inputs[inputIndex] = toUtxoPsbt(wrappedPsbt).data.inputs[inputIndex]; +} + +export function updateOutputWithDescriptor( + psbt: utxolib.Psbt, + outputIndex: number, + descriptor: Descriptor, +) { + const wrappedPsbt = toWrappedPsbt(psbt); + wrappedPsbt.updateOutputWithDescriptor(outputIndex, descriptor); + psbt.data.outputs[outputIndex] = toUtxoPsbt(wrappedPsbt).data.outputs[outputIndex]; +} + +export function finalizePsbt(psbt: utxolib.Psbt) { + const wrappedPsbt = toWrappedPsbt(psbt); + wrappedPsbt.finalize(); + const unwrappedPsbt = toUtxoPsbt(wrappedPsbt); + for (let i = 0; i < psbt.data.inputs.length; i++) { + psbt.data.inputs[i] = unwrappedPsbt.data.inputs[i]; + } +} + +function toEntries(k: string, v: unknown, path: (string | number)[]): [] | [[string, unknown]] { + if (matchPath(path, ["data", "inputs", any, "sighashType"])) { + return []; + } + if (matchPath(path.slice(-1), ["unknownKeyVals"])) { + if (Array.isArray(v) && v.length === 0) { + return []; + } + } + return [[k, toPlainObject(v, path)]]; +} + +const any = Symbol("any"); + +function matchPath(path: (string | number)[], pattern: (string | number | symbol)[]) { + if (path.length !== pattern.length) { + return false; + } + for (let i = 0; i < path.length; i++) { + if (pattern[i] !== any && path[i] !== pattern[i]) { + return false; + } + } + return true; +} + +function normalizeBip32Derivation(v: unknown) { + if (!Array.isArray(v)) { + throw new Error("Expected bip32Derivation to be an array"); + } + return ( + [...v] as { + masterFingerprint: Buffer; + path: string; + }[] + ) + .map((e) => { + let { path } = e; + if (path.startsWith("m/")) { + path = path.slice(2); + } + return { + ...e, + path, + }; + }) + .sort((a, b) => a.masterFingerprint.toString().localeCompare(b.masterFingerprint.toString())); +} + +function toPlainObject(v: unknown, path: (string | number)[]) { + // psbts have fun getters and other types of irregular properties that we mash into shape here + if (v === null || v === undefined) { + return v; + } + if ( + matchPath(path, ["data", "inputs", any, "bip32Derivation"]) || + matchPath(path, ["data", "outputs", any, "bip32Derivation"]) + ) { + v = normalizeBip32Derivation(v); + } + switch (typeof v) { + case "number": + case "bigint": + case "string": + case "boolean": + return v; + case "object": + if (v instanceof Buffer || v instanceof Uint8Array) { + return v.toString("hex"); + } + if (Array.isArray(v)) { + return v.map((v, i) => toPlainObject(v, [...path, i])); + } + return Object.fromEntries( + Object.entries(v) + .flatMap(([k, v]) => toEntries(k, v, [...path, k])) + .sort(([a], [b]) => a.localeCompare(b)), + ); + default: + throw new Error(`Unsupported type: ${typeof v}`); + } +} + +export function assertEqualPsbt(a: utxolib.Psbt, b: utxolib.Psbt) { + assert.deepStrictEqual(toPlainObject(a, []), toPlainObject(b, [])); +} diff --git a/packages/wasm-miniscript/test/psbtFixtures.ts b/packages/wasm-miniscript/test/psbtFixtures.ts index d5f2378..f9e6911 100644 --- a/packages/wasm-miniscript/test/psbtFixtures.ts +++ b/packages/wasm-miniscript/test/psbtFixtures.ts @@ -1,7 +1,7 @@ import * as utxolib from "@bitgo/utxo-lib"; import { RootWalletKeys } from "@bitgo/utxo-lib/dist/src/bitgo"; -type PsbtStage = "bare" | "unsigned" | "halfsigned" | "fullsigned"; +export type PsbtStage = "bare" | "unsigned" | "halfsigned" | "fullsigned"; export function toPsbtWithPrevOutOnly(psbt: utxolib.bitgo.UtxoPsbt) { const psbtCopy = utxolib.bitgo.UtxoPsbt.createPsbt({ @@ -43,7 +43,8 @@ function getPsbtWithScriptTypeAndStage( [ { value: BigInt(1e8 - 1000), - scriptType: "p2sh", + scriptType, + isInternalAddress: true, }, ], utxolib.networks.bitcoin,