From dc007ef47fc758fca827e58059a04e6f9fbe560f Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Sat, 20 Jul 2024 01:19:02 +0000 Subject: [PATCH] devtools: Import `zcash-inspect` tool Source: zcash/zcash@a0812adb18faabe99d9769403e8e4c41fa5b980c --- devtools/src/bin/inspect/address.rs | 140 ++++++ devtools/src/bin/inspect/block.rs | 401 +++++++++++++++++ devtools/src/bin/inspect/context.rs | 325 ++++++++++++++ devtools/src/bin/inspect/keys.rs | 168 ++++++++ devtools/src/bin/inspect/main.rs | 130 ++++++ devtools/src/bin/inspect/transaction.rs | 548 ++++++++++++++++++++++++ 6 files changed, 1712 insertions(+) create mode 100644 devtools/src/bin/inspect/address.rs create mode 100644 devtools/src/bin/inspect/block.rs create mode 100644 devtools/src/bin/inspect/context.rs create mode 100644 devtools/src/bin/inspect/keys.rs create mode 100644 devtools/src/bin/inspect/main.rs create mode 100644 devtools/src/bin/inspect/transaction.rs diff --git a/devtools/src/bin/inspect/address.rs b/devtools/src/bin/inspect/address.rs new file mode 100644 index 000000000..b89acedd6 --- /dev/null +++ b/devtools/src/bin/inspect/address.rs @@ -0,0 +1,140 @@ +use zcash_address::{ + unified::{self, Container, Encoding}, + ConversionError, Network, ToAddress, ZcashAddress, +}; + +#[allow(dead_code)] +enum AddressKind { + Sprout([u8; 64]), + Sapling([u8; 43]), + Unified(unified::Address), + P2pkh([u8; 20]), + P2sh([u8; 20]), +} + +struct Address { + net: Network, + kind: AddressKind, +} + +impl zcash_address::TryFromAddress for Address { + type Error = (); + + fn try_from_sprout(net: Network, data: [u8; 64]) -> Result> { + Ok(Address { + net, + kind: AddressKind::Sprout(data), + }) + } + + fn try_from_sapling( + net: Network, + data: [u8; 43], + ) -> Result> { + Ok(Address { + net, + kind: AddressKind::Sapling(data), + }) + } + + fn try_from_unified( + net: Network, + data: unified::Address, + ) -> Result> { + Ok(Address { + net, + kind: AddressKind::Unified(data), + }) + } + + fn try_from_transparent_p2pkh( + net: Network, + data: [u8; 20], + ) -> Result> { + Ok(Address { + net, + kind: AddressKind::P2pkh(data), + }) + } + + fn try_from_transparent_p2sh( + net: Network, + data: [u8; 20], + ) -> Result> { + Ok(Address { + net, + kind: AddressKind::P2sh(data), + }) + } +} + +pub(crate) fn inspect(addr: ZcashAddress) { + eprintln!("Zcash address"); + + match addr.convert::
() { + // TODO: Check for valid internals once we have migrated to a newer zcash_address + // version with custom errors. + Err(_) => unreachable!(), + Ok(addr) => { + eprintln!( + " - Network: {}", + match addr.net { + Network::Main => "main", + Network::Test => "testnet", + Network::Regtest => "regtest", + } + ); + eprintln!( + " - Kind: {}", + match addr.kind { + AddressKind::Sprout(_) => "Sprout", + AddressKind::Sapling(_) => "Sapling", + AddressKind::Unified(_) => "Unified Address", + AddressKind::P2pkh(_) => "Transparent P2PKH", + AddressKind::P2sh(_) => "Transparent P2SH", + } + ); + + if let AddressKind::Unified(ua) = addr.kind { + eprintln!(" - Receivers:"); + for receiver in ua.items() { + match receiver { + unified::Receiver::Orchard(data) => { + eprintln!( + " - Orchard ({})", + unified::Address::try_from_items(vec![unified::Receiver::Orchard( + data + )]) + .unwrap() + .encode(&addr.net) + ); + } + unified::Receiver::Sapling(data) => { + eprintln!( + " - Sapling ({})", + ZcashAddress::from_sapling(addr.net, data) + ); + } + unified::Receiver::P2pkh(data) => { + eprintln!( + " - Transparent P2PKH ({})", + ZcashAddress::from_transparent_p2pkh(addr.net, data) + ); + } + unified::Receiver::P2sh(data) => { + eprintln!( + " - Transparent P2SH ({})", + ZcashAddress::from_transparent_p2sh(addr.net, data) + ); + } + unified::Receiver::Unknown { typecode, data } => { + eprintln!(" - Unknown"); + eprintln!(" - Typecode: {}", typecode); + eprintln!(" - Payload: {}", hex::encode(data)); + } + } + } + } + } + } +} diff --git a/devtools/src/bin/inspect/block.rs b/devtools/src/bin/inspect/block.rs new file mode 100644 index 000000000..f6a79b73c --- /dev/null +++ b/devtools/src/bin/inspect/block.rs @@ -0,0 +1,401 @@ +// To silence lints in the `uint::construct_uint!` macro. +#![allow(clippy::assign_op_pattern)] +#![allow(clippy::ptr_offset_with_cast)] + +use std::cmp; +use std::convert::{TryFrom, TryInto}; +use std::io::{self, Read}; + +use sha2::{Digest, Sha256}; +use zcash_encoding::Vector; +use zcash_primitives::{ + block::BlockHeader, + consensus::{BlockHeight, BranchId, Network, NetworkUpgrade, Parameters}, + transaction::Transaction, +}; + +use crate::{ + transaction::{extract_height_from_coinbase, is_coinbase}, + Context, ZUint256, +}; + +const MIN_BLOCK_VERSION: i32 = 4; + +uint::construct_uint! { + pub(crate) struct U256(4); +} + +impl U256 { + fn from_compact(compact: u32) -> (Self, bool, bool) { + let size = compact >> 24; + let word = compact & 0x007fffff; + let result = if size <= 3 { + U256::from(word >> (8 * (3 - size))) + } else { + U256::from(word) << (8 * (size - 3)) + }; + ( + result, + word != 0 && (compact & 0x00800000) != 0, + word != 0 + && ((size > 34) || (word > 0xff && size > 33) || (word > 0xffff && size > 32)), + ) + } +} + +pub(crate) trait BlockParams: Parameters { + fn equihash_n(&self) -> u32; + fn equihash_k(&self) -> u32; + fn pow_limit(&self) -> U256; +} + +impl BlockParams for Network { + fn equihash_n(&self) -> u32 { + match self { + Self::MainNetwork | Self::TestNetwork => 200, + } + } + + fn equihash_k(&self) -> u32 { + match self { + Self::MainNetwork | Self::TestNetwork => 9, + } + } + + fn pow_limit(&self) -> U256 { + match self { + Self::MainNetwork => U256::from_big_endian( + &hex::decode("0007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + .unwrap(), + ), + Self::TestNetwork => U256::from_big_endian( + &hex::decode("07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + .unwrap(), + ), + } + } +} + +pub(crate) fn guess_params(header: &BlockHeader) -> Option { + // If the block target falls between the testnet and mainnet powLimit, assume testnet. + let (target, is_negative, did_overflow) = U256::from_compact(header.bits); + if !(is_negative || did_overflow) + && target > Network::MainNetwork.pow_limit() + && target <= Network::TestNetwork.pow_limit() + { + return Some(Network::TestNetwork); + } + + None +} + +fn check_equihash_solution(header: &BlockHeader, params: Network) -> Result<(), equihash::Error> { + let eh_input = { + let mut eh_input = vec![]; + header.write(&mut eh_input).unwrap(); + eh_input.truncate(4 + 32 + 32 + 32 + 4 + 4); + eh_input + }; + equihash::is_valid_solution( + params.equihash_n(), + params.equihash_k(), + &eh_input, + &header.nonce, + &header.solution, + ) +} + +fn check_proof_of_work(header: &BlockHeader, params: Network) -> Result<(), &str> { + let (target, is_negative, did_overflow) = U256::from_compact(header.bits); + let hash = U256::from_little_endian(&header.hash().0); + + if is_negative { + Err("nBits is negative") + } else if target.is_zero() { + Err("target is zero") + } else if did_overflow { + Err("nBits overflowed") + } else if target > params.pow_limit() { + Err("target is larger than powLimit") + } else if hash > target { + Err("block hash larger than target") + } else { + Ok(()) + } +} + +fn derive_block_commitments_hash( + chain_history_root: [u8; 32], + auth_data_root: [u8; 32], +) -> [u8; 32] { + blake2b_simd::Params::new() + .hash_length(32) + .personal(b"ZcashBlockCommit") + .to_state() + .update(&chain_history_root) + .update(&auth_data_root) + .update(&[0; 32]) + .finalize() + .as_bytes() + .try_into() + .unwrap() +} + +pub(crate) struct Block { + header: BlockHeader, + txs: Vec, +} + +impl Block { + pub(crate) fn read(mut reader: R) -> io::Result { + let header = BlockHeader::read(&mut reader)?; + let txs = Vector::read(reader, |r| Transaction::read(r, BranchId::Sprout))?; + + Ok(Block { header, txs }) + } + + pub(crate) fn guess_params(&self) -> Option { + guess_params(&self.header) + } + + fn extract_height(&self) -> Option { + self.txs.first().and_then(extract_height_from_coinbase) + } + + /// Builds the Merkle tree for this block and returns its root. + /// + /// The returned `bool` indicates whether mutation was detected in the Merkle tree (a + /// duplication of transactions in the block leading to an identical Merkle root). + fn build_merkle_root(&self) -> ([u8; 32], bool) { + // Safe upper bound for the number of total nodes. + let mut merkle_tree = Vec::with_capacity(self.txs.len() * 2 + 16); + for tx in &self.txs { + merkle_tree.push(sha2::digest::generic_array::GenericArray::from( + *tx.txid().as_ref(), + )); + } + let mut size = self.txs.len(); + let mut j = 0; + let mut mutated = false; + while size > 1 { + let mut i = 0; + while i < size { + let i2 = cmp::min(i + 1, size - 1); + if i2 == i + 1 && i2 + 1 == size && merkle_tree[j + i] == merkle_tree[j + i2] { + // Two identical hashes at the end of the list at a particular level. + mutated = true; + } + let mut inner_hasher = Sha256::new(); + inner_hasher.update(merkle_tree[j + i]); + inner_hasher.update(merkle_tree[j + i2]); + merkle_tree.push(Sha256::digest(inner_hasher.finalize())); + i += 2; + } + j += size; + size = (size + 1) / 2; + } + ( + merkle_tree + .last() + .copied() + .map(|root| root.into()) + .unwrap_or([0; 32]), + mutated, + ) + } + + fn build_auth_data_root(&self) -> [u8; 32] { + fn next_pow2(x: u64) -> u64 { + // Fails if `x` is greater than `1u64 << 63`, but this can't occur because a + // block can't feasibly contain that many transactions. + 1u64 << (64 - x.saturating_sub(1).leading_zeros()) + } + + let perfect_size = next_pow2(self.txs.len() as u64) as usize; + assert_eq!((perfect_size & (perfect_size - 1)), 0); + let expected_size = cmp::max(perfect_size * 2, 1) - 1; // The total number of nodes. + let mut tree = Vec::with_capacity(expected_size); + + // Add the leaves to the tree. v1-v4 transactions will append empty leaves. + for tx in &self.txs { + tree.push(<[u8; 32]>::try_from(tx.auth_commitment().as_bytes()).unwrap()); + } + // Append empty leaves until we get a perfect tree. + tree.resize(perfect_size, [0; 32]); + + let mut j = 0; + let mut layer_width = perfect_size; + while layer_width > 1 { + let mut i = 0; + while i < layer_width { + tree.push( + blake2b_simd::Params::new() + .hash_length(32) + .personal(b"ZcashAuthDatHash") + .to_state() + .update(&tree[j + i]) + .update(&tree[j + i + 1]) + .finalize() + .as_bytes() + .try_into() + .unwrap(), + ); + i += 2; + } + + // Move to the next layer. + j += layer_width; + layer_width /= 2; + } + + assert_eq!(tree.len(), expected_size); + tree.last().copied().unwrap_or([0; 32]) + } +} + +pub(crate) fn inspect_header(header: &BlockHeader, context: Option) { + eprintln!("Zcash block header"); + inspect_header_inner( + header, + guess_params(header).or_else(|| context.and_then(|c| c.network())), + ); +} + +fn inspect_header_inner(header: &BlockHeader, params: Option) { + eprintln!(" - Hash: {}", header.hash()); + eprintln!(" - Version: {}", header.version); + if header.version < MIN_BLOCK_VERSION { + // zcashd: version-too-low + eprintln!("⚠️ Version too low",); + } + if let Some(params) = params { + if let Err(e) = check_equihash_solution(header, params) { + // zcashd: invalid-solution + eprintln!("⚠️ Invalid Equihash solution: {}", e); + } + if let Err(e) = check_proof_of_work(header, params) { + // zcashd: high-hash + eprintln!("⚠️ Invalid Proof-of-Work: {}", e); + } + } else { + eprintln!("🔎 To check contextual rules, add \"network\" to context (either \"main\" or \"test\")"); + } +} + +pub(crate) fn inspect(block: &Block, context: Option) { + eprintln!("Zcash block"); + let params = block + .guess_params() + .or_else(|| context.as_ref().and_then(|c| c.network())); + inspect_header_inner(&block.header, params); + + let height = match block.txs.len() { + 0 => { + // zcashd: bad-cb-missing + eprintln!("⚠️ Missing coinbase transaction"); + None + } + txs => { + eprintln!(" - {} transaction(s) including coinbase", txs); + + if !is_coinbase(&block.txs[0]) { + // zcashd: bad-cb-missing + eprintln!("⚠️ vtx[0] is not a coinbase transaction"); + None + } else { + let height = block.extract_height(); + match height { + Some(h) => eprintln!(" - Height: {}", h), + // zcashd: bad-cb-height + None => eprintln!("⚠️ No height in coinbase transaction"), + } + height + } + } + }; + + for (i, tx) in block.txs.iter().enumerate().skip(1) { + if is_coinbase(tx) { + // zcashd: bad-cb-multiple + eprintln!("⚠️ vtx[{}] is a coinbase transaction", i); + } + } + + let (merkle_root, merkle_root_mutated) = block.build_merkle_root(); + if merkle_root != block.header.merkle_root { + // zcashd: bad-txnmrklroot + eprintln!("⚠️ header.merkleroot doesn't match transaction Merkle tree root"); + eprintln!(" - merkleroot (calc): {}", ZUint256(merkle_root)); + eprintln!( + " - header.merkleroot: {}", + ZUint256(block.header.merkle_root) + ); + } + if merkle_root_mutated { + // zcashd: bad-txns-duplicate + eprintln!("⚠️ Transaction Merkle tree is malleable"); + } + + // The rest of the checks require network parameters and a block height. + let (params, height) = match (params, height) { + (Some(params), Some(height)) => (params, height), + _ => return, + }; + + if params.is_nu_active(NetworkUpgrade::Nu5, height) { + if block.txs[0].expiry_height() != height { + // zcashd: bad-cb-height + eprintln!( + "⚠️ [NU5] coinbase expiry height ({}) doesn't match coinbase scriptSig height ({})", + block.txs[0].expiry_height(), + height + ); + } + + if let Some(chain_history_root) = context.and_then(|c| c.chainhistoryroot) { + let auth_data_root = block.build_auth_data_root(); + let block_commitments_hash = + derive_block_commitments_hash(chain_history_root.0, auth_data_root); + + if block_commitments_hash != block.header.final_sapling_root { + // zcashd: bad-block-commitments-hash + eprintln!( + "⚠️ [NU5] header.blockcommitments doesn't match ZIP 244 block commitment" + ); + eprintln!(" - chainhistoryroot: {}", chain_history_root); + eprintln!(" - authdataroot: {}", ZUint256(auth_data_root)); + eprintln!( + " - blockcommitments (calc): {}", + ZUint256(block_commitments_hash) + ); + eprintln!( + " - header.blockcommitments: {}", + ZUint256(block.header.final_sapling_root) + ); + } + } else { + eprintln!("🔎 To check header.blockcommitments, add \"chainhistoryroot\" to context"); + } + } else if Some(height) == params.activation_height(NetworkUpgrade::Heartwood) { + if block.header.final_sapling_root != [0; 32] { + // zcashd: bad-heartwood-root-in-block + eprintln!("⚠️ This is the block that activates Heartwood but header.blockcommitments is not null"); + } + } else if params.is_nu_active(NetworkUpgrade::Heartwood, height) { + if let Some(chain_history_root) = context.and_then(|c| c.chainhistoryroot) { + if chain_history_root.0 != block.header.final_sapling_root { + // zcashd: bad-heartwood-root-in-block + eprintln!( + "⚠️ [Heartwood] header.blockcommitments doesn't match provided chain history root" + ); + eprintln!(" - chainhistoryroot: {}", chain_history_root); + eprintln!( + " - header.blockcommitments: {}", + ZUint256(block.header.final_sapling_root) + ); + } + } else { + eprintln!("🔎 To check header.blockcommitments, add \"chainhistoryroot\" to context"); + } + } +} diff --git a/devtools/src/bin/inspect/context.rs b/devtools/src/bin/inspect/context.rs new file mode 100644 index 000000000..9f78cd42c --- /dev/null +++ b/devtools/src/bin/inspect/context.rs @@ -0,0 +1,325 @@ +use std::convert::TryFrom; +use std::fmt; +use std::str::FromStr; + +use serde::{ + de::{Unexpected, Visitor}, + Deserialize, Serialize, Serializer, +}; +use zcash_primitives::{ + consensus::Network, + legacy::Script, + transaction::components::{amount::NonNegativeAmount, transparent, TxOut}, + zip32::AccountId, +}; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct JsonNetwork(Network); + +struct JsonNetworkVisitor; + +impl<'de> Visitor<'de> for JsonNetworkVisitor { + type Value = JsonNetwork; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("either \"main\" or \"test\"") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + match v { + "main" => Ok(JsonNetwork(Network::MainNetwork)), + "test" => Ok(JsonNetwork(Network::TestNetwork)), + _ => Err(serde::de::Error::invalid_value( + Unexpected::Str(v), + &"either \"main\" or \"test\"", + )), + } + } +} + +impl<'de> Deserialize<'de> for JsonNetwork { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_str(JsonNetworkVisitor) + } +} + +#[derive(Clone, Copy, Debug)] +struct JsonAccountId(AccountId); + +struct JsonAccountIdVisitor; + +impl<'de> Visitor<'de> for JsonAccountIdVisitor { + type Value = JsonAccountId; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a u31") + } + + fn visit_i64(self, v: i64) -> Result + where + E: serde::de::Error, + { + u32::try_from(v) + .map_err(|_| E::custom(format!("u32 out of range: {}", v))) + .and_then(|a| { + AccountId::try_from(a).map_err(|e| E::custom(format!("AccountId invalid: {}", e))) + }) + .map(JsonAccountId) + } + + fn visit_i128(self, v: i128) -> Result + where + E: serde::de::Error, + { + u32::try_from(v) + .map_err(|_| E::custom(format!("u32 out of range: {}", v))) + .and_then(|a| { + AccountId::try_from(a).map_err(|e| E::custom(format!("AccountId invalid: {}", e))) + }) + .map(JsonAccountId) + } + + fn visit_u64(self, v: u64) -> Result + where + E: serde::de::Error, + { + u32::try_from(v) + .map_err(|_| E::custom(format!("u32 out of range: {}", v))) + .and_then(|a| { + AccountId::try_from(a).map_err(|e| E::custom(format!("AccountId invalid: {}", e))) + }) + .map(JsonAccountId) + } + + fn visit_u128(self, v: u128) -> Result + where + E: serde::de::Error, + { + u32::try_from(v) + .map_err(|_| E::custom(format!("u32 out of range: {}", v))) + .and_then(|a| { + AccountId::try_from(a).map_err(|e| E::custom(format!("AccountId invalid: {}", e))) + }) + .map(JsonAccountId) + } +} + +impl<'de> Deserialize<'de> for JsonAccountId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_u32(JsonAccountIdVisitor) + } +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct ZUint256(pub [u8; 32]); + +struct ZUint256Visitor; + +impl<'de> Visitor<'de> for ZUint256Visitor { + type Value = ZUint256; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a hex-encoded 32-byte array") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + let mut data = [0; 32]; + hex::decode_to_slice(v, &mut data).map_err(|e| match e { + hex::FromHexError::InvalidHexCharacter { c, .. } => { + serde::de::Error::invalid_value(Unexpected::Char(c), &"valid hex character") + } + hex::FromHexError::OddLength => { + serde::de::Error::invalid_length(v.len(), &"an even-length string") + } + hex::FromHexError::InvalidStringLength => { + serde::de::Error::invalid_length(v.len(), &"a 64-character string") + } + })?; + data.reverse(); + Ok(ZUint256(data)) + } +} + +impl<'de> Deserialize<'de> for ZUint256 { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_str(ZUint256Visitor) + } +} + +impl fmt::Display for ZUint256 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut data = self.0; + data.reverse(); + formatter.write_str(&hex::encode(data)) + } +} + +#[derive(Clone, Debug)] +struct ZOutputValue(NonNegativeAmount); + +struct ZOutputValueVisitor; + +impl<'de> Visitor<'de> for ZOutputValueVisitor { + type Value = ZOutputValue; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a non-negative integer number of zatoshis") + } + + fn visit_u64(self, v: u64) -> Result + where + E: serde::de::Error, + { + NonNegativeAmount::from_u64(v) + .map(ZOutputValue) + .map_err(|e| match e { + zcash_protocol::value::BalanceError::Overflow => serde::de::Error::invalid_type( + Unexpected::Unsigned(v), + &"a valid zatoshi amount", + ), + zcash_protocol::value::BalanceError::Underflow => serde::de::Error::invalid_type( + Unexpected::Unsigned(v), + &"a non-negative zatoshi amount", + ), + }) + } +} + +impl<'de> Deserialize<'de> for ZOutputValue { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_u64(ZOutputValueVisitor) + } +} + +impl Serialize for ZOutputValue { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_u64(u64::from(self.0)) + } +} + +#[derive(Clone, Debug)] +struct ZScript(Script); + +struct ZScriptVisitor; + +impl<'de> Visitor<'de> for ZScriptVisitor { + type Value = ZScript; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a hex-encoded Script") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + let data = hex::decode(v).map_err(|e| match e { + hex::FromHexError::InvalidHexCharacter { c, .. } => { + serde::de::Error::invalid_value(Unexpected::Char(c), &"valid hex character") + } + hex::FromHexError::OddLength => { + serde::de::Error::invalid_length(v.len(), &"an even-length string") + } + hex::FromHexError::InvalidStringLength => { + serde::de::Error::invalid_length(v.len(), &"a 64-character string") + } + })?; + Ok(ZScript(Script(data))) + } +} + +impl<'de> Deserialize<'de> for ZScript { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_str(ZScriptVisitor) + } +} + +impl Serialize for ZScript { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&hex::encode(&self.0 .0)) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ZTxOut { + value: ZOutputValue, + script_pubkey: ZScript, +} + +impl From for ZTxOut { + fn from(out: TxOut) -> Self { + ZTxOut { + value: ZOutputValue(out.value), + script_pubkey: ZScript(out.script_pubkey), + } + } +} + +#[derive(Debug, Deserialize)] +pub(crate) struct Context { + network: Option, + accounts: Option>, + pub(crate) chainhistoryroot: Option, + transparentcoins: Option>, +} + +impl FromStr for Context { + type Err = serde_json::Error; + + fn from_str(s: &str) -> Result { + serde_json::from_str(s) + } +} + +impl Context { + pub(crate) fn network(&self) -> Option { + self.network.map(|n| n.0) + } + + pub(crate) fn addr_network(&self) -> Option { + self.network().map(|params| match params { + Network::MainNetwork => zcash_address::Network::Main, + Network::TestNetwork => zcash_address::Network::Test, + }) + } + + pub(crate) fn accounts(&self) -> Option> { + self.accounts + .as_ref() + .map(|accounts| accounts.iter().map(|id| id.0).collect()) + } + + pub(crate) fn transparent_coins(&self) -> Option> { + self.transparentcoins.as_ref().map(|coins| { + coins + .iter() + .cloned() + .map(|coin| transparent::TxOut { + value: coin.value.0, + script_pubkey: coin.script_pubkey.0, + }) + .collect() + }) + } +} diff --git a/devtools/src/bin/inspect/keys.rs b/devtools/src/bin/inspect/keys.rs new file mode 100644 index 000000000..2f9166d8c --- /dev/null +++ b/devtools/src/bin/inspect/keys.rs @@ -0,0 +1,168 @@ +use std::convert::TryInto; +use std::iter; + +use bech32::ToBase32; +use secrecy::Zeroize; +use zcash_address::{ + unified::{self, Encoding}, + ToAddress, ZcashAddress, +}; +use zcash_primitives::{ + legacy::{ + keys::{AccountPrivKey, IncomingViewingKey}, + TransparentAddress, + }, + zip32, zip339, +}; +use zcash_protocol::consensus::NetworkConstants; + +use crate::Context; + +pub(crate) fn inspect_mnemonic( + mnemonic: zip339::Mnemonic, + lang: zip339::Language, + context: Option, +) { + eprintln!("Mnemonic phrase"); + eprintln!(" - Language: {}", lang); + + if let Some(((network, addr_net), accounts)) = + context.and_then(|c| c.network().zip(c.addr_network()).zip(c.accounts())) + { + let mut seed = mnemonic.to_seed(""); + for account in accounts { + eprintln!(" - Account {}:", u32::from(account)); + + let orchard_fvk = match orchard::keys::SpendingKey::from_zip32_seed( + &seed, + network.coin_type(), + account, + ) { + Ok(sk) => Some(orchard::keys::FullViewingKey::from(&sk)), + Err(e) => { + eprintln!( + " ⚠️ No valid Orchard key for this account under this seed: {}", + e + ); + None + } + }; + + eprintln!(" - Sapling:"); + let sapling_master = sapling::zip32::ExtendedSpendingKey::master(&seed); + let sapling_extsk = sapling::zip32::ExtendedSpendingKey::from_path( + &sapling_master, + &[ + zip32::ChildIndex::hardened(32), + zip32::ChildIndex::hardened(network.coin_type()), + account.into(), + ], + ); + #[allow(deprecated)] + let sapling_extfvk = sapling_extsk.to_extended_full_viewing_key(); + let sapling_default_addr = sapling_extfvk.default_address(); + + let mut sapling_extsk_bytes = vec![]; + sapling_extsk.write(&mut sapling_extsk_bytes).unwrap(); + eprintln!( + " - ExtSK: {}", + bech32::encode( + network.hrp_sapling_extended_spending_key(), + sapling_extsk_bytes.to_base32(), + bech32::Variant::Bech32, + ) + .unwrap(), + ); + + let mut sapling_extfvk_bytes = vec![]; + sapling_extfvk.write(&mut sapling_extfvk_bytes).unwrap(); + eprintln!( + " - ExtFVK: {}", + bech32::encode( + network.hrp_sapling_extended_full_viewing_key(), + sapling_extfvk_bytes.to_base32(), + bech32::Variant::Bech32, + ) + .unwrap(), + ); + + let sapling_addr_bytes = sapling_default_addr.1.to_bytes(); + eprintln!( + " - Default address: {}", + bech32::encode( + network.hrp_sapling_payment_address(), + sapling_addr_bytes.to_base32(), + bech32::Variant::Bech32, + ) + .unwrap(), + ); + + let transparent_fvk = match AccountPrivKey::from_seed(&network, &seed, account) + .map(|sk| sk.to_account_pubkey()) + { + Ok(fvk) => { + eprintln!(" - Transparent:"); + match fvk.derive_external_ivk().map(|ivk| ivk.default_address().0) { + Ok(addr) => eprintln!( + " - Default address: {}", + match addr { + TransparentAddress::PublicKeyHash(data) => ZcashAddress::from_transparent_p2pkh(addr_net, data), + TransparentAddress::ScriptHash(_) => unreachable!(), + }.encode(), + ), + Err(e) => eprintln!( + " ⚠️ No valid transparent default address for this account under this seed: {:?}", + e + ), + } + + Some(fvk) + } + Err(e) => { + eprintln!( + " ⚠️ No valid transparent key for this account under this seed: {:?}", + e + ); + None + } + }; + + let items: Vec<_> = iter::empty() + .chain( + orchard_fvk + .map(|fvk| fvk.to_bytes()) + .map(unified::Fvk::Orchard), + ) + .chain(Some(unified::Fvk::Sapling( + sapling_extfvk_bytes[41..].try_into().unwrap(), + ))) + .chain( + transparent_fvk + .map(|fvk| fvk.serialize()[..].try_into().unwrap()) + .map(unified::Fvk::P2pkh), + ) + .collect(); + let item_names: Vec<_> = items + .iter() + .map(|item| match item { + unified::Fvk::Orchard(_) => "Orchard", + unified::Fvk::Sapling(_) => "Sapling", + unified::Fvk::P2pkh(_) => "Transparent", + unified::Fvk::Unknown { .. } => unreachable!(), + }) + .collect(); + + eprintln!(" - Unified ({}):", item_names.join(", ")); + let ufvk = unified::Ufvk::try_from_items(items).unwrap(); + eprintln!(" - UFVK: {}", ufvk.encode(&addr_net)); + } + seed.zeroize(); + } else { + eprintln!("🔎 To show account details, add \"network\" (either \"main\" or \"test\") and \"accounts\" array to context"); + } + + eprintln!(); + eprintln!( + "WARNING: This mnemonic phrase is now likely cached in your terminal's history buffer." + ); +} diff --git a/devtools/src/bin/inspect/main.rs b/devtools/src/bin/inspect/main.rs new file mode 100644 index 000000000..158d6ba07 --- /dev/null +++ b/devtools/src/bin/inspect/main.rs @@ -0,0 +1,130 @@ +use std::env; +use std::io; +use std::io::Cursor; +use std::process; + +use gumdrop::{Options, ParsingStyle}; +use lazy_static::lazy_static; +use secrecy::Zeroize; +use zcash_address::ZcashAddress; +use zcash_primitives::zip339; +use zcash_primitives::{block::BlockHeader, consensus::BranchId, transaction::Transaction}; +use zcash_proofs::{default_params_folder, load_parameters, ZcashParameters}; + +mod context; +use context::{Context, ZUint256}; + +mod address; +mod block; +mod keys; +mod transaction; + +lazy_static! { + static ref GROTH16_PARAMS: ZcashParameters = { + let folder = default_params_folder().unwrap(); + load_parameters( + &folder.join("sapling-spend.params"), + &folder.join("sapling-output.params"), + Some(&folder.join("sprout-groth16.params")), + ) + }; + static ref ORCHARD_VK: orchard::circuit::VerifyingKey = orchard::circuit::VerifyingKey::build(); +} + +#[derive(Debug, Options)] +struct CliOptions { + #[options(help = "Print this help output")] + help: bool, + + #[options(free, required, help = "String or hex-encoded bytes to inspect")] + data: String, + + #[options( + free, + help = "JSON object with keys corresponding to requested context information" + )] + context: Option, +} + +fn main() { + let args = env::args().collect::>(); + let mut opts = + CliOptions::parse_args(&args[1..], ParsingStyle::default()).unwrap_or_else(|e| { + eprintln!("{}: {}", args[0], e); + process::exit(2); + }); + + if opts.help_requested() { + println!("Usage: {} data [context]", args[0]); + println!(); + println!("{}", CliOptions::usage()); + return; + } + + let lang = zip339::Language::English; + + if let Ok(mnemonic) = zip339::Mnemonic::from_phrase_in(lang, &opts.data) { + opts.data.zeroize(); + keys::inspect_mnemonic(mnemonic, lang, opts.context); + } else if let Ok(bytes) = hex::decode(&opts.data) { + inspect_bytes(bytes, opts.context); + } else if let Ok(addr) = ZcashAddress::try_from_encoded(&opts.data) { + address::inspect(addr); + } else { + // Unknown data format. + eprintln!("String does not match known Zcash data formats."); + process::exit(2); + } +} + +/// Ensures that the given reader completely consumes the given bytes. +fn complete(bytes: &[u8], f: F) -> Option +where + F: FnOnce(&mut Cursor<&[u8]>) -> io::Result, +{ + let mut cursor = Cursor::new(bytes); + let res = f(&mut cursor); + res.ok().and_then(|t| { + if cursor.position() >= bytes.len() as u64 { + Some(t) + } else { + None + } + }) +} + +fn inspect_bytes(bytes: Vec, context: Option) { + if let Some(block) = complete(&bytes, |r| block::Block::read(r)) { + block::inspect(&block, context); + } else if let Some(header) = complete(&bytes, |r| BlockHeader::read(r)) { + block::inspect_header(&header, context); + } else if let Some(tx) = complete(&bytes, |r| Transaction::read(r, BranchId::Nu5)) { + // TODO: Take the branch ID used above from the context if present. + // https://github.com/zcash/zcash/issues/6831 + transaction::inspect(tx, context); + } else { + // It's not a known variable-length format. check fixed-length data formats. + inspect_fixed_length_bytes(bytes); + } +} + +fn inspect_fixed_length_bytes(bytes: Vec) { + match bytes.len() { + 32 => { + eprintln!( + "This is most likely a hash of some sort, or maybe a commitment or nullifier." + ); + if bytes.iter().take(4).all(|c| c == &0) { + eprintln!("- It could be a mainnet block hash."); + } + } + 64 => { + // Could be a signature + eprintln!("This is most likely a signature."); + } + _ => { + eprintln!("Binary data does not match known Zcash data formats."); + process::exit(2); + } + } +} diff --git a/devtools/src/bin/inspect/transaction.rs b/devtools/src/bin/inspect/transaction.rs new file mode 100644 index 000000000..8e1fd9f53 --- /dev/null +++ b/devtools/src/bin/inspect/transaction.rs @@ -0,0 +1,548 @@ +use std::{ + collections::HashMap, + convert::{TryFrom, TryInto}, +}; + +use bellman::groth16; +use group::GroupEncoding; +use orchard::note_encryption::OrchardDomain; +use sapling::{note_encryption::SaplingDomain, SaplingVerificationContext}; +use secp256k1::{Secp256k1, VerifyOnly}; +use zcash_address::{ + unified::{self, Encoding}, + ToAddress, ZcashAddress, +}; +use zcash_note_encryption::try_output_recovery_with_ovk; +#[allow(deprecated)] +use zcash_primitives::{ + consensus::BlockHeight, + legacy::{keys::pubkey_to_address, Script, TransparentAddress}, + memo::{Memo, MemoBytes}, + transaction::{ + components::{amount::NonNegativeAmount, sapling as sapling_serialization, transparent}, + sighash::{signature_hash, SignableInput, TransparentAuthorizingContext}, + txid::TxIdDigester, + Authorization, Transaction, TransactionData, TxId, TxVersion, + }, +}; + +use crate::{ + context::{Context, ZTxOut}, + GROTH16_PARAMS, ORCHARD_VK, +}; + +pub fn is_coinbase(tx: &Transaction) -> bool { + tx.transparent_bundle() + .map(|b| b.is_coinbase()) + .unwrap_or(false) +} + +pub fn extract_height_from_coinbase(tx: &Transaction) -> Option { + const OP_0: u8 = 0x00; + const OP_1NEGATE: u8 = 0x4f; + const OP_1: u8 = 0x51; + const OP_16: u8 = 0x60; + + tx.transparent_bundle() + .and_then(|bundle| bundle.vin.first()) + .and_then(|input| match input.script_sig.0.first().copied() { + // {0, -1} will never occur as the first byte of a coinbase scriptSig. + Some(OP_0 | OP_1NEGATE) => None, + // Blocks 1 to 16. + Some(h @ OP_1..=OP_16) => Some(BlockHeight::from_u32((h - OP_1 + 1).into())), + // All other heights use CScriptNum encoding, which will never be longer + // than 5 bytes for Zcash heights. These have the format + // `[len(encoding)] || encoding`. + Some(h @ 1..=5) => { + let rest = &input.script_sig.0[1..]; + let encoding_len = h as usize; + if rest.len() < encoding_len { + None + } else { + // Parse the encoding. + let encoding = &rest[..encoding_len]; + if encoding.last().unwrap() & 0x80 != 0 { + // Height is never negative. + None + } else { + let mut height: u64 = 0; + for (i, b) in encoding.iter().enumerate() { + height |= (*b as u64) << (8 * i); + } + height.try_into().ok() + } + } + } + // Anything else is an invalid height encoding. + _ => None, + }) +} + +fn render_value(value: u64) -> String { + format!( + "{} zatoshis ({} ZEC)", + value, + (value as f64) / 1_0000_0000f64 + ) +} + +fn render_memo(memo_bytes: MemoBytes) -> String { + match Memo::try_from(memo_bytes) { + Ok(Memo::Empty) => "No memo".to_string(), + Ok(Memo::Text(memo)) => format!("Text memo: '{}'", String::from(memo)), + Ok(memo) => format!("{:?}", memo), + Err(e) => format!("Invalid memo: {}", e), + } +} + +#[derive(Clone, Debug)] +pub(crate) struct TransparentAuth { + all_prev_outputs: Vec, +} + +impl transparent::Authorization for TransparentAuth { + type ScriptSig = Script; +} + +impl TransparentAuthorizingContext for TransparentAuth { + fn input_amounts(&self) -> Vec { + self.all_prev_outputs + .iter() + .map(|prevout| prevout.value) + .collect() + } + + fn input_scriptpubkeys(&self) -> Vec