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