From 2788a62eb651129706b27e617c3da895cfc5edd2 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 2 Oct 2024 11:12:26 +0200 Subject: [PATCH 1/6] refactor: move psbt util functions to separate file Issue: BTC-1451 --- packages/wasm-miniscript/test/psbt.ts | 25 ++-------------------- packages/wasm-miniscript/test/psbt.util.ts | 24 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 23 deletions(-) create mode 100644 packages/wasm-miniscript/test/psbt.util.ts diff --git a/packages/wasm-miniscript/test/psbt.ts b/packages/wasm-miniscript/test/psbt.ts index 5632551..6458b0b 100644 --- a/packages/wasm-miniscript/test/psbt.ts +++ b/packages/wasm-miniscript/test/psbt.ts @@ -1,34 +1,13 @@ import * as utxolib from "@bitgo/utxo-lib"; import * as assert from "node:assert"; -import { getPsbtFixtures, toPsbtWithPrevOutOnly } from "./psbtFixtures"; +import { getPsbtFixtures } from "./psbtFixtures"; import { Descriptor, Psbt } from "../js"; import { getDescriptorForScriptType } from "./descriptorUtil"; +import { toUtxoPsbt, toWrappedPsbt } 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); } diff --git a/packages/wasm-miniscript/test/psbt.util.ts b/packages/wasm-miniscript/test/psbt.util.ts new file mode 100644 index 0000000..eed9a64 --- /dev/null +++ b/packages/wasm-miniscript/test/psbt.util.ts @@ -0,0 +1,24 @@ +import * as utxolib from "@bitgo/utxo-lib"; +import { Psbt } from "../js"; + +export 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"); +} + +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"); +} \ No newline at end of file From 445fd1bc8bae9ff34a7d1548a5eefd0c92a1d8d2 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 2 Oct 2024 11:18:13 +0200 Subject: [PATCH 2/6] feat: add updateInputWithDescriptor util This util function allows working with native UtxoPsbt Issue: BTC-1451 --- packages/wasm-miniscript/test/psbt.ts | 26 +++++++++++++++++----- packages/wasm-miniscript/test/psbt.util.ts | 14 ++++++++++-- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/packages/wasm-miniscript/test/psbt.ts b/packages/wasm-miniscript/test/psbt.ts index 6458b0b..473e318 100644 --- a/packages/wasm-miniscript/test/psbt.ts +++ b/packages/wasm-miniscript/test/psbt.ts @@ -4,7 +4,7 @@ import { getPsbtFixtures } from "./psbtFixtures"; import { Descriptor, Psbt } from "../js"; import { getDescriptorForScriptType } from "./descriptorUtil"; -import { toUtxoPsbt, toWrappedPsbt } from "./psbt.util"; +import { toUtxoPsbt, toWrappedPsbt, updateInputWithDescriptor } from "./psbt.util"; const rootWalletKeys = new utxolib.bitgo.RootWalletKeys(utxolib.testutil.getKeyTriple("wasm")); @@ -25,11 +25,12 @@ function describeUpdateInputWithDescriptor( throw new Error("Could not find fullsigned fixture"); } - 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)); const updatedPsbt = toUtxoPsbt(wrappedPsbt); @@ -47,6 +48,21 @@ function describeUpdateInputWithDescriptor( ); }); }); + + 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( + fullSignedFixture.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 index eed9a64..7cd0dda 100644 --- a/packages/wasm-miniscript/test/psbt.util.ts +++ b/packages/wasm-miniscript/test/psbt.util.ts @@ -1,5 +1,5 @@ import * as utxolib from "@bitgo/utxo-lib"; -import { Psbt } from "../js"; +import { Descriptor, Psbt } from "../js"; export function toWrappedPsbt(psbt: utxolib.bitgo.UtxoPsbt | Buffer | Uint8Array) { if (psbt instanceof utxolib.bitgo.UtxoPsbt) { @@ -21,4 +21,14 @@ export function toUtxoPsbt(psbt: Psbt | Buffer | Uint8Array) { }); } throw new Error("Invalid input"); -} \ No newline at end of file +} + +export function updateInputWithDescriptor( + psbt: utxolib.bitgo.UtxoPsbt, + inputIndex: number, + descriptor: Descriptor, +) { + const wrappedPsbt = toWrappedPsbt(psbt); + wrappedPsbt.updateInputWithDescriptor(inputIndex, descriptor); + psbt.data.inputs[inputIndex] = toUtxoPsbt(wrappedPsbt).data.inputs[inputIndex]; +} From d8b182b93f3c154ff9a62903e9293ee697485c4a Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 2 Oct 2024 13:42:23 +0200 Subject: [PATCH 3/6] feat: add finalizePsbt util Issue: BTC-1451 --- packages/wasm-miniscript/test/psbt.util.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/wasm-miniscript/test/psbt.util.ts b/packages/wasm-miniscript/test/psbt.util.ts index 7cd0dda..ecfab87 100644 --- a/packages/wasm-miniscript/test/psbt.util.ts +++ b/packages/wasm-miniscript/test/psbt.util.ts @@ -1,8 +1,12 @@ import * as utxolib from "@bitgo/utxo-lib"; import { Descriptor, Psbt } from "../js"; -export function toWrappedPsbt(psbt: utxolib.bitgo.UtxoPsbt | Buffer | Uint8Array) { - if (psbt instanceof utxolib.bitgo.UtxoPsbt) { +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) { @@ -24,7 +28,7 @@ export function toUtxoPsbt(psbt: Psbt | Buffer | Uint8Array) { } export function updateInputWithDescriptor( - psbt: utxolib.bitgo.UtxoPsbt, + psbt: utxolib.Psbt, inputIndex: number, descriptor: Descriptor, ) { @@ -32,3 +36,12 @@ export function updateInputWithDescriptor( wrappedPsbt.updateInputWithDescriptor(inputIndex, descriptor); psbt.data.inputs[inputIndex] = toUtxoPsbt(wrappedPsbt).data.inputs[inputIndex]; } + +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]; + } +} From e8b4e5e886f4c52763d601ba8c9ee82851bfea1c Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Thu, 24 Oct 2024 17:53:30 +0200 Subject: [PATCH 4/6] feat: add updateOutputWithDescriptor to WrapPsbt Issue: BTC-1451 --- packages/wasm-miniscript/src/psbt.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/wasm-miniscript/src/psbt.rs b/packages/wasm-miniscript/src/psbt.rs index 3c9a655..480d661 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 From f4c5db240d3e6fea0baf578e14f327068eef8340 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Thu, 24 Oct 2024 17:53:42 +0200 Subject: [PATCH 5/6] feat: add tests for updateOutputWithDescriptor Issue: BTC-1451 --- packages/wasm-miniscript/test/psbt.ts | 39 ++++++-- packages/wasm-miniscript/test/psbt.util.ts | 98 +++++++++++++++++++ packages/wasm-miniscript/test/psbtFixtures.ts | 5 +- 3 files changed, 131 insertions(+), 11 deletions(-) diff --git a/packages/wasm-miniscript/test/psbt.ts b/packages/wasm-miniscript/test/psbt.ts index 473e318..5aee0f7 100644 --- a/packages/wasm-miniscript/test/psbt.ts +++ b/packages/wasm-miniscript/test/psbt.ts @@ -1,10 +1,10 @@ import * as utxolib from "@bitgo/utxo-lib"; import * as assert from "node:assert"; -import { getPsbtFixtures } from "./psbtFixtures"; +import { getPsbtFixtures, PsbtStage } from "./psbtFixtures"; import { Descriptor, Psbt } from "../js"; import { getDescriptorForScriptType } from "./descriptorUtil"; -import { toUtxoPsbt, toWrappedPsbt, updateInputWithDescriptor } from "./psbt.util"; +import { assertEqualPsbt, toUtxoPsbt, toWrappedPsbt, updateInputWithDescriptor } from "./psbt.util"; const rootWalletKeys = new utxolib.bitgo.RootWalletKeys(utxolib.testutil.getKeyTriple("wasm")); @@ -14,15 +14,26 @@ function assertEqualBuffer(a: Buffer | Uint8Array, b: Buffer | Uint8Array, messa 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; } const descriptorStr = getDescriptorForScriptType(rootWalletKeys, scriptType, "internal"); @@ -33,7 +44,9 @@ function describeUpdateInputWithDescriptor( it("should update the input with the descriptor", function () { 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); @@ -43,7 +56,11 @@ function describeUpdateInputWithDescriptor( assertEqualBuffer(updatedPsbt.toBuffer(), wrappedSignedPsbt.serialize()); assertEqualBuffer( - fullSignedFixture.psbt.clone().finalizeAllInputs().extractTransaction().toBuffer(), + getFixtureAtStage("fullsigned") + .psbt.clone() + .finalizeAllInputs() + .extractTransaction() + .toBuffer(), updatedPsbt.extractTransaction().toBuffer(), ); }); @@ -58,7 +75,11 @@ function describeUpdateInputWithDescriptor( cloned.finalizeAllInputs(); assertEqualBuffer( - fullSignedFixture.psbt.clone().finalizeAllInputs().extractTransaction().toBuffer(), + getFixtureAtStage("fullsigned") + .psbt.clone() + .finalizeAllInputs() + .extractTransaction() + .toBuffer(), cloned.extractTransaction().toBuffer(), ); }); diff --git a/packages/wasm-miniscript/test/psbt.util.ts b/packages/wasm-miniscript/test/psbt.util.ts index ecfab87..e5cf18b 100644 --- a/packages/wasm-miniscript/test/psbt.util.ts +++ b/packages/wasm-miniscript/test/psbt.util.ts @@ -1,3 +1,4 @@ +import * as assert from "node:assert"; import * as utxolib from "@bitgo/utxo-lib"; import { Descriptor, Psbt } from "../js"; @@ -37,6 +38,16 @@ export function updateInputWithDescriptor( 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(); @@ -45,3 +56,90 @@ export function finalizePsbt(psbt: utxolib.Psbt) { 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, From 8520223b33176ccdd279a2878b62a7cfd261c959 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Thu, 24 Oct 2024 18:10:23 +0200 Subject: [PATCH 6/6] fixup! feat: add updateOutputWithDescriptor to WrapPsbt --- packages/wasm-miniscript/src/psbt.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/wasm-miniscript/src/psbt.rs b/packages/wasm-miniscript/src/psbt.rs index 480d661..9fb32d4 100644 --- a/packages/wasm-miniscript/src/psbt.rs +++ b/packages/wasm-miniscript/src/psbt.rs @@ -57,9 +57,9 @@ impl WrapPsbt { WrapDescriptorEnum::Derivable(_, _) => Err(JsError::new( "Cannot update output with a derivable descriptor", )), - WrapDescriptorEnum::String(_) => { - Err(JsError::new("Cannot update output with a string descriptor")) - } + WrapDescriptorEnum::String(_) => Err(JsError::new( + "Cannot update output with a string descriptor", + )), } }