diff --git a/zallet/src/components/json_rpc.rs b/zallet/src/components/json_rpc.rs index 19ce3077..fb390192 100644 --- a/zallet/src/components/json_rpc.rs +++ b/zallet/src/components/json_rpc.rs @@ -17,6 +17,7 @@ use crate::{ use super::{TaskHandle, chain_view::ChainView, database::Database, keystore::KeyStore}; mod asyncop; +mod balance; pub(crate) mod methods; mod payments; pub(crate) mod server; diff --git a/zallet/src/components/json_rpc/balance.rs b/zallet/src/components/json_rpc/balance.rs new file mode 100644 index 00000000..3c454f7a --- /dev/null +++ b/zallet/src/components/json_rpc/balance.rs @@ -0,0 +1,298 @@ +use rusqlite::named_params; +use transparent::bundle::TxOut; +use zaino_state::{FetchServiceSubscriber, MempoolKey}; +use zcash_client_backend::data_api::WalletRead; +use zcash_client_sqlite::error::SqliteClientError; +use zcash_keys::encoding::AddressCodec; +use zcash_primitives::transaction::Transaction; +use zcash_protocol::{consensus::BlockHeight, value::Zatoshis}; + +use crate::components::database::DbConnection; + +/// Coinbase transaction outputs can only be spent after this number of new blocks +/// (consensus rule). +const COINBASE_MATURITY: u32 = 100; + +enum IsMine { + Spendable, + WatchOnly, + Either, +} + +/// Returns `true` if this output is owned by some account in the wallet and can be spent. +pub(super) fn is_mine_spendable( + wallet: &DbConnection, + tx_out: &TxOut, +) -> Result { + is_mine(wallet, tx_out, IsMine::Spendable) +} + +/// Returns `true` if this output is owned by some account in the wallet, but cannot be +/// spent (e.g. because we don't have the spending key, or do not know how to spend it). +#[allow(dead_code)] +pub(super) fn is_mine_watchonly( + wallet: &DbConnection, + tx_out: &TxOut, +) -> Result { + is_mine(wallet, tx_out, IsMine::WatchOnly) +} + +/// Returns `true` if this output is owned by some account in the wallet. +pub(super) fn is_mine_spendable_or_watchonly( + wallet: &DbConnection, + tx_out: &TxOut, +) -> Result { + is_mine(wallet, tx_out, IsMine::Either) +} + +/// Logically equivalent to [`IsMine(CTxDestination)`] in `zcashd`. +/// +/// A transaction is only considered "mine" by virtue of having a P2SH multisig +/// output if we own *all* of the keys involved. Multi-signature transactions that +/// are partially owned (somebody else has a key that can spend them) enable +/// spend-out-from-under-you attacks, especially in shared-wallet situations. +/// Non-P2SH ("bare") multisig outputs never make a transaction "mine". +/// +/// [`IsMine(CTxDestination)`]: https://github.com/zcash/zcash/blob/2352fbc1ed650ac4369006bea11f7f20ee046b84/src/script/ismine.cpp#L121 +fn is_mine( + wallet: &DbConnection, + tx_out: &TxOut, + include: IsMine, +) -> Result { + match tx_out.recipient_address() { + Some(address) => wallet.with_raw(|conn| { + let mut stmt_addr_mine = conn.prepare( + "SELECT EXISTS( + SELECT 1 + FROM addresses + JOIN accounts ON account_id = accounts.id + WHERE cached_transparent_receiver_address = :address + AND ( + :allow_either = 1 + OR accounts.has_spend_key = :has_spend_key + ) + )", + )?; + + Ok(stmt_addr_mine.query_row( + named_params! { + ":address": address.encode(wallet.params()), + ":allow_either": matches!(include, IsMine::Either), + ":has_spend_key": matches!(include, IsMine::Spendable), + }, + |row| row.get(0), + )?) + }), + // TODO: Use `zcash_script` to discover other ways the output might belong to + // the wallet (like `IsMine(CScript)` does in `zcashd`). + None => Ok(false), + } +} + +/// Equivalent to [`CTransaction::GetValueOut`] in `zcashd`. +/// +/// [`CTransaction::GetValueOut`]: https://github.com/zcash/zcash/blob/2352fbc1ed650ac4369006bea11f7f20ee046b84/src/primitives/transaction.cpp#L214 +pub(super) fn wtx_get_value_out(tx: &Transaction) -> Option { + std::iter::empty() + .chain( + tx.transparent_bundle() + .into_iter() + .flat_map(|bundle| bundle.vout.iter().map(|txout| txout.value)), + ) + // Note: negative valueBalanceSapling "takes" money from the transparent value pool just as outputs do + .chain((-tx.sapling_value_balance()).try_into().ok()) + // Note: negative valueBalanceOrchard "takes" money from the transparent value pool just as outputs do + .chain( + tx.orchard_bundle() + .and_then(|b| (-*b.value_balance()).try_into().ok()), + ) + .chain(tx.sprout_bundle().into_iter().flat_map(|b| { + b.joinsplits + .iter() + // Consensus rule: either `vpub_old` or `vpub_new` MUST be zero. + // Therefore if `JsDescription::net_value() <= 0`, it is equal to + // `-vpub_old`. + .flat_map(|jsdesc| (-jsdesc.net_value()).try_into().ok()) + })) + .sum() +} + +/// Equivalent to [`CWalletTx::GetDebit`] in `zcashd`. +/// +/// [`CWalletTx::GetDebit`]: https://github.com/zcash/zcash/blob/2352fbc1ed650ac4369006bea11f7f20ee046b84/src/wallet/wallet.cpp#L4822 +pub(super) fn wtx_get_debit( + wallet: &DbConnection, + tx: &Transaction, + is_mine: impl Fn(&DbConnection, &TxOut) -> Result, +) -> Result, SqliteClientError> { + match tx.transparent_bundle() { + None => Ok(Some(Zatoshis::ZERO)), + Some(bundle) if bundle.vin.is_empty() => Ok(Some(Zatoshis::ZERO)), + // Equivalent to `CWallet::GetDebit(CTransaction)` in `zcashd`. + Some(bundle) => { + let mut acc = Some(Zatoshis::ZERO); + for txin in &bundle.vin { + // Equivalent to `CWallet::GetDebit(CTxIn)` in `zcashd`. + if let Some(txout) = wallet + .get_transaction(*txin.prevout.txid())? + .as_ref() + .and_then(|prev_tx| prev_tx.transparent_bundle()) + .and_then(|bundle| bundle.vout.get(txin.prevout.n() as usize)) + { + if is_mine(wallet, txout)? { + acc = acc + txout.value; + } + } + } + Ok(acc) + } + } +} + +/// Equivalent to [`CWalletTx::GetCredit`] in `zcashd`. +/// +/// [`CWalletTx::GetCredit`]: https://github.com/zcash/zcash/blob/2352fbc1ed650ac4369006bea11f7f20ee046b84/src/wallet/wallet.cpp#L4853 +pub(super) async fn wtx_get_credit( + wallet: &DbConnection, + chain: &FetchServiceSubscriber, + tx: &Transaction, + as_of_height: Option, + is_mine: impl Fn(&DbConnection, &TxOut) -> Result, +) -> Result, SqliteClientError> { + match tx.transparent_bundle() { + None => Ok(Some(Zatoshis::ZERO)), + // Must wait until coinbase is safely deep enough in the chain before valuing it. + Some(bundle) + if bundle.is_coinbase() + && wtx_get_blocks_to_maturity(wallet, chain, tx, as_of_height).await? > 0 => + { + Ok(Some(Zatoshis::ZERO)) + } + // Equivalent to `CWallet::GetCredit(CTransaction)` in `zcashd`. + Some(bundle) => { + let mut acc = Some(Zatoshis::ZERO); + for txout in &bundle.vout { + // Equivalent to `CWallet::GetCredit(CTxOut)` in `zcashd`. + if is_mine(wallet, txout)? { + acc = acc + txout.value; + } + } + Ok(acc) + } + } +} + +/// Equivalent to [`CWalletTx::IsFromMe`] in `zcashd`. +/// +/// [`CWalletTx::IsFromMe`]: https://github.com/zcash/zcash/blob/2352fbc1ed650ac4369006bea11f7f20ee046b84/src/wallet/wallet.cpp#L4967 +pub(super) fn wtx_is_from_me( + wallet: &DbConnection, + tx: &Transaction, + is_mine: impl Fn(&DbConnection, &TxOut) -> Result, +) -> Result { + if wtx_get_debit(wallet, tx, is_mine)?.ok_or_else(|| { + SqliteClientError::BalanceError(zcash_protocol::value::BalanceError::Overflow) + })? > Zatoshis::ZERO + { + return Ok(true); + } + + wallet.with_raw(|conn| { + if let Some(bundle) = tx.sapling_bundle() { + let mut stmt_note_exists = conn.prepare( + "SELECT EXISTS( + SELECT 1 + FROM sapling_received_notes + WHERE nf = :nf + )", + )?; + + for spend in bundle.shielded_spends() { + if stmt_note_exists + .query_row(named_params! {":nf": spend.nullifier().0}, |row| row.get(0))? + { + return Ok(true); + } + } + } + + if let Some(bundle) = tx.orchard_bundle() { + let mut stmt_note_exists = conn.prepare( + "SELECT EXISTS( + SELECT 1 + FROM orchard_received_notes + WHERE nf = :nf + )", + )?; + + for action in bundle.actions() { + if stmt_note_exists.query_row( + named_params! {":nf": action.nullifier().to_bytes()}, + |row| row.get(0), + )? { + return Ok(true); + } + } + } + + Ok(false) + }) +} + +/// Equivalent to [`CMerkleTx::GetBlocksToMaturity`] in `zcashd`. +/// +/// [`CMerkleTx::GetBlocksToMaturity`]: https://github.com/zcash/zcash/blob/2352fbc1ed650ac4369006bea11f7f20ee046b84/src/wallet/wallet.cpp#L6915 +async fn wtx_get_blocks_to_maturity( + wallet: &DbConnection, + chain: &FetchServiceSubscriber, + tx: &Transaction, + as_of_height: Option, +) -> Result { + Ok( + if tx.transparent_bundle().map_or(false, |b| b.is_coinbase()) { + if let Some(depth) = + wtx_get_depth_in_main_chain(wallet, chain, tx, as_of_height).await? + { + (COINBASE_MATURITY + 1).saturating_sub(depth) + } else { + // TODO: Confirm this is what `zcashd` computes for an orphaned coinbase. + COINBASE_MATURITY + 2 + } + } else { + 0 + }, + ) +} + +/// Returns depth of transaction in blockchain. +/// +/// - `None` : not in blockchain, and not in memory pool (conflicted transaction) +/// - `Some(0)` : in memory pool, waiting to be included in a block (never returned if `as_of_height` is set) +/// - `Some(1..)` : this many blocks deep in the main chain +async fn wtx_get_depth_in_main_chain( + wallet: &DbConnection, + chain: &FetchServiceSubscriber, + tx: &Transaction, + as_of_height: Option, +) -> Result, SqliteClientError> { + let chain_height = wallet + .chain_height()? + .ok_or_else(|| SqliteClientError::ChainHeightUnknown)?; + + let effective_chain_height = chain_height.min(as_of_height.unwrap_or(chain_height)); + + let depth = if let Some(mined_height) = wallet.get_tx_height(tx.txid())? { + Some(effective_chain_height + 1 - mined_height) + } else if as_of_height.is_none() + && chain + .mempool + .contains_txid(&MempoolKey(tx.txid().to_string())) + .await + { + Some(0) + } else { + None + }; + + Ok(depth) +} diff --git a/zallet/src/components/json_rpc/methods.rs b/zallet/src/components/json_rpc/methods.rs index 33b8b721..a945e56a 100644 --- a/zallet/src/components/json_rpc/methods.rs +++ b/zallet/src/components/json_rpc/methods.rs @@ -19,6 +19,7 @@ mod get_address_for_account; mod get_new_account; mod get_notes_count; mod get_operation; +mod get_transaction; mod get_wallet_info; mod help; mod list_accounts; @@ -229,6 +230,37 @@ pub(crate) trait Rpc { #[method(name = "z_listunifiedreceivers")] fn list_unified_receivers(&self, unified_address: &str) -> list_unified_receivers::Response; + /// Returns detailed information about in-wallet transaction `txid`. + /// + /// This does not include complete information about shielded components of the + /// transaction; to obtain details about shielded components of the transaction use + /// `z_viewtransaction`. + /// + /// # Parameters + /// + /// - `includeWatchonly` (bool, optional, default=false): Whether to include watchonly + /// addresses in balance calculation and `details`. + /// - `verbose`: Must be `false` or omitted. + /// - `asOfHeight` (numeric, optional, default=-1): Execute the query as if it were + /// run when the blockchain was at the height specified by this argument. The + /// default is to use the entire blockchain that the node is aware of. -1 can be + /// used as in other RPC calls to indicate the current height (including the + /// mempool), but this does not support negative values in general. A "future" + /// height will fall back to the current height. Any explicit value will cause the + /// mempool to be ignored, meaning no unconfirmed tx will be considered. + /// + /// # Bitcoin compatibility + /// + /// Compatible up to three arguments, but can only use the default value for `verbose`. + #[method(name = "gettransaction")] + async fn get_transaction( + &self, + txid: &str, + include_watchonly: Option, + verbose: Option, + as_of_height: Option, + ) -> get_transaction::Response; + /// Returns detailed shielded information about in-wallet transaction `txid`. #[method(name = "z_viewtransaction")] async fn view_transaction(&self, txid: &str) -> view_transaction::Response; @@ -468,6 +500,24 @@ impl RpcServer for RpcImpl { list_unified_receivers::call(unified_address) } + async fn get_transaction( + &self, + txid: &str, + include_watchonly: Option, + verbose: Option, + as_of_height: Option, + ) -> get_transaction::Response { + get_transaction::call( + self.wallet().await?.as_ref(), + self.chain().await?, + txid, + include_watchonly.unwrap_or(false), + verbose.unwrap_or(false), + as_of_height, + ) + .await + } + async fn view_transaction(&self, txid: &str) -> view_transaction::Response { view_transaction::call(self.wallet().await?.as_ref(), txid) } diff --git a/zallet/src/components/json_rpc/methods/get_transaction.rs b/zallet/src/components/json_rpc/methods/get_transaction.rs new file mode 100644 index 00000000..929ca16b --- /dev/null +++ b/zallet/src/components/json_rpc/methods/get_transaction.rs @@ -0,0 +1,360 @@ +use documented::Documented; +use jsonrpsee::{core::RpcResult, types::ErrorCode as RpcErrorCode}; +use schemars::JsonSchema; +use serde::Serialize; +use zaino_proto::proto::service::BlockId; +use zaino_state::{FetchServiceSubscriber, LightWalletIndexer}; +use zcash_client_backend::data_api::WalletRead; +use zcash_protocol::{ + consensus::BlockHeight, + value::{ZatBalance, Zatoshis}, +}; + +use crate::components::{ + database::DbConnection, + json_rpc::{ + balance::{ + is_mine_spendable, is_mine_spendable_or_watchonly, wtx_get_credit, wtx_get_debit, + wtx_get_value_out, wtx_is_from_me, + }, + server::LegacyCode, + utils::{JsonZecBalance, parse_as_of_height, parse_txid, value_from_zat_balance}, + }, +}; + +/// Response to a `gettransaction` RPC request. +pub(crate) type Response = RpcResult; +pub(crate) type ResultType = Transaction; + +/// Detailed transparent 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 transaction version. + version: u32, + + /// The transaction amount in ZEC. + amount: JsonZecBalance, + + /// The amount in zatoshis. + #[serde(rename = "amountZat")] + amount_zat: i64, + + // TODO: Fee field might be negative when shown + #[serde(skip_serializing_if = "Option::is_none")] + fee: Option, + + /// 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, + + #[serde(skip_serializing_if = "Option::is_none")] + generated: Option, + + /// The block hash. + #[serde(skip_serializing_if = "Option::is_none")] + blockhash: Option, + + /// The block index. + #[serde(skip_serializing_if = "Option::is_none")] + blockindex: Option, + + /// The time in seconds since epoch (1 Jan 1970 GMT). + #[serde(skip_serializing_if = "Option::is_none")] + blocktime: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + expiryheight: Option, + + walletconflicts: Vec, + + /// The transaction time in seconds since epoch (1 Jan 1970 GMT). + time: u64, + + /// The time received in seconds since epoch (1 Jan 1970 GMT). + timereceived: u64, + + details: Vec, + + /// Raw data for transaction. + hex: String, +} + +#[derive(Clone, Debug, Serialize, JsonSchema)] +struct Detail { + /// The Zcash address involved in the transaction. + address: String, + + /// The category. + /// + /// One of 'send' or 'receive'. + category: String, + + /// The amount in ZEC. + amount: f64, + + /// The amount in zatoshis. + #[serde(rename = "amountZat")] + amount_zat: u64, + + /// The vout value. + vout: u64, +} + +pub(super) const PARAM_TXID_DESC: &str = "The ID of the transaction to view."; +pub(super) const PARAM_INCLUDE_WATCHONLY_DESC: &str = + "Whether to include watchonly addresses in balance calculation and `details`."; +pub(super) const PARAM_VERBOSE_DESC: &str = "Must be `false` or omitted."; +pub(super) const PARAM_AS_OF_HEIGHT_DESC: &str = "Execute the query as if it were run when the blockchain was at the height specified by this argument."; + +pub(crate) async fn call( + wallet: &DbConnection, + chain: FetchServiceSubscriber, + txid_str: &str, + include_watchonly: bool, + verbose: bool, + as_of_height: Option, +) -> Response { + let txid = parse_txid(txid_str)?; + + let filter = if include_watchonly { + is_mine_spendable_or_watchonly + } else { + is_mine_spendable + }; + + if verbose { + return Err(LegacyCode::InvalidParameter.with_static("verbose must be set to false")); + } + + let as_of_height = parse_as_of_height(as_of_height)?; + + // Fetch this early so we can detect if the wallet is not ready yet. + 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(format!("Failed to fetch transaction: {}", e.to_string())) + })? + .ok_or(LegacyCode::InvalidParameter.with_static("Invalid or non-wallet transaction id"))?; + + // TODO: In zcashd `filter` is for the entire transparent wallet. Here we have multiple + // mnemonics; do we have multiple transparent buckets of funds? + + // `gettransaction` was never altered to take wallet shielded notes into account. + // As such, its `amount` and `fee` fields are calculated as if the wallet only has + // transparent addresses. + let (amount, fee) = { + let credit = wtx_get_credit(wallet, &chain, &tx, as_of_height, filter) + .await + .map_err(|e| { + LegacyCode::Database + .with_message(format!("wtx_get_credit failed: {}", e.to_string())) + })? + .ok_or_else(|| { + // TODO: Either ensure this matches zcashd, or pick something better. + LegacyCode::Misc.with_static("CWallet::GetCredit(): value out of range") + })?; + + let debit = wtx_get_debit(wallet, &tx, filter) + .map_err(|e| { + LegacyCode::Database + .with_message(format!("wtx_get_debit failed: {}", e.to_string())) + })? + .ok_or_else(|| { + // TODO: Either ensure this matches zcashd, or pick something better. + LegacyCode::Misc.with_static("CWallet::GetDebit(): value out of range") + })?; + + // - For transparent receive, this is `received` + // - For transparent spend, this is `change - spent` + let net = (ZatBalance::from(credit) - ZatBalance::from(debit)).expect("cannot underflow"); + + // TODO: Alter the semantics here to instead use the concrete fee (spends - outputs). + // In particular, for v6 txs this should equal the fee field, and it wouldn't with zcashd semantics. + // See also https://github.com/zcash/zcash/issues/6821 + let fee = if wtx_is_from_me(wallet, &tx, filter).map_err(|e| { + // TODO: Either ensure this matches zcashd, or pick something better. + LegacyCode::Misc.with_message(e.to_string()) + })? { + // - For transparent receive, this would be `value_out`, but we don't expose fee in this case. + // - For transparent spend, this is `value_out - spent`, which should be negative. + Some( + (wtx_get_value_out(&tx).ok_or_else(|| { + // TODO: Either ensure this matches zcashd, or pick something better. + LegacyCode::Misc.with_static("CTransaction::GetValueOut(): value out of range") + })? - debit) + .expect("cannot underflow"), + ) + } else { + None + }; + + ( + // - For transparent receive, this is `received` + // - For transparent spend, this is `(change - spent) - (value_out - spent) = change - value_out`. + (net - fee.unwrap_or(Zatoshis::ZERO).into()).expect("cannot underflow"), + fee.map(u64::from), + ) + }; + + // TODO: Either update `zcash_client_sqlite` to store the time a transaction was first + // detected, or add a Zallet database for tracking Zallet-specific tx metadata. + let timereceived = 0; + + // + // Below here is equivalent to `WalletTxToJSON` in `zcashd`. + // + + let mined_height = wallet.get_tx_height(txid).map_err(|e| { + LegacyCode::Database.with_message(format!("get_tx_height failed: {}", e.to_string())) + })?; + + let confirmations = { + let effective_chain_height = as_of_height.unwrap_or(chain_height).min(chain_height); + match mined_height { + Some(mined_height) => (effective_chain_height + 1 - mined_height) as i32, + None => { + // TODO: Also check if the transaction is in the mempool for this branch. + if as_of_height.is_some() { -1 } else { 0 } + } + } + }; + + 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, expiryheight) = if let Some(height) = mined_height { + status = "mined"; + let block_metadata = wallet + .block_metadata(height) + .map_err(|e| { + LegacyCode::Database + .with_message(format!("block_metadata failed: {}", e.to_string())) + })? + // This would be a race condition between this and a reorg. + .ok_or(RpcErrorCode::InternalError)?; + + let block = chain + .get_block(BlockId { + height: 0, + hash: block_metadata.block_hash().0.to_vec(), + }) + .await + // This would be a race condition between this and a reorg. + // 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. + .map_err(|e| { + LegacyCode::Database.with_message(format!("get_block failed: {}", e.to_string())) + })?; + + let tx_index = block + .vtx + .iter() + .find(|tx| tx.hash == block_metadata.block_hash().0) + .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()), + Some(tx.expiry_height().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, None) + }; + + let walletconflicts = vec![]; + + // TODO: Enable wallet DB to track "smart" times per `zcashd` logic (Zallet database?). + let time = blocktime.unwrap_or(timereceived); + + // + // Below here is equivalent to `ListTransactions` in `zcashd`. + // + + let details = vec![]; + + let hex_tx = { + let mut bytes = vec![]; + tx.write(&mut bytes).expect("can write to Vec"); + hex::encode(bytes) + }; + + Ok(Transaction { + txid: txid_str.to_ascii_lowercase(), + status, + version: tx.version().header() & 0x7FFFFFFF, + amount: value_from_zat_balance(amount), + amount_zat: amount.into(), + fee, + confirmations, + generated, + blockhash, + blockindex, + blocktime, + expiryheight, + walletconflicts, + time, + timereceived, + details, + hex: hex_tx, + }) +} + +/// The number of blocks within expiry height when a tx is considered to be expiring soon. +const TX_EXPIRING_SOON_THRESHOLD: u32 = 3; + +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) +} diff --git a/zallet/src/components/json_rpc/utils.rs b/zallet/src/components/json_rpc/utils.rs index 74276929..b89d5a7d 100644 --- a/zallet/src/components/json_rpc/utils.rs +++ b/zallet/src/components/json_rpc/utils.rs @@ -12,6 +12,7 @@ use zcash_client_backend::data_api::{Account, WalletRead}; use zcash_client_sqlite::AccountUuid; use zcash_protocol::{ TxId, + consensus::BlockHeight, value::{BalanceError, COIN, ZatBalance, Zatoshis}, }; use zip32::{DiversifierIndex, fingerprint::SeedFingerprint}; @@ -136,6 +137,37 @@ pub(super) fn parse_diversifier_index(diversifier_index: u128) -> RpcResult) -> RpcResult> { + match as_of_height { + None | Some(-1) => Ok(None), + Some(..0) => Err(LegacyCode::InvalidParameter + .with_static("Can not perform the query as of a negative block height")), + Some(0) => Err(LegacyCode::InvalidParameter + .with_static("Can not perform the query as of the genesis block")), + Some(requested_height @ 1..) => u32::try_from(requested_height) + .map(BlockHeight::from_u32) + .map(Some) + .map_err(|_| { + LegacyCode::InvalidParameter.with_static("asOfHeight parameter is too big") + }), + } +} + /// Equivalent of `AmountFromValue` in `zcashd`, permitting the same input formats. pub(super) fn zatoshis_from_value(value: &JsonValue) -> RpcResult { let amount_str = match value {