From 770c516f839a0a597decbb03c88d9bd9e6e61d61 Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Fri, 30 Oct 2026 14:09:03 -0400 Subject: [PATCH] feat: path-agnostic wallet PSBT parsing and wallet prefix helpers Add deriveWalletAccount and deriveWalletPrefix (44//

/

) with the shared test vectors. Parse, explain and getWalletKeysFromPsbt now classify inputs and outputs by the full derivation path recorded in the PSBT, matching key origins by fingerprint and deriving a missing key (taproot and MuSig2 inputs) along a present entry's path; the script comparison still binds the result. A non-matching output returns None instead of aborting the parse. Hardened derivation prefixes return a typed error instead of panicking, and add-input accepts an optional expectedScript that fails with WalletInputScriptMismatch. Ticket: WCN-3934 --- .../js/fixedScriptWallet/BitGoPsbt.ts | 27 +- .../wasm-utxo/js/fixedScriptWallet/index.ts | 5 + .../js/fixedScriptWallet/walletPrefix.ts | 74 ++++++ packages/wasm-utxo/src/error.rs | 30 +++ .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 116 ++++++++- .../bitgo_psbt/psbt_wallet_input.rs | 66 ++++- .../bitgo_psbt/psbt_wallet_output.rs | 4 +- .../wasm-utxo/src/fixed_script_wallet/mod.rs | 1 + .../src/fixed_script_wallet/wallet_keys.rs | 85 ++++++- .../src/fixed_script_wallet/wallet_prefix.rs | 185 ++++++++++++++ .../fixed_script_wallet/wallet_scripts/mod.rs | 236 +++++++++++++----- .../src/wasm/fixed_script_wallet/mod.rs | 65 +++-- packages/wasm-utxo/src/wasm/wallet_keys.rs | 7 +- 13 files changed, 798 insertions(+), 103 deletions(-) create mode 100644 packages/wasm-utxo/js/fixedScriptWallet/walletPrefix.ts create mode 100644 packages/wasm-utxo/src/fixed_script_wallet/wallet_prefix.rs diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index a0738051b05..665185b8286 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -40,7 +40,11 @@ export type ParsedInput = { scriptId: ScriptId | null; scriptType: InputScriptType; sequence: number; - /** Full BIP32 derivation path from the wallet xpub (e.g. "0/1"). Null for replay-protection inputs. */ + /** + * Full BIP32 derivation path recorded in the PSBT, from the wallet root key: the prefix followed + * by `[chain, index]` (e.g. "0/0/20/5" for legacy, "44/0/

/

/20/5" for BIP44-style). + * The prefix is everything except the last two components. Null for replay-protection inputs. + */ derivationPath: string | null; }; @@ -51,7 +55,11 @@ export type ParsedOutput = { /** Set only when the derivation path is chain-standard (chain code encodes script type per BitGo convention). */ scriptId: ScriptId | null; paygo: boolean; - /** Full BIP32 derivation path from the wallet xpub (e.g. "0/1"). Null for external outputs. */ + /** + * Full BIP32 derivation path recorded in the PSBT, from the wallet root key: the prefix followed + * by `[chain, index]` (e.g. "0/0/20/5" for legacy, "44/0/

/

/20/5" for BIP44-style). + * The prefix is everything except the last two components. Null for external outputs. + */ derivationPath: string | null; }; @@ -114,6 +122,14 @@ export type AddWalletInputOptions = { scriptId: ScriptId; /** Sign path - required for p2tr/p2trMusig2 (chains 30-41) */ signPath?: SignPath; + /** + * The output script the input is expected to spend (e.g. the script of the unspent as indexed). + * If provided, it is compared with the script derived for `scriptId` from the wallet keys, and + * a difference throws a `WasmUtxoError` with code `AddWalletInputError.WalletInputScriptMismatch` + * (the PSBT is left unchanged). Catches wallet keys whose derivation prefix does not match the + * one the unspent was created under. If omitted, nothing is compared. + */ + expectedScript?: Uint8Array; }; export type AddWalletOutputOptions = { @@ -493,6 +509,7 @@ export class BitGoPsbt< walletOptions.signPath?.cosigner, inputOptions.sequence, inputOptions.prevTx, + walletOptions.expectedScript, ); } @@ -513,6 +530,7 @@ export class BitGoPsbt< walletOptions.signPath?.cosigner, inputOptions.sequence, inputOptions.prevTx, + walletOptions.expectedScript, ); } @@ -1098,6 +1116,11 @@ export class BitGoPsbt< * was built with derived keys (from coldDerivationSeed) but the caller only * has root xpubs. Prefer passing wallet keys explicitly wherever possible. * + * The PSBT's key origins are matched by fingerprint along their full recorded path, so legacy + * (`0/0`), BIP44-style (`44//

/

`) and mixed PSBTs are all resolved. The + * returned keys carry the derivation prefix recorded in the PSBT's wallet inputs; if the inputs + * record more than one prefix (or none), the default `0/0` prefix is used. + * * @returns Sorted [user, backup, bitgo] RootWalletKeys */ export function getWalletKeysFromPsbt(psbt: BitGoPsbt, xpubs: BIP32[]): RootWalletKeys { diff --git a/packages/wasm-utxo/js/fixedScriptWallet/index.ts b/packages/wasm-utxo/js/fixedScriptWallet/index.ts index 0c4e15180fc..e8d41bd5511 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/index.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/index.ts @@ -4,6 +4,11 @@ import type { CoinName } from "../coinName.js"; export { RootWalletKeys, type WalletKeysArg, type IWalletKeys } from "./RootWalletKeys.js"; export { ReplayProtection, type ReplayProtectionArg } from "./ReplayProtection.js"; export { outputScript, address } from "./address.js"; +export { + deriveWalletAccount, + deriveWalletPrefix, + type WalletPrefixKeysArg, +} from "./walletPrefix.js"; export { Dimensions } from "./Dimensions.js"; export { outputScriptTypes, diff --git a/packages/wasm-utxo/js/fixedScriptWallet/walletPrefix.ts b/packages/wasm-utxo/js/fixedScriptWallet/walletPrefix.ts new file mode 100644 index 00000000000..7809bc0f841 --- /dev/null +++ b/packages/wasm-utxo/js/fixedScriptWallet/walletPrefix.ts @@ -0,0 +1,74 @@ +import { BIP32, type BIP32Arg } from "../bip32.js"; +import type { Triple } from "../triple.js"; +import { FixedScriptWalletNamespace } from "../wasm/wasm_utxo.js"; +import { RootWalletKeys, type WalletKeysArg } from "./RootWalletKeys.js"; + +/** + * The three wallet root keys in `[user, backup, bitgo]` order. + * + * Accepts everything {@link RootWalletKeys.from} accepts, plus a triple of any {@link BIP32Arg} + * (xpub/xprv strings, `BIP32` instances, `BIP32Interface` objects). Private keys are neutered + * before use: the result is the same for an xprv and the matching xpub. + */ +export type WalletPrefixKeysArg = WalletKeysArg | Triple; + +function toBIP32Triple(keys: WalletPrefixKeysArg): Triple { + if (keys instanceof RootWalletKeys) { + return [keys.userKey(), keys.backupKey(), keys.bitgoKey()]; + } + if (Array.isArray(keys)) { + return (keys as Triple).map((key) => BIP32.from(key)) as Triple; + } + return keys.triple.map((key) => BIP32.from(key)) as Triple; +} + +/** + * Compute the `[h1, h2]` account indices of a wallet from its three root keys. + * + * ``` + * d = sha256("BitGo/utxo/wallet-prefix/v1" || K_user || K_backup || K_bitgo) + * K = compressed public key (33 bytes) || chain code (32 bytes) (of the neutered root key) + * h1 = u32be(d[0..4]) & 0x7fffffff + * h2 = u32be(d[4..8]) & 0x7fffffff + * ``` + * + * Both are valid non-hardened BIP32 indices (`0..=2^31-1`). The result depends on the key order + * `[user, backup, bitgo]`. These are indices, not amounts, hence `number`. + * + * @param keys - The three wallet root keys `[user, backup, bitgo]` + */ +export function deriveWalletAccount(keys: WalletPrefixKeysArg): [number, number] { + const [user, backup, bitgo] = toBIP32Triple(keys); + const account = FixedScriptWalletNamespace.derive_wallet_account( + user.wasm, + backup.wasm, + bitgo.wasm, + ); + return [account[0], account[1]]; +} + +/** + * Compute the relative per-wallet derivation prefix `44//

/

` (no leading `/` or + * `m/`), see {@link deriveWalletAccount}. Addresses of a wallet using this prefix derive at + * `//` from each of the three root keys. + * + * @param keys - The three wallet root keys `[user, backup, bitgo]` + * @param coinType - BIP44 coin type of the coin family, an integer in `0..=2^31-1`. wasm-utxo has + * no coin registry: the caller supplies it (e.g. `coin.bip44CoinType` from `@bitgo/statics`). + * @throws a `WasmUtxoError` with code `WalletPrefixError.CoinTypeOutOfRange` if `coinType` is + * larger than `2^31-1` + */ +export function deriveWalletPrefix(keys: WalletPrefixKeysArg, coinType: number): string { + // wasm-bindgen wraps numbers into u32, so reject what cannot be a u32 before it gets there; + // values in (2^31-1, 2^32-1] are rejected by the Rust side with a typed error. + if (!Number.isInteger(coinType) || coinType < 0 || coinType > 0xffffffff) { + throw new RangeError(`coinType must be an integer in 0..=2^31-1, got ${coinType}`); + } + const [user, backup, bitgo] = toBIP32Triple(keys); + return FixedScriptWalletNamespace.derive_wallet_prefix( + user.wasm, + backup.wasm, + bitgo.wasm, + coinType, + ); +} diff --git a/packages/wasm-utxo/src/error.rs b/packages/wasm-utxo/src/error.rs index 90214e15dd3..a495f29211e 100644 --- a/packages/wasm-utxo/src/error.rs +++ b/packages/wasm-utxo/src/error.rs @@ -1,7 +1,10 @@ use core::fmt; +use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::AddWalletInputError; use crate::fixed_script_wallet::bitgo_psbt::zcash_psbt::VerifyV6SignatureError; use crate::fixed_script_wallet::bitgo_psbt::ParseTransactionError; +use crate::fixed_script_wallet::wallet_prefix::WalletPrefixError; +use crate::fixed_script_wallet::WalletKeysError; pub trait WasmErrorCode { fn code(&self) -> String; @@ -32,6 +35,9 @@ pub enum WasmUtxoError { VerifyIronwood(crate::zcash::ironwood_build::IronwoodVerifyError), IronwoodPsbt(crate::fixed_script_wallet::bitgo_psbt::zcash::error::IronwoodPsbtError), VerifyV6Signature(VerifyV6SignatureError), + WalletKeys(WalletKeysError), + WalletPrefix(WalletPrefixError), + AddWalletInput(AddWalletInputError), } impl std::error::Error for WasmUtxoError {} @@ -49,6 +55,9 @@ impl fmt::Display for WasmUtxoError { WasmUtxoError::VerifyIronwood(e) => write!(f, "{}", e), WasmUtxoError::IronwoodPsbt(e) => write!(f, "{}", e), WasmUtxoError::VerifyV6Signature(e) => write!(f, "{}", e), + WasmUtxoError::WalletKeys(e) => write!(f, "{}", e), + WasmUtxoError::WalletPrefix(e) => write!(f, "{}", e), + WasmUtxoError::AddWalletInput(e) => write!(f, "{}", e), } } } @@ -76,6 +85,9 @@ impl WasmErrorCode for WasmUtxoError { WasmUtxoError::VerifyIronwood(e) => e.code(), WasmUtxoError::IronwoodPsbt(e) => e.code(), WasmUtxoError::VerifyV6Signature(e) => e.code(), + WasmUtxoError::WalletKeys(e) => e.code(), + WasmUtxoError::WalletPrefix(e) => e.code(), + WasmUtxoError::AddWalletInput(e) => e.code(), } } } @@ -159,6 +171,24 @@ impl From for WasmUtxoError { } } +impl From for WasmUtxoError { + fn from(err: WalletKeysError) -> Self { + WasmUtxoError::WalletKeys(err) + } +} + +impl From for WasmUtxoError { + fn from(err: WalletPrefixError) -> Self { + WasmUtxoError::WalletPrefix(err) + } +} + +impl From for WasmUtxoError { + fn from(err: AddWalletInputError) -> Self { + WasmUtxoError::AddWalletInput(err) + } +} + impl WasmUtxoError { pub fn new(s: &str) -> WasmUtxoError { WasmUtxoError::StringError(s.to_string()) diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index 45db627f8a0..d30fa20419a 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -109,7 +109,7 @@ pub enum BitGoPsbt { // Re-export types from submodules for convenience pub use crate::fixed_script_wallet::{ScriptId, ScriptIdWithValue}; pub use psbt_wallet_input::{ - InputScriptType, ParsedInput, ReplayProtectionOptions, WalletInputOptions, + AddWalletInputError, InputScriptType, ParsedInput, ReplayProtectionOptions, WalletInputOptions, }; pub use psbt_wallet_output::ParsedOutput; @@ -643,6 +643,7 @@ impl BitGoPsbt { sign_path: None, sequence: Some(tx_in.sequence.0), prev_tx: None, + expected_script: None, }, ) .map_err(|e| format!("Input {}: {}", i, e))?; @@ -1032,7 +1033,7 @@ impl BitGoPsbt { wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, script_id: ScriptId, options: WalletInputOptions, - ) -> Result<(), String> { + ) -> Result<(), AddWalletInputError> { use crate::fixed_script_wallet::to_pub_triple; use crate::fixed_script_wallet::wallet_scripts::{ chain_index_path, OutputScriptType, WalletScripts, @@ -1060,6 +1061,17 @@ impl BitGoPsbt { let output_script = scripts.output_script(); + if let Some(expected) = options.expected_script { + if expected != output_script.as_bytes() { + return Err(AddWalletInputError::WalletInputScriptMismatch { + chain, + index: derivation_index, + expected: miniscript::bitcoin::ScriptBuf::from_bytes(expected.to_vec()), + actual: output_script, + }); + } + } + let tx_in = TxIn { previous_output: OutPoint { txid, vout }, script_sig: miniscript::bitcoin::ScriptBuf::new(), @@ -1104,7 +1116,9 @@ impl BitGoPsbt { psbt_input.witness_script = Some(script.witness_script.clone()); } WalletScripts::P2mr(_) => { - return Err("P2MR PSBT input signing is not yet supported".to_string()); + return Err("P2MR PSBT input signing is not yet supported" + .to_string() + .into()); } WalletScripts::P2trLegacy(script) | WalletScripts::P2trMusig2(script) => { let sign_path = options.sign_path.ok_or_else(|| { @@ -1174,11 +1188,18 @@ impl BitGoPsbt { } } - crate::psbt_ops::insert_input(psbt, index, tx_in, psbt_input).map(|_| ()) + crate::psbt_ops::insert_input(psbt, index, tx_in, psbt_input) + .map(|_| ()) + .map_err(AddWalletInputError::Invalid) } + /// Add a wallet input at `index`, reporting failures as a typed [`AddWalletInputError`]. + /// + /// If `options.expected_script` is set and differs from the script derived for `script_id` + /// from `wallet_keys`, fails with [`AddWalletInputError::WalletInputScriptMismatch`] and + /// leaves the PSBT unchanged. #[allow(clippy::too_many_arguments)] - pub fn add_wallet_input_at_index( + pub fn try_add_wallet_input_at_index( &mut self, index: usize, txid: Txid, @@ -1187,7 +1208,7 @@ impl BitGoPsbt { wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, script_id: ScriptId, options: WalletInputOptions, - ) -> Result { + ) -> Result { // One input kind: the Zcash arm rejects a transparent input on a PSBT that already // spends shielded notes (see `add_input_at_index`). if let BitGoPsbt::Zcash(zcash, _) = self { @@ -1209,6 +1230,29 @@ impl BitGoPsbt { Ok(index) } + #[allow(clippy::too_many_arguments)] + pub fn add_wallet_input_at_index( + &mut self, + index: usize, + txid: Txid, + vout: u32, + value: u64, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + script_id: ScriptId, + options: WalletInputOptions, + ) -> Result { + self.try_add_wallet_input_at_index( + index, + txid, + vout, + value, + wallet_keys, + script_id, + options, + ) + .map_err(|e| e.to_string()) + } + pub fn add_wallet_input( &mut self, txid: Txid, @@ -3383,12 +3427,54 @@ const XPUB_TRIPLE_PERMUTATIONS: [[usize; 3]; 6] = [ [2, 1, 0], ]; +/// The one derivation prefix (path minus the trailing `[chain, index]`) recorded for the keys of +/// `xpubs` across `inputs`, or `None` if the inputs record none, or more than one (a PSBT +/// mixing e.g. legacy `0/0` and BIP44-style prefixes). +fn recorded_derivation_prefix<'a>( + xpubs: &crate::fixed_script_wallet::XpubTriple, + inputs: impl Iterator, +) -> Option { + use miniscript::bitcoin::bip32::{ChildNumber, DerivationPath}; + + let fingerprints: Vec<_> = xpubs.iter().map(|xpub| xpub.fingerprint()).collect(); + let mut prefix: Option = None; + for input in inputs { + let origins = input + .bip32_derivation + .values() + .map(|(fingerprint, path)| (fingerprint, path)) + .chain( + input + .tap_key_origins + .values() + .map(|(_, (fingerprint, path))| (fingerprint, path)), + ); + for (fingerprint, path) in origins { + if !fingerprints.contains(fingerprint) { + continue; + } + let children: Vec = path.into_iter().cloned().collect(); + let prefix_len = children.len().checked_sub(2)?; + let candidate = DerivationPath::from(children[..prefix_len].to_vec()); + if *prefix.get_or_insert_with(|| candidate.clone()) != candidate { + return None; + } + } + } + prefix +} + /// Sort an xpub triple into `[user, backup, bitgo]` order by trying all permutations /// against the PSBT's wallet inputs. /// /// For each permutation, constructs `RootWalletKeys` and validates every non-replay-protection /// input against it. The first permutation where all inputs pass validation is returned. /// Works for all script types including p2tr. +/// +/// Validation follows the key origins recorded in the PSBT, so legacy (`0/0`), BIP44-style +/// (`44/ct/h1/h2`) and mixed PSBTs are all resolved. The returned keys carry the derivation +/// prefix the PSBT records for its wallet inputs; if the inputs record more than one prefix (or +/// none), the legacy default `m/0/0` is used. pub fn to_wallet_keys( psbt: &BitGoPsbt, xpubs: crate::fixed_script_wallet::XpubTriple, @@ -3415,7 +3501,8 @@ pub fn to_wallet_keys( for perm in &XPUB_TRIPLE_PERMUTATIONS { let permuted = [xpubs[perm[0]], xpubs[perm[1]], xpubs[perm[2]]]; - let wallet_keys = RootWalletKeys::new(permuted); + // Classification does not depend on the prefix of these keys. + let candidate = RootWalletKeys::new(permuted); let all_match = wallet_inputs.iter().all(|(tx_input, psbt_input)| { let output_script = psbt_wallet_input::get_output_script_and_value( @@ -3424,7 +3511,7 @@ pub fn to_wallet_keys( ); match output_script { Ok((script, _value)) => crate::fixed_script_wallet::WalletOutputScript::from_psbt( - &wallet_keys, + &candidate, &psbt_input.bip32_derivation, &psbt_input.tap_key_origins, psbt_input.witness_script.is_some(), @@ -3437,7 +3524,17 @@ pub fn to_wallet_keys( }); if all_match { - return Ok(wallet_keys); + let Some(prefix) = recorded_derivation_prefix( + &permuted, + wallet_inputs.iter().map(|(_, psbt_input)| *psbt_input), + ) else { + return Ok(candidate); + }; + return RootWalletKeys::new_with_derivation_prefixes( + permuted, + [prefix.clone(), prefix.clone(), prefix], + ) + .map_err(|e| e.to_string()); } } @@ -5272,6 +5369,7 @@ mod tests { sign_path, sequence: Some(sequence), prev_tx: prev_tx.as_deref(), + expected_script: None, }, ); assert!( diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs index ddb3058f83c..76b5d54aaf7 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs @@ -432,8 +432,64 @@ pub struct WalletInputOptions<'a> { pub sequence: Option, /// Previous transaction bytes; if provided, uses non_witness_utxo pub prev_tx: Option<&'a [u8]>, + /// The output script the caller expects the input to spend (e.g. the script of the unspent + /// as indexed). If provided, it is compared with the script derived for + /// `(chain, index)` from the wallet keys, and a difference fails with + /// [`AddWalletInputError::WalletInputScriptMismatch`] before the input is added. This catches + /// wallet keys whose derivation prefix does not match the one the unspent was created under. + /// If omitted, no comparison takes place. + pub expected_script: Option<&'a [u8]>, } +/// Errors while adding a wallet input to a PSBT. +#[derive(Debug, Clone, PartialEq, Eq, strum::IntoStaticStr)] +pub enum AddWalletInputError { + /// The script derived for `(chain, index)` from the wallet keys differs from the script the + /// caller expected. + WalletInputScriptMismatch { + chain: u32, + index: u32, + expected: ScriptBuf, + actual: ScriptBuf, + }, + /// Any other failure (invalid chain, derivation, missing sign path, PSBT insertion, ...). + Invalid(String), +} + +impl std::fmt::Display for AddWalletInputError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::WalletInputScriptMismatch { + chain, + index, + expected, + actual, + } => write!( + f, + "wallet input script mismatch (chain {}, index {}): expected {}, derived {}", + chain, + index, + expected.to_hex_string(), + actual.to_hex_string() + ), + Self::Invalid(message) => write!(f, "{}", message), + } + } +} + +impl std::error::Error for AddWalletInputError {} +crate::impl_wasm_error_code!(AddWalletInputError); + +impl From for AddWalletInputError { + fn from(message: String) -> Self { + Self::Invalid(message) + } +} + +/// Why an input does not belong to the wallet keys it was parsed with. +const NO_WALLET_MATCH_MESSAGE: &str = + "no key origin of the input derives its script from the wallet keys"; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InputScriptType { P2shP2pk, @@ -492,7 +548,9 @@ pub struct ParsedInput { pub script_id: Option, pub script_type: InputScriptType, pub sequence: u32, - /// Full BIP32 derivation path from the wallet xpub (e.g. `[chain, index]`). + /// Full BIP32 derivation path recorded in the PSBT, from the wallet root key: the prefix + /// followed by `[chain, index]` (e.g. `0/0/20/5` legacy, `44/0/

/

/20/5` BIP44-style). + /// The prefix is everything except the last two components. /// `None` for replay-protection inputs which have no wallet derivation. pub derivation_path: Option, } @@ -525,9 +583,7 @@ impl ParsedInput { ) .map_err(ParseInputError::WalletValidation)? .ok_or_else(|| { - ParseInputError::WalletValidation( - "no derivation path matched wallet keys".to_string(), - ) + ParseInputError::WalletValidation(NO_WALLET_MATCH_MESSAGE.to_string()) })?; let script_id = wos.script_id(); @@ -870,7 +926,7 @@ pub fn validate_psbt_wallet_inputs( output_script, network, ) - .and_then(|opt| opt.ok_or_else(|| "no derivation path matched wallet keys".to_string())) + .and_then(|opt| opt.ok_or_else(|| NO_WALLET_MATCH_MESSAGE.to_string())) .and_then(|wos| { if wos.chain_standard() { Ok(()) diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs index f9db3c62edf..c6d542ea3f2 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs @@ -12,7 +12,9 @@ pub struct ParsedOutput { pub value: u64, pub script_id: Option, pub paygo: bool, - /// Full BIP32 derivation path from the wallet xpub (e.g. `[chain, index]`). + /// Full BIP32 derivation path recorded in the PSBT, from the wallet root key: the prefix + /// followed by `[chain, index]` (e.g. `0/0/20/5` legacy, `44/0/

/

/20/5` BIP44-style). + /// The prefix is everything except the last two components. /// `None` for outputs that do not belong to this wallet. pub derivation_path: Option, } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs index 1f7ed2d7420..82fcb3445b6 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs @@ -4,6 +4,7 @@ pub mod bitgo_psbt; pub mod replay_protection; pub mod script_id; mod wallet_keys; +pub mod wallet_prefix; pub mod wallet_scripts; #[cfg(test)] diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs index 706133bf718..64d223d4903 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs @@ -40,32 +40,89 @@ pub struct RootWalletKeys { secp: Secp256k1, } +/// Names of the three wallet keys in `[user, backup, bitgo]` order, for error messages. +const KEY_NAMES: [&str; 3] = ["user", "backup", "bitgo"]; + +/// Errors while building a [`RootWalletKeys`]. +#[derive(Debug, Clone, PartialEq, Eq, strum::IntoStaticStr)] +pub enum WalletKeysError { + /// A derivation prefix contains a hardened component. Wallet keys are public (xpub) + /// keys, so only non-hardened (soft) derivation is possible. + HardenedDerivationPrefix { key: &'static str, prefix: String }, + /// Deriving a key along its prefix failed for a reason other than hardening. + PrefixDerivation { + key: &'static str, + prefix: String, + reason: String, + }, +} + +impl std::fmt::Display for WalletKeysError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::HardenedDerivationPrefix { key, prefix } => write!( + f, + "derivation prefix of the {key} key contains a hardened component \ + (only non-hardened derivation is possible from an xpub): {prefix}" + ), + Self::PrefixDerivation { + key, + prefix, + reason, + } => write!( + f, + "could not derive the {key} key along prefix {prefix}: {reason}" + ), + } + } +} + +impl std::error::Error for WalletKeysError {} +crate::impl_wasm_error_code!(WalletKeysError); + impl RootWalletKeys { + /// Build wallet keys with one derivation prefix per key. + /// + /// Returns an error if a prefix has a hardened component (an xpub cannot derive it). pub fn new_with_derivation_prefixes( xpubs: XpubTriple, derivation_prefixes: [DerivationPath; 3], - ) -> Self { + ) -> Result { let secp = Secp256k1::new(); // Pre-derive keys to prefix level (e.g., m/0/0) - let prefix_derived: XpubTriple = xpubs - .iter() - .zip(derivation_prefixes.iter()) - .map(|(xpub, prefix)| { - xpub.derive_pub(&secp, prefix) - .expect("valid prefix derivation") - }) - .collect::>() - .try_into() - .expect("3 keys"); + let prefix_derived: XpubTriple = { + let mut derived = Vec::with_capacity(3); + for (i, (xpub, prefix)) in xpubs.iter().zip(derivation_prefixes.iter()).enumerate() { + if prefix.into_iter().any(|c| c.is_hardened()) { + return Err(WalletKeysError::HardenedDerivationPrefix { + key: KEY_NAMES[i], + prefix: prefix.to_string(), + }); + } + derived.push(xpub.derive_pub(&secp, prefix).map_err(|e| { + WalletKeysError::PrefixDerivation { + key: KEY_NAMES[i], + prefix: prefix.to_string(), + reason: e.to_string(), + } + })?); + } + derived.try_into().expect("3 keys") + }; - Self { + Ok(Self { xpubs, derivation_prefixes, prefix_derived, derivation_cache: RefCell::new(HashMap::new()), secp, - } + }) + } + + /// Shared secp256k1 context. + pub(crate) fn secp(&self) -> &Secp256k1 { + &self.secp } pub fn user_key(&self) -> &Xpub { @@ -80,6 +137,7 @@ impl RootWalletKeys { &self.xpubs[2] } + /// Wallet keys with the legacy `m/0/0` prefix on all three keys. pub fn new(xpubs: XpubTriple) -> Self { Self::new_with_derivation_prefixes( xpubs, @@ -89,6 +147,7 @@ impl RootWalletKeys { DerivationPath::from_str("m/0/0").unwrap(), ], ) + .expect("m/0/0 is a valid non-hardened prefix") } /// Derive keys from `self.prefix_derived` along `path`. diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_prefix.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_prefix.rs new file mode 100644 index 00000000000..050f7b19d68 --- /dev/null +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_prefix.rs @@ -0,0 +1,185 @@ +//! Per-wallet derivation prefix for BitGo fixed-script wallets. +//! +//! Besides the legacy layout `/0/0//`, a wallet may derive its addresses +//! below a BIP44-style prefix `/44//

/

//`. All levels are +//! non-hardened, so everything can be derived from xpubs alone. +//! +//! `h1` and `h2` are never stored: they are a pure function of the three wallet root keys, +//! +//! ```text +//! d = sha256("BitGo/utxo/wallet-prefix/v1" || K_user || K_backup || K_bitgo) +//! K = compressed public key (33 bytes) || chain code (32 bytes) (of the neutered root key) +//! h1 = u32be(d[0..4]) & 0x7fffffff +//! h2 = u32be(d[4..8]) & 0x7fffffff +//! ``` +//! +//! The result depends on the key order `[user, backup, bitgo]`. + +use crate::bitcoin::hashes::{sha256, Hash}; + +use super::wallet_keys::XpubTriple; + +/// Domain-separation tag hashed in front of the three root keys. +const WALLET_PREFIX_TAG: &[u8] = b"BitGo/utxo/wallet-prefix/v1"; + +/// Largest value of a non-hardened BIP32 child index (`2^31 - 1`). +pub const MAX_SOFT_INDEX: u32 = 0x7fff_ffff; + +/// Errors while computing a wallet prefix. +#[derive(Debug, Clone, PartialEq, Eq, strum::IntoStaticStr)] +pub enum WalletPrefixError { + /// The coin type is not a valid non-hardened index (`> 2^31 - 1`). + CoinTypeOutOfRange { coin_type: u32 }, +} + +impl std::fmt::Display for WalletPrefixError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CoinTypeOutOfRange { coin_type } => write!( + f, + "coin type {coin_type} is out of range: must be at most {MAX_SOFT_INDEX}" + ), + } + } +} + +impl std::error::Error for WalletPrefixError {} +crate::impl_wasm_error_code!(WalletPrefixError); + +/// Compute the `(h1, h2)` account indices of a wallet from its three root keys +/// `[user, backup, bitgo]`. +/// +/// Only the public key and chain code of each key enter the hash, so the result is the same +/// whether the keys were an xprv or the matching xpub, and on any network. +pub fn derive_wallet_account(xpubs: &XpubTriple) -> (u32, u32) { + let mut preimage = Vec::with_capacity(WALLET_PREFIX_TAG.len() + 3 * 65); + preimage.extend_from_slice(WALLET_PREFIX_TAG); + for xpub in xpubs { + preimage.extend_from_slice(&xpub.public_key.serialize()); + preimage.extend_from_slice(&xpub.chain_code.to_bytes()); + } + let digest = sha256::Hash::hash(&preimage).to_byte_array(); + + let word = |offset: usize| { + let bytes: [u8; 4] = [ + digest[offset], + digest[offset + 1], + digest[offset + 2], + digest[offset + 3], + ]; + u32::from_be_bytes(bytes) & MAX_SOFT_INDEX + }; + (word(0), word(4)) +} + +/// Compute the relative derivation prefix `44//

/

` of a wallet (no leading +/// `/` or `m/`). +/// +/// `coin_type` is the BIP44 coin type of the coin family (supplied by the caller: wasm-utxo has +/// no coin registry). It must be a valid non-hardened index. +pub fn derive_wallet_prefix( + xpubs: &XpubTriple, + coin_type: u32, +) -> Result { + if coin_type > MAX_SOFT_INDEX { + return Err(WalletPrefixError::CoinTypeOutOfRange { coin_type }); + } + let (h1, h2) = derive_wallet_account(xpubs); + Ok(format!("44/{coin_type}/{h1}/{h2}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bitcoin::bip32::Xpub; + use crate::error::WasmErrorCode; + use crate::fixed_script_wallet::wallet_keys::tests::get_xpriv_from_seed; + use crate::fixed_script_wallet::RootWalletKeys; + + fn xpub_from_seed(seed: &str) -> Xpub { + let secp = crate::bitcoin::secp256k1::Secp256k1::new(); + Xpub::from_priv(&secp, &get_xpriv_from_seed(seed)) + } + + fn triple(bitgo_seed: &str) -> XpubTriple { + [ + xpub_from_seed("wcn3907-u"), + xpub_from_seed("wcn3907-b"), + xpub_from_seed(bitgo_seed), + ] + } + + /// Shared with the mocha test `test/fixedScript/walletPrefix.ts`. + const VECTORS: [(&str, u32, u32); 3] = [ + ("wcn3907-g1", 1528449227, 506574854), + ("wcn3907-g2", 1401193521, 285195054), + ("wcn3907-g", 1639221447, 1133889609), + ]; + + #[test] + fn derive_wallet_account_matches_the_ticket_vectors() { + for (bitgo_seed, h1, h2) in VECTORS { + assert_eq!( + derive_wallet_account(&triple(bitgo_seed)), + (h1, h2), + "bitgo seed {bitgo_seed}" + ); + } + } + + #[test] + fn derive_wallet_prefix_formats_the_relative_prefix() { + assert_eq!( + derive_wallet_prefix(&triple("wcn3907-g"), 0).unwrap(), + "44/0/1639221447/1133889609" + ); + assert_eq!( + derive_wallet_prefix(&triple("wcn3907-g1"), 145).unwrap(), + "44/145/1528449227/506574854" + ); + assert_eq!( + derive_wallet_prefix(&triple("wcn3907-g2"), MAX_SOFT_INDEX).unwrap(), + "44/2147483647/1401193521/285195054" + ); + } + + #[test] + fn derive_wallet_prefix_rejects_a_hardened_coin_type() { + let err = derive_wallet_prefix(&triple("wcn3907-g"), MAX_SOFT_INDEX + 1).unwrap_err(); + assert_eq!( + err, + WalletPrefixError::CoinTypeOutOfRange { + coin_type: MAX_SOFT_INDEX + 1 + } + ); + assert_eq!( + crate::error::WasmUtxoError::from(err).code(), + "WalletPrefixError.CoinTypeOutOfRange" + ); + assert!(derive_wallet_prefix(&triple("wcn3907-g"), u32::MAX).is_err()); + } + + #[test] + fn derive_wallet_account_depends_on_key_order() { + let [user, backup, bitgo] = triple("wcn3907-g"); + assert_ne!( + derive_wallet_account(&[user, backup, bitgo]), + derive_wallet_account(&[backup, user, bitgo]) + ); + assert_ne!( + derive_wallet_account(&[user, backup, bitgo]), + derive_wallet_account(&[user, bitgo, backup]) + ); + } + + #[test] + fn derive_wallet_account_ignores_derivation_prefixes_of_the_wallet_keys() { + // The account depends on the three root keys only, not on how a RootWalletKeys derives. + let xpubs = triple("wcn3907-g"); + let keys = RootWalletKeys::new(xpubs); + assert_eq!( + derive_wallet_account(&keys.xpubs), + derive_wallet_account(&xpubs) + ); + } +} diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs index 4974e4a184f..93978d249c2 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs @@ -16,9 +16,9 @@ pub use checksigverify::{ pub use singlesig::{build_p2pk_script, parse_p2pk_script, ScriptP2shP2pk}; use crate::address::networks::OutputScriptSupport; -use crate::bitcoin::bip32::{ChildNumber, DerivationPath, Fingerprint}; +use crate::bitcoin::bip32::{ChildNumber, DerivationPath, Fingerprint, Xpub}; use crate::bitcoin::secp256k1::PublicKey as Secp256k1PublicKey; -use crate::bitcoin::{ScriptBuf, TapLeafHash, XOnlyPublicKey}; +use crate::bitcoin::{CompressedPublicKey, ScriptBuf, TapLeafHash, XOnlyPublicKey}; use crate::error::WasmUtxoError; use crate::fixed_script_wallet::wallet_keys::{to_pub_triple, PubTriple, RootWalletKeys}; use crate::Network; @@ -214,22 +214,20 @@ impl OutputScriptType { } } - /// Try to find which script type the wallet uses for `output_script` at `path`. + /// Try to find which script type the wallet uses for `output_script`, given the three wallet + /// public keys `[user, backup, bitgo]` the script was derived from. /// Iterates all known script types; skips types unsupported by the network or - /// incompatible with the script shape, then checks by derivation. + /// incompatible with the script shape, then checks by building the script. /// Returns the first matching type, or `None` if none match. + /// + /// The keys are derived by the caller, along the derivation path recorded for the script + /// (see [`WalletOutputScript::from_psbt`]); this function is independent of any prefix. pub fn check( - wallet_keys: &RootWalletKeys, + pub_triple: &PubTriple, output_script: &ScriptBuf, has_witness_script: bool, - path: &DerivationPath, script_support: &OutputScriptSupport, ) -> Option { - let (chain, index) = path_chain_index(path)?; - let derived_keys = wallet_keys - .derive_path(&chain_index_path(chain, index)) - .ok()?; - let pub_triple = to_pub_triple(&derived_keys); for &script_type in Self::all() { if !script_type.is_network_supported(script_support) { continue; @@ -237,7 +235,7 @@ impl OutputScriptType { if !script_type.is_script_compatible(output_script, has_witness_script) { continue; } - if WalletScripts::new(&pub_triple, script_type, script_support) + if WalletScripts::new(pub_triple, script_type, script_support) .ok() .is_some_and(|s| s.output_script() == *output_script) { @@ -263,16 +261,156 @@ pub(crate) fn path_chain_index(path: &DerivationPath) -> Option<(u32, u32)> { } } +/// One key-origin record of a PSBT input or output: the root key it was derived from +/// (identified by fingerprint), the full derivation path from that root key, and the public +/// key the PSBT records at that path. +#[derive(Debug, Clone, Copy)] +struct KeyOrigin<'a> { + fingerprint: Fingerprint, + path: &'a DerivationPath, + key: OriginKey, +} + +/// The public key of a [`KeyOrigin`] as recorded in the PSBT. +#[derive(Debug, Clone, Copy)] +enum OriginKey { + /// Full compressed key (`bip32_derivation`). + Full(Secp256k1PublicKey), + /// X-only key (`tap_key_origins`). + XOnly(XOnlyPublicKey), +} + +impl OriginKey { + fn matches(&self, derived: &Secp256k1PublicKey) -> bool { + match self { + Self::Full(key) => key == derived, + Self::XOnly(key) => *key == derived.x_only_public_key().0, + } + } +} + +fn collect_key_origins<'a>( + bip32_derivation: &'a BTreeMap, + tap_key_origins: &'a BTreeMap< + XOnlyPublicKey, + (Vec, (Fingerprint, DerivationPath)), + >, +) -> Vec> { + let ecdsa = bip32_derivation + .iter() + .map(|(key, (fingerprint, path))| KeyOrigin { + fingerprint: *fingerprint, + path, + key: OriginKey::Full(*key), + }); + let taproot = tap_key_origins + .iter() + .map(|(key, (_, (fingerprint, path)))| KeyOrigin { + fingerprint: *fingerprint, + path, + key: OriginKey::XOnly(*key), + }); + ecdsa.chain(taproot).collect() +} + +/// Derive the three wallet public keys `[user, backup, bitgo]` and the full derivation path +/// they belong to from the key origins recorded in a PSBT input or output. +/// +/// Origins are matched to the wallet's root keys by fingerprint, and each key is derived from its +/// root key along the FULL recorded path (the path is not assumed to start with the prefix of +/// `wallet_keys`, so legacy `0/0/c/i` and `44/ct/h1/h2/c/i` paths are both understood by the same +/// `wallet_keys`). +/// +/// Taproot and MuSig2 inputs only carry the origins of the signer and the cosigner. A root key +/// without an origin is derived from the supplied root key along the path of the present +/// origins, which must then agree on their full path. The caller compares the resulting script +/// with the real one, which is what binds the derived keys to the PSBT. +/// +/// Returns `Ok(None)` if no origin belongs to the wallet, if the present origins disagree on +/// `(chain, index)`, or if a missing key cannot be derived. Returns `Err` if an origin carries the +/// fingerprint of a wallet key and a derivable path, but records a different public key than that +/// path derives (an inconsistent, i.e. malformed, key origin). +fn derive_triple_from_origins( + wallet_keys: &RootWalletKeys, + origins: &[KeyOrigin<'_>], +) -> Result, String> { + let secp = wallet_keys.secp(); + + // Per root key: the derived key and the recorded path of its origin. + let mut found: [Option<(Xpub, &DerivationPath)>; 3] = [None, None, None]; + for (slot, xpub) in found.iter_mut().zip(wallet_keys.xpubs.iter()) { + let fingerprint = xpub.fingerprint(); + for origin in origins.iter().filter(|o| o.fingerprint == fingerprint) { + // A path with hardened components cannot be derived from an xpub: it does not + // describe a key of this wallet. + let Ok(derived) = xpub.derive_pub(secp, origin.path) else { + continue; + }; + if !origin.key.matches(&derived.public_key) { + return Err(format!( + "key origin {} (fingerprint {:?}) does not derive the recorded public key", + origin.path, origin.fingerprint + )); + } + *slot = Some((derived, origin.path)); + break; + } + } + + let Some(reference_path) = found.iter().flatten().map(|(_, path)| *path).next() else { + return Ok(None); + }; + + // The (chain, index) tail must be the same for every present key. + let tail = path_chain_index(reference_path); + if tail.is_none() + || found + .iter() + .flatten() + .any(|(_, path)| path_chain_index(path) != tail) + { + return Ok(None); + } + + // A key without origin is derived along the path of the others, which must be unambiguous. + let complete = found.iter().all(Option::is_some); + if !complete + && found + .iter() + .flatten() + .any(|(_, path)| *path != reference_path) + { + return Ok(None); + } + + let resolve = |i: usize| -> Option { + match &found[i] { + Some((derived, _)) => Some(derived.to_pub()), + None => wallet_keys.xpubs[i] + .derive_pub(secp, reference_path) + .ok() + .map(|derived| derived.to_pub()), + } + }; + let (Some(user), Some(backup), Some(bitgo)) = (resolve(0), resolve(1), resolve(2)) else { + return Ok(None); + }; + + Ok(Some(([user, backup, bitgo], reference_path.clone()))) +} + /// A wallet output script matched to a derivation path. #[derive(Debug, Clone)] pub struct WalletOutputScript { pub script_type: OutputScriptType, - /// The BIP32 derivation path; last two Normal components are (chain, index). + /// The full BIP32 derivation path from the wallet root keys, e.g. `0/0/20/5` (legacy) or + /// `44/0/

/

/20/5`; the last two Normal components are (chain, index). pub derivation_path: DerivationPath, } impl WalletOutputScript { - /// Try to match `output_script` at `path` against all script types supported by `network`. + /// Try to match `output_script` against all script types supported by `network`, with the + /// three keys derived from the root keys along the full `path`. /// Returns `None` if no script type produces a match. pub fn from( wallet_keys: &RootWalletKeys, @@ -280,11 +418,19 @@ impl WalletOutputScript { path: DerivationPath, network: Network, ) -> Option { + path_chain_index(&path)?; + let secp = wallet_keys.secp(); + let derive = |i: usize| { + wallet_keys.xpubs[i] + .derive_pub(secp, &path) + .ok() + .map(|derived| derived.to_pub()) + }; + let pub_triple = [derive(0)?, derive(1)?, derive(2)?]; let script_type = OutputScriptType::check( - wallet_keys, + &pub_triple, output_script, false, - &path, &network.output_script_support(), )?; Some(Self { @@ -295,8 +441,15 @@ impl WalletOutputScript { /// Match `output_script` against wallet keys using PSBT derivation metadata. /// - /// Returns `Ok(None)` if the derivation maps are empty or belong to a different wallet, - /// `Ok(Some(wos))` if the script matches, `Err` if keys are ours but no script type fits. + /// Classification follows the key origins recorded in the PSBT, not the derivation prefix of + /// `wallet_keys`: see [`derive_triple_from_origins`]. One `RootWalletKeys` therefore + /// recognises legacy (`0/0/c/i`), BIP44-style (`44/ct/h1/h2/c/i`) and mixed PSBTs. + /// + /// Returns `Ok(None)` if the derivation maps are empty, belong to a different wallet, or the + /// keys they describe do not produce `output_script`. Callers decide what a missing match + /// means: an output is then not a wallet output, an input is invalid. + /// Returns `Ok(Some(wos))` if the script matches, and `Err` if a key origin of this wallet is + /// inconsistent with the public key it records. pub fn from_psbt( wallet_keys: &RootWalletKeys, bip32_derivation: &BTreeMap, @@ -312,51 +465,22 @@ impl WalletOutputScript { return Ok(None); } - let belongs_to_wallet = if !bip32_derivation.is_empty() { - bip32_derivation.values().all(|(fp, _)| { - wallet_keys - .xpubs - .iter() - .any(|xpub| xpub.fingerprint() == *fp) - }) - } else { - tap_key_origins.values().all(|(_, (fp, _))| { - wallet_keys - .xpubs - .iter() - .any(|xpub| xpub.fingerprint() == *fp) - }) - }; - - if !belongs_to_wallet { + let origins = collect_key_origins(bip32_derivation, tap_key_origins); + let Some((pub_triple, derivation_path)) = + derive_triple_from_origins(wallet_keys, &origins)? + else { return Ok(None); - } - - let path = if !bip32_derivation.is_empty() { - bip32_derivation.values().next().map(|(_, path)| path) - } else { - tap_key_origins.values().next().map(|(_, (_, path))| path) - } - .ok_or_else(|| "no derivation paths".to_string())? - .clone(); + }; - let script_type = OutputScriptType::check( - wallet_keys, + Ok(OutputScriptType::check( + &pub_triple, output_script, has_witness_script, - &path, &network.output_script_support(), ) - .ok_or_else(|| { - format!( - "wallet keys match but no script type matches actual script {}", - output_script - ) - })?; - - Ok(Some(Self { + .map(|script_type| Self { script_type, - derivation_path: path, + derivation_path, })) } } diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index 90d4f36c56e..93177f86022 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -274,6 +274,39 @@ impl FixedScriptWalletNamespace { Ok(WasmRootWalletKeys::from_inner(wallet_keys)) } + /// Compute the `[h1, h2]` account indices of a wallet from its three root keys + /// `[user, backup, bitgo]` (xpubs or xprvs; private keys are neutered first). + /// + /// `h1` and `h2` are non-hardened BIP32 indices (`0..=2^31-1`), derived as + /// `sha256("BitGo/utxo/wallet-prefix/v1" || K_user || K_backup || K_bitgo)` where + /// `K = compressed public key || chain code`. Returns a two-element array. + #[wasm_bindgen] + pub fn derive_wallet_account( + user: &WasmBIP32, + backup: &WasmBIP32, + bitgo: &WasmBIP32, + ) -> Result, WasmUtxoError> { + let xpubs = [user.to_xpub()?, backup.to_xpub()?, bitgo.to_xpub()?]; + let (h1, h2) = crate::fixed_script_wallet::wallet_prefix::derive_wallet_account(&xpubs); + Ok(vec![h1, h2]) + } + + /// Compute the relative derivation prefix `44//

/

` of a wallet from its + /// three root keys `[user, backup, bitgo]` (no leading `/` or `m/`). + /// + /// `coin_type` must be a non-hardened index (`<= 2^31-1`), otherwise this fails with + /// `WalletPrefixError.CoinTypeOutOfRange`. + #[wasm_bindgen] + pub fn derive_wallet_prefix( + user: &WasmBIP32, + backup: &WasmBIP32, + bitgo: &WasmBIP32, + coin_type: u32, + ) -> Result { + let xpubs = [user.to_xpub()?, backup.to_xpub()?, bitgo.to_xpub()?]; + Ok(crate::fixed_script_wallet::wallet_prefix::derive_wallet_prefix(&xpubs, coin_type)?) + } + /// Returns an object mapping BitGo proprietary key subtype names to their `u8` values. /// Values are loaded directly from the Rust enum at build time — no duplication in TypeScript. #[wasm_bindgen] @@ -621,6 +654,7 @@ impl BitGoPsbt { cosigner: Option, sequence: Option, prev_tx: Option>, + expected_script: Option>, ) -> Result { use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::{SignPath, SignerKey}; use crate::fixed_script_wallet::bitgo_psbt::WalletInputOptions; @@ -654,21 +688,20 @@ impl BitGoPsbt { } }; - self.psbt - .add_wallet_input_at_index( - index, - txid, - vout, - value, - wallet_keys, - script_id, - WalletInputOptions { - sign_path, - sequence, - prev_tx: prev_tx.as_deref(), - }, - ) - .map_err(|e| WasmUtxoError::new(&e)) + Ok(self.psbt.try_add_wallet_input_at_index( + index, + txid, + vout, + value, + wallet_keys, + script_id, + WalletInputOptions { + sign_path, + sequence, + prev_tx: prev_tx.as_deref(), + expected_script: expected_script.as_deref(), + }, + )?) } #[allow(clippy::too_many_arguments)] @@ -684,6 +717,7 @@ impl BitGoPsbt { cosigner: Option, sequence: Option, prev_tx: Option>, + expected_script: Option>, ) -> Result { let insert_index = self.psbt.psbt().inputs.len(); self.add_wallet_input_at_index( @@ -698,6 +732,7 @@ impl BitGoPsbt { cosigner, sequence, prev_tx, + expected_script, ) } diff --git a/packages/wasm-utxo/src/wasm/wallet_keys.rs b/packages/wasm-utxo/src/wasm/wallet_keys.rs index aa552ab0752..49109a9f917 100644 --- a/packages/wasm-utxo/src/wasm/wallet_keys.rs +++ b/packages/wasm-utxo/src/wasm/wallet_keys.rs @@ -49,12 +49,15 @@ impl WasmRootWalletKeys { DerivationPath::from_str("m/0/0").unwrap(), DerivationPath::from_str("m/0/0").unwrap(), ], - ); + )?; Ok(WasmRootWalletKeys { inner }) } /// Create a RootWalletKeys from three BIP32 keys with custom derivation prefixes /// + /// Prefixes must be non-hardened: an xpub cannot derive a hardened child, so a hardened + /// component (`'` or `h`) is rejected with `WalletKeysError.HardenedDerivationPrefix`. + /// /// # Arguments /// - `user`: User key (first xpub) /// - `backup`: Backup key (second xpub) @@ -93,7 +96,7 @@ impl WasmRootWalletKeys { .try_into() .map_err(|_| WasmUtxoError::new("Failed to convert derivation paths"))?; - let inner = RootWalletKeys::new_with_derivation_prefixes(xpubs, derivation_paths); + let inner = RootWalletKeys::new_with_derivation_prefixes(xpubs, derivation_paths)?; Ok(WasmRootWalletKeys { inner }) }