Skip to content
Merged
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 Cargo.lock

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

6 changes: 6 additions & 0 deletions crates/zcash-wasm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ group = "0.13"
# Orchard protocol - real key derivation and note encryption
# Use latest versions to avoid halo2_proofs/indexmap conflict
orchard = { version = "0.12", default-features = false, features = ["circuit"] }

# Pinned to the orchard version that zcash_primitives 0.21 brings in.
# Used only inside frost_parse_tx_outputs so the parsed bundle and the
# OVK-decryption types come from the same crate; mismatching versions
# hits "different but same" trait-bound errors otherwise.
orchard_legacy = { package = "orchard", version = "0.10", default-features = false }
zcash_keys = { version = "0.12", default-features = false, features = ["orchard", "transparent-inputs"] }
zcash_address = { version = "0.10", default-features = false }
zcash_protocol = { version = "0.7", default-features = false }
Expand Down
284 changes: 284 additions & 0 deletions crates/zcash-wasm/src/frost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,290 @@ pub fn frost_spend_aggregate(
).map_err(|e| JsError::new(&e.to_string()))
}

// ── Multisig verifier: parse outputs from unsigned tx using spender's UFVK ──

/// Parse the unsigned v5 transaction and recover what each Orchard action
/// is sending, using the FROST wallet's UFVK to OVK-decrypt outputs.
///
/// The spender (= each FROST joiner) owns the OVK that was used to encrypt
/// every action's output, so OVK decryption yields:
/// - external scope hits → real recipients of the spend
/// - internal scope hits → change back to our own multisig
/// - non-decryptable → dummy padding action (zero value by construction)
///
/// Each joiner runs this on the unsigned tx bytes the host claims to have
/// built and compares the derived summary to the host's claimed
/// (recipient, amount, fee). A mismatch means the host lied.
///
/// `orchard_fvk_uview` is the ZIP-316 unified viewing key string
/// (`uview1…` / `uviewtest1…`) stored alongside the wallet.
///
/// Returns JSON:
/// {
/// "actions": [
/// { "index": u32,
/// "amount_zat": u64,
/// "recipient_raw_hex": "<43-byte hex>" | null,
/// "is_change": bool,
/// "decrypted": bool }
/// ],
/// "summary": {
/// "total_send_zat": u64,
/// "total_change_zat": u64,
/// "decrypted_count": u32,
/// "action_count": u32
/// }
/// }
#[wasm_bindgen]
pub fn frost_parse_tx_outputs(
unsigned_tx_hex: &str,
orchard_fvk_uview: &str,
) -> Result<String, JsError> {
use std::io::Cursor;
use orchard_legacy::keys::Scope;
use orchard_legacy::note_encryption::OrchardDomain;
use zcash_keys::keys::UnifiedFullViewingKey;
use zcash_note_encryption::try_output_recovery_with_ovk;
use zcash_primitives::consensus::BranchId;
use zcash_primitives::transaction::Transaction;
use zcash_protocol::consensus::{MainNetwork, TestNetwork};

let mut tx_bytes = hex::decode(unsigned_tx_hex)
.map_err(|e| JsError::new(&format!("bad tx hex: {}", e)))?;

// Capture the original branch id before any patching — sighash
// personalization and header_digest both bake it in, so we MUST use
// the real value (e.g. NU6.1 = 0x4dec_4df0) when reproducing sighash,
// not the NU5 substitute we patch in to satisfy zcash_primitives.
let original_branch_id: Option<u32> = if tx_bytes.len() >= 12 {
Some(u32::from_le_bytes([tx_bytes[8], tx_bytes[9], tx_bytes[10], tx_bytes[11]]))
} else {
None
};

// zcash_primitives 0.21 ships zcash_protocol 0.4 whose BranchId enum
// tops out at NU6 (0xc8e7_1055). Mainnet tx builds today use NU6.1
// (0x4dec_4df0) and the parser rejects it with "Unknown consensus
// branch ID". The orchard bundle layout is identical across
// NU5/NU6/NU6.1, so we rewrite the branch-id field (bytes 8..12 of a
// v5 tx header: version(4) + version_group_id(4) + branch_id(4)) to
// NU5 just for parsing. We never re-serialize, so the original bytes
// (including the real branch id committed to in sighash) are unaffected
// outside this function.
if tx_bytes.len() >= 12 {
let branch = u32::from_le_bytes([tx_bytes[8], tx_bytes[9], tx_bytes[10], tx_bytes[11]]);
if !matches!(branch, 0 | 0x5ba8_1b19 | 0x76b8_09bb | 0x2bb4_0e60
| 0xf5b9_230b | 0xe9ff_75a6 | 0xc2d6_d0b4 | 0xc8e7_1055)
{
let nu5 = 0xc2d6_d0b4u32.to_le_bytes();
tx_bytes[8..12].copy_from_slice(&nu5);
}
}

let mut cursor = Cursor::new(&tx_bytes);
let tx = Transaction::read(&mut cursor, BranchId::Nu5)
.map_err(|e| JsError::new(&format!("parse v5 tx: {:?}", e)))?;

// testnet uview prefix is `uviewtest1`, mainnet is `uview1`.
let mainnet = !orchard_fvk_uview.starts_with("uviewtest");
let ufvk = if mainnet {
UnifiedFullViewingKey::decode(&MainNetwork, orchard_fvk_uview)
} else {
UnifiedFullViewingKey::decode(&TestNetwork, orchard_fvk_uview)
}
.map_err(|e| JsError::new(&format!("invalid UFVK: {}", e)))?;

let orchard_fvk_keys = ufvk
.orchard()
.ok_or_else(|| JsError::new("UFVK has no orchard component"))?;

// The zcash_keys orchard FVK comes from a different orchard version than
// the one zcash_primitives uses for tx parsing. Cross through the 96-byte
// wire format so OVK derivation, OrchardDomain, and the Action all share
// a single orchard crate version (orchard_legacy = orchard 0.10).
let fvk_bytes = orchard_fvk_keys.to_bytes();
let fvk = orchard_legacy::keys::FullViewingKey::from_bytes(&fvk_bytes)
.ok_or_else(|| JsError::new("invalid orchard FVK in UFVK"))?;

let ovk_external = fvk.to_ovk(Scope::External);
let ovk_internal = fvk.to_ovk(Scope::Internal);

let bundle = match tx.orchard_bundle() {
Some(b) => b,
None => {
return Ok(serde_json::json!({
"actions": [],
"summary": {
"total_send_zat": 0u64,
"total_change_zat": 0u64,
"decrypted_count": 0u32,
"action_count": 0u32,
},
})
.to_string());
}
};

let actions: Vec<_> = bundle.actions().iter().collect();
let mut actions_json = Vec::with_capacity(actions.len());
let mut total_send: u64 = 0;
let mut total_change: u64 = 0;
let mut decrypted_count: u32 = 0;

for (idx, action) in actions.iter().enumerate() {
let domain = OrchardDomain::for_action(*action);
let cv = action.cv_net();
let out_ct = action.encrypted_note().out_ciphertext;

// external: real recipient of a spend
if let Some((note, addr, _memo)) =
try_output_recovery_with_ovk(&domain, &ovk_external, *action, cv, &out_ct)
{
let amount = note.value().inner();
total_send = total_send.saturating_add(amount);
decrypted_count += 1;
actions_json.push(serde_json::json!({
"index": idx as u32,
"amount_zat": amount,
"recipient_raw_hex": hex::encode(addr.to_raw_address_bytes()),
"is_change": false,
"decrypted": true,
}));
continue;
}

// internal: change back to our own multisig
if let Some((note, addr, _memo)) =
try_output_recovery_with_ovk(&domain, &ovk_internal, *action, cv, &out_ct)
{
let amount = note.value().inner();
total_change = total_change.saturating_add(amount);
decrypted_count += 1;
actions_json.push(serde_json::json!({
"index": idx as u32,
"amount_zat": amount,
"recipient_raw_hex": hex::encode(addr.to_raw_address_bytes()),
"is_change": true,
"decrypted": true,
}));
continue;
}

// could not decrypt — dummy action, zero-value by construction
actions_json.push(serde_json::json!({
"index": idx as u32,
"amount_zat": 0u64,
"recipient_raw_hex": serde_json::Value::Null,
"is_change": false,
"decrypted": false,
}));
}

// ── ZIP-244 sighash check ──
// Recompute the message that the joiner is being asked to sign, from
// the bundle they verified above. If the host published a real sighash
// but a decoy unsignedTx (the "decoy bundle" attack the verifier closes), the
// recomputed sighash will not match the host's claimed one — that's
// the only way to detect a decoy that's internally consistent.
//
// We only support pure-orchard v5 txs here. If transparent or sapling
// bundles are present, return None and the TS verdict layer treats it
// as "unverified — sighash check unavailable for this shape".
let pure_orchard = tx
.transparent_bundle()
.map_or(true, |t| t.vin.is_empty() && t.vout.is_empty())
&& tx.sapling_bundle().is_none();

let computed_sighash_hex: Option<String> = if let (Some(branch_id), true) =
(original_branch_id, pure_orchard)
{
// T.1 header_digest
let mut header_data = Vec::with_capacity(20);
header_data.extend_from_slice(&(5u32 | (1u32 << 31)).to_le_bytes());
header_data.extend_from_slice(&0x26A7270Au32.to_le_bytes());
header_data.extend_from_slice(&branch_id.to_le_bytes());
header_data.extend_from_slice(&tx.lock_time().to_le_bytes());
let expiry: u32 = u32::from(tx.expiry_height());
header_data.extend_from_slice(&expiry.to_le_bytes());
let header_digest = crate::blake2b_256_personal(b"ZTxIdHeadersHash", &header_data);

// T.2 transparent_digest (empty — we asserted pure-orchard above)
let transparent_digest = crate::blake2b_256_personal(b"ZTxIdTranspaHash", &[]);
// T.3 sapling_digest (empty)
let sapling_digest = crate::blake2b_256_personal(b"ZTxIdSaplingHash", &[]);
// T.4 orchard_digest
let orchard_digest = compute_orchard_digest_legacy(bundle);

let mut personal = [0u8; 16];
personal[..12].copy_from_slice(b"ZcashTxHash_");
personal[12..16].copy_from_slice(&branch_id.to_le_bytes());

let mut input = Vec::with_capacity(128);
input.extend_from_slice(&header_digest);
input.extend_from_slice(&transparent_digest);
input.extend_from_slice(&sapling_digest);
input.extend_from_slice(&orchard_digest);

Some(hex::encode(crate::blake2b_256_personal(&personal, &input)))
} else {
None
};

Ok(serde_json::json!({
"actions": actions_json,
"summary": {
"total_send_zat": total_send,
"total_change_zat": total_change,
"decrypted_count": decrypted_count,
"action_count": actions.len() as u32,
},
"computed_sighash_hex": computed_sighash_hex,
})
.to_string())
}

/// ZIP-244 orchard tx body digest (T.4) for an orchard_legacy bundle.
/// Mirrors `compute_orchard_digest` in `lib.rs` byte-for-byte but uses
/// orchard 0.10 types so it can consume what `tx.orchard_bundle()` returns
/// from zcash_primitives 0.21.
fn compute_orchard_digest_legacy<A: orchard_legacy::bundle::Authorization>(
bundle: &orchard_legacy::Bundle<A, zcash_primitives::transaction::components::Amount>,
) -> [u8; 32] {
let mut compact_data = Vec::new();
let mut memos_data = Vec::new();
let mut noncompact_data = Vec::new();

for action in bundle.actions().iter() {
compact_data.extend_from_slice(&action.nullifier().to_bytes());
compact_data.extend_from_slice(&action.cmx().to_bytes());
let enc = &action.encrypted_note().enc_ciphertext;
let epk = &action.encrypted_note().epk_bytes;
compact_data.extend_from_slice(epk);
compact_data.extend_from_slice(&enc[..52]);

memos_data.extend_from_slice(&enc[52..564]);

noncompact_data.extend_from_slice(&action.cv_net().to_bytes());
noncompact_data.extend_from_slice(&<[u8; 32]>::from(action.rk()));
noncompact_data.extend_from_slice(&enc[564..580]);
noncompact_data.extend_from_slice(&action.encrypted_note().out_ciphertext);
}

let compact_digest = crate::blake2b_256_personal(b"ZTxIdOrcActCHash", &compact_data);
let memos_digest = crate::blake2b_256_personal(b"ZTxIdOrcActMHash", &memos_data);
let noncompact_digest = crate::blake2b_256_personal(b"ZTxIdOrcActNHash", &noncompact_data);

let mut orchard_data = Vec::new();
orchard_data.extend_from_slice(&compact_digest);
orchard_data.extend_from_slice(&memos_digest);
orchard_data.extend_from_slice(&noncompact_digest);
orchard_data.push(bundle.flags().to_byte());
orchard_data.extend_from_slice(&bundle.value_balance().to_i64_le_bytes());
orchard_data.extend_from_slice(&bundle.anchor().to_bytes());

crate::blake2b_256_personal(b"ZTxIdOrchardHash", &orchard_data)
}

// ── anchor attestation (domain-separated from spend auth) ──
//
// Signing uses the existing orchestrate::sign_round1/sign_round2/aggregate_shares
Expand Down
27 changes: 21 additions & 6 deletions crates/zcash-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1944,17 +1944,26 @@ pub fn build_unsigned_transaction(
// between the recipient note and the change note).
let recipient_memo = decode_memo_hex(memo_hex.as_deref())?;

// OVK for outputs: bind out_ciphertext to the wallet's own OVK so the
// FVK holder (every FROST co-signer in multisig, the user in single-key)
// can OVK-decrypt and recover (recipient, amount). This unlocks the
// multisig verifier on co-signers and outgoing-tx history on the
// sending wallet. Network-layer privacy is unchanged — the ciphertext
// is still opaque to anyone without the FVK.
let ovk_external = fvk.to_ovk(Scope::External);
let ovk_internal = fvk.to_ovk(Scope::Internal);

// add recipient output (orchard only — transparent outputs are added to the tx directly)
if let Some(ref addr) = recipient_addr {
builder
.add_output(None, *addr, NoteValue::from_raw(amount), recipient_memo)
.add_output(Some(ovk_external.clone()), *addr, NoteValue::from_raw(amount), recipient_memo)
.map_err(|e| JsError::new(&format!("add_output (recipient): {:?}", e)))?;
}

// add change output if needed (for z→t, all orchard value minus amount+fee goes to change)
if change > 0 {
builder
.add_output(None, change_addr, NoteValue::from_raw(change), [0u8; 512])
.add_output(Some(ovk_internal.clone()), change_addr, NoteValue::from_raw(change), [0u8; 512])
.map_err(|e| JsError::new(&format!("add_output (change): {:?}", e)))?;
}

Expand Down Expand Up @@ -3067,17 +3076,23 @@ pub fn build_signed_spend_transaction(
// decode memo — recipient gets the memo, change output stays empty.
let recipient_memo = decode_memo_hex(memo_hex.as_deref())?;

// OVK for outputs: bind out_ciphertext to the wallet's OVK so the FVK
// holder can recover (recipient, amount) — outgoing-tx history without
// re-querying the chain. Network privacy unchanged.
let ovk_external = fvk.to_ovk(Scope::External);
let ovk_internal = fvk.to_ovk(Scope::Internal);

// add recipient output (orchard only — transparent outputs are added to the tx directly)
if let Some(ref addr) = recipient_addr {
builder
.add_output(None, *addr, NoteValue::from_raw(amount), recipient_memo)
.add_output(Some(ovk_external.clone()), *addr, NoteValue::from_raw(amount), recipient_memo)
.map_err(|e| JsError::new(&format!("add_output (recipient): {:?}", e)))?;
}

// add change output if needed (for z→t, all orchard value minus amount+fee goes to change)
if change > 0 {
builder
.add_output(None, change_addr, NoteValue::from_raw(change), [0u8; 512])
.add_output(Some(ovk_internal.clone()), change_addr, NoteValue::from_raw(change), [0u8; 512])
.map_err(|e| JsError::new(&format!("add_output (change): {:?}", e)))?;
}

Expand Down Expand Up @@ -3419,7 +3434,7 @@ struct TransparentUtxo {
}

/// Personalized Blake2b-256 hash (ZIP-244 style)
fn blake2b_256_personal(personalization: &[u8; 16], data: &[u8]) -> [u8; 32] {
pub(crate) fn blake2b_256_personal(personalization: &[u8; 16], data: &[u8]) -> [u8; 32] {
let h = blake2b_simd::Params::new()
.hash_length(32)
.personal(personalization)
Expand Down Expand Up @@ -4304,7 +4319,7 @@ fn make_p2pkh_script(pubkey_hash: &[u8; 20]) -> Vec<u8> {
}

/// Bitcoin-style CompactSize encoding
fn compact_size(n: u64) -> Vec<u8> {
pub(crate) fn compact_size(n: u64) -> Vec<u8> {
if n < 0xfd {
vec![n as u8]
} else if n <= 0xffff {
Expand Down
Loading