From ef67dcbea3c3dad270e4db3a4d32a4d068797130 Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Tue, 26 Aug 2025 18:33:01 +0000 Subject: [PATCH 1/7] rpc: Fix bugs and add more docs to `z_viewtransaction` --- .../components/json_rpc/methods/view_transaction.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/zallet/src/components/json_rpc/methods/view_transaction.rs b/zallet/src/components/json_rpc/methods/view_transaction.rs index 8b95064b..06744087 100644 --- a/zallet/src/components/json_rpc/methods/view_transaction.rs +++ b/zallet/src/components/json_rpc/methods/view_transaction.rs @@ -32,8 +32,10 @@ pub(crate) struct Transaction { /// The transaction ID. txid: String, + /// 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, } @@ -56,12 +58,14 @@ struct Spend { #[serde(rename = "txidPrev")] txid_prev: String, - /// (sapling) the index of the output within the `vShieldedOutput`. + /// (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, @@ -88,11 +92,11 @@ struct Output { /// One of `["sapling", "orchard"]`. pool: &'static str, - /// (sapling) the index of the output within the vShieldedOutput\n" + /// (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, From df2f8c89e8ce95d44b765c97857af7aaa851aa3a Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Tue, 26 Aug 2025 18:39:25 +0000 Subject: [PATCH 2/7] rpc: Cleanups of `z_viewtransaction` implementation --- .../components/json_rpc/methods/view_transaction.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/zallet/src/components/json_rpc/methods/view_transaction.rs b/zallet/src/components/json_rpc/methods/view_transaction.rs index 06744087..14c0378a 100644 --- a/zallet/src/components/json_rpc/methods/view_transaction.rs +++ b/zallet/src/components/json_rpc/methods/view_transaction.rs @@ -22,6 +22,9 @@ use crate::components::{ }, }; +const POOL_SAPLING: &str = "sapling"; +const POOL_ORCHARD: &str = "orchard"; + /// Response to a `z_viewtransaction` RPC request. pub(crate) type Response = RpcResult; pub(crate) type ResultType = Transaction; @@ -313,7 +316,7 @@ 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, spend: Some(idx), action: None, txid_prev: txid_prev.to_string(), @@ -354,7 +357,7 @@ pub(crate) fn call(wallet: &DbConnection, txid_str: &str) -> Response { let memo = hex::encode(memo); outputs.push(Output { - pool: "sapling", + pool: POOL_SAPLING, output: Some(idx), action: None, address, @@ -420,7 +423,7 @@ 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, spend: None, action: Some(idx), txid_prev: txid_prev.to_string(), @@ -461,7 +464,7 @@ pub(crate) fn call(wallet: &DbConnection, txid_str: &str) -> Response { let memo = hex::encode(memo); outputs.push(Output { - pool: "orchard", + pool: POOL_ORCHARD, output: None, action: Some(idx), address, From de70e46e37f903de4e182c5a823551b90a5bf80b Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Tue, 26 Aug 2025 23:38:26 +0000 Subject: [PATCH 3/7] rpc: Add transparent information to `z_viewtransaction` --- book/src/zcashd/json_rpc.md | 14 ++ zallet/src/components/json_rpc/methods.rs | 2 +- .../json_rpc/methods/view_transaction.rs | 144 ++++++++++++++++-- 3 files changed, 149 insertions(+), 11 deletions(-) diff --git a/book/src/zcashd/json_rpc.md b/book/src/zcashd/json_rpc.md index 241b2c5d..e84a0ef7 100644 --- a/book/src/zcashd/json_rpc.md +++ b/book/src/zcashd/json_rpc.md @@ -43,6 +43,20 @@ Changes to response: listed in a new `derived_transparent` field (an array of objects) instead of the `transparent` field. +### `z_viewtransaction` + +Changes to response: +- 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 is no longer only omitted if the output was + received on an account-internal address; use `walletInternal` for this. + - `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 14c0378a..ae46f3eb 100644 --- a/zallet/src/components/json_rpc/methods/view_transaction.rs +++ b/zallet/src/components/json_rpc/methods/view_transaction.rs @@ -6,13 +6,17 @@ use orchard::note_encryption::OrchardDomain; use rusqlite::{OptionalExtension, named_params}; use schemars::JsonSchema; use serde::Serialize; +use transparent::keys::TransparentKeyScope; +use zaino_state::{FetchServiceSubscriber, ZcashIndexer}; use zcash_address::{ ToAddress, ZcashAddress, unified::{self, Encoding}, }; use zcash_client_backend::data_api::WalletRead; +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 zebra_rpc::methods::GetRawTransaction; use crate::components::{ database::DbConnection, @@ -22,6 +26,7 @@ use crate::components::{ }, }; +const POOL_TRANSPARENT: &str = "transparent"; const POOL_SAPLING: &str = "sapling"; const POOL_ORCHARD: &str = "orchard"; @@ -29,7 +34,7 @@ const POOL_ORCHARD: &str = "orchard"; 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. @@ -44,11 +49,16 @@ pub(crate) struct Transaction { #[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, @@ -61,6 +71,12 @@ struct Spend { #[serde(rename = "txidPrev")] txid_prev: String, + /// (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")] @@ -90,11 +106,16 @@ 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, + /// (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, @@ -106,7 +127,8 @@ struct Output { /// 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, @@ -125,9 +147,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, @@ -135,7 +162,11 @@ 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)?; let tx = wallet @@ -264,6 +295,93 @@ 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!(), + }; + + spends.push(Spend { + pool: POOL_TRANSPARENT, + t_in: Some(idx), + spend: None, + action: None, + txid_prev, + t_out_prev: Some( + input + .prevout() + .n() + .try_into() + .expect("should always be small enough"), + ), + 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 @@ -317,9 +435,11 @@ 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: 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, @@ -354,10 +474,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: POOL_SAPLING, + t_out: None, output: Some(idx), action: None, address, @@ -424,9 +545,11 @@ 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: 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, @@ -461,10 +584,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: POOL_ORCHARD, + t_out: None, output: None, action: Some(idx), address, From 6a09d24cf758d052e607498962fc1386fcd58679 Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Tue, 2 Sep 2025 19:07:19 +0000 Subject: [PATCH 4/7] rpc: Add top-level `gettransaction` fields to `z_viewtransaction` Most of the semantics are preserved, but a few are adjusted to make more sense. --- book/src/zcashd/json_rpc.md | 11 + .../json_rpc/methods/view_transaction.rs | 211 +++++++++++++++++- 2 files changed, 219 insertions(+), 3 deletions(-) diff --git a/book/src/zcashd/json_rpc.md b/book/src/zcashd/json_rpc.md index e84a0ef7..02ce8296 100644 --- a/book/src/zcashd/json_rpc.md +++ b/book/src/zcashd/json_rpc.md @@ -46,6 +46,17 @@ Changes to response: ### `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"`. diff --git a/zallet/src/components/json_rpc/methods/view_transaction.rs b/zallet/src/components/json_rpc/methods/view_transaction.rs index ae46f3eb..142f204a 100644 --- a/zallet/src/components/json_rpc/methods/view_transaction.rs +++ b/zallet/src/components/json_rpc/methods/view_transaction.rs @@ -7,15 +7,22 @@ use rusqlite::{OptionalExtension, named_params}; use schemars::JsonSchema; use serde::Serialize; use transparent::keys::TransparentKeyScope; -use zaino_state::{FetchServiceSubscriber, ZcashIndexer}; +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::{ @@ -30,6 +37,9 @@ 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; @@ -40,6 +50,58 @@ 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: i32, + + /// 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, @@ -169,6 +231,13 @@ pub(crate) async fn call( ) -> 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()))? @@ -183,6 +252,8 @@ pub(crate) async fn call( 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![]; @@ -320,6 +391,8 @@ pub(crate) async fn call( Err(_) => todo!(), }; + transparent_input_values.insert(input.prevout(), value); + spends.push(Spend { pool: POOL_TRANSPARENT, t_in: Some(idx), @@ -603,9 +676,141 @@ pub(crate) async fn call( } } + 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: i32, + 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) => (chain_height + 1 - mined_height) as i32, + 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(|tx| u16::try_from(tx.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) +} From c650eb98a6c4ea368cef3f55ea3963541bb9a729 Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Wed, 3 Sep 2025 16:35:21 +0000 Subject: [PATCH 5/7] rpc: Use larger numeric sizes in `z_viewtransaction` outputs This makes the conversions from their (current) source data infallible, and doesn't actually matter in practice because these fields are only used for serializing to JSON numbers (which are arbitrary size). --- .../json_rpc/methods/view_transaction.rs | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/zallet/src/components/json_rpc/methods/view_transaction.rs b/zallet/src/components/json_rpc/methods/view_transaction.rs index 142f204a..fd0a2dc6 100644 --- a/zallet/src/components/json_rpc/methods/view_transaction.rs +++ b/zallet/src/components/json_rpc/methods/view_transaction.rs @@ -63,7 +63,7 @@ pub(crate) struct Transaction { /// - 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: i32, + confirmations: i64, /// The hash of the main chain block that this transaction is mined in. /// @@ -75,7 +75,7 @@ pub(crate) struct Transaction { /// /// Omitted if this transaction is not mined within a block in the current best chain. #[serde(skip_serializing_if = "Option::is_none")] - blockindex: Option, + blockindex: Option, /// The time in seconds since epoch (1 Jan 1970 GMT) that the main chain block /// containing this transaction was mined. @@ -137,7 +137,7 @@ struct Spend { /// transaction's `vout`. #[serde(rename = "tOutPrev")] #[serde(skip_serializing_if = "Option::is_none")] - t_out_prev: Option, + t_out_prev: Option, /// (sapling) the index of the corresponding output within the previous transaction's /// `vShieldedOutput`. @@ -399,13 +399,7 @@ pub(crate) async fn call( spend: None, action: None, txid_prev, - t_out_prev: Some( - input - .prevout() - .n() - .try_into() - .expect("should always be small enough"), - ), + t_out_prev: Some(input.prevout().n()), output_prev: None, action_prev: None, address, @@ -704,10 +698,10 @@ pub(crate) async fn call( struct WalletTxInfo { status: &'static str, - confirmations: i32, + confirmations: i64, generated: Option, blockhash: Option, - blockindex: Option, + blockindex: Option, blocktime: Option, expiryheight: u64, } @@ -724,7 +718,7 @@ impl WalletTxInfo { let confirmations = { match mined_height { - Some(mined_height) => (chain_height + 1 - mined_height) as i32, + 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 @@ -765,7 +759,7 @@ impl WalletTxInfo { .vtx .iter() .find(|ctx| ctx.hash == tx.txid().as_ref()) - .map(|tx| u16::try_from(tx.index).expect("Zaino should provide valid data")); + .map(|ctx| u32::try_from(ctx.index).expect("Zaino should provide valid data")); ( Some(block_metadata.block_hash().to_string()), From f6cf9b1eb0450754602881bc5c9518fcf95651f9 Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Wed, 3 Sep 2025 17:35:55 +0000 Subject: [PATCH 6/7] book: Clarify change to `z_viewtransaction` response --- book/src/zcashd/json_rpc.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/book/src/zcashd/json_rpc.md b/book/src/zcashd/json_rpc.md index 02ce8296..0d611c51 100644 --- a/book/src/zcashd/json_rpc.md +++ b/book/src/zcashd/json_rpc.md @@ -62,8 +62,10 @@ Changes to response: - `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 is no longer only omitted if the output was - received on an account-internal address; use `walletInternal` for this. + - `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. From 927ce8d9b1317147142668d26d598499fed1453b Mon Sep 17 00:00:00 2001 From: Kris Nuttycombe Date: Wed, 3 Sep 2025 11:37:09 -0600 Subject: [PATCH 7/7] rpc (z_viewtransaction): Ensure that spent wallet-internal outputs appear in spends vector. --- zallet/src/components/json_rpc/methods/view_transaction.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zallet/src/components/json_rpc/methods/view_transaction.rs b/zallet/src/components/json_rpc/methods/view_transaction.rs index fd0a2dc6..aa65e45b 100644 --- a/zallet/src/components/json_rpc/methods/view_transaction.rs +++ b/zallet/src/components/json_rpc/methods/view_transaction.rs @@ -305,7 +305,7 @@ pub(crate) async fn call( "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! { @@ -314,7 +314,7 @@ pub(crate) async fn call( |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")?), ))