@@ -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) ]
30073088mod 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}
0 commit comments