Skip to content

Commit 039009d

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): add Transaction class and extract Zcash transaction handling
Adds Transaction and ZcashTransaction wrapper classes to provide a clean, typed API over the WASM bindings. Extracts Zcash transaction parsing logic into a separate module for better reusability, making the implementation more modular and maintainable. Issue: BTC-2659 Co-authored-by: llm-git <[email protected]>
1 parent 5df410a commit 039009d

File tree

8 files changed

+393
-204
lines changed

8 files changed

+393
-204
lines changed

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 { Transaction, ZcashTransaction } from "./transaction.js";
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { 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+
}

packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs

Lines changed: 28 additions & 204 deletions
Original file line numberDiff line numberDiff line change
@@ -5,97 +5,12 @@
55
66
use miniscript::bitcoin::consensus::{Decodable, Encodable};
77
use miniscript::bitcoin::psbt::Psbt;
8-
use miniscript::bitcoin::{Transaction, TxIn, TxOut, VarInt};
8+
use miniscript::bitcoin::{Transaction, VarInt};
99
use std::io::Read;
1010

11-
/// Zcash Sapling version group ID
12-
pub const ZCASH_SAPLING_VERSION_GROUP_ID: u32 = 0x892F2085;
13-
14-
/// Zcash transaction metadata extracted from transaction bytes
15-
///
16-
/// This struct provides the Zcash-specific fields without requiring
17-
/// the full transaction to be stored.
18-
#[derive(Debug, Clone)]
19-
pub struct ZcashTransactionMeta {
20-
/// Number of inputs
21-
pub input_count: usize,
22-
/// Number of outputs
23-
pub output_count: usize,
24-
/// Zcash-specific: Version group ID for overwintered transactions
25-
pub version_group_id: Option<u32>,
26-
/// Zcash-specific: Expiry height
27-
pub expiry_height: Option<u32>,
28-
/// Whether this is a Zcash overwintered transaction
29-
pub is_overwintered: bool,
30-
}
31-
32-
/// Decode Zcash transaction metadata from bytes
33-
///
34-
/// Extracts input/output counts and Zcash-specific fields (version_group_id, expiry_height)
35-
/// from a Zcash overwintered transaction.
36-
pub fn decode_zcash_transaction_meta(bytes: &[u8]) -> Result<ZcashTransactionMeta, String> {
37-
let mut slice = bytes;
38-
39-
// Read version
40-
let version = u32::consensus_decode(&mut slice)
41-
.map_err(|e| format!("Failed to decode version: {}", e))?;
42-
43-
let is_overwintered = (version & 0x80000000) != 0;
44-
45-
let version_group_id = if is_overwintered {
46-
Some(
47-
u32::consensus_decode(&mut slice)
48-
.map_err(|e| format!("Failed to decode version group ID: {}", e))?,
49-
)
50-
} else {
51-
None
52-
};
53-
54-
// Read inputs
55-
let inputs: Vec<TxIn> =
56-
Vec::consensus_decode(&mut slice).map_err(|e| format!("Failed to decode inputs: {}", e))?;
57-
58-
// Read outputs
59-
let outputs: Vec<TxOut> = Vec::consensus_decode(&mut slice)
60-
.map_err(|e| format!("Failed to decode outputs: {}", e))?;
61-
62-
// Read lock_time
63-
let _lock_time =
64-
miniscript::bitcoin::locktime::absolute::LockTime::consensus_decode(&mut slice)
65-
.map_err(|e| format!("Failed to decode lock_time: {}", e))?;
66-
67-
// Read expiry height if overwintered
68-
let expiry_height = if is_overwintered {
69-
Some(
70-
u32::consensus_decode(&mut slice)
71-
.map_err(|e| format!("Failed to decode expiry height: {}", e))?,
72-
)
73-
} else {
74-
None
75-
};
76-
77-
Ok(ZcashTransactionMeta {
78-
input_count: inputs.len(),
79-
output_count: outputs.len(),
80-
version_group_id,
81-
expiry_height,
82-
is_overwintered,
83-
})
84-
}
85-
86-
/// Decoded Zcash transaction with extracted Zcash-specific fields (internal use)
87-
#[derive(Debug, Clone)]
88-
struct DecodedZcashTransaction {
89-
/// The transaction in Bitcoin-compatible format
90-
transaction: Transaction,
91-
/// Zcash-specific: Version group ID for overwintered transactions
92-
version_group_id: Option<u32>,
93-
/// Zcash-specific: Expiry height
94-
expiry_height: Option<u32>,
95-
/// Zcash-specific: Additional Sapling fields (valueBalance, nShieldedSpend, nShieldedOutput, etc.)
96-
/// These are preserved as-is to maintain exact serialization
97-
sapling_fields: Vec<u8>,
98-
}
11+
pub use crate::zcash::transaction::{
12+
decode_zcash_transaction_meta, ZcashTransactionMeta, ZCASH_SAPLING_VERSION_GROUP_ID,
13+
};
9914

10015
/// A Zcash-compatible PSBT that can handle overwintered transactions
10116
///
@@ -116,61 +31,6 @@ pub struct ZcashBitGoPsbt {
11631
pub sapling_fields: Vec<u8>,
11732
}
11833

119-
/// Decode a Zcash transaction from bytes, extracting Zcash-specific fields
120-
fn decode_zcash_transaction(
121-
bytes: &[u8],
122-
) -> Result<DecodedZcashTransaction, super::DeserializeError> {
123-
let mut slice = bytes;
124-
125-
// Read version
126-
let version = u32::consensus_decode(&mut slice)?;
127-
128-
let is_overwintered = (version & 0x80000000) != 0;
129-
130-
let version_group_id = if is_overwintered {
131-
Some(u32::consensus_decode(&mut slice)?)
132-
} else {
133-
None
134-
};
135-
136-
// Read inputs
137-
let inputs: Vec<TxIn> = Vec::consensus_decode(&mut slice)?;
138-
139-
// Read outputs
140-
let outputs: Vec<TxOut> = Vec::consensus_decode(&mut slice)?;
141-
142-
// Read lock_time
143-
let lock_time =
144-
miniscript::bitcoin::locktime::absolute::LockTime::consensus_decode(&mut slice)?;
145-
146-
// Read expiry height if overwintered
147-
let expiry_height = if is_overwintered {
148-
Some(u32::consensus_decode(&mut slice)?)
149-
} else {
150-
None
151-
};
152-
153-
// Capture any remaining bytes (Sapling fields: valueBalance, nShieldedSpend, nShieldedOutput, etc.)
154-
let sapling_fields = slice.to_vec();
155-
156-
// Create transaction with standard version (without overwintered bit)
157-
let transaction = Transaction {
158-
version: miniscript::bitcoin::transaction::Version::non_standard(
159-
(version & 0x7FFFFFFF) as i32,
160-
),
161-
input: inputs,
162-
output: outputs,
163-
lock_time,
164-
};
165-
166-
Ok(DecodedZcashTransaction {
167-
transaction,
168-
version_group_id,
169-
expiry_height,
170-
sapling_fields,
171-
})
172-
}
173-
17434
impl ZcashBitGoPsbt {
17535
/// Get the network this PSBT is for
17636
pub fn network(&self) -> crate::Network {
@@ -182,52 +42,18 @@ impl ZcashBitGoPsbt {
18242
&self,
18343
tx: &Transaction,
18444
) -> Result<Vec<u8>, super::DeserializeError> {
185-
let mut tx_bytes = Vec::new();
186-
187-
// Version with overwintered bit
188-
let zcash_version = (tx.version.0 as u32) | 0x80000000;
189-
zcash_version.consensus_encode(&mut tx_bytes).map_err(|e| {
190-
super::DeserializeError::Network(format!("Failed to encode Zcash version: {}", e))
191-
})?;
192-
193-
// Version group ID
194-
self.version_group_id
195-
.unwrap_or(ZCASH_SAPLING_VERSION_GROUP_ID)
196-
.consensus_encode(&mut tx_bytes)
197-
.map_err(|e| {
198-
super::DeserializeError::Network(format!(
199-
"Failed to encode version group ID: {}",
200-
e
201-
))
202-
})?;
203-
204-
// Inputs
205-
tx.input.consensus_encode(&mut tx_bytes).map_err(|e| {
206-
super::DeserializeError::Network(format!("Failed to encode inputs: {}", e))
207-
})?;
208-
209-
// Outputs
210-
tx.output.consensus_encode(&mut tx_bytes).map_err(|e| {
211-
super::DeserializeError::Network(format!("Failed to encode outputs: {}", e))
212-
})?;
213-
214-
// Lock time
215-
tx.lock_time.consensus_encode(&mut tx_bytes).map_err(|e| {
216-
super::DeserializeError::Network(format!("Failed to encode lock_time: {}", e))
217-
})?;
218-
219-
// Expiry height
220-
self.expiry_height
221-
.unwrap_or(0)
222-
.consensus_encode(&mut tx_bytes)
223-
.map_err(|e| {
224-
super::DeserializeError::Network(format!("Failed to encode expiry height: {}", e))
225-
})?;
226-
227-
// Sapling fields (valueBalance, nShieldedSpend, nShieldedOutput, etc.)
228-
tx_bytes.extend_from_slice(&self.sapling_fields);
229-
230-
Ok(tx_bytes)
45+
let parts = crate::zcash::transaction::ZcashTransactionParts {
46+
transaction: tx.clone(),
47+
is_overwintered: true,
48+
version_group_id: Some(
49+
self.version_group_id
50+
.unwrap_or(ZCASH_SAPLING_VERSION_GROUP_ID),
51+
),
52+
expiry_height: Some(self.expiry_height.unwrap_or(0)),
53+
sapling_fields: self.sapling_fields.clone(),
54+
};
55+
crate::zcash::transaction::encode_zcash_transaction_parts(&parts)
56+
.map_err(super::DeserializeError::Network)
23157
}
23258

23359
/// Reconstruct the unsigned Zcash transaction bytes from the PSBT
@@ -330,14 +156,15 @@ impl ZcashBitGoPsbt {
330156
if !key_data.is_empty() && key_data[0] == 0x00 && key_data.len() == 1 {
331157
// This is the unsigned transaction
332158
found_tx = true;
333-
let decoded = decode_zcash_transaction(&val_data)?;
334-
version_group_id = decoded.version_group_id;
335-
expiry_height = decoded.expiry_height;
336-
sapling_fields = decoded.sapling_fields;
159+
let parts = crate::zcash::transaction::decode_zcash_transaction_parts(&val_data)
160+
.map_err(super::DeserializeError::Network)?;
161+
version_group_id = parts.version_group_id;
162+
expiry_height = parts.expiry_height;
163+
sapling_fields = parts.sapling_fields;
337164

338165
// Serialize the modified transaction
339166
let mut tx_bytes = Vec::new();
340-
decoded
167+
parts
341168
.transaction
342169
.consensus_encode(&mut tx_bytes)
343170
.map_err(|e| {
@@ -577,17 +404,14 @@ mod tests {
577404
// Expiry height
578405
0u32.consensus_encode(&mut tx_bytes).unwrap();
579406

580-
let decoded = decode_zcash_transaction(&tx_bytes).unwrap();
407+
let parts = crate::zcash::transaction::decode_zcash_transaction_parts(&tx_bytes).unwrap();
581408

582-
assert_eq!(
583-
decoded.version_group_id,
584-
Some(ZCASH_SAPLING_VERSION_GROUP_ID)
585-
);
586-
assert_eq!(decoded.expiry_height, Some(0));
587-
assert_eq!(decoded.transaction.input.len(), 0);
588-
assert_eq!(decoded.transaction.output.len(), 0);
409+
assert_eq!(parts.version_group_id, Some(ZCASH_SAPLING_VERSION_GROUP_ID));
410+
assert_eq!(parts.expiry_height, Some(0));
411+
assert_eq!(parts.transaction.input.len(), 0);
412+
assert_eq!(parts.transaction.output.len(), 0);
589413
// Should be empty for this simple test tx
590-
assert!(decoded.sapling_fields.is_empty());
414+
assert!(parts.sapling_fields.is_empty());
591415
}
592416

593417
#[test]

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mod miniscript;
77
mod psbt;
88
mod recursive_tap_tree;
99
mod replay_protection;
10+
mod transaction;
1011
mod try_from_js_value;
1112
mod try_into_js_value;
1213
mod utxolib_compat;
@@ -20,5 +21,6 @@ pub use fixed_script_wallet::FixedScriptWalletNamespace;
2021
pub use miniscript::WrapMiniscript;
2122
pub use psbt::WrapPsbt;
2223
pub use replay_protection::WasmReplayProtection;
24+
pub use transaction::{WasmTransaction, WasmZcashTransaction};
2325
pub use utxolib_compat::UtxolibCompatNamespace;
2426
pub use wallet_keys::WasmRootWalletKeys;

0 commit comments

Comments
 (0)