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
891 changes: 370 additions & 521 deletions Cargo.lock

Large diffs are not rendered by default.

16 changes: 9 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,17 @@ reqwest = { version = "0.12", features = ["json"] }
# WebSocket client (for ZecBlock real-time mempool push)
tokio-tungstenite = { version = "0.26", features = ["native-tls"] }

# Zcash crates (NU6.3 / Ironwood support)
# Zcash crates (NU7 / consensus branch 0x77190AD9, Ironwood support)
# Zakura Common: performance-optimized forks of librustzcash (1.5x trial decryption,
# 21x Sinsemilla). Same APIs, lib names unchanged — `use zcash_primitives::...` and
# `use orchard::...` still work.
zcash_primitives = { package = "zakura-primitives", version = "1.0.1", default-features = false }
zcash_note_encryption = "0.4.2"
orchard = { package = "zakura-orchard", version = "1.0.1", default-features = false, features = ["std"] }
zcash_address = "0.13"
zcash_protocol = "0.10"
# `use orchard::...` still work. Keep every Zcash crate on the same Zakura release so
# BranchId, NetworkType and note-encryption traits are one set of types; mixing in
# upstream zcash_protocol/zcash_address/zcash_note_encryption breaks that.
zcash_primitives = { package = "zakura-primitives", version = "5.2.0", default-features = false }
zcash_note_encryption = { package = "zakura-note-encryption", version = "5.2.0" }
orchard = { package = "zakura-orchard", version = "5.2.0", default-features = false, features = ["std", "backend-halo2", "zip32-addresses"] }
zcash_address = { package = "zakura-address", version = "5.2.0" }
zcash_protocol = { package = "zakura-protocol", version = "5.2.0" }

# WebAuthn / Passkeys
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation"] }
Expand Down
55 changes: 55 additions & 0 deletions src/addresses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,59 @@ mod tests {
assert_ne!(addr0.ua_string, addr1.ua_string);
assert_ne!(addr0.orchard_receiver_hex, addr1.orchard_receiver_hex);
}

/// Golden values produced by the pre-NU7 production build (b902f50, Zakura 1.x
/// crates). Existing invoices are matched by these receivers, so a Zcash crate
/// upgrade must derive byte-identical keys and addresses.
#[test]
fn derivation_matches_pre_nu7_production_build() {
use orchard::keys::{FullViewingKey, SpendingKey};
use zcash_address::unified::{Fvk, Ufvk};
use zcash_protocol::consensus::NetworkType;

const RECEIVERS: [(u32, &str); 4] = [
(0, "4559029c0b5dbf941c5ad181a5fe8f45b34630f29d0c8dd8dc1cc3573386f416cb324133156d723df5e62d"),
(1, "37f1c1f4c922ecb98f3cf2dbe6f12f65a73fc93316a1c0ad4a23a8a3b9b4eadbb7687b6e82161d031698bf"),
(7, "75bef073ad7a0fb0ab7d8aff0284d9f57d9efb6b32ab6916cbbb119961d55f45a3c719edee4a2e5acbff84"),
(1000, "6a61da5631788cc6ecc9db8e8cbaaf8d3f3b5ea81a2704c9da47a060274f257d5a044fb39ae3ebe14977af"),
];
let cases = [
(
NetworkType::Main,
"uivk1ewu2n4n4xgwxvem5krz36flunule40kyss82myn583hj9ypsrmt34yw3t5h6utkcx45w3tcharxck5fzurycymxp408wxh7f7u2snqdekm8d9au580fvm7yj9y3qsw7d4l9qsmsz98",
[
"u1u4zdvtcrjt4cnffw3shrx440754g0mhr4a7fenck686tfr6rdqy9wu5v39ydpgm4ut37qnlh9kpw9fsp8wcwyu2y2r5stjhj0qwfnmrq",
"u16p5xgserze3c9lj4pyuxhesqsssllaunzhehy2y0se5r8ac0tn7eaz4525kvf84sxhahngkd3wp3vlcydapyqyhehttncc0u0y4kqqje",
"u12lqydgardgp2g3d8ndvl09zycau3a008njs3uww4a2j2l9yvnul5g5lvqw6f6v08h00jy46p84qq2uaqmrwfp3n6yx7wzadq8vd0dc4h",
"u1rg3yrz2c34qgky548d57s8nvts8txja4m04ug4l5v7nfaxwhjrpjtynjtkqjdyhpwmqy4nn8624wftr84dgdnmm5nalzcpqe4g3cfsk7",
],
),
(
NetworkType::Test,
"uivktest1zk0r9g2yavyhv03qdeexrwkfzp5vt7sspfa3fstt7pz3z0zwgw6gppm96vp5fd8atsftgn92agytakhwshmswsrxrgzprzzyzq63dyvvpagd3ktt8qvwpu3xpcmgjk0x9a0qaqjz24",
[
"utest1j6uf5arygvvnypvh7n9pgcmt70xapzx70t3nemk2yy6646ekn2d296wyc355pvjtwnftcaukuqv687wnecvs47h9w82z4v498c58d6w8",
"utest1scuz7xf9wxep7xzuf5utvwclg649svw8wk2z90zdygpza0mkpe4070nleu5me07j8g7re27ed2y2qy5p5vtwgcf0vrr3ym33jve4eafm",
"utest1m7pvrhnnpjajfww846shv52puz5palj068m83e4mdklw70ckmt7nmfluap4vuguh7hm9pr53k4sne8dxnvrvlkqn646a822ppgfq074s",
"utest1p5wvwuhera8t7pp2z3uhelvpxfkv2e5e2302kjt45lwyf3lw6qtyr9ul2954tkcrg9qp3d593d6zs5q4g8lxhpkpvldruwrqpyw4vtvg",
],
),
];

let fvk = FullViewingKey::from(&SpendingKey::from_bytes([7u8; 32]).unwrap());
for (network, expected_uivk, uas) in cases {
let ufvk = Ufvk::try_from_items(vec![Fvk::Orchard(fvk.to_bytes())])
.unwrap()
.encode(&network);
let uivk = crate::scanner::decrypt::derive_uivk_from_ufvk(&ufvk).unwrap();
assert_eq!(uivk, expected_uivk, "{network:?} UIVK");
for ((index, receiver), ua) in RECEIVERS.iter().zip(uas) {
for key in [&ufvk, &uivk] {
let addr = derive_invoice_address(key, *index).unwrap();
assert_eq!(addr.orchard_receiver_hex, *receiver, "{network:?} #{index}");
assert_eq!(addr.ua_string, ua, "{network:?} #{index}");
}
}
}
}
}
147 changes: 131 additions & 16 deletions src/scanner/decrypt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,21 +115,30 @@ pub fn derive_uivk_from_ufvk(ufvk_str: &str) -> Result<String> {
pub fn validate_raw_transaction(raw_hex: &str) -> Result<()> {
let bytes = hex::decode(raw_hex)?;
anyhow::ensure!(bytes.len() >= 4, "Truncated raw transaction");
Transaction::read(&mut Cursor::new(&bytes[..]), BranchId::Nu6_3)?;
parse_transaction(&bytes)?;
Ok(())
}

/// Parse a raw transaction.
///
/// v5 and v6 transactions carry their consensus branch ID in the header (NU6.3 =
/// `0x37A5165B`, NU7 = `0x77190AD9`) and the parser validates that header value; the
/// argument below is used only for legacy v4 transactions, which NU7 no longer
/// accepts. A parse failure is returned as an error, never as "no outputs", so a
/// future network upgrade the parser does not know is visible instead of silently
/// hiding payments.
fn parse_transaction(bytes: &[u8]) -> Result<Transaction> {
Transaction::read(&mut Cursor::new(bytes), BranchId::Nu6_3)
.map_err(|e| anyhow::anyhow!("Transaction parse failed: {e}"))
}

pub fn try_decrypt_with_keys(raw_hex: &str, keys: &CachedKeys) -> Result<Vec<DecryptedOutput>> {
let tx_bytes = hex::decode(raw_hex)?;
if tx_bytes.len() < 4 {
return Ok(vec![]);
}

let mut cursor = Cursor::new(&tx_bytes[..]);
let tx = match Transaction::read(&mut cursor, BranchId::Nu6_3) {
Ok(tx) => tx,
Err(_) => return Ok(vec![]),
};
let tx = parse_transaction(&tx_bytes)?;

let mut outputs = Vec::new();

Expand Down Expand Up @@ -236,11 +245,7 @@ pub fn try_decrypt_all_outputs_ivk(raw_hex: &str, key_str: &str) -> Result<Vec<D

let prepared_ivk = PreparedIncomingViewingKey::new(&ivk);

let mut cursor = Cursor::new(&tx_bytes[..]);
let tx = match Transaction::read(&mut cursor, BranchId::Nu6_3) {
Ok(tx) => tx,
Err(_) => return Ok(vec![]),
};
let tx = parse_transaction(&tx_bytes)?;

let mut outputs = Vec::new();

Expand Down Expand Up @@ -287,11 +292,7 @@ pub fn try_decrypt_all_outputs(raw_hex: &str, ufvk_str: &str) -> Result<Vec<Decr
}
};

let mut cursor = Cursor::new(&tx_bytes[..]);
let tx = match Transaction::read(&mut cursor, BranchId::Nu6_3) {
Ok(tx) => tx,
Err(_) => return Ok(vec![]),
};
let tx = parse_transaction(&tx_bytes)?;

let mut outputs = Vec::new();

Expand Down Expand Up @@ -384,4 +385,118 @@ mod tests {
let result = try_decrypt_memo("deadbeef", "uviewtest1dummy").unwrap();
assert_eq!(result, None);
}

// Real testnet transactions around NU7 activation (testnet height 4,465,026).
// Pre-activation transactions carry NU6.3 branch 0x37A5165B; activation and later
// carry NU7 branch 0x77190AD9. The expected txids pin the parsed bytes.
const NU7_FIXTURES: &[(&str, &str, u32, &str)] = &[
(
"pre_activation_v6_coinbase_4465025",
include_str!("fixtures/nu7_testnet/pre_activation_v6_coinbase_4465025.hex"),
0x37A5_165B,
"d501345693ac4c2ca5855258f5ec55389abbfa0fc71f12d79384d8b305e4e1d3",
),
(
"pre_activation_v5_4465025",
include_str!("fixtures/nu7_testnet/pre_activation_v5_4465025.hex"),
0x37A5_165B,
"d759e629035fc77989d8b3253d16660e5cd3beef33bd8a011ba335d0f44d7bb8",
),
(
"activation_v6_coinbase_4465026",
include_str!("fixtures/nu7_testnet/activation_v6_coinbase_4465026.hex"),
0x7719_0AD9,
"98ccf227cfa19e8ad89312500bf724090997443e6ae0656e33b2d5cec99bb608",
),
(
"activation_v5_4465026",
include_str!("fixtures/nu7_testnet/activation_v5_4465026.hex"),
0x7719_0AD9,
"a85b0f6a352b9699d4221a93eeadbab457862c4dec2a3ac7ccd0d2f034fc89ea",
),
(
"post_v6_orchard7_ironwood2_4513429",
include_str!("fixtures/nu7_testnet/post_v6_orchard7_ironwood2_4513429.hex"),
0x7719_0AD9,
"462b5634f6e8bf8892db9c81260057957ac14e7bb3d8427a3dd165918a5cec25",
),
];

fn test_ufvk() -> String {
let sk = orchard::keys::SpendingKey::from_bytes([7u8; 32]).unwrap();
let fvk = FullViewingKey::from(&sk);
Ufvk::try_from_items(vec![Fvk::Orchard(fvk.to_bytes())])
.unwrap()
.encode(&NetworkType::Test)
}

#[test]
fn nu6_3_and_nu7_testnet_transactions_parse() {
for (name, raw_hex, branch, txid) in NU7_FIXTURES {
let bytes = hex::decode(raw_hex.trim()).unwrap();
let header_branch = u32::from_le_bytes(bytes[8..12].try_into().unwrap());
assert_eq!(header_branch, *branch, "{name}: fixture header branch");

validate_raw_transaction(raw_hex.trim())
.unwrap_or_else(|e| panic!("{name}: validate failed: {e}"));
let tx = parse_transaction(&bytes).unwrap();
assert_eq!(u32::from(tx.consensus_branch_id()), *branch, "{name}");
assert_eq!(tx.txid().to_string(), *txid, "{name}: txid");
}
}

#[test]
fn nu7_orchard_and_ironwood_bundles_are_read() {
let (_, raw_hex, _, _) = NU7_FIXTURES[4];
let tx = parse_transaction(&hex::decode(raw_hex.trim()).unwrap()).unwrap();
assert_eq!(tx.orchard_bundle().map(|b| b.actions().len()), Some(7));
assert_eq!(tx.ironwood_bundle().map(|b| b.actions().len()), Some(2));

let (_, coinbase_hex, _, _) = NU7_FIXTURES[2];
let coinbase = parse_transaction(&hex::decode(coinbase_hex.trim()).unwrap()).unwrap();
assert_eq!(
coinbase.ironwood_bundle().map(|b| b.actions().len()),
Some(1)
);
}

#[test]
fn nu7_trial_decryption_runs_for_every_caller_path() {
// A key unrelated to these transactions must decrypt nothing, without error,
// through each entry point the scanner, x402 and session deposits use.
let ufvk = test_ufvk();
let keys = prepare_keys(&ufvk).unwrap();
for (name, raw_hex, _, _) in NU7_FIXTURES {
let raw_hex = raw_hex.trim();
assert!(
try_decrypt_with_keys(raw_hex, &keys).unwrap().is_empty(),
"{name}"
);
assert!(
try_decrypt_all_outputs_ivk(raw_hex, &ufvk)
.unwrap()
.is_empty(),
"{name}"
);
assert!(
try_decrypt_all_outputs(raw_hex, &ufvk).unwrap().is_empty(),
"{name}"
);
}
}

#[test]
fn unknown_branch_id_is_an_error_not_an_empty_result() {
let (_, raw_hex, _, _) = NU7_FIXTURES[3];
let mut bytes = hex::decode(raw_hex.trim()).unwrap();
bytes[8..12].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes());
let mangled = hex::encode(bytes);

assert!(validate_raw_transaction(&mangled).is_err());
let ufvk = test_ufvk();
let keys = prepare_keys(&ufvk).unwrap();
assert!(try_decrypt_with_keys(&mangled, &keys).is_err());
assert!(try_decrypt_all_outputs_ivk(&mangled, &ufvk).is_err());
assert!(try_decrypt_all_outputs(&mangled, &ufvk).is_err());
}
}
1 change: 1 addition & 0 deletions src/scanner/fixtures/nu7_testnet/activation_v5_4465026.hex
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
050000800a27a726d90a197700000000fa21440001b87b4df4d035a31b018abd33efbed35c0e66163d25b3d88979c75f0329e659d7020000006a473044022043c971e06555e2413276314bfc5d881487e4eaa749148f87f3ac55b2b8fe07f1022061d53ac6c9d6e053cca4ce5b072c34eb252683a4cbd14da6a5b0be1cc8e549e8012102bc0a8bf008166b8a99a1bcf27086172fd709467ea55b429449a3fe6a92f03738ffffffff0300000000000000001d6a1b535601bd8a560dfb415d4babb99662dc157267d166b7c90000000010270000000000001976a914000000000000000000000000000000000000000088aca0aa762a000000001976a9141aded9db78a48344f8541ffa7d4300ada64fc68088ac000000
Loading
Loading