Skip to content

Commit a95f989

Browse files
Merge pull request #133 from BitGo/BTC-2650.fix-bip32-from
feat(wasm-utxo): improve BIP32 implementation with private key support
2 parents cdd95d6 + fa78f4b commit a95f989

3 files changed

Lines changed: 283 additions & 361 deletions

File tree

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

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use crate::bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv, Xpub};
44
use crate::bitcoin::secp256k1::Secp256k1;
55
use crate::bitcoin::{PrivateKey, PublicKey};
66
use crate::error::WasmUtxoError;
7-
use crate::wasm::try_from_js_value::{get_buffer_field, get_field, get_nested_field};
7+
use crate::wasm::try_from_js_value::{get_field, get_nested_field, Bytes};
88
use wasm_bindgen::prelude::*;
99
use wasm_bindgen::JsValue;
1010

@@ -156,30 +156,50 @@ impl WasmBIP32 {
156156
}
157157

158158
/// Create a BIP32 key from BIP32 properties
159-
/// Extracts properties from a JavaScript object and constructs an xpub
159+
/// Extracts properties from a JavaScript object and constructs an xpub or xprv
160160
#[wasm_bindgen]
161161
pub fn from_bip32_properties(bip32_key: &JsValue) -> Result<WasmBIP32, WasmUtxoError> {
162-
// Extract properties using helper functions
163-
let version: u32 = get_nested_field(bip32_key, "network.bip32.public")?;
162+
// Extract common properties
164163
let depth: u8 = get_field(bip32_key, "depth")?;
165164
let parent_fingerprint: u32 = get_field(bip32_key, "parentFingerprint")?;
166165
let index: u32 = get_field(bip32_key, "index")?;
167-
let chain_code_bytes: [u8; 32] = get_buffer_field(bip32_key, "chainCode")?;
168-
let public_key_bytes: [u8; 33] = get_buffer_field(bip32_key, "publicKey")?;
169-
170-
// Build BIP32 serialization (78 bytes total)
171-
let mut data = Vec::with_capacity(78);
172-
data.extend_from_slice(&version.to_be_bytes()); // 4 bytes: version
173-
data.push(depth); // 1 byte: depth
174-
data.extend_from_slice(&parent_fingerprint.to_be_bytes()); // 4 bytes: parent fingerprint
175-
data.extend_from_slice(&index.to_be_bytes()); // 4 bytes: index
176-
data.extend_from_slice(&chain_code_bytes); // 32 bytes: chain code
177-
data.extend_from_slice(&public_key_bytes); // 33 bytes: public key
178-
179-
// Use the Xpub::decode method which properly handles network detection and constructs the Xpub
180-
let xpub = Xpub::decode(&data)
181-
.map_err(|e| WasmUtxoError::new(&format!("Failed to decode xpub: {}", e)))?;
182-
Ok(WasmBIP32(BIP32Key::Public(xpub)))
166+
let chain_code: Bytes<32> = get_field(bip32_key, "chainCode")?;
167+
168+
// Check if private key exists
169+
let private_key: Option<Bytes<32>> = get_field(bip32_key, "privateKey")?;
170+
171+
if let Some(priv_key) = private_key {
172+
// Build xprv serialization (78 bytes total)
173+
let version: u32 = get_nested_field(bip32_key, "network.bip32.private")?;
174+
let mut data = Vec::with_capacity(78);
175+
data.extend_from_slice(&version.to_be_bytes()); // 4 bytes: version
176+
data.push(depth); // 1 byte: depth
177+
data.extend_from_slice(&parent_fingerprint.to_be_bytes()); // 4 bytes: parent fingerprint
178+
data.extend_from_slice(&index.to_be_bytes()); // 4 bytes: index
179+
data.extend_from_slice(chain_code.as_ref()); // 32 bytes: chain code
180+
data.push(0x00); // 1 byte: padding for private key
181+
data.extend_from_slice(priv_key.as_ref()); // 32 bytes: private key
182+
183+
let xpriv = Xpriv::decode(&data)
184+
.map_err(|e| WasmUtxoError::new(&format!("Failed to decode xprv: {}", e)))?;
185+
Ok(WasmBIP32(BIP32Key::Private(xpriv)))
186+
} else {
187+
// Build xpub serialization (78 bytes total)
188+
let version: u32 = get_nested_field(bip32_key, "network.bip32.public")?;
189+
let public_key: Bytes<33> = get_field(bip32_key, "publicKey")?;
190+
191+
let mut data = Vec::with_capacity(78);
192+
data.extend_from_slice(&version.to_be_bytes()); // 4 bytes: version
193+
data.push(depth); // 1 byte: depth
194+
data.extend_from_slice(&parent_fingerprint.to_be_bytes()); // 4 bytes: parent fingerprint
195+
data.extend_from_slice(&index.to_be_bytes()); // 4 bytes: index
196+
data.extend_from_slice(chain_code.as_ref()); // 32 bytes: chain code
197+
data.extend_from_slice(public_key.as_ref()); // 33 bytes: public key
198+
199+
let xpub = Xpub::decode(&data)
200+
.map_err(|e| WasmUtxoError::new(&format!("Failed to decode xpub: {}", e)))?;
201+
Ok(WasmBIP32(BIP32Key::Public(xpub)))
202+
}
183203
}
184204

185205
/// Create a BIP32 master key from a seed

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

Lines changed: 103 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,64 @@
1+
use std::ops::Deref;
2+
13
use crate::address::utxolib_compat::{CashAddr, UtxolibNetwork};
24
use crate::error::WasmUtxoError;
35
use wasm_bindgen::JsValue;
46

5-
pub(crate) trait TryFromJsValue {
6-
fn try_from_js_value(value: &JsValue) -> Result<Self, WasmUtxoError>
7-
where
8-
Self: Sized;
7+
// =============================================================================
8+
// TryFromJsValue trait
9+
// =============================================================================
10+
11+
/// Trait for converting JsValue to Rust types
12+
pub(crate) trait TryFromJsValue: Sized {
13+
fn try_from_js_value(value: &JsValue) -> Result<Self, WasmUtxoError>;
14+
}
15+
16+
// =============================================================================
17+
// Bytes<N>: Fixed-size byte array wrapper
18+
// =============================================================================
19+
20+
/// Fixed-size byte array that implements TryFromJsValue
21+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22+
pub(crate) struct Bytes<const N: usize>(pub [u8; N]);
23+
24+
impl<const N: usize> Deref for Bytes<N> {
25+
type Target = [u8; N];
26+
fn deref(&self) -> &Self::Target {
27+
&self.0
28+
}
29+
}
30+
31+
impl<const N: usize> AsRef<[u8]> for Bytes<N> {
32+
fn as_ref(&self) -> &[u8] {
33+
&self.0
34+
}
35+
}
36+
37+
impl<const N: usize> From<Bytes<N>> for [u8; N] {
38+
fn from(bytes: Bytes<N>) -> Self {
39+
bytes.0
40+
}
41+
}
42+
43+
impl<const N: usize> TryFromJsValue for Bytes<N> {
44+
fn try_from_js_value(value: &JsValue) -> Result<Self, WasmUtxoError> {
45+
let buffer = js_sys::Uint8Array::new(value);
46+
if buffer.length() as usize != N {
47+
return Err(WasmUtxoError::new(&format!(
48+
"Expected {} bytes, got {}",
49+
N,
50+
buffer.length()
51+
)));
52+
}
53+
let mut bytes = [0u8; N];
54+
buffer.copy_to(&mut bytes);
55+
Ok(Bytes(bytes))
56+
}
957
}
1058

11-
// Implement TryFromJsValue for primitive types
59+
// =============================================================================
60+
// TryFromJsValue implementations for primitive types
61+
// =============================================================================
1262

1363
impl TryFromJsValue for String {
1464
fn try_from_js_value(value: &JsValue) -> Result<Self, WasmUtxoError> {
@@ -36,6 +86,15 @@ impl TryFromJsValue for u32 {
3686
}
3787
}
3888

89+
impl TryFromJsValue for Vec<u8> {
90+
fn try_from_js_value(value: &JsValue) -> Result<Self, WasmUtxoError> {
91+
let buffer = js_sys::Uint8Array::new(value);
92+
let mut bytes = vec![0u8; buffer.length() as usize];
93+
buffer.copy_to(&mut bytes);
94+
Ok(bytes)
95+
}
96+
}
97+
3998
impl<T: TryFromJsValue> TryFromJsValue for Option<T> {
4099
fn try_from_js_value(value: &JsValue) -> Result<Self, WasmUtxoError> {
41100
if value.is_undefined() || value.is_null() {
@@ -46,130 +105,72 @@ impl<T: TryFromJsValue> TryFromJsValue for Option<T> {
46105
}
47106
}
48107

49-
// Helper function to get a field from an object and convert it using TryFromJsValue
50-
pub(crate) fn get_field<T: TryFromJsValue>(obj: &JsValue, key: &str) -> Result<T, WasmUtxoError> {
51-
let field_value = js_sys::Reflect::get(obj, &JsValue::from_str(key))
52-
.map_err(|_| WasmUtxoError::new(&format!("Failed to read {} from object", key)))?;
108+
// =============================================================================
109+
// Field access functions
110+
// =============================================================================
53111

54-
T::try_from_js_value(&field_value)
55-
.map_err(|e| WasmUtxoError::new(&format!("{} (field: {})", e, key)))
112+
/// Get a raw JsValue field from an object without conversion
113+
fn get_raw_field(obj: &JsValue, key: &str) -> Result<JsValue, WasmUtxoError> {
114+
js_sys::Reflect::get(obj, &JsValue::from_str(key))
115+
.map_err(|_| WasmUtxoError::new(&format!("Failed to read {} from object", key)))
56116
}
57117

58-
// Helper function to get an optional field (returns None if undefined/null)
59-
#[allow(dead_code)]
60-
pub(crate) fn get_optional_field<T: TryFromJsValue>(
61-
obj: &JsValue,
62-
key: &str,
63-
) -> Result<Option<T>, WasmUtxoError> {
64-
let field_value = js_sys::Reflect::get(obj, &JsValue::from_str(key))
65-
.map_err(|_| WasmUtxoError::new(&format!("Failed to read {} from object", key)))?;
66-
67-
if field_value.is_undefined() || field_value.is_null() {
68-
Ok(None)
69-
} else {
70-
T::try_from_js_value(&field_value)
71-
.map(Some)
72-
.map_err(|e| WasmUtxoError::new(&format!("{} (field: {})", e, key)))
73-
}
118+
/// Navigate to a nested object using dot notation (e.g., "network.bip32")
119+
fn get_nested_raw(obj: &JsValue, path: &str) -> Result<JsValue, WasmUtxoError> {
120+
path.split('.').try_fold(obj.clone(), |current, part| {
121+
js_sys::Reflect::get(&current, &JsValue::from_str(part))
122+
.map_err(|_| WasmUtxoError::new(&format!("Failed to read {} from object", part)))
123+
})
124+
}
125+
126+
/// Get a field and convert it using TryFromJsValue
127+
pub(crate) fn get_field<T: TryFromJsValue>(obj: &JsValue, key: &str) -> Result<T, WasmUtxoError> {
128+
let field_value = get_raw_field(obj, key)?;
129+
T::try_from_js_value(&field_value)
130+
.map_err(|e| WasmUtxoError::new(&format!("{} (field: {})", e, key)))
74131
}
75132

76-
// Helper function to get a nested field using dot notation (e.g., "network.bip32.public")
133+
/// Get a nested field using dot notation (e.g., "network.bip32.public")
77134
pub(crate) fn get_nested_field<T: TryFromJsValue>(
78135
obj: &JsValue,
79136
path: &str,
80137
) -> Result<T, WasmUtxoError> {
81-
let parts: Vec<&str> = path.split('.').collect();
82-
let mut current = obj.clone();
83-
84-
for (i, part) in parts.iter().enumerate() {
85-
if i == parts.len() - 1 {
86-
// Last part - extract and convert
87-
return get_field(&current, part);
88-
} else {
89-
// Intermediate part - just get the object
90-
current = js_sys::Reflect::get(&current, &JsValue::from_str(part))
91-
.map_err(|_| WasmUtxoError::new(&format!("Failed to read {} from object", part)))?;
92-
}
93-
}
94-
95-
Err(WasmUtxoError::new("Empty path"))
96-
}
97-
98-
// Helper function to get a buffer field as a fixed-size byte array
99-
pub(crate) fn get_buffer_field<const N: usize>(
100-
obj: &JsValue,
101-
key: &str,
102-
) -> Result<[u8; N], WasmUtxoError> {
103-
let field_value = js_sys::Reflect::get(obj, &JsValue::from_str(key))
104-
.map_err(|_| WasmUtxoError::new(&format!("Failed to read {} from object", key)))?;
105-
106-
let buffer = js_sys::Uint8Array::new(&field_value);
107-
if buffer.length() as usize != N {
108-
return Err(WasmUtxoError::new(&format!(
109-
"{} must be {} bytes, got {}",
110-
key,
111-
N,
112-
buffer.length()
113-
)));
114-
}
115-
116-
let mut bytes = [0u8; N];
117-
buffer.copy_to(&mut bytes);
118-
Ok(bytes)
138+
let field_value = get_nested_raw(obj, path)?;
139+
T::try_from_js_value(&field_value)
140+
.map_err(|e| WasmUtxoError::new(&format!("{} (path: {})", e, path)))
119141
}
120142

121-
// Helper function to get a buffer field as a Vec
122-
#[allow(dead_code)]
123-
pub(crate) fn get_buffer_field_vec(obj: &JsValue, key: &str) -> Result<Vec<u8>, WasmUtxoError> {
124-
let field_value = js_sys::Reflect::get(obj, &JsValue::from_str(key))
125-
.map_err(|_| WasmUtxoError::new(&format!("Failed to read {} from object", key)))?;
126-
127-
let buffer = js_sys::Uint8Array::new(&field_value);
128-
let mut bytes = vec![0u8; buffer.length() as usize];
129-
buffer.copy_to(&mut bytes);
130-
Ok(bytes)
131-
}
143+
// =============================================================================
144+
// TryFromJsValue implementations for domain types
145+
// =============================================================================
132146

133147
impl TryFromJsValue for UtxolibNetwork {
134148
fn try_from_js_value(value: &JsValue) -> Result<Self, WasmUtxoError> {
135-
let pub_key_hash = get_field(value, "pubKeyHash")?;
136-
let script_hash = get_field(value, "scriptHash")?;
137-
let bech32 = get_field(value, "bech32")?;
138-
let cash_addr = get_field(value, "cashAddr")?;
139-
140149
Ok(UtxolibNetwork {
141-
pub_key_hash,
142-
script_hash,
143-
cash_addr,
144-
bech32,
150+
pub_key_hash: get_field(value, "pubKeyHash")?,
151+
script_hash: get_field(value, "scriptHash")?,
152+
bech32: get_field(value, "bech32")?,
153+
cash_addr: get_field(value, "cashAddr")?,
145154
})
146155
}
147156
}
148157

149158
impl TryFromJsValue for CashAddr {
150159
fn try_from_js_value(value: &JsValue) -> Result<Self, WasmUtxoError> {
151-
let prefix = get_field(value, "prefix")?;
152-
let pub_key_hash = get_field(value, "pubKeyHash")?;
153-
let script_hash = get_field(value, "scriptHash")?;
154-
155160
Ok(CashAddr {
156-
prefix,
157-
pub_key_hash,
158-
script_hash,
161+
prefix: get_field(value, "prefix")?,
162+
pub_key_hash: get_field(value, "pubKeyHash")?,
163+
script_hash: get_field(value, "scriptHash")?,
159164
})
160165
}
161166
}
162167

163168
impl TryFromJsValue for crate::inscriptions::TapLeafScript {
164169
fn try_from_js_value(value: &JsValue) -> Result<Self, WasmUtxoError> {
165-
let leaf_version: u8 = get_field(value, "leafVersion")?;
166-
let script = get_buffer_field_vec(value, "script")?;
167-
let control_block = get_buffer_field_vec(value, "controlBlock")?;
168-
169170
Ok(crate::inscriptions::TapLeafScript {
170-
leaf_version,
171-
script,
172-
control_block,
171+
leaf_version: get_field(value, "leafVersion")?,
172+
script: get_field(value, "script")?,
173+
control_block: get_field(value, "controlBlock")?,
173174
})
174175
}
175176
}
@@ -184,7 +185,8 @@ impl TryFromJsValue for crate::networks::Network {
184185
.or_else(|| crate::networks::Network::from_coin_name(&network_str))
185186
.ok_or_else(|| {
186187
WasmUtxoError::new(&format!(
187-
"Unknown network '{}'. Expected a utxolib name (e.g., 'bitcoin', 'testnet') or coin name (e.g., 'btc', 'tbtc')",
188+
"Unknown network '{}'. Expected a utxolib name (e.g., 'bitcoin', 'testnet') \
189+
or coin name (e.g., 'btc', 'tbtc')",
188190
network_str
189191
))
190192
})

0 commit comments

Comments
 (0)