From 1b5c6aa2e0507530119a98ba0ca4f1409b048e66 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Fri, 21 Aug 2026 16:13:29 +0200 Subject: [PATCH] feat(wasm-utxo): finalize descriptor inputs with preimages Expose standard BIP174 SHA256 preimage metadata and single-input Miniscript finalization for PoX-5 early-exit satisfaction. Keep generated WASM exports idiomatic and verify per-input finalization with an incomplete input. Refs: WAL-1911 --- .../wasm-utxo/js/descriptorWallet/Psbt.ts | 10 +++ packages/wasm-utxo/src/wasm/psbt.rs | 29 +++++++ packages/wasm-utxo/test/pox5.ts | 81 ++++++++++++++++++- 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/packages/wasm-utxo/js/descriptorWallet/Psbt.ts b/packages/wasm-utxo/js/descriptorWallet/Psbt.ts index dbc06cb7953..87faaedc2cc 100644 --- a/packages/wasm-utxo/js/descriptorWallet/Psbt.ts +++ b/packages/wasm-utxo/js/descriptorWallet/Psbt.ts @@ -89,6 +89,11 @@ export class Psbt extends PsbtBase implements IPsbt { this._wasm.update_output_with_descriptor(outputIndex, descriptor); } + /** Add a 32-byte SHA256 preimage for Miniscript descriptor finalization. */ + addSha256Preimage(inputIndex: number, preimage: Uint8Array): void { + this._wasm.add_sha256_preimage(inputIndex, preimage); + } + // -- Signing -- signWithXprv(xprv: string): SignPsbtResult { @@ -140,6 +145,11 @@ export class Psbt extends PsbtBase implements IPsbt { this._wasm.finalize_mut(); } + /** Finalize one Miniscript input without requiring all inputs to be complete. */ + finalizeInput(inputIndex: number): void { + this._wasm.finalize_input(inputIndex); + } + extractTransaction(): Transaction { return Transaction.fromWasm(this._wasm.extract_transaction()); } diff --git a/packages/wasm-utxo/src/wasm/psbt.rs b/packages/wasm-utxo/src/wasm/psbt.rs index 29e73a38daa..514c8c86dd4 100644 --- a/packages/wasm-utxo/src/wasm/psbt.rs +++ b/packages/wasm-utxo/src/wasm/psbt.rs @@ -8,6 +8,7 @@ use crate::wasm::psbt_ops::WasmPsbtOps; use crate::wasm::try_into_js_value::TryIntoJsValue; use crate::wasm::WrapDescriptor; use crate::zcash::transaction::{ZcashTransactionParts, ZCASH_SAPLING_VERSION_GROUP_ID}; +use miniscript::bitcoin::hashes::{sha256, Hash}; use miniscript::bitcoin::locktime::absolute::LockTime; use miniscript::bitcoin::secp256k1::Secp256k1; use miniscript::bitcoin::transaction::{Transaction, Version}; @@ -358,6 +359,27 @@ impl WrapPsbt { } } + /// Add a 32-byte SHA256 preimage to an input's standard BIP174 metadata. + /// + /// Descriptor finalization uses this metadata to satisfy `sha256(H)` + /// Miniscript fragments when `H == sha256(preimage)`. + pub fn add_sha256_preimage( + &mut self, + input_index: usize, + preimage: Vec, + ) -> Result<(), WasmUtxoError> { + let preimage: [u8; 32] = preimage + .try_into() + .map_err(|_| WasmUtxoError::new("sha256 preimage must be 32 bytes"))?; + let input = self.0.inputs.get_mut(input_index).ok_or_else(|| { + WasmUtxoError::new(&format!("Input index {} out of bounds", input_index)) + })?; + input + .sha256_preimages + .insert(sha256::Hash::hash(&preimage), preimage.to_vec()); + Ok(()) + } + pub fn sign_with_xprv(&mut self, xprv: String) -> Result { let key = bip32::Xpriv::from_str(&xprv).map_err(|_| WasmUtxoError::new("Invalid xprv"))?; self.0 @@ -528,6 +550,13 @@ impl WrapPsbt { .map_err(WasmUtxoError::from_errors) } + /// Finalize one Miniscript input, preserving any other incomplete inputs. + pub fn finalize_input(&mut self, input_index: usize) -> Result<(), WasmUtxoError> { + self.0 + .finalize_inp_mut(&Secp256k1::verification_only(), input_index) + .map_err(|error| WasmUtxoError::new(&error.to_string())) + } + /// Finalize all Zcash transparent inputs using ZIP-243 sighash verification. /// /// Use this instead of `finalize_mut()` for Zcash PSBTs signed with diff --git a/packages/wasm-utxo/test/pox5.ts b/packages/wasm-utxo/test/pox5.ts index 12b46685edd..3f7b514b682 100644 --- a/packages/wasm-utxo/test/pox5.ts +++ b/packages/wasm-utxo/test/pox5.ts @@ -6,7 +6,8 @@ import { type DescriptorNode, type MiniscriptNode, } from "../js/ast/index.js"; -import { Descriptor, Miniscript } from "../js/index.js"; +import { Descriptor, Miniscript, Psbt } from "../js/index.js"; +import { getKey, getKeyTriple } from "../js/testutils/keys.js"; // PoX-5 Bitcoin Staking lockup script (P2WSH + CLTV conditional branch). // @@ -72,6 +73,64 @@ const POX5_DESCRIPTOR_NODE: DescriptorNode = { wsh: POX5_MINISCRIPT_NODE }; const POX5_MINISCRIPT = formatNode(POX5_MINISCRIPT_NODE); const POX5_DESCRIPTOR = formatNode(POX5_DESCRIPTOR_NODE); +function createEarlyExitPsbt(): { + psbt: Psbt; + principalPreimage: Buffer; +} { + const [user, backup, bitgo] = getKeyTriple("pox5-finalization"); + const earlyExit = getKey("pox5-early-exit"); + const incompleteKey = getKey("pox5-incomplete"); + const principalPreimage = Buffer.alloc(32, 0x42); + const descriptor = Descriptor.fromString( + formatNode({ + wsh: { + and_v: [ + { + "v:or_i": [ + { after: UNLOCK_HEIGHT }, + { + and_v: [ + { + "v:sha256": crypto.createHash("sha256").update(principalPreimage).digest("hex"), + }, + { pk: Buffer.from(earlyExit.publicKey).toString("hex") }, + ], + }, + ], + }, + { + multi: [ + 2, + Buffer.from(user.publicKey).toString("hex"), + Buffer.from(backup.publicKey).toString("hex"), + Buffer.from(bitgo.publicKey).toString("hex"), + ], + }, + ], + }, + }), + "definite", + ); + const scriptPubKey = descriptor.scriptPubkey(); + const psbt = Psbt.create(2, 0); + psbt.addInput("01".repeat(32), 0, 100_000n, scriptPubKey, 0xfffffffe); + psbt.addOutput(scriptPubKey, 90_000n); + psbt.updateInputWithDescriptor(0, descriptor); + + const incompleteDescriptor = Descriptor.fromString( + formatNode({ wsh: { pk: Buffer.from(incompleteKey.publicKey).toString("hex") } }), + "definite", + ); + psbt.addInput("02".repeat(32), 0, 100_000n, incompleteDescriptor.scriptPubkey(), 0xfffffffe); + psbt.updateInputWithDescriptor(1, incompleteDescriptor); + + for (const key of [user, backup, earlyExit]) { + assert.ok(key.privateKey, "test key must include private key material"); + psbt.signWithPrv(key.privateKey); + } + return { psbt, principalPreimage }; +} + // Expected script flow, verified structurally against construct-lockup-script (pox-5.clar:3711-3732). // // OP_IF OP_CLTV @@ -162,6 +221,26 @@ describe("PoX-5 Bitcoin Staking lockup script", function () { }); }); + describe("PSBT early-exit finalization", function () { + it("satisfies the SHA256 branch from standard PSBT preimage metadata", function () { + const { psbt, principalPreimage } = createEarlyExitPsbt(); + + // Leave a second descriptor input incomplete to prove this only finalizes + // the requested input rather than requiring every input to be complete. + assert.throws(() => psbt.finalizeInput(0), /satisfy|preimage|finalize/i); + psbt.addSha256Preimage(0, principalPreimage); + psbt.finalizeInput(0); + + assert.deepStrictEqual(psbt.getPartialSignatures(0), []); + assert.throws(() => psbt.finalizeInput(1), /satisfy|signature|finalize/i); + }); + + it("rejects non-32-byte SHA256 preimages", function () { + const { psbt } = createEarlyExitPsbt(); + assert.throws(() => psbt.addSha256Preimage(0, Buffer.alloc(31)), /32 bytes/); + }); + }); + describe("AST round-trip", function () { it("fromDescriptor produces expected formatNode output", function () { const desc = Descriptor.fromString(POX5_DESCRIPTOR, "definite");