Skip to content

Commit 5dc7786

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): add PSBT global xpub extraction and sorting
Add `getGlobalXpubs()` method to extract global xpubs from PSBTs, and implement `getWalletKeysFromPsbt()` utility to sort unordered xpubs into canonical [user, backup, bitgo] order by validating against PSBT wallet inputs. This supports legacy cold wallets where PSBTs were built with derived keys but only root xpubs are available. The sorting algorithm tries all 6 permutations and validates each against wallet inputs to find the correct ordering. Works for all script types including p2tr. Issue: BTC-XXXX Co-authored-by: llm-git <llm-git@ttll.de>
1 parent cfcddc0 commit 5dc7786

6 files changed

Lines changed: 230 additions & 0 deletions

File tree

packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import {
22
BitGoPsbt as WasmBitGoPsbt,
3+
FixedScriptWalletNamespace,
4+
WasmBIP32,
35
type PsbtInputData,
46
type PsbtOutputData,
57
type PsbtOutputDataWithAddress,
@@ -972,4 +974,38 @@ export class BitGoPsbt implements IPsbtWithAddress {
972974
getOutputsWithAddress(): PsbtOutputDataWithAddress[] {
973975
return this._wasm.get_outputs_with_address() as PsbtOutputDataWithAddress[];
974976
}
977+
978+
/**
979+
* Returns the unordered global xpubs from this PSBT as BIP32 instances,
980+
* or undefined if the PSBT has no global xpubs.
981+
*/
982+
getGlobalXpubs(): [BIP32, BIP32, BIP32] | undefined {
983+
const result = this._wasm.get_global_xpubs();
984+
if (result === null) return undefined;
985+
const arr = result as WasmBIP32[];
986+
return arr.map((w) => BIP32.fromWasm(w)) as [BIP32, BIP32, BIP32];
987+
}
988+
}
989+
990+
/**
991+
* Extract sorted wallet keys from a PSBT's global xpub fields.
992+
*
993+
* This should only be used in exceptional circumstances where the real wallet
994+
* keys are not available — for example, legacy cold wallets where the PSBT
995+
* was built with derived keys (from coldDerivationSeed) but the caller only
996+
* has root xpubs. Prefer passing wallet keys explicitly wherever possible.
997+
*
998+
* @returns Sorted [user, backup, bitgo] RootWalletKeys, or undefined if the
999+
* PSBT has no global xpubs.
1000+
*/
1001+
export function getWalletKeysFromPsbt(psbt: BitGoPsbt): RootWalletKeys | undefined {
1002+
const xpubs = psbt.getGlobalXpubs();
1003+
if (!xpubs) return undefined;
1004+
const wasmKeys = FixedScriptWalletNamespace.to_wallet_keys(
1005+
psbt.wasm,
1006+
xpubs[0].wasm,
1007+
xpubs[1].wasm,
1008+
xpubs[2].wasm,
1009+
);
1010+
return RootWalletKeys.fromWasm(wasmKeys);
9751011
}

packages/wasm-utxo/js/fixedScriptWallet/RootWalletKeys.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ function extractDerivationPrefixes(keys: WalletKeysArg): Triple<string> | null {
5454
export class RootWalletKeys {
5555
private constructor(private _wasm: WasmRootWalletKeys) {}
5656

57+
/**
58+
* Create a RootWalletKeys instance from a WasmRootWalletKeys instance (internal use)
59+
* @internal
60+
*/
61+
static fromWasm(wasm: WasmRootWalletKeys): RootWalletKeys {
62+
return new RootWalletKeys(wasm);
63+
}
64+
5765
/**
5866
* Create a RootWalletKeys from various input formats
5967
* @param keys - Can be a triple of xpub strings, an IWalletKeys object, or another RootWalletKeys instance

packages/wasm-utxo/js/fixedScriptWallet/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export { ChainCode, chainCodes, assertChainCode, type Scope } from "./chains.js"
1717
// Bitcoin-like PSBT (for all non-Zcash networks)
1818
export {
1919
BitGoPsbt,
20+
getWalletKeysFromPsbt,
2021
type NetworkName,
2122
type ScriptId,
2223
type ParsedInput,

packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,6 +1292,22 @@ impl BitGoPsbt {
12921292
}
12931293
}
12941294

1295+
/// Returns the global xpubs from the PSBT, or None if the PSBT has no global xpubs.
1296+
///
1297+
/// # Panics
1298+
/// Panics if the PSBT has global xpubs but not exactly 3.
1299+
pub fn get_global_xpubs(&self) -> Option<crate::fixed_script_wallet::XpubTriple> {
1300+
let xpubs: Vec<_> = self.psbt().xpub.keys().copied().collect();
1301+
if xpubs.is_empty() {
1302+
return None;
1303+
}
1304+
Some(
1305+
xpubs
1306+
.try_into()
1307+
.expect("expected exactly 3 global xpubs in PSBT"),
1308+
)
1309+
}
1310+
12951311
/// Set version information in the PSBT's proprietary fields
12961312
///
12971313
/// This embeds the wasm-utxo version and git hash into the PSBT's global
@@ -3003,6 +3019,71 @@ impl BitGoPsbt {
30033019
}
30043020
}
30053021

3022+
/// All 6 orderings of a 3-element array, used to brute-force the
3023+
/// [user, backup, bitgo] assignment from an unordered xpub triple.
3024+
const XPUB_TRIPLE_PERMUTATIONS: [[usize; 3]; 6] = [
3025+
[0, 1, 2],
3026+
[0, 2, 1],
3027+
[1, 0, 2],
3028+
[1, 2, 0],
3029+
[2, 0, 1],
3030+
[2, 1, 0],
3031+
];
3032+
3033+
/// Sort an xpub triple into `[user, backup, bitgo]` order by trying all permutations
3034+
/// against the PSBT's wallet inputs.
3035+
///
3036+
/// For each permutation, constructs `RootWalletKeys` and validates every non-replay-protection
3037+
/// input against it. The first permutation where all inputs pass validation is returned.
3038+
/// Works for all script types including p2tr.
3039+
pub fn to_wallet_keys(
3040+
psbt: &BitGoPsbt,
3041+
xpubs: crate::fixed_script_wallet::XpubTriple,
3042+
) -> Result<crate::fixed_script_wallet::RootWalletKeys, String> {
3043+
use crate::fixed_script_wallet::RootWalletKeys;
3044+
3045+
let inner_psbt = psbt.psbt();
3046+
3047+
// Collect non-replay-protection inputs (those with derivation info)
3048+
let wallet_inputs: Vec<_> = inner_psbt
3049+
.unsigned_tx
3050+
.input
3051+
.iter()
3052+
.zip(inner_psbt.inputs.iter())
3053+
.filter(|(_tx_input, psbt_input)| {
3054+
!psbt_input.bip32_derivation.is_empty() || !psbt_input.tap_key_origins.is_empty()
3055+
})
3056+
.collect();
3057+
3058+
if wallet_inputs.is_empty() {
3059+
return Err("no wallet inputs found in PSBT".to_string());
3060+
}
3061+
3062+
for perm in &XPUB_TRIPLE_PERMUTATIONS {
3063+
let permuted = [xpubs[perm[0]], xpubs[perm[1]], xpubs[perm[2]]];
3064+
let wallet_keys = RootWalletKeys::new(permuted);
3065+
3066+
let all_match = wallet_inputs.iter().all(|(tx_input, psbt_input)| {
3067+
let output_script = psbt_wallet_input::get_output_script_and_value(
3068+
psbt_input,
3069+
tx_input.previous_output,
3070+
);
3071+
match output_script {
3072+
Ok((script, _value)) => {
3073+
psbt_wallet_input::assert_wallet_input(&wallet_keys, psbt_input, script).is_ok()
3074+
}
3075+
Err(_) => false,
3076+
}
3077+
});
3078+
3079+
if all_match {
3080+
return Ok(wallet_keys);
3081+
}
3082+
}
3083+
3084+
Err("no permutation of xpubs matches the PSBT wallet inputs".to_string())
3085+
}
3086+
30063087
#[cfg(test)]
30073088
mod tests {
30083089
use super::*;
@@ -4812,4 +4893,66 @@ mod tests {
48124893
assert!(!version_info.version.is_empty());
48134894
assert!(!version_info.git_hash.is_empty());
48144895
}
4896+
4897+
#[test]
4898+
fn test_get_global_xpubs() {
4899+
use crate::fixed_script_wallet::test_utils::get_test_wallet_keys;
4900+
4901+
let xpubs = get_test_wallet_keys("test_global_xpubs");
4902+
let wallet_keys = RootWalletKeys::new(xpubs);
4903+
let psbt = BitGoPsbt::new(Network::Bitcoin, &wallet_keys, Some(2), Some(0));
4904+
4905+
let global = psbt.get_global_xpubs().expect("should have global xpubs");
4906+
// The xpubs may be in BTreeMap order, not insertion order
4907+
let mut sorted_input: Vec<_> = xpubs.iter().map(|x| x.to_string()).collect();
4908+
sorted_input.sort();
4909+
let mut sorted_output: Vec<_> = global.iter().map(|x| x.to_string()).collect();
4910+
sorted_output.sort();
4911+
assert_eq!(sorted_input, sorted_output);
4912+
}
4913+
4914+
#[test]
4915+
fn test_to_wallet_keys_canonical_order() {
4916+
use crate::fixed_script_wallet::test_utils::get_test_wallet_keys;
4917+
use miniscript::bitcoin::hashes::Hash;
4918+
4919+
let xpubs = get_test_wallet_keys("test_to_wallet_keys");
4920+
let wallet_keys = RootWalletKeys::new(xpubs);
4921+
let mut psbt = BitGoPsbt::new(Network::Bitcoin, &wallet_keys, Some(2), Some(0));
4922+
4923+
let txid = Txid::all_zeros();
4924+
psbt.add_wallet_input(
4925+
txid, 0, 100_000, &wallet_keys,
4926+
ScriptId { chain: 10, index: 0 },
4927+
WalletInputOptions::default(),
4928+
)
4929+
.expect("add_wallet_input");
4930+
4931+
let result = to_wallet_keys(&psbt, xpubs).expect("should find correct order");
4932+
assert_eq!(result.xpubs, xpubs);
4933+
}
4934+
4935+
#[test]
4936+
fn test_to_wallet_keys_shuffled_order() {
4937+
use crate::fixed_script_wallet::test_utils::get_test_wallet_keys;
4938+
use miniscript::bitcoin::hashes::Hash;
4939+
4940+
let xpubs = get_test_wallet_keys("test_to_wallet_keys_shuffled");
4941+
let wallet_keys = RootWalletKeys::new(xpubs);
4942+
let mut psbt = BitGoPsbt::new(Network::Bitcoin, &wallet_keys, Some(2), Some(0));
4943+
4944+
let txid = Txid::all_zeros();
4945+
psbt.add_wallet_input(
4946+
txid, 0, 100_000, &wallet_keys,
4947+
ScriptId { chain: 10, index: 0 },
4948+
WalletInputOptions::default(),
4949+
)
4950+
.expect("add_wallet_input");
4951+
4952+
// Shuffle the xpubs: [bitgo, user, backup] instead of [user, backup, bitgo]
4953+
let shuffled = [xpubs[2], xpubs[0], xpubs[1]];
4954+
let result = to_wallet_keys(&psbt, shuffled).expect("should find correct order");
4955+
// Result should be sorted back to [user, backup, bitgo]
4956+
assert_eq!(result.xpubs, xpubs);
4957+
}
48154958
}

packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,29 @@ impl FixedScriptWalletNamespace {
224224

225225
result.into()
226226
}
227+
228+
/// Sort an xpub triple into [user, backup, bitgo] order by validating
229+
/// against the PSBT's wallet inputs. Returns a RootWalletKeys with the
230+
/// correct ordering.
231+
#[wasm_bindgen]
232+
pub fn to_wallet_keys(
233+
psbt: &BitGoPsbt,
234+
user_or_a: &WasmBIP32,
235+
backup_or_b: &WasmBIP32,
236+
bitgo_or_c: &WasmBIP32,
237+
) -> Result<WasmRootWalletKeys, WasmUtxoError> {
238+
let xpubs = [
239+
user_or_a.to_xpub()?,
240+
backup_or_b.to_xpub()?,
241+
bitgo_or_c.to_xpub()?,
242+
];
243+
let wallet_keys =
244+
crate::fixed_script_wallet::bitgo_psbt::to_wallet_keys(&psbt.psbt, xpubs)
245+
.map_err(|e| WasmUtxoError::new(&e))?;
246+
Ok(WasmRootWalletKeys::from_inner(wallet_keys))
247+
}
227248
}
249+
228250
#[wasm_bindgen]
229251
pub struct BitGoPsbt {
230252
pub(crate) psbt: crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt,
@@ -730,6 +752,21 @@ impl BitGoPsbt {
730752
crate::wasm::psbt::get_outputs_with_address_from_psbt(self.psbt.psbt(), self.psbt.network())
731753
}
732754

755+
/// Returns the global xpubs from the PSBT as an array of WasmBIP32 instances,
756+
/// or null if the PSBT has no global xpubs.
757+
pub fn get_global_xpubs(&self) -> Result<JsValue, WasmUtxoError> {
758+
match self.psbt.get_global_xpubs() {
759+
Some(xpubs) => {
760+
let arr = js_sys::Array::new();
761+
for xpub in &xpubs {
762+
arr.push(&WasmBIP32::from_xpub_internal(*xpub).into());
763+
}
764+
Ok(arr.into())
765+
}
766+
None => Ok(JsValue::NULL),
767+
}
768+
}
769+
733770
/// Parse transaction with wallet keys to identify wallet inputs/outputs
734771
pub fn parse_transaction_with_wallet_keys(
735772
&self,

packages/wasm-utxo/src/wasm/wallet_keys.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ impl WasmRootWalletKeys {
1919
pub(crate) fn inner(&self) -> &RootWalletKeys {
2020
&self.inner
2121
}
22+
23+
/// Create from an inner RootWalletKeys
24+
pub(crate) fn from_inner(inner: RootWalletKeys) -> Self {
25+
Self { inner }
26+
}
2227
}
2328

2429
#[wasm_bindgen]

0 commit comments

Comments
 (0)