Skip to content

Commit 083dc37

Browse files
Merge pull request #81 from BitGo/BTC-2659.dash-doge
feat(wasm-utxo): dash and doge support
2 parents 4b89ac9 + 0858563 commit 083dc37

File tree

69 files changed

+63614
-215
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

69 files changed

+63614
-215
lines changed

packages/wasm-utxo/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ This project is under active development.
2020
| Descriptor Wallet: Address Support | ✅ Complete | 🚫 | 🚫 | 🚫 | 🚫 | 🚫 | 🚫 |
2121
| Descriptor Wallet: Transaction Support | ✅ Complete | 🚫 | 🚫 | 🚫 | 🚫 | 🚫 | 🚫 |
2222
| FixedScript Wallet: Address Generation | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Complete |
23-
| FixedScript Wallet: Transaction Support | ✅ Complete | ✅ Complete | ✅ Complete | ⏳ TODO | ⏳ TODO | ✅ Complete | ✅ Complete |
23+
| FixedScript Wallet: Transaction Support | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Complete |
2424

2525
### Zcash Features
2626

packages/wasm-utxo/js/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,4 @@ declare module "./wasm/wasm_utxo.js" {
6363
export { WrapDescriptor as Descriptor } from "./wasm/wasm_utxo.js";
6464
export { WrapMiniscript as Miniscript } from "./wasm/wasm_utxo.js";
6565
export { WrapPsbt as Psbt } from "./wasm/wasm_utxo.js";
66+
export { DashTransaction, Transaction, ZcashTransaction } from "./transaction.js";
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { WasmDashTransaction, WasmTransaction, WasmZcashTransaction } from "./wasm/wasm_utxo.js";
2+
3+
/**
4+
* Transaction wrapper (Bitcoin-like networks)
5+
*
6+
* Provides a camelCase, strongly-typed API over the snake_case WASM bindings.
7+
*/
8+
export class Transaction {
9+
private constructor(private _wasm: WasmTransaction) {}
10+
11+
static fromBytes(bytes: Uint8Array): Transaction {
12+
return new Transaction(WasmTransaction.from_bytes(bytes));
13+
}
14+
15+
toBytes(): Uint8Array {
16+
return this._wasm.to_bytes();
17+
}
18+
19+
/**
20+
* @internal
21+
*/
22+
get wasm(): WasmTransaction {
23+
return this._wasm;
24+
}
25+
}
26+
27+
/**
28+
* Zcash Transaction wrapper
29+
*
30+
* Provides a camelCase, strongly-typed API over the snake_case WASM bindings.
31+
*/
32+
export class ZcashTransaction {
33+
private constructor(private _wasm: WasmZcashTransaction) {}
34+
35+
static fromBytes(bytes: Uint8Array): ZcashTransaction {
36+
return new ZcashTransaction(WasmZcashTransaction.from_bytes(bytes));
37+
}
38+
39+
toBytes(): Uint8Array {
40+
return this._wasm.to_bytes();
41+
}
42+
43+
/**
44+
* @internal
45+
*/
46+
get wasm(): WasmZcashTransaction {
47+
return this._wasm;
48+
}
49+
}
50+
51+
/**
52+
* Dash Transaction wrapper (supports EVO special transactions)
53+
*
54+
* Round-trip only: bytes -> parse -> bytes.
55+
*/
56+
export class DashTransaction {
57+
private constructor(private _wasm: WasmDashTransaction) {}
58+
59+
static fromBytes(bytes: Uint8Array): DashTransaction {
60+
return new DashTransaction(WasmDashTransaction.from_bytes(bytes));
61+
}
62+
63+
toBytes(): Uint8Array {
64+
return this._wasm.to_bytes();
65+
}
66+
67+
/**
68+
* @internal
69+
*/
70+
get wasm(): WasmDashTransaction {
71+
return this._wasm;
72+
}
73+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pub mod transaction;
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
//! Dash transaction encoding/decoding helpers
2+
//!
3+
//! Dash "special transactions" encode an extra `type` in the transaction version and append an
4+
//! extra payload after the lock_time:
5+
//! - version: u32 where low 16 bits are the base version and high 16 bits are the special tx type
6+
//! - if type != 0: varint payload_size + payload bytes
7+
8+
use miniscript::bitcoin::consensus::{Decodable, Encodable};
9+
use miniscript::bitcoin::{Transaction, TxIn, TxOut, VarInt};
10+
11+
/// Parsed Dash transaction fields needed for round-tripping.
12+
#[derive(Debug, Clone)]
13+
pub struct DashTransactionParts {
14+
/// Bitcoin-compatible transaction (version without the Dash type bits)
15+
pub transaction: Transaction,
16+
/// Dash-specific special transaction type (0 = standard transaction)
17+
pub tx_type: u16,
18+
/// Extra payload for special transactions (empty when tx_type == 0)
19+
pub extra_payload: Vec<u8>,
20+
}
21+
22+
fn u16_to_u32(v: u16) -> u32 {
23+
u32::from(v)
24+
}
25+
26+
fn version_i32_to_u16(version: i32) -> Result<u16, String> {
27+
let v = u32::try_from(version)
28+
.map_err(|_| format!("Invalid tx version (negative): {}", version))?;
29+
u16::try_from(v & 0xFFFF).map_err(|_| "Invalid base version".to_string())
30+
}
31+
32+
/// Decode a Dash transaction, extracting the special tx type and extra payload (if present).
33+
pub fn decode_dash_transaction_parts(bytes: &[u8]) -> Result<DashTransactionParts, String> {
34+
let mut slice = bytes;
35+
36+
// Dash encodes tx_type in the high 16 bits of the version.
37+
let version_u32 = u32::consensus_decode(&mut slice)
38+
.map_err(|e| format!("Failed to decode version: {}", e))?;
39+
let base_version = (version_u32 & 0xFFFF) as i32;
40+
let tx_type = ((version_u32 >> 16) & 0xFFFF) as u16;
41+
42+
let inputs: Vec<TxIn> =
43+
Vec::consensus_decode(&mut slice).map_err(|e| format!("Failed to decode inputs: {}", e))?;
44+
let outputs: Vec<TxOut> = Vec::consensus_decode(&mut slice)
45+
.map_err(|e| format!("Failed to decode outputs: {}", e))?;
46+
let lock_time = miniscript::bitcoin::locktime::absolute::LockTime::consensus_decode(&mut slice)
47+
.map_err(|e| format!("Failed to decode lock_time: {}", e))?;
48+
49+
let (extra_payload, remaining) = if tx_type != 0 {
50+
let payload_len: VarInt = Decodable::consensus_decode(&mut slice)
51+
.map_err(|e| format!("Failed to decode extra_payload size: {}", e))?;
52+
let payload_len = payload_len.0 as usize;
53+
if slice.len() < payload_len {
54+
return Err("extra_payload size exceeds remaining bytes".to_string());
55+
}
56+
let payload = slice[..payload_len].to_vec();
57+
(payload, &slice[payload_len..])
58+
} else {
59+
(Vec::new(), slice)
60+
};
61+
62+
if !remaining.is_empty() {
63+
return Err("Unexpected trailing bytes after Dash transaction".to_string());
64+
}
65+
66+
Ok(DashTransactionParts {
67+
transaction: Transaction {
68+
version: miniscript::bitcoin::transaction::Version::non_standard(base_version),
69+
input: inputs,
70+
output: outputs,
71+
lock_time,
72+
},
73+
tx_type,
74+
extra_payload,
75+
})
76+
}
77+
78+
/// Encode a Dash transaction back to bytes, including tx_type and extra payload.
79+
pub fn encode_dash_transaction_parts(parts: &DashTransactionParts) -> Result<Vec<u8>, String> {
80+
let mut bytes = Vec::new();
81+
82+
let base_version_u16 = version_i32_to_u16(parts.transaction.version.0)?;
83+
let version_u32 = u16_to_u32(base_version_u16) | (u16_to_u32(parts.tx_type) << 16);
84+
version_u32
85+
.consensus_encode(&mut bytes)
86+
.map_err(|e| format!("Failed to encode version: {}", e))?;
87+
88+
parts
89+
.transaction
90+
.input
91+
.consensus_encode(&mut bytes)
92+
.map_err(|e| format!("Failed to encode inputs: {}", e))?;
93+
parts
94+
.transaction
95+
.output
96+
.consensus_encode(&mut bytes)
97+
.map_err(|e| format!("Failed to encode outputs: {}", e))?;
98+
parts
99+
.transaction
100+
.lock_time
101+
.consensus_encode(&mut bytes)
102+
.map_err(|e| format!("Failed to encode lock_time: {}", e))?;
103+
104+
if parts.tx_type != 0 {
105+
VarInt(parts.extra_payload.len() as u64)
106+
.consensus_encode(&mut bytes)
107+
.map_err(|e| format!("Failed to encode extra_payload size: {}", e))?;
108+
bytes.extend_from_slice(&parts.extra_payload);
109+
} else if !parts.extra_payload.is_empty() {
110+
return Err("tx_type=0 must not have extra_payload".to_string());
111+
}
112+
113+
Ok(bytes)
114+
}
115+
116+
#[cfg(all(test, not(target_arch = "wasm32")))]
117+
mod tests {
118+
use super::*;
119+
use serde::Deserialize;
120+
121+
#[derive(Debug, Clone, Deserialize)]
122+
struct DashRpcTransaction {
123+
hex: String,
124+
}
125+
126+
fn dash_evo_fixture_dir() -> String {
127+
format!(
128+
"{}/test/fixtures_thirdparty/dashTestExtra",
129+
env!("CARGO_MANIFEST_DIR")
130+
)
131+
}
132+
133+
#[test]
134+
fn test_dash_evo_fixtures_round_trip() {
135+
let fixtures_dir = dash_evo_fixture_dir();
136+
137+
let entries = std::fs::read_dir(&fixtures_dir)
138+
.unwrap_or_else(|_| panic!("Failed to read fixtures directory: {}", fixtures_dir));
139+
140+
let mut fixture_files: Vec<_> = entries
141+
.filter_map(|entry| {
142+
let entry = entry.ok()?;
143+
let path = entry.path();
144+
if path.extension()? == "json" {
145+
Some(path)
146+
} else {
147+
None
148+
}
149+
})
150+
.collect();
151+
152+
fixture_files.sort();
153+
154+
assert!(
155+
!fixture_files.is_empty(),
156+
"No fixture files found in {}",
157+
fixtures_dir
158+
);
159+
assert_eq!(
160+
fixture_files.len(),
161+
29,
162+
"Expected 29 Dash EVO fixtures in {}",
163+
fixtures_dir
164+
);
165+
166+
for (idx, fixture_path) in fixture_files.iter().enumerate() {
167+
let content = std::fs::read_to_string(fixture_path)
168+
.unwrap_or_else(|_| panic!("Failed to read fixture: {:?}", fixture_path));
169+
let tx: DashRpcTransaction = serde_json::from_str(&content)
170+
.unwrap_or_else(|_| panic!("Failed to parse fixture: {:?}", fixture_path));
171+
172+
let bytes = hex::decode(&tx.hex).unwrap_or_else(|_| {
173+
panic!(
174+
"Failed to decode tx hex in {:?} (idx={}): {}",
175+
fixture_path, idx, tx.hex
176+
)
177+
});
178+
179+
let parts = decode_dash_transaction_parts(&bytes).unwrap_or_else(|e| {
180+
panic!(
181+
"Failed to decode Dash tx in {:?} (idx={}): {}",
182+
fixture_path, idx, e
183+
)
184+
});
185+
186+
if parts.tx_type == 0 {
187+
assert!(
188+
parts.extra_payload.is_empty(),
189+
"tx_type=0 must not have extra_payload in {:?} (idx={})",
190+
fixture_path,
191+
idx
192+
);
193+
} else {
194+
assert!(
195+
!parts.extra_payload.is_empty(),
196+
"tx_type!=0 should have extra_payload in {:?} (idx={})",
197+
fixture_path,
198+
idx
199+
);
200+
}
201+
202+
let encoded = encode_dash_transaction_parts(&parts).unwrap_or_else(|e| {
203+
panic!(
204+
"Failed to encode Dash tx in {:?} (idx={}): {}",
205+
fixture_path, idx, e
206+
)
207+
});
208+
209+
assert_eq!(
210+
encoded, bytes,
211+
"Dash EVO tx failed round-trip in {:?} (idx={})",
212+
fixture_path, idx
213+
);
214+
}
215+
}
216+
}

0 commit comments

Comments
 (0)