Skip to content

Commit 951cf14

Browse files
authored
Merge pull request #8 from BitGo/BTC-2650.add-altcoin-address-support
feat(wasm-utxo): implement cross-chain address functionality
2 parents 05da226 + a649c99 commit 951cf14

40 files changed

+3381
-23
lines changed

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ jobs:
4646
rustup component add rustfmt
4747
cargo install wasm-pack --version 0.13.1
4848
cargo install wasm-opt --version 0.116.1
49+
cargo install cargo-deny --locked
4950
5051
- name: Build Info
5152
run: |
@@ -54,6 +55,7 @@ jobs:
5455
echo "rustc $(rustc --version)"
5556
echo "wasm-pack $(wasm-pack --version)"
5657
echo "wasm-opt $(wasm-opt --version)"
58+
echo "cargo-deny $(cargo deny --version)"
5759
git --version
5860
echo "base ref $GITHUB_BASE_REF"
5961
echo "head ref $GITHUB_HEAD_REF"
@@ -65,6 +67,10 @@ jobs:
6567
- name: Install Packages
6668
run: npm ci --workspaces --include-workspace-root
6769

70+
- name: Check dependencies with cargo-deny
71+
run: cargo deny check
72+
working-directory: packages/wasm-utxo
73+
6874
- name: test
6975
run: npx --version
7076

packages/wasm-utxo/Cargo.lock

Lines changed: 25 additions & 22 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/wasm-utxo/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ crate-type = ["cdylib"]
1010
wasm-bindgen = "0.2"
1111
js-sys = "0.3"
1212
miniscript = { git = "https://github.com/BitGo/rust-miniscript", tag = "miniscript-12.3.4-opdrop" }
13+
bech32 = "0.11"
1314

1415
[dev-dependencies]
1516
base64 = "0.22.1"

packages/wasm-utxo/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ endef
1212

1313
# run wasm-opt separately so we can pass `--enable-bulk-memory`
1414
define WASM_OPT_COMMAND
15-
$(WASM_OPT) --enable-bulk-memory -Oz $(1)/*.wasm -o $(1)/*.wasm
15+
$(WASM_OPT) --enable-bulk-memory --enable-nontrapping-float-to-int -Oz $(1)/*.wasm -o $(1)/*.wasm
1616
endef
1717

1818
define REMOVE_GITIGNORE

packages/wasm-utxo/deny.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Deny multiple versions of dependencies
2+
[bans]
3+
multiple-versions = "deny"
4+
# Highlight bech32 specifically (ensures it's caught if duplicated)
5+
highlight = "all"
6+
7+
# Allow git sources (needed for miniscript)
8+
[sources]
9+
allow-git = ["https://github.com/BitGo/rust-miniscript"]
10+
11+
# Allow common licenses used in the Rust ecosystem
12+
[licenses]
13+
allow = ["MIT", "Apache-2.0", "CC0-1.0", "MITNFA", "Unicode-DFS-2016"]
14+
# Clarify license for unlicensed crate
15+
[[licenses.clarify]]
16+
name = "wasm-utxo"
17+
expression = "MIT OR Apache-2.0"
18+
license-files = []

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";
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
//! Base58Check encoding/decoding for traditional Bitcoin addresses (P2PKH, P2SH).
2+
3+
use super::{AddressCodec, AddressError, Result};
4+
use crate::bitcoin::hashes::Hash;
5+
use crate::bitcoin::{base58, PubkeyHash, Script, ScriptBuf, ScriptHash};
6+
7+
/// Base58Check codec with network-specific version bytes
8+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9+
pub struct Base58CheckCodec {
10+
/// Base58Check P2PKH version byte(s)
11+
pub pub_key_hash: u32,
12+
/// Base58Check P2SH version byte(s)
13+
pub script_hash: u32,
14+
}
15+
16+
impl Base58CheckCodec {
17+
/// Create a new Base58Check codec with specified version bytes
18+
pub const fn new(pub_key_hash: u32, script_hash: u32) -> Self {
19+
Self {
20+
pub_key_hash,
21+
script_hash,
22+
}
23+
}
24+
}
25+
26+
/// Encode a hash with version bytes to Base58Check format using bitcoin crate
27+
fn to_base58_check(hash: &[u8], version: u32) -> Result<String> {
28+
let mut data = Vec::new();
29+
30+
// Encode version bytes (1-4 bytes depending on size)
31+
if version <= 0xff {
32+
data.push(version as u8);
33+
} else if version <= 0xffff {
34+
data.extend_from_slice(&(version as u16).to_be_bytes());
35+
} else {
36+
// For Zcash (up to 4 bytes)
37+
let bytes = version.to_be_bytes();
38+
let start = bytes.iter().position(|&b| b != 0).unwrap_or(0);
39+
data.extend_from_slice(&bytes[start..]);
40+
}
41+
42+
data.extend_from_slice(hash);
43+
44+
// Use bitcoin crate's base58 encode_check which adds the checksum
45+
Ok(base58::encode_check(&data))
46+
}
47+
48+
/// Decode a Base58Check address to (hash, version) using bitcoin crate
49+
fn from_base58_check(address: &str) -> Result<(Vec<u8>, u32)> {
50+
// Use bitcoin crate's base58 decode_check which verifies the checksum
51+
let payload =
52+
base58::decode_check(address).map_err(|e| AddressError::Base58Error(e.to_string()))?;
53+
54+
if payload.is_empty() {
55+
return Err(AddressError::Base58Error("Empty payload".to_string()));
56+
}
57+
58+
// Extract version and hash
59+
// Try different version byte lengths
60+
let (version, hash) = if payload.len() >= 21 && (payload[0] == 0x1c || payload[0] == 0x1d) {
61+
// Zcash uses 2-byte versions starting with 0x1c or 0x1d
62+
if payload.len() >= 22 {
63+
let version = u32::from_be_bytes([0, 0, payload[0], payload[1]]);
64+
let hash = payload[2..].to_vec();
65+
(version, hash)
66+
} else {
67+
// Single byte version
68+
let version = payload[0] as u32;
69+
let hash = payload[1..].to_vec();
70+
(version, hash)
71+
}
72+
} else {
73+
// Standard single-byte version
74+
let version = payload[0] as u32;
75+
let hash = payload[1..].to_vec();
76+
(version, hash)
77+
};
78+
79+
Ok((hash, version))
80+
}
81+
82+
impl AddressCodec for Base58CheckCodec {
83+
fn encode(&self, script: &Script) -> Result<String> {
84+
if script.is_p2pkh() {
85+
if script.len() != 25 {
86+
return Err(AddressError::InvalidScript(
87+
"Invalid P2PKH script length".to_string(),
88+
));
89+
}
90+
let hash = &script.as_bytes()[3..23];
91+
to_base58_check(hash, self.pub_key_hash)
92+
} else if script.is_p2sh() {
93+
if script.len() != 23 {
94+
return Err(AddressError::InvalidScript(
95+
"Invalid P2SH script length".to_string(),
96+
));
97+
}
98+
let hash = &script.as_bytes()[2..22];
99+
to_base58_check(hash, self.script_hash)
100+
} else {
101+
Err(AddressError::UnsupportedScriptType(
102+
"Base58Check only supports P2PKH and P2SH".to_string(),
103+
))
104+
}
105+
}
106+
107+
fn decode(&self, address: &str) -> Result<ScriptBuf> {
108+
let (hash, version) = from_base58_check(address)?;
109+
110+
if version == self.pub_key_hash {
111+
let hash_array: [u8; 20] = hash.try_into().map_err(|_| {
112+
AddressError::InvalidAddress("Invalid pubkey hash length".to_string())
113+
})?;
114+
let pubkey_hash = PubkeyHash::from_byte_array(hash_array);
115+
Ok(ScriptBuf::new_p2pkh(&pubkey_hash))
116+
} else if version == self.script_hash {
117+
let hash_array: [u8; 20] = hash.try_into().map_err(|_| {
118+
AddressError::InvalidAddress("Invalid script hash length".to_string())
119+
})?;
120+
let script_hash = ScriptHash::from_byte_array(hash_array);
121+
Ok(ScriptBuf::new_p2sh(&script_hash))
122+
} else {
123+
Err(AddressError::InvalidAddress(format!(
124+
"Version mismatch: expected {} or {}, got {}",
125+
self.pub_key_hash, self.script_hash, version
126+
)))
127+
}
128+
}
129+
}

0 commit comments

Comments
 (0)