Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions zallet/src/components/json_rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
298 changes: 298 additions & 0 deletions zallet/src/components/json_rpc/balance.rs
Original file line number Diff line number Diff line change
@@ -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<bool, SqliteClientError> {
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<bool, SqliteClientError> {
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<bool, SqliteClientError> {
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<bool, SqliteClientError> {
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),
Comment on lines +86 to +88
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to cause anxiety when zallet reports a lower balance than zcashd did, because it isn't seeing some outputs as "mine".

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, which is why the TODO is present (so that we can detect IsMine the same way as zcashd)

}
}

/// 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<Zatoshis> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The semantics of this whole method are very weird; how would someone reasonably use this value?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you provide a suggestion on what to do here?

Copy link
Contributor

@daira daira Aug 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notice the TODO at https://github.com/zcash/wallet/pull/36/files#diff-aa4c5eb784ec34fa821ac213851b030ecd0d609b8d5b9f94252db39e476b68f6R191-R193 , just before the only call to this method:

// 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

I think the best way to proceed is to remove wtx_get_value_out and fix that TODO in the way it suggests, and so avoid introducing an equivalent issue to zcash/zcash#6821 in Zallet's gettransaction (if we decide to implement it). Note that #6821 is unequivocally a bug, not just a quirk.

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<bool, SqliteClientError>,
) -> Result<Option<Zatoshis>, 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))
Comment on lines +136 to +140
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug: This seems unusable in the context of zallet; in zcashd, this behavior was okay because you could be confident that you had access to the transaction inputs; here, this will just silently ignore any value for inputs for which you don't have the full transaction.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you provide a suggestion on what to do here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should just omit fields that we can't calculate from the RPC output. These internal methods, if they are retained, should do whatever is needed to implement that.

{
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<BlockHeight>,
is_mine: impl Fn(&DbConnection, &TxOut) -> Result<bool, SqliteClientError>,
) -> Result<Option<Zatoshis>, 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<bool, SqliteClientError>,
) -> Result<bool, SqliteClientError> {
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<BlockHeight>,
) -> Result<u32, SqliteClientError> {
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// - `Some(1..)` : this many blocks deep in the main chain
/// - `Some(1..)` : this many blocks deep in the main chain, where `Some(1)` represents the tip

async fn wtx_get_depth_in_main_chain(
wallet: &DbConnection,
chain: &FetchServiceSubscriber,
tx: &Transaction,
as_of_height: Option<BlockHeight>,
) -> Result<Option<u32>, 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)
}
50 changes: 50 additions & 0 deletions zallet/src/components/json_rpc/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<bool>,
verbose: Option<bool>,
as_of_height: Option<i64>,
) -> 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;
Expand Down Expand Up @@ -468,6 +500,24 @@ impl RpcServer for RpcImpl {
list_unified_receivers::call(unified_address)
}

async fn get_transaction(
&self,
txid: &str,
include_watchonly: Option<bool>,
verbose: Option<bool>,
as_of_height: Option<i64>,
) -> 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)
}
Expand Down
Loading
Loading