Skip to content

Commit b46483a

Browse files
Merge pull request #192 from BitGo/BTC-3000.add-get-wallet-keys
feat(wasm-utxo): add PSBT global xpub extraction and sorting
2 parents 3242b49 + 75561a9 commit b46483a

9 files changed

Lines changed: 243 additions & 0 deletions

File tree

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

Lines changed: 30 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,32 @@ 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+
*/
981+
getGlobalXpubs(): BIP32[] {
982+
const result = this._wasm.get_global_xpubs() as WasmBIP32[];
983+
return result.map((w) => BIP32.fromWasm(w));
984+
}
985+
}
986+
987+
/**
988+
* Extract sorted wallet keys from a PSBT's global xpub fields.
989+
*
990+
* This should only be used in exceptional circumstances where the real wallet
991+
* keys are not available — for example, legacy cold wallets where the PSBT
992+
* was built with derived keys (from coldDerivationSeed) but the caller only
993+
* has root xpubs. Prefer passing wallet keys explicitly wherever possible.
994+
*
995+
* @returns Sorted [user, backup, bitgo] RootWalletKeys
996+
*/
997+
export function getWalletKeysFromPsbt(psbt: BitGoPsbt, xpubs: BIP32[]): RootWalletKeys {
998+
const wasmKeys = FixedScriptWalletNamespace.to_wallet_keys(
999+
psbt.wasm,
1000+
xpubs[0].wasm,
1001+
xpubs[1].wasm,
1002+
xpubs[2].wasm,
1003+
);
1004+
return RootWalletKeys.fromWasm(wasmKeys);
9751005
}

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/js/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ declare module "./wasm/wasm_utxo.js" {
106106
getInputs(): PsbtInputData[];
107107
getOutputs(): PsbtOutputData[];
108108
getOutputsWithAddress(coin: import("./coinName.js").CoinName): PsbtOutputDataWithAddress[];
109+
getGlobalXpubs(): WasmBIP32[];
109110
getPartialSignatures(inputIndex: number): Array<{
110111
pubkey: Uint8Array;
111112
signature: Uint8Array;

packages/wasm-utxo/js/psbt.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import type { PsbtInputData, PsbtOutputData, PsbtOutputDataWithAddress } from "./wasm/wasm_utxo.js";
2+
import type { BIP32 } from "./bip32.js";
23

34
/** Common interface for PSBT types */
45
export interface IPsbt {
56
inputCount(): number;
67
outputCount(): number;
78
getInputs(): PsbtInputData[];
89
getOutputs(): PsbtOutputData[];
10+
getGlobalXpubs(): BIP32[];
911
version(): number;
1012
lockTime(): number;
1113
unsignedTxId(): string;

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

Lines changed: 155 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,78 @@ 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,
4926+
0,
4927+
100_000,
4928+
&wallet_keys,
4929+
ScriptId {
4930+
chain: 10,
4931+
index: 0,
4932+
},
4933+
WalletInputOptions::default(),
4934+
)
4935+
.expect("add_wallet_input");
4936+
4937+
let result = to_wallet_keys(&psbt, xpubs).expect("should find correct order");
4938+
assert_eq!(result.xpubs, xpubs);
4939+
}
4940+
4941+
#[test]
4942+
fn test_to_wallet_keys_shuffled_order() {
4943+
use crate::fixed_script_wallet::test_utils::get_test_wallet_keys;
4944+
use miniscript::bitcoin::hashes::Hash;
4945+
4946+
let xpubs = get_test_wallet_keys("test_to_wallet_keys_shuffled");
4947+
let wallet_keys = RootWalletKeys::new(xpubs);
4948+
let mut psbt = BitGoPsbt::new(Network::Bitcoin, &wallet_keys, Some(2), Some(0));
4949+
4950+
let txid = Txid::all_zeros();
4951+
psbt.add_wallet_input(
4952+
txid,
4953+
0,
4954+
100_000,
4955+
&wallet_keys,
4956+
ScriptId {
4957+
chain: 10,
4958+
index: 0,
4959+
},
4960+
WalletInputOptions::default(),
4961+
)
4962+
.expect("add_wallet_input");
4963+
4964+
// Shuffle the xpubs: [bitgo, user, backup] instead of [user, backup, bitgo]
4965+
let shuffled = [xpubs[2], xpubs[0], xpubs[1]];
4966+
let result = to_wallet_keys(&psbt, shuffled).expect("should find correct order");
4967+
// Result should be sorted back to [user, backup, bitgo]
4968+
assert_eq!(result.xpubs, xpubs);
4969+
}
48154970
}

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,28 @@ 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 = crate::fixed_script_wallet::bitgo_psbt::to_wallet_keys(&psbt.psbt, xpubs)
244+
.map_err(|e| WasmUtxoError::new(&e))?;
245+
Ok(WasmRootWalletKeys::from_inner(wallet_keys))
246+
}
227247
}
248+
228249
#[wasm_bindgen]
229250
pub struct BitGoPsbt {
230251
pub(crate) psbt: crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt,
@@ -730,6 +751,11 @@ impl BitGoPsbt {
730751
crate::wasm::psbt::get_outputs_with_address_from_psbt(self.psbt.psbt(), self.psbt.network())
731752
}
732753

754+
/// Returns the global xpubs from the PSBT as an array of WasmBIP32 instances.
755+
pub fn get_global_xpubs(&self) -> JsValue {
756+
crate::wasm::psbt::get_global_xpubs_from_psbt(self.psbt.psbt())
757+
}
758+
733759
/// Parse transaction with wallet keys to identify wallet inputs/outputs
734760
pub fn parse_transaction_with_wallet_keys(
735761
&self,

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,15 @@ pub fn get_outputs_from_psbt(psbt: &Psbt) -> Result<JsValue, WasmUtxoError> {
225225
outputs.try_to_js_value()
226226
}
227227

228+
/// Get global xpubs from a PSBT as an array of WasmBIP32 instances
229+
pub fn get_global_xpubs_from_psbt(psbt: &Psbt) -> JsValue {
230+
let arr = js_sys::Array::new();
231+
for xpub in psbt.xpub.keys() {
232+
arr.push(&WasmBIP32::from_xpub_internal(*xpub).into());
233+
}
234+
arr.into()
235+
}
236+
228237
/// Get all PSBT outputs with resolved address strings
229238
pub fn get_outputs_with_address_from_psbt(
230239
psbt: &Psbt,
@@ -674,6 +683,12 @@ impl WrapPsbt {
674683
get_outputs_with_address_from_psbt(&self.0, network)
675684
}
676685

686+
/// Get global xpubs from the PSBT as an array of WasmBIP32 instances.
687+
#[wasm_bindgen(js_name = getGlobalXpubs)]
688+
pub fn get_global_xpubs(&self) -> JsValue {
689+
get_global_xpubs_from_psbt(&self.0)
690+
}
691+
677692
/// Get partial signatures for an input
678693
/// Returns array of { pubkey: Uint8Array, signature: Uint8Array }
679694
#[wasm_bindgen(js_name = getPartialSignatures)]

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)