diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index 23cd94fc2ea..7b802b37fb9 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -123,6 +123,12 @@ declare module "./wasm/wasm_utxo.js" { tapBip32Derivation: PsbtBip32Derivation[]; } + /** A transparent input's previous-output outpoint (display-order txid) returned by getInputOutpoints() */ + interface PsbtInputOutpoint { + txid: string; + vout: number; + } + /** A serialized input-map record with a standard PSBT key type. */ interface PsbtKnownInputKeyValue { type: "known"; diff --git a/packages/wasm-utxo/js/psbtBase.ts b/packages/wasm-utxo/js/psbtBase.ts index 0bd67b54325..83cfa15bef3 100644 --- a/packages/wasm-utxo/js/psbtBase.ts +++ b/packages/wasm-utxo/js/psbtBase.ts @@ -1,6 +1,7 @@ import type { PsbtInputData, PsbtInputKeyValue, + PsbtInputOutpoint, PsbtOutputData, WasmBIP32, } from "./wasm/wasm_utxo.js"; @@ -15,6 +16,7 @@ interface WasmPsbtBase { unsigned_tx_id(): string; serialize(): Uint8Array; get_inputs(): unknown; + get_input_outpoints(): unknown; get_input_key_values(index: number): unknown; get_outputs(): unknown; get_global_xpubs(): unknown; @@ -59,6 +61,9 @@ export abstract class PsbtBase { getInputs(): PsbtInputData[] { return this._wasm.get_inputs() as PsbtInputData[]; } + getInputOutpoints(): PsbtInputOutpoint[] { + return this._wasm.get_input_outpoints() as PsbtInputOutpoint[]; + } getInputKeyValues(index: number): PsbtInputKeyValue[] { return this._wasm.get_input_key_values(index) as PsbtInputKeyValue[]; } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/v6.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/v6.rs index 4ea95800570..4c13874056f 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/v6.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/v6.rs @@ -16,6 +16,59 @@ use orchard::tree::Anchor; use orchard::Proof; use rand::rngs::OsRng; +/// `input_outpoints` is version-symmetric: the same mock outpoint spent by a v4 and a v6 +/// PSBT yields the identical display-order `{txid, vout}` pair — the wasm counterpart of +/// utxo-lib's raw `txInputs` + `getOutputIdForInput`, so unspent-id extraction (bms's +/// PA-creation path) behaves the same for both versions. +#[test] +fn input_outpoints_are_version_symmetric() { + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("outpoints")); + let mock_txid = Txid::from_byte_array([0x44u8; 32]); + let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); + + let mut v6 = + BitGoPsbt::new_zcash_v6_at_height(Network::ZcashTestnet, &wallet_keys, nu6_3, None, None) + .unwrap(); + let mut v4 = BitGoPsbt::new_zcash_at_height( + Network::ZcashTestnet, + &wallet_keys, + 2_000_000, + None, + None, + None, + None, + ) + .unwrap(); + for psbt in [&mut v6, &mut v4] { + psbt.add_wallet_input( + mock_txid, + 7, + 200_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap(); + } + + let BitGoPsbt::Zcash(z6, _) = &v6 else { + panic!("expected Zcash PSBT"); + }; + let BitGoPsbt::Zcash(z4, _) = &v4 else { + panic!("expected Zcash PSBT"); + }; + let out6 = crate::wasm::input_outpoints(&z6.psbt); + let out4 = crate::wasm::input_outpoints(&z4.psbt); + assert_eq!(out6, out4, "v4 and v6 outpoints must be identical"); + assert_eq!(out6.len(), 1); + assert_eq!( + out6[0].txid, + mock_txid.to_string(), + "txid must be in display (reversed) order, like utxo-lib's getOutputIdForInput" + ); + assert_eq!(out6[0].vout, 7); +} + /// A transparent-only v6 (Ironwood) PSBT — no shielded output is ever added — is the shape /// IMS will build once t->t ZEC sends move to v6 ahead of a shielded-send path existing for /// them. Verifies the dedicated no-PCZT extractor produces a structurally valid, zebra-chain- diff --git a/packages/wasm-utxo/src/wasm/mod.rs b/packages/wasm-utxo/src/wasm/mod.rs index dda613a99ea..0bdae0e378e 100644 --- a/packages/wasm-utxo/src/wasm/mod.rs +++ b/packages/wasm-utxo/src/wasm/mod.rs @@ -33,6 +33,7 @@ pub use inscriptions::InscriptionsNamespace; pub use message::MessageNamespace; pub use miniscript::WrapMiniscript; pub use package_info::WasmUtxoNamespace; +pub use psbt::input_outpoints; pub use psbt::WrapPsbt; pub use replay_protection::WasmReplayProtection; pub use transaction::{WasmTransaction, WasmZcashTransaction}; diff --git a/packages/wasm-utxo/src/wasm/psbt.rs b/packages/wasm-utxo/src/wasm/psbt.rs index b368230b4d7..afa6c612b0b 100644 --- a/packages/wasm-utxo/src/wasm/psbt.rs +++ b/packages/wasm-utxo/src/wasm/psbt.rs @@ -45,6 +45,15 @@ pub struct PsbtInputData { pub tap_bip32_derivation: Vec, } +/// A transparent input's previous-output outpoint, with the txid in display (reversed) +/// order — the wasm counterpart of utxo-lib's raw `txInputs` access: callers that only +/// need the spent outpoints (e.g. unspent-id extraction) behave the same for v4 and v6. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PsbtInputOutpoint { + pub txid: String, + pub vout: u32, +} + /// A PSBT input key classified from its BIP-174 key type. #[derive(Debug, Clone)] pub enum PsbtInputKey { @@ -180,6 +189,26 @@ pub fn get_inputs_from_psbt(psbt: &Psbt) -> Result { inputs.try_to_js_value() } +/// Get every transparent input's previous-output outpoint from the embedded unsigned +/// transaction, in input order — the wasm counterpart of utxo-lib's `txInputs` + +/// `getOutputIdForInput` introspection, so callers extracting unspent ids behave +/// identically for v4 and v6 PSBTs. +pub fn get_input_outpoints_from_psbt(psbt: &Psbt) -> Result { + input_outpoints(psbt).try_to_js_value() +} + +/// The outpoints alone, as plain Rust values — separated from the JsValue wrapper so the +/// extraction is unit-testable on non-wasm targets. +pub fn input_outpoints(psbt: &Psbt) -> Vec { + psbt.unsigned_tx + .input + .iter() + .map(|txin| PsbtInputOutpoint { + txid: txin.previous_output.txid.to_string(), + vout: txin.previous_output.vout, + }) + .collect() +} /// Get every serialized key-value record for one PSBT input. pub fn get_input_key_values_from_psbt( psbt: &Psbt, @@ -886,6 +915,11 @@ macro_rules! impl_wasm_psbt_ops { ) -> Result<::wasm_bindgen::JsValue, $crate::error::WasmUtxoError> { self.wasm_get_inputs() } + pub fn get_input_outpoints( + &self, + ) -> Result<::wasm_bindgen::JsValue, $crate::error::WasmUtxoError> { + self.wasm_get_input_outpoints() + } pub fn get_input_key_values( &self, index: usize, @@ -1000,6 +1034,11 @@ macro_rules! impl_wasm_psbt_ops { ) -> Result<::wasm_bindgen::JsValue, $crate::error::WasmUtxoError> { self.$field.wasm_get_inputs() } + pub fn get_input_outpoints( + &self, + ) -> Result<::wasm_bindgen::JsValue, $crate::error::WasmUtxoError> { + self.$field.wasm_get_input_outpoints() + } pub fn get_input_key_values( &self, index: usize, diff --git a/packages/wasm-utxo/src/wasm/psbt_ops.rs b/packages/wasm-utxo/src/wasm/psbt_ops.rs index 3a084476878..d5e309f5348 100644 --- a/packages/wasm-utxo/src/wasm/psbt_ops.rs +++ b/packages/wasm-utxo/src/wasm/psbt_ops.rs @@ -37,6 +37,9 @@ pub(crate) trait WasmPsbtOps: PsbtAccess { fn wasm_get_inputs(&self) -> Result { crate::wasm::psbt::get_inputs_from_psbt(self.psbt()) } + fn wasm_get_input_outpoints(&self) -> Result { + crate::wasm::psbt::get_input_outpoints_from_psbt(self.psbt()) + } fn wasm_get_input_key_values(&self, index: usize) -> Result { crate::wasm::psbt::get_input_key_values_from_psbt(self.psbt(), index) diff --git a/packages/wasm-utxo/src/wasm/try_into_js_value.rs b/packages/wasm-utxo/src/wasm/try_into_js_value.rs index f83585fbc00..317f9039631 100644 --- a/packages/wasm-utxo/src/wasm/try_into_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_into_js_value.rs @@ -526,6 +526,15 @@ impl TryIntoJsValue for crate::wasm::psbt::PsbtInputData { } } +impl TryIntoJsValue for crate::wasm::psbt::PsbtInputOutpoint { + fn try_to_js_value(&self) -> Result { + js_obj!( + "txid" => self.txid.clone(), + "vout" => self.vout + ) + } +} + impl TryIntoJsValue for crate::wasm::psbt::PsbtInputKeyValue { fn try_to_js_value(&self) -> Result { match &self.key { diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts index dba4ea11b36..23140fc083e 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts @@ -1298,3 +1298,22 @@ describe("ZcashIronwoodBitGoPsbt v6 (Ironwood)", function () { }); }); }); + +describe("getInputOutpoints", () => { + it("is version-symmetric: a v4 and a v6 PSBT spending the same outpoint return identical {txid, vout} in display order", () => { + const keys = getWalletKeysForSeed("outpoints"); + // asymmetric txid so the display-order assertion cannot pass by accident + const mockTxid = `a1${"00".repeat(30)}b2`; + const v6 = ZcashIronwoodBitGoPsbt.createEmpty("tzec", keys, { + blockHeight: NU6_3_TESTNET_HEIGHT + 1, + }); + const v4 = ZcashBitGoPsbt.createEmpty("tzec", keys, { blockHeight: 2_000_000 }); + for (const psbt of [v6, v4]) { + psbt.addWalletInput({ txid: mockTxid, vout: 7, value: 200_000_000n }, keys, { + scriptId: SCRIPT_ID, + }); + } + assert.deepEqual(v6.getInputOutpoints(), v4.getInputOutpoints()); + assert.deepEqual(v6.getInputOutpoints(), [{ txid: mockTxid, vout: 7 }]); + }); +});