Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/wasm-utxo/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/wasm-utxo/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ js-sys = "0.3"
strum = { version = "0.27", features = ["derive"] }
miniscript = { git = "https://github.com/BitGo/rust-miniscript", tag = "miniscript-13.0.0-bitgo.5" }
bech32 = "0.11"
# BLAKE2b with personalization for Zcash ZIP-243/ZIP-244 hashing.
# Already present transitively via the miniscript fork; pinned here as a direct dependency.
blake2 = "0.10"
musig2 = { version = "0.3.1", default-features = false, features = ["k256"] }
getrandom = { version = "0.2", features = ["js"] }
pastey = "0.1"
Expand Down
78 changes: 77 additions & 1 deletion packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { BitGoPsbt as WasmBitGoPsbt, zcash_branch_id_for_height } from "../wasm/wasm_utxo.js";
import {
BitGoPsbt as WasmBitGoPsbt,
zcash_branch_id_for_height,
zcash_compute_v6_txid,
zcash_is_address_component_of,
zcash_parse_unified_address,
zcash_resolve_unified_address_component,
} from "../wasm/wasm_utxo.js";
import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js";
import { BitGoPsbt, type CreateEmptyOptions, type HydrationUnspent } from "./BitGoPsbt.js";
import { ZcashTransaction, type ITransaction } from "../transaction.js";
Expand Down Expand Up @@ -280,6 +287,75 @@ export class ZcashBitGoPsbt extends BitGoPsbt {
return zcash_branch_id_for_height(network, height);
}

/**
* Compute the ZIP-244 txid of a Zcash v6 (Ironwood/NU6.3) transaction.
*
* @param txBytes - Raw v6 transaction bytes
* @returns The 32-byte txid in internal byte order (reverse for display)
* @throws If the bytes are not a valid v6 transaction
*/
static computeV6Txid(txBytes: Uint8Array): Uint8Array {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not a method on a psbt? why take txBytes?

return zcash_compute_v6_txid(txBytes);
}

/**
* Parse a ZIP-316 Unified Address and return the requested receiver bytes.
*
* Ironwood reuses the existing Orchard receiver (typecode 0x03).
*
* @param address - The Bech32m unified address string
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
* @param ironwood - `true` → Orchard/Ironwood receiver (43 bytes: diversifier +
* pk_d); `false` → transparent receiver as scriptPubKey bytes (P2PKH/P2SH)
* @returns The receiver bytes
* @throws If the address is malformed or lacks a receiver of the requested type
*/
static parseUnifiedAddress(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same "opaque bytes" concern as the computeV6Txid thread above, on the return side: these return a bare Uint8Array whose meaning depends on the boolean arg — an Orchard receiver (43 bytes) when true, a transparent scriptPubKey (25 bytes) when false. The caller has to remember which shape they asked for.

Prefer a structured return: decode once into a typed UnifiedAddress with named accessors (.orchardReceiver, .transparentScript), or a discriminated union { kind: "shielded" | "transparent"; ... }. That also removes the boolean flag (CONVENTIONS §3) and collapses parseUnifiedAddress/resolveUnifiedAddressComponent, which are currently the same function under a renamed bool.


Generated by Claude Code

address: string,
network: ZcashNetworkName,
ironwood: boolean,
): Uint8Array {
return zcash_parse_unified_address(address, network, ironwood);
}

/**
* Resolve a single component of a ZIP-316 Unified Address.
*
* @param address - The Bech32m unified address string
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
* @param resolveShieldedComponent - `true` → the shielded (Ironwood/Orchard)
* receiver (43 bytes: diversifier + pk_d); `false` → the transparent receiver
* as scriptPubKey bytes (P2PKH/P2SH)
* @returns The requested component's bytes
* @throws If the address is malformed or lacks a receiver of the requested type
*/
static resolveUnifiedAddressComponent(
address: string,
network: ZcashNetworkName,
resolveShieldedComponent: boolean,
): Uint8Array {
return zcash_resolve_unified_address_component(address, network, resolveShieldedComponent);
}

/**
* Determine whether `candidate` is a component of the Unified Address `unified`.
*
* @param unified - A ZIP-316 Unified Address (the container)
* @param candidate - Either another Unified Address (matches if all of its
* receivers are contained in `unified`) or a transparent Zcash address (matches
* if `unified`'s transparent receiver equals it)
* @param network - Zcash network name; both addresses must belong to it
* @returns `true` if `candidate` is contained in `unified`
* @throws If either address is malformed or on the wrong network
*/
static isAddressComponentOf(
unified: string,
candidate: string,
network: ZcashNetworkName,
): boolean {
return zcash_is_address_component_of(unified, candidate, network);
}

/**
* Extract the final Zcash transaction from a finalized PSBT
*
Expand Down
70 changes: 70 additions & 0 deletions packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

additions should go to zec-namespaced file, maybe take some zcash scoped funcs here with you as well

Original file line number Diff line number Diff line change
Expand Up @@ -2020,3 +2020,73 @@ pub fn zcash_branch_id_for_height(network: &str, height: u32) -> Result<Option<u
};
Ok(crate::zcash::branch_id_for_height(height, is_mainnet))
}

/// Compute the ZIP-244 txid of a Zcash v6 (Ironwood/NU6.3) transaction.
///
/// Accepts raw v6 transaction bytes and returns the 32-byte txid in internal
/// (non-reversed) byte order — reverse for the canonical display form.
/// Throws if the bytes are not a valid v6 transaction.
#[wasm_bindgen]
pub fn zcash_compute_v6_txid(tx_bytes: &[u8]) -> Result<Vec<u8>, JsValue> {
crate::zcash::v6::compute_v6_txid_from_bytes(tx_bytes)
.map(|h| h.to_vec())
.map_err(|e| JsValue::from_str(&e))
}

/// Parse a ZIP-316 Unified Address and return the requested receiver bytes.
///
/// `network`: "zcash"/"zec" (mainnet) or "zcashTest"/"tzec" (testnet).
///
/// * `ironwood = true` → the Orchard receiver's raw 43 bytes (11-byte diversifier +
/// 32-byte pk_d); Ironwood reuses the Orchard receiver.
/// * `ironwood = false` → the transparent receiver as scriptPubKey bytes (P2PKH/P2SH).
///
/// Throws if the address is malformed or lacks a receiver of the requested type.
#[wasm_bindgen]
pub fn zcash_parse_unified_address(
address: &str,
network: &str,
ironwood: bool,
) -> Result<Vec<u8>, JsValue> {
crate::zcash::unified_address::parse_unified_address(address, network, ironwood)
.map_err(|e| JsValue::from_str(&e))
}

/// Resolve a single component of a ZIP-316 Unified Address.
///
/// `network`: "zcash"/"zec" (mainnet) or "zcashTest"/"tzec" (testnet).
///
/// * `resolve_shielded = true` → the shielded (Orchard/Ironwood) receiver's raw 43
/// bytes (11-byte diversifier + 32-byte pk_d).
/// * `resolve_shielded = false` → the transparent receiver as scriptPubKey bytes.
///
/// Throws if the address is malformed or lacks a receiver of the requested type.
#[wasm_bindgen]
pub fn zcash_resolve_unified_address_component(
address: &str,
network: &str,
resolve_shielded: bool,
) -> Result<Vec<u8>, JsValue> {
crate::zcash::unified_address::resolve_unified_address_component(
address,
network,
resolve_shielded,
)
.map_err(|e| JsValue::from_str(&e))
}

/// Determine whether `candidate` is a component of the Unified Address `unified`.
///
/// `unified` must be a Unified Address. `candidate` may be another Unified Address
/// (true iff all its receivers are contained in `unified`) or a transparent Zcash
/// address (true iff `unified`'s transparent receiver matches it). Both must belong
/// to `network` ("zcash"/"zec" or "zcashTest"/"tzec").
#[wasm_bindgen]
pub fn zcash_is_address_component_of(
unified: &str,
candidate: &str,
network: &str,
) -> Result<bool, JsValue> {
crate::zcash::unified_address::is_address_component_of(unified, candidate, network)
.map_err(|e| JsValue::from_str(&e))
}
72 changes: 72 additions & 0 deletions packages/wasm-utxo/src/zcash/blake2b.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//! BLAKE2b-256 with personalization, as used by Zcash ZIP-243 (sighash) and
//! ZIP-244 (txid / v6 sighash) hash trees.
//!
//! Every digest in the ZIP-244 tree is a BLAKE2b-256 hash keyed by a 16-byte
//! personalization string. This mirrors the `blake2b_256_personal` helper in the
//! BitGo miniscript fork (which is `pub(crate)` there and therefore not reachable
//! from this crate), including the fix for block-aligned inputs.

use blake2::digest::core_api::{Buffer, UpdateCore, VariableOutputCore};
use blake2::digest::Output;
use blake2::Blake2bVarCore;

/// Compute a BLAKE2b digest of `data` with a `personalization` string and an
/// explicit output length (1..=64 bytes).
///
/// The output length is part of the BLAKE2b parameter block, so it changes the
/// digest — this is not truncation. Used by F4Jumble (ZIP-316), which needs both
/// 64-byte and shorter outputs.
///
/// The `Buffer`/`digest_blocks` path retains the final block until finalization so
/// the correct finalization flag is used even when `data.len()` is an exact multiple
/// of the 128-byte BLAKE2b block size (the block-aligned bug fixed in the fork).
pub fn blake2b_var_personal(data: &[u8], personalization: &[u8], out_len: usize) -> Vec<u8> {
debug_assert!(
(1..=64).contains(&out_len),
"BLAKE2b output length out of range"
);
let mut core = Blake2bVarCore::new_with_params(&[], personalization, 0, out_len);
let mut buffer: Buffer<Blake2bVarCore> = Default::default();
buffer.digest_blocks(data, |blocks| core.update_blocks(blocks));

let mut full_output: Output<Blake2bVarCore> = Default::default();
VariableOutputCore::finalize_variable_core(&mut core, &mut buffer, &mut full_output);

full_output[..out_len].to_vec()
}

/// Compute a BLAKE2b-256 digest of `data` with a 16-byte `personalization` string.
///
/// The personalization must be at most 16 bytes (ZIP personalization strings are
/// exactly 16 bytes, e.g. `b"ZTxIdIronwd_H_v6"`).
pub fn blake2b_256_personal(data: &[u8], personalization: &[u8]) -> [u8; 32] {
let out = blake2b_var_personal(data, personalization, 32);
let mut result = [0u8; 32];
result.copy_from_slice(&out);
result
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn personalization_affects_output() {
let data = b"the quick brown fox";
let a = blake2b_256_personal(data, b"ZTxIdPrevoutHash");
let b = blake2b_256_personal(data, b"ZTxIdSequencHash");
assert_ne!(a, b);
// Deterministic
assert_eq!(a, blake2b_256_personal(data, b"ZTxIdPrevoutHash"));
}

#[test]
fn block_aligned_input_is_stable() {
// 256 bytes = exactly 2 BLAKE2b blocks — exercises the finalization-flag fix.
let data = vec![0xabu8; 256];
let h = blake2b_256_personal(&data, b"ZTxIdOutputsHash");
// Just assert it produces a non-trivial, deterministic 32-byte output.
assert_ne!(h, [0u8; 32]);
assert_eq!(h, blake2b_256_personal(&data, b"ZTxIdOutputsHash"));
}
}
3 changes: 3 additions & 0 deletions packages/wasm-utxo/src/zcash/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
//!
//! Tests verify parity with `zebra-chain` crate.
pub mod blake2b;
pub mod transaction;
pub mod unified_address;
pub mod v6;

/// Zcash network upgrade identifiers
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
Expand Down
21 changes: 20 additions & 1 deletion packages/wasm-utxo/src/zcash/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,18 @@
use miniscript::bitcoin::consensus::{Decodable, Encodable};
use miniscript::bitcoin::{Transaction, TxIn, TxOut};

/// Zcash Sapling version group ID
/// Zcash Sapling version group ID (v4 transactions)
pub const ZCASH_SAPLING_VERSION_GROUP_ID: u32 = 0x892F2085;

/// Zcash Ironwood version group ID (v6 / NU6.3 transactions)
pub const ZCASH_IRONWOOD_VERSION_GROUP_ID: u32 = 0xD884B698;

/// Transaction version header for v4 transactions (Sapling), overwintered bit set.
pub const ZCASH_V4_VERSION_HEADER: u32 = 0x80000004;

/// Transaction version header for v6 transactions (Ironwood/NU6.3), overwintered bit set.
pub const ZCASH_V6_VERSION_HEADER: u32 = 0x80000006;

/// Parsed Zcash transaction fields, preserving Zcash-specific data needed for round-tripping.
#[derive(Debug, Clone)]
pub struct ZcashTransactionParts {
Expand Down Expand Up @@ -117,6 +126,16 @@ pub fn decode_zcash_transaction_parts(bytes: &[u8]) -> Result<ZcashTransactionPa
None
};

// The v6 (Ironwood/NU6.3) wire format reorders the header (consensusBranchId,
// lockTime, expiryHeight move to the front) and is not a v4 tail extension.
// Route callers to the dedicated v6 codec instead of mis-parsing.
if version_group_id == Some(ZCASH_IRONWOOD_VERSION_GROUP_ID) {
return Err(
"v6 (Ironwood) transaction detected; use crate::zcash::v6::decode_v6_transaction"
.to_string(),
);
}

// Read inputs
let inputs: Vec<TxIn> =
Vec::consensus_decode(&mut slice).map_err(|e| format!("Failed to decode inputs: {}", e))?;
Expand Down
Loading