Skip to content

Commit

Permalink
Merge pull request #1192 from Oscar-Pepper/spendable_balance_command
Browse files Browse the repository at this point in the history
spendable balance command
  • Loading branch information
zancas authored Jun 7, 2024
2 parents 0689f3f + 1c179bb commit 2f58af3
Show file tree
Hide file tree
Showing 7 changed files with 218 additions and 75 deletions.
51 changes: 51 additions & 0 deletions zingolib/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ use zcash_primitives::consensus::Parameters;
use zcash_primitives::transaction::components::amount::NonNegativeAmount;
use zcash_primitives::transaction::fees::zip317::MINIMUM_FEE;

use self::utils::parse_spendable_balance_args;

/// Errors associated with the commands interface
mod error;
/// Utilities associated with the commands interface
Expand Down Expand Up @@ -596,6 +598,54 @@ impl Command for BalanceCommand {
}
}

#[cfg(feature = "zip317")]
struct SpendableBalanceCommand {}
#[cfg(feature = "zip317")]
impl Command for SpendableBalanceCommand {
fn help(&self) -> &'static str {
indoc! {r#"
Display the wallet's spendable balance.
Calculated as the confirmed shielded balance minus the fee required to send all funds to
the given address.
An address must be specified as fees, and therefore spendable balance, depends on the receiver
type.
Usage:
spendablebalance <address>
"#}
}

fn short_help(&self) -> &'static str {
"Display the wallet's spendable balance."
}

fn exec(&self, args: &[&str], lightclient: &LightClient) -> String {
let address = match parse_spendable_balance_args(args, &lightclient.config.chain) {
Ok(addr) => addr,
Err(e) => {
return format!(
"Error: {}\nTry 'help spendablebalance' for correct usage and examples.",
e
);
}
};
RT.block_on(async move {
match lightclient.spendable_balance(address).await {
Ok(bal) => {
object! {
"balance" => bal.into_u64(),
}
}
Err(e) => {
object! { "error" => e.to_string() }
}
}
.pretty(2)
})
}
}

struct AddressCommand {}
impl Command for AddressCommand {
fn help(&self) -> &'static str {
Expand Down Expand Up @@ -1769,6 +1819,7 @@ pub fn get_commands() -> HashMap<&'static str, Box<dyn Command>> {
}
#[cfg(feature = "zip317")]
{
entries.push(("spendablebalance", Box::new(SpendableBalanceCommand {})));
entries.push(("sendall", Box::new(SendAllCommand {})));
entries.push(("quicksend", Box::new(QuickSendCommand {})));
entries.push(("quickshield", Box::new(QuickShieldCommand {})));
Expand Down
44 changes: 43 additions & 1 deletion zingolib/src/commands/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ pub(super) fn parse_send_args(args: &[&str], chain: &ChainType) -> Result<Receiv
// Parse the send arguments for `propose_send` when sending all funds from shielded pools.
// The send arguments have two possible formats:
// - 1 argument in the form of a JSON string (single address only). '[{"address":"<address>", "memo":"<optional memo>"}]'
// - 2 (+1 optional) arguments for a single address send. &["<address>", "<optional memo>"]
// - 1 (+1 optional) arguments for a single address send. &["<address>", "<optional memo>"]
#[cfg(feature = "zip317")]
pub(super) fn parse_send_all_args(
args: &[&str],
Expand Down Expand Up @@ -156,6 +156,48 @@ pub(super) fn parse_send_all_args(
Ok((address, memo))
}

// Parse the arguments for `spendable_balance`.
// The arguments have two possible formats:
// - 1 argument in the form of a JSON string (single address only). '[{"address":"<address>"}]'
// - 1 argument for a single address. &["<address>"]
#[cfg(feature = "zip317")]
pub(super) fn parse_spendable_balance_args(
args: &[&str],
chain: &ChainType,
) -> Result<Address, CommandError> {
let address: Address;

if args.len() != 1 {
return Err(CommandError::InvalidArguments);
}

if let Ok(addr) = address_from_str(args[0], chain) {
address = addr;
} else {
let json_args =
json::parse(args[0]).map_err(|_e| CommandError::ArgNotJsonOrValidAddress)?;

if !json_args.is_array() {
return Err(CommandError::SingleArgNotJsonArray(json_args.to_string()));
}
if json_args.is_empty() {
return Err(CommandError::EmptyJsonArray);
}
let json_args = if json_args.len() == 1 {
json_args
.members()
.next()
.expect("should have a single json member")
} else {
return Err(CommandError::MultipleReceivers);
};

address = address_from_json(json_args, chain)?;
}

Ok(address)
}

// Checks send inputs do not contain memo's to transparent addresses.
fn check_memo_compatibility(
address: &Address,
Expand Down
115 changes: 61 additions & 54 deletions zingolib/src/lightclient/propose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ use std::convert::Infallible;
use std::num::NonZeroU32;
use std::ops::DerefMut;

use orchard::note_encryption::OrchardDomain;
use sapling_crypto::note_encryption::SaplingDomain;
use zcash_client_backend::data_api::wallet::input_selection::GreedyInputSelector;
use zcash_client_backend::zip321::TransactionRequest;
use zcash_client_backend::zip321::Zip321Error;
Expand All @@ -20,7 +18,6 @@ use crate::data::proposal::ZingoProposal;
use crate::data::receivers::transaction_request_from_receivers;
use crate::data::receivers::Receiver;
use crate::lightclient::LightClient;
use crate::utils::conversion::zatoshis_from_u64;
use crate::wallet::tx_map_and_maybe_trees::TxMapAndMaybeTrees;
use crate::wallet::tx_map_and_maybe_trees::TxMapAndMaybeTreesTraitError;
use zingoconfig::ChainType;
Expand All @@ -33,8 +30,8 @@ type GISKit = GreedyInputSelector<
/// Errors that can result from do_propose
#[derive(Debug, Error)]
pub enum ProposeSendError {
#[error("{0:?}")]
/// error in using trait to create spend proposal
#[error("{0}")]
Proposal(
zcash_client_backend::data_api::error::Error<
TxMapAndMaybeTreesTraitError,
Expand All @@ -46,27 +43,24 @@ pub enum ProposeSendError {
zcash_primitives::transaction::fees::zip317::FeeError,
>,
),
#[error("{0:?}")]
/// failed to construct a transaction request
TransactionRequestFailed(Zip321Error),
#[error("{0:?}")]
/// conversion failed
ConversionFailed(crate::utils::error::ConversionError),
#[error("send all is transferring no value. only enough funds to pay the fees!")]
#[error("{0}")]
TransactionRequestFailed(#[from] Zip321Error),
/// send all is transferring no value
#[error("send all is transferring no value. only enough funds to pay the fees!")]
ZeroValueSendAll,
#[error("failed to retrieve full viewing key for balance calculation")]
/// failed to retrieve full viewing key for balance calculation
NoFullViewingKey,
/// failed to calculate balance.
#[error("failed to calculated balance. {0}")]
BalanceError(#[from] crate::wallet::error::BalanceError),
}

/// Errors that can result from do_propose
#[derive(Debug, Error)]
pub enum ProposeShieldError {
/// error in parsed addresses
#[error("{0:?}")]
#[error("{0}")]
Receiver(zcash_client_backend::zip321::Zip321Error),
#[error("{0:?}")]
#[error("{0}")]
/// error in using trait to create shielding proposal
Component(
zcash_client_backend::data_api::error::Error<
Expand Down Expand Up @@ -146,68 +140,81 @@ impl LightClient {

/// Unstable function to expose the zip317 interface for development
// TOdo: add correct functionality and doc comments / tests
// TODO: Add migrate_sapling_to_orchard argument
pub async fn propose_send_all(
&self,
address: zcash_keys::address::Address,
memo: Option<zcash_primitives::memo::MemoBytes>,
) -> Result<TransferProposal, ProposeSendError> {
let confirmed_shielded_balance = zatoshis_from_u64(
self.wallet
.confirmed_balance_excluding_dust::<OrchardDomain>(None)
.await
.ok_or(ProposeSendError::NoFullViewingKey)?
+ self
.wallet
.confirmed_balance_excluding_dust::<SaplingDomain>(None)
.await
.ok_or(ProposeSendError::NoFullViewingKey)?,
)
.map_err(ProposeSendError::ConversionFailed)?;
let spendable_balance = self.spendable_balance(address.clone()).await?;
if spendable_balance == NonNegativeAmount::ZERO {
return Err(ProposeSendError::ZeroValueSendAll);
}
let request = transaction_request_from_receivers(vec![Receiver::new(
address.clone(),
confirmed_shielded_balance,
memo.clone(),
address,
spendable_balance,
memo,
)])
.map_err(ProposeSendError::TransactionRequestFailed)?;
let proposal = self.create_send_proposal(request).await?;
self.store_proposal(ZingoProposal::Transfer(proposal.clone()))
.await;
Ok(proposal)
}

/// Returns the total confirmed shielded balance minus any fees required to send those funds to
/// a given address
///
/// # Error
///
/// Will return an error if this method fails to calculate the total wallet balance or create the
/// proposal needed to calculate the fee
// TODO: move spendable balance and create proposal to wallet layer
pub async fn spendable_balance(
&self,
address: zcash_keys::address::Address,
) -> Result<NonNegativeAmount, ProposeSendError> {
let confirmed_shielded_balance = self
.wallet
.confirmed_shielded_balance_excluding_dust(None)
.await?;
let request = transaction_request_from_receivers(vec![Receiver::new(
address.clone(),
confirmed_shielded_balance,
None,
)])?;
let failing_proposal = self.create_send_proposal(request).await;

// subtract shoftfall from available shielded balance to find spendable balance
let spendable_balance = match failing_proposal {
let shortfall = match failing_proposal {
Err(ProposeSendError::Proposal(
zcash_client_backend::data_api::error::Error::InsufficientFunds {
required, ..
available,
required,
},
)) => {
if let Some(shortfall) = required - confirmed_shielded_balance {
(confirmed_shielded_balance - shortfall).ok_or(ProposeSendError::Proposal(
Ok(shortfall)
} else {
// bugged underflow case, required should always be larger than available balance to cause
// insufficient funds error. would suggest discrepancy between `available` and `confirmed_shielded_balance`
// returns insufficient funds error with same values from original error for debugging
Err(ProposeSendError::Proposal(
zcash_client_backend::data_api::error::Error::InsufficientFunds {
available: confirmed_shielded_balance,
required: shortfall,
available,
required,
},
))
} else {
return failing_proposal; // return the proposal in the case there is zero fee
}
}
Err(e) => Err(e),
Ok(_) => return failing_proposal, // return the proposal in the case there is zero fee
Ok(_) => Ok(NonNegativeAmount::ZERO), // in the case there is zero fee and the proposal is successful
}?;
if spendable_balance == NonNegativeAmount::ZERO {
return Err(ProposeSendError::ZeroValueSendAll);
}

// new proposal with spendable balance
let request = transaction_request_from_receivers(vec![Receiver::new(
address,
spendable_balance,
memo,
)])
.map_err(ProposeSendError::TransactionRequestFailed)?;
let proposal = self.create_send_proposal(request).await?;
self.store_proposal(ZingoProposal::Transfer(proposal.clone()))
.await;
Ok(proposal)
(confirmed_shielded_balance - shortfall).ok_or(ProposeSendError::Proposal(
zcash_client_backend::data_api::error::Error::InsufficientFunds {
available: confirmed_shielded_balance,
required: shortfall,
},
))
}

fn get_transparent_addresses(&self) -> Vec<zcash_primitives::legacy::TransparentAddress> {
Expand Down
4 changes: 2 additions & 2 deletions zingolib/src/utils/conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use super::error::ConversionError;
#[allow(missing_docs)] // error types document themselves
#[derive(Debug, Error)]
pub enum TxIdFromHexEncodedStrError {
#[error("{0:?}")]
#[error("{0}")]
Decode(hex::FromHexError),
#[error("{0:?}")]
Code(Vec<u8>),
Expand All @@ -29,7 +29,7 @@ pub fn txid_from_hex_encoded_str(txid: &str) -> Result<TxId, TxIdFromHexEncodedS
Ok(TxId::from_bytes(txid_bytes))
}

/// Convert a &str to an Adddress
/// Convert a &str to an Address
pub fn address_from_str(address: &str, chain: &ChainType) -> Result<Address, ConversionError> {
Address::decode(chain, address)
.ok_or_else(|| ConversionError::InvalidAddress(address.to_string()))
Expand Down
25 changes: 25 additions & 0 deletions zingolib/src/wallet/describe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
use orchard::note_encryption::OrchardDomain;

use sapling_crypto::note_encryption::SaplingDomain;
use zcash_primitives::transaction::components::amount::NonNegativeAmount;
use zcash_primitives::transaction::fees::zip317::MARGINAL_FEE;

use std::{cmp, sync::Arc};
Expand All @@ -12,12 +13,14 @@ use zcash_note_encryption::Domain;

use zcash_primitives::consensus::BlockHeight;

use crate::utils;
use crate::wallet::data::TransactionRecord;
use crate::wallet::notes::OutputInterface;
use crate::wallet::notes::ShieldedNoteInterface;

use crate::wallet::traits::Diversifiable as _;

use super::error::BalanceError;
use super::keys::unified::{Capability, WalletCapability};
use super::notes::TransparentOutput;
use super::traits::DomainWalletExt;
Expand Down Expand Up @@ -183,6 +186,28 @@ impl LightWallet {
self.shielded_balance::<D>(target_addr, filters).await
}

/// Returns total balance of all shielded pools excluding any notes with value less than marginal fee
/// that are confirmed on the block chain (the block has at least 1 confirmation).
/// Does not include transparent funds.
///
/// # Error
///
/// Returns an error if the full viewing key is not found or if the balance summation exceeds the valid range of zatoshis.
pub async fn confirmed_shielded_balance_excluding_dust(
&self,
target_addr: Option<String>,
) -> Result<NonNegativeAmount, BalanceError> {
Ok(utils::conversion::zatoshis_from_u64(
self.confirmed_balance_excluding_dust::<OrchardDomain>(target_addr.clone())
.await
.ok_or(BalanceError::NoFullViewingKey)?
+ self
.confirmed_balance_excluding_dust::<SaplingDomain>(target_addr)
.await
.ok_or(BalanceError::NoFullViewingKey)?,
)?)
}

/// Deprecated for `shielded_balance`
#[deprecated(note = "deprecated for `shielded_balance` as incorrectly named and unnecessary")]
pub async fn maybe_verified_orchard_balance(&self, addr: Option<String>) -> Option<u64> {
Expand Down
Loading

0 comments on commit 2f58af3

Please sign in to comment.