Skip to content

Commit ad2a991

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): add address compatibility layer for altcoins
Add a compatibility layer for address encoding/decoding that works with networks from @bitgo/utxo-lib. This enables support for all altcoin addresses through a common interface. Issue: BTC-2650 Co-authored-by: llm-git <[email protected]>
1 parent 824ae04 commit ad2a991

File tree

7 files changed

+333
-33
lines changed

7 files changed

+333
-33
lines changed

packages/wasm-utxo/js/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,14 @@ declare module "./wasm/wasm_utxo" {
3939
}
4040
}
4141

42+
import { Address as WasmAddress } from "./wasm/wasm_utxo";
43+
4244
export { WrapDescriptor as Descriptor } from "./wasm/wasm_utxo";
4345
export { WrapMiniscript as Miniscript } from "./wasm/wasm_utxo";
4446
export { WrapPsbt as Psbt } from "./wasm/wasm_utxo";
4547

48+
export namespace utxolibCompat {
49+
export const Address = WasmAddress;
50+
}
51+
4652
export * as ast from "./ast";

packages/wasm-utxo/src/address/bech32.rs

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ impl Bech32Codec {
2222
}
2323

2424
/// Encode witness program with custom HRP
25-
fn encode_witness_with_custom_hrp(
25+
pub fn encode_witness_with_custom_hrp(
2626
program: &[u8],
2727
version: WitnessVersion,
2828
hrp_str: &str,
@@ -49,8 +49,38 @@ fn encode_witness_with_custom_hrp(
4949
Ok(address)
5050
}
5151

52+
/// Extract witness version and program from a script
53+
pub fn extract_witness_program(script: &Script) -> Result<(WitnessVersion, &[u8])> {
54+
if script.is_p2wpkh() {
55+
if script.len() != 22 {
56+
return Err(AddressError::InvalidScript(
57+
"Invalid P2WPKH script length".to_string(),
58+
));
59+
}
60+
Ok((WitnessVersion::V0, &script.as_bytes()[2..22]))
61+
} else if script.is_p2wsh() {
62+
if script.len() != 34 {
63+
return Err(AddressError::InvalidScript(
64+
"Invalid P2WSH script length".to_string(),
65+
));
66+
}
67+
Ok((WitnessVersion::V0, &script.as_bytes()[2..34]))
68+
} else if script.is_p2tr() {
69+
if script.len() != 34 {
70+
return Err(AddressError::InvalidScript(
71+
"Invalid P2TR script length".to_string(),
72+
));
73+
}
74+
Ok((WitnessVersion::V1, &script.as_bytes()[2..34]))
75+
} else {
76+
Err(AddressError::UnsupportedScriptType(
77+
"Bech32 only supports witness programs (P2WPKH, P2WSH, P2TR)".to_string(),
78+
))
79+
}
80+
}
81+
5282
/// Decode witness program with custom HRP
53-
fn decode_witness_with_custom_hrp(address: &str, expected_hrp: &str) -> Result<Vec<u8>> {
83+
pub fn decode_witness_with_custom_hrp(address: &str, expected_hrp: &str) -> Result<Vec<u8>> {
5484
use bech32::{self, Hrp};
5585

5686
// Parse the expected HRP
@@ -92,34 +122,7 @@ fn decode_witness_with_custom_hrp(address: &str, expected_hrp: &str) -> Result<V
92122

93123
impl AddressCodec for Bech32Codec {
94124
fn encode(&self, script: &Script) -> Result<String> {
95-
let (witness_version, program) = if script.is_p2wpkh() {
96-
if script.len() != 22 {
97-
return Err(AddressError::InvalidScript(
98-
"Invalid P2WPKH script length".to_string(),
99-
));
100-
}
101-
(WitnessVersion::V0, &script.as_bytes()[2..22])
102-
} else if script.is_p2wsh() {
103-
if script.len() != 34 {
104-
return Err(AddressError::InvalidScript(
105-
"Invalid P2WSH script length".to_string(),
106-
));
107-
}
108-
(WitnessVersion::V0, &script.as_bytes()[2..34])
109-
} else if script.is_p2tr() {
110-
if script.len() != 34 {
111-
return Err(AddressError::InvalidScript(
112-
"Invalid P2TR script length".to_string(),
113-
));
114-
}
115-
(WitnessVersion::V1, &script.as_bytes()[2..34])
116-
} else {
117-
return Err(AddressError::UnsupportedScriptType(
118-
"Bech32 only supports witness programs (P2WPKH, P2WSH, P2TR)".to_string(),
119-
));
120-
};
121-
122-
// Use custom HRP encoding for all networks
125+
let (witness_version, program) = extract_witness_program(script)?;
123126
encode_witness_with_custom_hrp(program, witness_version, self.hrp)
124127
}
125128

packages/wasm-utxo/src/address/cashaddr.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ fn polymod(values: &[u8]) -> u64 {
276276
}
277277

278278
/// Encode hash to cashaddr format
279-
fn encode_cashaddr(hash: &[u8], is_p2sh: bool, prefix: &str) -> Result<String> {
279+
pub fn encode_cashaddr(hash: &[u8], is_p2sh: bool, prefix: &str) -> Result<String> {
280280
if hash.len() != 20 {
281281
return Err(AddressError::CashaddrError(
282282
"Hash must be 20 bytes".to_string(),
@@ -326,7 +326,7 @@ fn encode_cashaddr(hash: &[u8], is_p2sh: bool, prefix: &str) -> Result<String> {
326326
}
327327

328328
/// Decode cashaddr to (hash, is_p2sh)
329-
fn decode_cashaddr(address: &str, expected_prefix: &str) -> Result<(Vec<u8>, bool)> {
329+
pub fn decode_cashaddr(address: &str, expected_prefix: &str) -> Result<(Vec<u8>, bool)> {
330330
// Check for mixed case
331331
let has_lower = address.chars().any(|c| c.is_lowercase());
332332
let has_upper = address.chars().any(|c| c.is_uppercase());

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
mod base58check;
3636
mod bech32;
3737
pub mod cashaddr;
38+
pub mod utxolib_compat;
3839

3940
pub use base58check::Base58CheckCodec;
4041
pub use bech32::Bech32Codec;
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
/// Helper structs for compatibility with npm @bitgo/utxo-lib
2+
/// Long-term we should not use the `Network` objects from @bitgo/utxo-lib any longer,
3+
/// but for now we need to keep this compatibility layer.
4+
use wasm_bindgen::JsValue;
5+
6+
use crate::address::{bech32, cashaddr, AddressError, Base58CheckCodec};
7+
use crate::bitcoin::{Script, ScriptBuf};
8+
9+
type Result<T> = std::result::Result<T, AddressError>;
10+
11+
pub struct CashAddr {
12+
pub prefix: String,
13+
pub pub_key_hash: u32,
14+
pub script_hash: u32,
15+
}
16+
17+
pub struct Network {
18+
pub pub_key_hash: u32,
19+
pub script_hash: u32,
20+
pub cash_addr: Option<CashAddr>,
21+
pub bech32: Option<String>,
22+
}
23+
24+
impl Network {
25+
/// Parse a Network object from a JavaScript value
26+
pub fn from_js_value(js_network: &JsValue) -> Result<Self> {
27+
// Helper to get a required number field
28+
let get_number = |key: &str| -> Result<u32> {
29+
let value =
30+
js_sys::Reflect::get(js_network, &JsValue::from_str(key)).map_err(|_| {
31+
AddressError::InvalidAddress(format!(
32+
"Failed to read {} from network object",
33+
key
34+
))
35+
})?;
36+
37+
value
38+
.as_f64()
39+
.ok_or_else(|| AddressError::InvalidAddress(format!("{} must be a number", key)))
40+
.map(|n| n as u32)
41+
};
42+
43+
// Helper to get an optional string field
44+
let get_optional_string = |key: &str| -> Result<Option<String>> {
45+
let value =
46+
js_sys::Reflect::get(js_network, &JsValue::from_str(key)).map_err(|_| {
47+
AddressError::InvalidAddress(format!(
48+
"Failed to read {} from network object",
49+
key
50+
))
51+
})?;
52+
53+
if value.is_undefined() || value.is_null() {
54+
Ok(None)
55+
} else {
56+
value
57+
.as_string()
58+
.ok_or_else(|| {
59+
AddressError::InvalidAddress(format!("{} must be a string", key))
60+
})
61+
.map(Some)
62+
}
63+
};
64+
65+
let pub_key_hash = get_number("pubKeyHash")?;
66+
let script_hash = get_number("scriptHash")?;
67+
let bech32 = get_optional_string("bech32")?;
68+
69+
// Parse optional cashAddr object
70+
let cash_addr = {
71+
let cash_addr_obj = js_sys::Reflect::get(js_network, &JsValue::from_str("cashAddr"))
72+
.map_err(|_| {
73+
AddressError::InvalidAddress(
74+
"Failed to read cashAddr from network object".to_string(),
75+
)
76+
})?;
77+
78+
if cash_addr_obj.is_undefined() || cash_addr_obj.is_null() {
79+
None
80+
} else {
81+
let prefix = js_sys::Reflect::get(&cash_addr_obj, &JsValue::from_str("prefix"))
82+
.map_err(|_| {
83+
AddressError::InvalidAddress("Failed to read cashAddr.prefix".to_string())
84+
})?
85+
.as_string()
86+
.ok_or_else(|| {
87+
AddressError::InvalidAddress("cashAddr.prefix must be a string".to_string())
88+
})?;
89+
90+
let pub_key_hash =
91+
js_sys::Reflect::get(&cash_addr_obj, &JsValue::from_str("pubKeyHash"))
92+
.map_err(|_| {
93+
AddressError::InvalidAddress(
94+
"Failed to read cashAddr.pubKeyHash".to_string(),
95+
)
96+
})?
97+
.as_f64()
98+
.ok_or_else(|| {
99+
AddressError::InvalidAddress(
100+
"cashAddr.pubKeyHash must be a number".to_string(),
101+
)
102+
})? as u32;
103+
104+
let script_hash =
105+
js_sys::Reflect::get(&cash_addr_obj, &JsValue::from_str("scriptHash"))
106+
.map_err(|_| {
107+
AddressError::InvalidAddress(
108+
"Failed to read cashAddr.scriptHash".to_string(),
109+
)
110+
})?
111+
.as_f64()
112+
.ok_or_else(|| {
113+
AddressError::InvalidAddress(
114+
"cashAddr.scriptHash must be a number".to_string(),
115+
)
116+
})? as u32;
117+
118+
Some(CashAddr {
119+
prefix,
120+
pub_key_hash,
121+
script_hash,
122+
})
123+
}
124+
};
125+
126+
Ok(Network {
127+
pub_key_hash,
128+
script_hash,
129+
cash_addr,
130+
bech32,
131+
})
132+
}
133+
}
134+
135+
/// Convert output script to address string using a utxolib Network object
136+
pub fn from_output_script_with_network(script: &Script, network: &Network) -> Result<String> {
137+
// Determine script type and choose appropriate codec
138+
// Note: We always use base58check for P2PKH/P2SH to match utxolib behavior,
139+
// even if cashAddr is available. Cashaddr is only used for decoding.
140+
if script.is_p2pkh() || script.is_p2sh() {
141+
let codec = Base58CheckCodec::new(network.pub_key_hash, network.script_hash);
142+
use crate::address::AddressCodec;
143+
codec.encode(script)
144+
} else if script.is_p2wpkh() || script.is_p2wsh() || script.is_p2tr() {
145+
// For witness scripts, use bech32 if available
146+
if let Some(ref hrp) = network.bech32 {
147+
let (witness_version, program) = bech32::extract_witness_program(script)?;
148+
bech32::encode_witness_with_custom_hrp(program, witness_version, hrp)
149+
} else {
150+
Err(AddressError::UnsupportedScriptType(
151+
"Network does not support bech32 addresses".to_string(),
152+
))
153+
}
154+
} else {
155+
Err(AddressError::UnsupportedScriptType(format!(
156+
"Unsupported script type for address encoding, length: {}",
157+
script.len()
158+
)))
159+
}
160+
}
161+
162+
/// Convert address string to output script using a utxolib Network object
163+
pub fn to_output_script_with_network(address: &str, network: &Network) -> Result<ScriptBuf> {
164+
use crate::address::AddressCodec;
165+
use crate::bitcoin::hashes::Hash;
166+
use crate::bitcoin::{PubkeyHash, ScriptHash};
167+
168+
// Try base58check first (always available)
169+
let base58_codec = Base58CheckCodec::new(network.pub_key_hash, network.script_hash);
170+
if let Ok(script) = base58_codec.decode(address) {
171+
return Ok(script);
172+
}
173+
174+
// Try bech32 if available
175+
if let Some(ref hrp) = network.bech32 {
176+
if let Ok(script_bytes) = bech32::decode_witness_with_custom_hrp(address, hrp) {
177+
return Ok(ScriptBuf::from_bytes(script_bytes));
178+
}
179+
}
180+
181+
// Try cashaddr if available
182+
if let Some(ref cash_addr) = network.cash_addr {
183+
if let Ok((hash, is_p2sh)) = cashaddr::decode_cashaddr(address, &cash_addr.prefix) {
184+
let hash_array: [u8; 20] = hash
185+
.try_into()
186+
.map_err(|_| AddressError::CashaddrError("Invalid hash length".to_string()))?;
187+
188+
return if is_p2sh {
189+
let script_hash = ScriptHash::from_byte_array(hash_array);
190+
Ok(ScriptBuf::new_p2sh(&script_hash))
191+
} else {
192+
let pubkey_hash = PubkeyHash::from_byte_array(hash_array);
193+
Ok(ScriptBuf::new_p2pkh(&pubkey_hash))
194+
};
195+
}
196+
}
197+
198+
Err(AddressError::InvalidAddress(format!(
199+
"Could not decode address with any available codec: {}",
200+
address
201+
)))
202+
}
203+
204+
// WASM bindings for utxolib-compatible address functions
205+
use wasm_bindgen::prelude::*;
206+
207+
#[wasm_bindgen]
208+
pub struct Address;
209+
210+
#[wasm_bindgen]
211+
impl Address {
212+
/// Convert output script to address string
213+
///
214+
/// # Arguments
215+
/// * `script` - The output script as a byte array
216+
/// * `network` - The utxolib Network object from JavaScript
217+
#[wasm_bindgen(js_name = fromOutputScript)]
218+
pub fn from_output_script_js(
219+
script: &[u8],
220+
network: JsValue,
221+
) -> std::result::Result<String, JsValue> {
222+
let network =
223+
Network::from_js_value(&network).map_err(|e| JsValue::from_str(&e.to_string()))?;
224+
225+
let script_obj = Script::from_bytes(script);
226+
227+
from_output_script_with_network(script_obj, &network)
228+
.map_err(|e| JsValue::from_str(&e.to_string()))
229+
}
230+
231+
/// Convert address string to output script
232+
///
233+
/// # Arguments
234+
/// * `address` - The address string
235+
/// * `network` - The utxolib Network object from JavaScript
236+
#[wasm_bindgen(js_name = toOutputScript)]
237+
pub fn to_output_script_js(
238+
address: &str,
239+
network: JsValue,
240+
) -> std::result::Result<Vec<u8>, JsValue> {
241+
let network =
242+
Network::from_js_value(&network).map_err(|e| JsValue::from_str(&e.to_string()))?;
243+
244+
to_output_script_with_network(address, &network)
245+
.map(|script| script.to_bytes())
246+
.map_err(|e| JsValue::from_str(&e.to_string()))
247+
}
248+
}

packages/wasm-utxo/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ mod try_into_js_value;
1010
// this package is transitioning to a all-purpose bitcoin package, so we want easy access
1111
pub use ::miniscript::bitcoin;
1212

13-
pub use address::*;
13+
pub use address::utxolib_compat;
1414
pub use descriptor::WrapDescriptor;
1515
pub use miniscript::WrapMiniscript;
1616
pub use psbt::WrapPsbt;

0 commit comments

Comments
 (0)