diff --git a/book/src/zcashd/json_rpc.md b/book/src/zcashd/json_rpc.md index 241b2c5d..0d611c51 100644 --- a/book/src/zcashd/json_rpc.md +++ b/book/src/zcashd/json_rpc.md @@ -43,6 +43,33 @@ Changes to response: listed in a new `derived_transparent` field (an array of objects) instead of the `transparent` field. +### `z_viewtransaction` + +Changes to response: +- Some top-level fields from `gettransaction` have been added: + - `status` + - `confirmations` + - `blockhash`, `blockindex`, `blocktime` + - `version` + - `expiryheight`, which is now always included (instead of only when a + transaction has been mined). + - `fee`, which is now included even if the transaction does not spend any + value from any account in the wallet, but can also be omitted if the + transparent inputs for a transaction cannot be found. + - `generated` +- Information about all transparent inputs and outputs (which are always visible + to the wallet) are now included. This causes the following semantic changes: + - `pool` field on both inputs and outputs can be `"transparent"`. + - New fields `tIn` and `tOutPrev` on inputs. + - New field `tOut` on outputs. + - `address` field on outputs: in `zcashd`, this was omitted only if the output + was received on an account-internal address; it is now also omitted if it is + a transparent output to a script that doesn't have an address encoding. Use + `walletInternal` if you need to identify change outputs. + - `memo` field on outputs is omitted if `pool = "transparent"`. + - `memoStr` field on outputs is no longer only omitted if `memo` does not + contain valid UTF-8. + ### `z_sendmany` Changes to parameters: diff --git a/zallet/src/components/json_rpc/methods.rs b/zallet/src/components/json_rpc/methods.rs index 6d4a9b80..48360d2e 100644 --- a/zallet/src/components/json_rpc/methods.rs +++ b/zallet/src/components/json_rpc/methods.rs @@ -495,7 +495,7 @@ impl RpcServer for RpcImpl { } async fn view_transaction(&self, txid: &str) -> view_transaction::Response { - view_transaction::call(self.wallet().await?.as_ref(), txid) + view_transaction::call(self.wallet().await?.as_ref(), self.chain().await?, txid).await } async fn list_unspent(&self) -> list_unspent::Response { diff --git a/zallet/src/components/json_rpc/methods/view_transaction.rs b/zallet/src/components/json_rpc/methods/view_transaction.rs index 8b95064b..aa65e45b 100644 --- a/zallet/src/components/json_rpc/methods/view_transaction.rs +++ b/zallet/src/components/json_rpc/methods/view_transaction.rs @@ -6,13 +6,24 @@ use orchard::note_encryption::OrchardDomain; use rusqlite::{OptionalExtension, named_params}; use schemars::JsonSchema; use serde::Serialize; +use transparent::keys::TransparentKeyScope; +use zaino_proto::proto::service::BlockId; +use zaino_state::{FetchServiceSubscriber, LightWalletIndexer, ZcashIndexer}; use zcash_address::{ ToAddress, ZcashAddress, unified::{self, Encoding}, }; use zcash_client_backend::data_api::WalletRead; +use zcash_client_sqlite::error::SqliteClientError; +use zcash_keys::encoding::AddressCodec; use zcash_note_encryption::{try_note_decryption, try_output_recovery_with_ovk}; -use zcash_protocol::{ShieldedProtocol, TxId, consensus::Parameters, memo::Memo, value::Zatoshis}; +use zcash_protocol::{ + ShieldedProtocol, TxId, + consensus::{BlockHeight, Parameters}, + memo::Memo, + value::{BalanceError, Zatoshis}, +}; +use zebra_rpc::methods::GetRawTransaction; use crate::components::{ database::DbConnection, @@ -22,28 +33,94 @@ use crate::components::{ }, }; +const POOL_TRANSPARENT: &str = "transparent"; +const POOL_SAPLING: &str = "sapling"; +const POOL_ORCHARD: &str = "orchard"; + +/// The number of blocks within expiry height when a tx is considered to be expiring soon. +const TX_EXPIRING_SOON_THRESHOLD: u32 = 3; + /// Response to a `z_viewtransaction` RPC request. pub(crate) type Response = RpcResult; pub(crate) type ResultType = Transaction; -/// Detailed shielded information about an in-wallet transaction. +/// Detailed information about an in-wallet transaction. #[derive(Clone, Debug, Serialize, Documented, JsonSchema)] pub(crate) struct Transaction { /// The transaction ID. txid: String, + /// The transaction status. + /// + /// One of 'mined', 'waiting', 'expiringsoon' or 'expired'. + status: &'static str, + + /// The number of confirmations. + /// + /// - A positive value is the number of blocks that have been mined including the + /// transaction in the chain. For example, 1 confirmation means the transaction is + /// in the block currently at the chain tip. + /// - 0 means the transaction is in the mempool. If `asOfHeight` was set, this case + /// will not occur. + /// - -1 means the transaction cannot be mined. + confirmations: i64, + + /// The hash of the main chain block that this transaction is mined in. + /// + /// Omitted if this transaction is not mined within a block in the current best chain. + #[serde(skip_serializing_if = "Option::is_none")] + blockhash: Option, + + /// The index of the transaction within its block's `vtx` field. + /// + /// Omitted if this transaction is not mined within a block in the current best chain. + #[serde(skip_serializing_if = "Option::is_none")] + blockindex: Option, + + /// The time in seconds since epoch (1 Jan 1970 GMT) that the main chain block + /// containing this transaction was mined. + /// + /// Omitted if this transaction is not mined within a block in the current best chain. + #[serde(skip_serializing_if = "Option::is_none")] + blocktime: Option, + + /// The transaction version. + version: u32, + + /// The greatest height at which this transaction can be mined, or 0 if this + /// transaction does not expire. + expiryheight: u64, + + /// The fee paid by the transaction. + /// + /// Omitted if the fee cannot be determined because one or more transparent inputs of + /// the transaction cannot be found. + #[serde(skip_serializing_if = "Option::is_none")] + fee: Option, + + /// Set to `true` if this is a coinbase transaction, omitted otherwise. + #[serde(skip_serializing_if = "Option::is_none")] + generated: Option, + + /// The inputs to the transaction that the wallet is capable of viewing. spends: Vec, + /// The outputs of the transaction that the wallet is capable of viewing. outputs: Vec, } #[derive(Clone, Debug, Serialize, JsonSchema)] struct Spend { - /// The shielded value pool. + /// The value pool. /// - /// One of `["sapling", "orchard"]`. + /// One of `["transparent", "sapling", "orchard"]`. pool: &'static str, + /// (transparent) the index of the spend within `vin`. + #[serde(rename = "tIn")] + #[serde(skip_serializing_if = "Option::is_none")] + t_in: Option, + /// (sapling) the index of the spend within `vShieldedSpend`. #[serde(skip_serializing_if = "Option::is_none")] spend: Option, @@ -56,12 +133,20 @@ struct Spend { #[serde(rename = "txidPrev")] txid_prev: String, - /// (sapling) the index of the output within the `vShieldedOutput`. + /// (transparent) the index of the corresponding output within the previous + /// transaction's `vout`. + #[serde(rename = "tOutPrev")] + #[serde(skip_serializing_if = "Option::is_none")] + t_out_prev: Option, + + /// (sapling) the index of the corresponding output within the previous transaction's + /// `vShieldedOutput`. #[serde(rename = "outputPrev")] #[serde(skip_serializing_if = "Option::is_none")] output_prev: Option, - /// (orchard) the index of the action within the orchard bundle. + /// (orchard) the index of the corresponding action within the previous transaction's + /// Orchard bundle. #[serde(rename = "actionPrev")] #[serde(skip_serializing_if = "Option::is_none")] action_prev: Option, @@ -83,23 +168,29 @@ struct Spend { #[derive(Clone, Debug, Serialize, JsonSchema)] struct Output { - /// The shielded value pool. + /// The value pool. /// - /// One of `["sapling", "orchard"]`. + /// One of `["transparent", "sapling", "orchard"]`. pool: &'static str, - /// (sapling) the index of the output within the vShieldedOutput\n" + /// (transparent) the index of the output within the `vout`. + #[serde(rename = "tOut")] + #[serde(skip_serializing_if = "Option::is_none")] + t_out: Option, + + /// (sapling) the index of the output within the `vShieldedOutput`. #[serde(skip_serializing_if = "Option::is_none")] output: Option, - /// (orchard) the index of the action within the orchard bundle\n" + /// (orchard) the index of the action within the orchard bundle. #[serde(skip_serializing_if = "Option::is_none")] action: Option, /// The Zcash address involved in the transaction. /// /// Omitted if this output was received on an account-internal address (e.g. change - /// outputs). + /// outputs), or is a transparent output to a script that is not either P2PKH or P2SH + /// (and thus doesn't have an address encoding). #[serde(skip_serializing_if = "Option::is_none")] address: Option, @@ -118,9 +209,14 @@ struct Output { value_zat: u64, /// Hexadecimal string representation of the memo field. - memo: String, + /// + /// Omitted if this is a transparent output. + #[serde(skip_serializing_if = "Option::is_none")] + memo: Option, /// UTF-8 string representation of memo field (if it contains valid UTF-8). + /// + /// Omitted if this is a transparent output. #[serde(rename = "memoStr")] #[serde(skip_serializing_if = "Option::is_none")] memo_str: Option, @@ -128,9 +224,20 @@ struct Output { pub(super) const PARAM_TXID_DESC: &str = "The ID of the transaction to view."; -pub(crate) fn call(wallet: &DbConnection, txid_str: &str) -> Response { +pub(crate) async fn call( + wallet: &DbConnection, + chain: FetchServiceSubscriber, + txid_str: &str, +) -> Response { let txid = parse_txid(txid_str)?; + // Fetch this early so we can detect if the wallet is not ready yet. + // TODO: Replace with Zaino `ChainIndex` so we can operate against a chain snapshot. + let chain_height = wallet + .chain_height() + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + .ok_or_else(|| LegacyCode::InWarmup.with_static("Wait for the wallet to start up"))?; + let tx = wallet .get_transaction(txid) .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? @@ -145,6 +252,8 @@ pub(crate) fn call(wallet: &DbConnection, txid_str: &str) -> Response { let mut spends = vec![]; let mut outputs = vec![]; + let mut transparent_input_values = BTreeMap::new(); + // Collect viewing keys for recovering output information. // - OVKs are used cross-protocol and thus are collected as byte arrays. let mut sapling_ivks = vec![]; @@ -196,7 +305,7 @@ pub(crate) fn call(wallet: &DbConnection, txid_str: &str) -> Response { "SELECT txid, {output_prefix}_index, address, value FROM {pool_prefix}_received_notes JOIN transactions ON tx = id_tx - JOIN addresses ON address_id = addresses.id + LEFT OUTER JOIN addresses ON address_id = addresses.id WHERE nf = :nf" ), named_params! { @@ -205,7 +314,7 @@ pub(crate) fn call(wallet: &DbConnection, txid_str: &str) -> Response { |row| { Ok(( TxId::from_bytes(row.get("txid")?), - row.get("output_index")?, + row.get(format!("{output_prefix}_index").as_str())?, row.get("address")?, Zatoshis::const_from_u64(row.get("value")?), )) @@ -257,6 +366,89 @@ pub(crate) fn call(wallet: &DbConnection, txid_str: &str) -> Response { .unwrap_or_else(fallback_addr)) } + if let Some(bundle) = tx.transparent_bundle() { + // Transparent inputs + for (input, idx) in bundle.vin.iter().zip(0u16..) { + let txid_prev = input.prevout().txid().to_string(); + + // TODO: Migrate to a hopefully much nicer Rust API once we migrate to the new Zaino ChainIndex trait. + let (address, value) = match chain.get_raw_transaction(txid_prev.clone(), Some(1)).await + { + Ok(GetRawTransaction::Object(tx)) => { + let output = tx + .outputs() + .get(usize::from(idx)) + .expect("Zaino should have rejected this earlier"); + ( + transparent::address::Script::from(output.script_pub_key().hex()) + .address() + .map(|addr| addr.encode(wallet.params())), + Zatoshis::from_nonnegative_i64(output.value_zat()) + .expect("Zaino should have rejected this earlier"), + ) + } + Ok(_) => unreachable!(), + Err(_) => todo!(), + }; + + transparent_input_values.insert(input.prevout(), value); + + spends.push(Spend { + pool: POOL_TRANSPARENT, + t_in: Some(idx), + spend: None, + action: None, + txid_prev, + t_out_prev: Some(input.prevout().n()), + output_prev: None, + action_prev: None, + address, + value: value_from_zatoshis(value), + value_zat: value.into_u64(), + }); + } + + // Transparent outputs + for (output, idx) in bundle.vout.iter().zip(0..) { + let (address, outgoing, wallet_internal) = match output.recipient_address() { + None => (None, true, false), + Some(address) => { + let wallet_scope = + wallet + .get_account_ids() + .unwrap() + .into_iter() + .find_map(|account| { + match wallet.get_transparent_address_metadata(account, &address) { + Ok(Some(metadata)) => Some(metadata.scope()), + _ => None, + } + }); + + ( + Some(address.encode(wallet.params())), + wallet_scope.is_none(), + matches!(wallet_scope, Some(TransparentKeyScope::INTERNAL)), + ) + } + }; + + outputs.push(Output { + pool: POOL_TRANSPARENT, + t_out: Some(idx), + output: None, + action: None, + address, + outgoing, + wallet_internal, + value: value_from_zatoshis(output.value()), + value_zat: output.value().into_u64(), + memo: None, + memo_str: None, + }); + } + } + if let Some(bundle) = tx.sapling_bundle() { let incoming: BTreeMap, [u8; 512])> = bundle @@ -309,10 +501,12 @@ pub(crate) fn call(wallet: &DbConnection, txid_str: &str) -> Response { if let Some((txid_prev, output_prev, address, value)) = spent_note { spends.push(Spend { - pool: "sapling", + pool: POOL_SAPLING, + t_in: None, spend: Some(idx), action: None, txid_prev: txid_prev.to_string(), + t_out_prev: None, output_prev: Some(output_prev), action_prev: None, address, @@ -347,10 +541,11 @@ pub(crate) fn call(wallet: &DbConnection, txid_str: &str) -> Response { Ok(Memo::Text(text_memo)) => Some(text_memo.into()), _ => None, }; - let memo = hex::encode(memo); + let memo = Some(hex::encode(memo)); outputs.push(Output { - pool: "sapling", + pool: POOL_SAPLING, + t_out: None, output: Some(idx), action: None, address, @@ -416,10 +611,12 @@ pub(crate) fn call(wallet: &DbConnection, txid_str: &str) -> Response { if let Some((txid_prev, action_prev, address, value)) = spent_note { spends.push(Spend { - pool: "orchard", + pool: POOL_ORCHARD, + t_in: None, spend: None, action: Some(idx), txid_prev: txid_prev.to_string(), + t_out_prev: None, output_prev: None, action_prev: Some(action_prev), address, @@ -454,10 +651,11 @@ pub(crate) fn call(wallet: &DbConnection, txid_str: &str) -> Response { Ok(Memo::Text(text_memo)) => Some(text_memo.into()), _ => None, }; - let memo = hex::encode(memo); + let memo = Some(hex::encode(memo)); outputs.push(Output { - pool: "orchard", + pool: POOL_ORCHARD, + t_out: None, output: None, action: Some(idx), address, @@ -472,9 +670,141 @@ pub(crate) fn call(wallet: &DbConnection, txid_str: &str) -> Response { } } + let wallet_tx_info = WalletTxInfo::fetch(wallet, &chain, &tx, chain_height) + .await + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))?; + + let fee = tx + .fee_paid(|prevout| Ok::<_, BalanceError>(transparent_input_values.get(prevout).copied())) + // This should never occur, as a transaction that violated balance would be + // rejected by the backing full node. + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))?; + Ok(Transaction { - txid: txid_str.into(), + txid: txid_str.to_ascii_lowercase(), + status: wallet_tx_info.status, + confirmations: wallet_tx_info.confirmations, + blockhash: wallet_tx_info.blockhash, + blockindex: wallet_tx_info.blockindex, + blocktime: wallet_tx_info.blocktime, + version: tx.version().header() & 0x7FFFFFFF, + expiryheight: wallet_tx_info.expiryheight, + fee: fee.map(value_from_zatoshis), + generated: wallet_tx_info.generated, spends, outputs, }) } + +struct WalletTxInfo { + status: &'static str, + confirmations: i64, + generated: Option, + blockhash: Option, + blockindex: Option, + blocktime: Option, + expiryheight: u64, +} + +impl WalletTxInfo { + /// Logic adapted from `WalletTxToJSON` in `zcashd`, to match the semantics of the `gettransaction` fields. + async fn fetch( + wallet: &DbConnection, + chain: &FetchServiceSubscriber, + tx: &zcash_primitives::transaction::Transaction, + chain_height: BlockHeight, + ) -> Result { + let mined_height = wallet.get_tx_height(tx.txid())?; + + let confirmations = { + match mined_height { + Some(mined_height) => i64::from(chain_height + 1 - mined_height), + None => { + // TODO: Also check if the transaction is in the mempool for this branch. + -1 + } + } + }; + + let generated = if tx + .transparent_bundle() + .is_some_and(|bundle| bundle.is_coinbase()) + { + Some(true) + } else { + None + }; + + let mut status = "waiting"; + + let (blockhash, blockindex, blocktime) = if let Some(height) = mined_height { + status = "mined"; + + // TODO: Once Zaino updates its API to support atomic queries, it should not + // be possible to fail to fetch the block that a transaction was observed + // mined in. + let block_metadata = wallet + .block_metadata(height)? + // This would be a race condition between this and a reorg. + .ok_or(SqliteClientError::ChainHeightUnknown)?; + let block = chain + .get_block(BlockId { + height: 0, + hash: block_metadata.block_hash().0.to_vec(), + }) + .await + .map_err(|_| SqliteClientError::ChainHeightUnknown)?; + + let tx_index = block + .vtx + .iter() + .find(|ctx| ctx.hash == tx.txid().as_ref()) + .map(|ctx| u32::try_from(ctx.index).expect("Zaino should provide valid data")); + + ( + Some(block_metadata.block_hash().to_string()), + tx_index, + Some(block.time.into()), + ) + } else { + match ( + is_expired_tx(tx, chain_height), + is_expiring_soon_tx(tx, chain_height + 1), + ) { + (false, true) => status = "expiringsoon", + (true, _) => status = "expired", + _ => (), + } + (None, None, None) + }; + + Ok(Self { + status, + confirmations, + generated, + blockhash, + blockindex, + blocktime, + expiryheight: tx.expiry_height().into(), + }) + } +} + +fn is_expired_tx(tx: &zcash_primitives::transaction::Transaction, height: BlockHeight) -> bool { + if tx.expiry_height() == 0.into() + || tx + .transparent_bundle() + .is_some_and(|bundle| bundle.is_coinbase()) + { + false + } else { + height > tx.expiry_height() + } +} + +fn is_expiring_soon_tx( + tx: &zcash_primitives::transaction::Transaction, + next_height: BlockHeight, +) -> bool { + is_expired_tx(tx, next_height + TX_EXPIRING_SOON_THRESHOLD) +}