Skip to content

Commit

Permalink
WIP: zcash_client_backend: Allow change strategies to act based on wa…
Browse files Browse the repository at this point in the history
…llet balance.
  • Loading branch information
nuttycom committed Oct 21, 2024
1 parent 5afea2e commit 9ca4f50
Show file tree
Hide file tree
Showing 6 changed files with 212 additions and 75 deletions.
57 changes: 55 additions & 2 deletions zcash_client_backend/src/data_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -804,20 +804,28 @@ impl<NoteRef> SpendableNotes<NoteRef> {
/// the wallet.
pub struct WalletMeta {
sapling_note_count: usize,
sapling_total_value: NonNegativeAmount,
#[cfg(feature = "orchard")]
orchard_note_count: usize,
#[cfg(feature = "orchard")]
orchard_total_value: NonNegativeAmount,
}

impl WalletMeta {
/// Constructs a new [`WalletMeta`] value from its constituent parts.
pub fn new(
sapling_note_count: usize,
sapling_total_value: NonNegativeAmount,
#[cfg(feature = "orchard")] orchard_note_count: usize,
#[cfg(feature = "orchard")] orchard_total_value: NonNegativeAmount,
) -> Self {
Self {
sapling_note_count,
sapling_total_value,
#[cfg(feature = "orchard")]
orchard_note_count,
#[cfg(feature = "orchard")]
orchard_total_value,
}
}

Expand All @@ -838,18 +846,60 @@ impl WalletMeta {
self.sapling_note_count
}

/// Returns the total value of Sapling notes represented by [`Self::sapling_note_count`].
pub fn sapling_total_value(&self) -> NonNegativeAmount {
self.sapling_total_value
}

/// Returns the number of unspent Orchard notes belonging to the account for which this was
/// generated.
#[cfg(feature = "orchard")]
pub fn orchard_note_count(&self) -> usize {
self.orchard_note_count
}

/// Returns the total value of Orchard notes represented by [`Self::orchard_note_count`].
#[cfg(feature = "orchard")]
pub fn orchard_total_value(&self) -> NonNegativeAmount {
self.orchard_total_value
}

/// Returns the total number of unspent shielded notes belonging to the account for which this
/// was generated.
pub fn total_note_count(&self) -> usize {
self.sapling_note_count + self.note_count(ShieldedProtocol::Orchard)
}

/// Returns the total value of shielded notes represented by [`Self::total_note_count`]
pub fn total_value(&self) -> NonNegativeAmount {
#[cfg(feature = "orchard")]
let orchard_value = self.orchard_total_value;
#[cfg(not(feature = "orchard"))]
let orchard_value = NonNegativeAmount::ZERO;

(self.sapling_total_value + orchard_value).expect("Does not overflow Zcash maximum value.")
}
}

/// A small query language for filtering notes belonging to an account.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NoteSelector {
/// Selects notes having value greater than or equal to the provided value.
MinValue(NonNegativeAmount),
/// Selects notes having value greater than or equal to the n'th percentile of previously sent
/// notes in the wallet. The wrapped value must be in the range `1..=100`
PriorSendPercentile(u8),
/// Selects notes having value greater than or equal to the specified percentage of the wallet
/// balance. The wrapped value must be in the range `1..=100`
BalancePercentage(u8),
/// A note will be selected if it satisfies the first condition; if it is not possible to
/// evaaluate that condition (for example, [`NoteSelector::PriorSendPercentile`] cannot be
/// evaluated if no sends have been performed) then the second condition will be used for
/// evaluation.
Try {
condition: Box<NoteSelector>,
fallback: Box<NoteSelector>,
},
}

/// A trait representing the capability to query a data store for unspent transaction outputs
Expand Down Expand Up @@ -900,12 +950,15 @@ pub trait InputSource {
///
/// The returned metadata value must exclude:
/// - spent notes;
/// - unspent notes having value less than the specified minimum value;
/// - unspent notes excluded by the provided selector;
/// - unspent notes identified in the given `exclude` list.
///
/// Implementations of this method may limit the complexity of supported queries. Such
/// limitations should be clearly documented for the implementing type.
fn get_wallet_metadata(
&self,
account: Self::AccountId,
min_value: NonNegativeAmount,
selector: &NoteSelector,
exclude: &[Self::NoteRef],
) -> Result<WalletMeta, Self::Error>;

Expand Down
60 changes: 41 additions & 19 deletions zcash_client_backend/src/fees.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use zcash_primitives::{
};
use zcash_protocol::{PoolType, ShieldedProtocol};

use crate::data_api::InputSource;
use crate::data_api::{InputSource, Ratio};

pub(crate) mod common;
pub mod fixed;
Expand Down Expand Up @@ -344,14 +344,14 @@ impl Default for DustOutputPolicy {
#[derive(Clone, Copy, Debug)]
pub struct SplitPolicy {
target_output_count: NonZeroUsize,
min_split_output_size: NonNegativeAmount,
min_split_output_size: Option<NonNegativeAmount>,
}

impl SplitPolicy {
/// Constructs a new [`SplitPolicy`] from its constituent parts.
pub fn new(
target_output_count: NonZeroUsize,
min_split_output_size: NonNegativeAmount,
min_split_output_size: Option<NonNegativeAmount>,
) -> Self {
Self {
target_output_count,
Expand All @@ -363,15 +363,15 @@ impl SplitPolicy {
pub fn single_output() -> Self {
Self {
target_output_count: NonZeroUsize::MIN,
min_split_output_size: NonNegativeAmount::ZERO,
min_split_output_size: None,
}
}

/// Returns the minimum value for a note resulting from splitting of change.
///
/// If splitting change would result in notes of value less than the minimum split output size,
/// a smaller number of splits should be chosen.
pub fn min_split_output_size(&self) -> NonNegativeAmount {
pub fn min_split_output_size(&self) -> Option<NonNegativeAmount> {
self.min_split_output_size
}

Expand All @@ -380,27 +380,49 @@ impl SplitPolicy {
pub fn split_count(
&self,
existing_notes: usize,
existing_notes_total: NonNegativeAmount,
total_change: NonNegativeAmount,
) -> NonZeroUsize {
fn to_nonzero_u64(value: usize) -> NonZeroU64 {
NonZeroU64::new(u64::try_from(value).expect("usize fits into u64"))
.expect("NonZeroU64 input derived from NonZeroUsize")
}

let mut split_count =
NonZeroUsize::new(usize::from(self.target_output_count).saturating_sub(existing_notes))
.unwrap_or(NonZeroUsize::MIN);

loop {
let per_output_change = total_change.div_with_remainder(
NonZeroU64::new(
u64::try_from(usize::from(split_count)).expect("usize fits into u64"),
)
.unwrap(),
);
if *per_output_change.quotient() >= self.min_split_output_size {
return split_count;
} else if let Some(new_count) = NonZeroUsize::new(usize::from(split_count) - 1) {
split_count = new_count;
} else {
// We always create at least one change output.
return NonZeroUsize::MIN;
let min_split_output_size = self.min_split_output_size.or_else(|| {
// If no minimum split output size is set, we choose the minimum split size to be a
// quarter of the average value of notes in the wallet after the transaction.
(existing_notes_total + total_change).map(|total| {
*total
.div_with_remainder(to_nonzero_u64(
usize::from(self.target_output_count).saturating_mul(4),
))
.quotient()
})
});

if let Some(min_split_output_size) = min_split_output_size {
loop {
let per_output_change =
total_change.div_with_remainder(to_nonzero_u64(usize::from(split_count)));
if *per_output_change.quotient() >= self.min_split_output_size {
return split_count;
} else if let Some(new_count) = NonZeroUsize::new(usize::from(split_count) - 1) {
split_count = new_count;
} else {
// We always create at least one change output.
return NonZeroUsize::MIN;
}
}
} else {
// This is purely defensive; this case would only arise in the case that the addition
// of the existing notes with the total change overflows the maximum monetary amount.
// Since it's always safe to fall back to a single change value, this is better than a
// panic.
return NonZeroUsize::MIN;
}
}
}
Expand Down
12 changes: 9 additions & 3 deletions zcash_client_backend/src/fees/zip317.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ mod tests {

{
// spend a single Sapling note and produce 5 outputs
let balance = |existing_notes| {
let balance = |existing_notes, total| {
change_strategy.compute_balance(
&Network::TestNetwork,
Network::TestNetwork
Expand All @@ -326,14 +326,17 @@ mod tests {
None,
Some(&WalletMeta::new(
existing_notes,
total,
#[cfg(feature = "orchard")]
0,
#[cfg(feature = "orchard")]
NonNegativeAmount::ZERO,
)),
)
};

assert_matches!(
balance(0),
balance(0, NonNegativeAmount::ZERO),
Ok(balance) if
balance.proposed_change() == [
ChangeValue::sapling(NonNegativeAmount::const_from_u64(129_4000), None),
Expand All @@ -346,7 +349,7 @@ mod tests {
);

assert_matches!(
balance(2),
balance(2, NonNegativeAmount::const_from_u64(100_0000)),
Ok(balance) if
balance.proposed_change() == [
ChangeValue::sapling(NonNegativeAmount::const_from_u64(216_0000), None),
Expand Down Expand Up @@ -382,8 +385,11 @@ mod tests {
None,
Some(&WalletMeta::new(
0,
NonNegativeAmount::ZERO,
#[cfg(feature = "orchard")]
0,
#[cfg(feature = "orchard")]
NonNegativeAmount::ZERO,
)),
);

Expand Down
4 changes: 4 additions & 0 deletions zcash_client_sqlite/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ pub enum SqliteClientError {
/// An error occurred in computing wallet balance
BalanceError(BalanceError),

/// A note selection query contained an invalid constant or was otherwise not supported.
NoteSelectorInvalid(NoteSelector),

/// The proposal cannot be constructed until transactions with previously reserved
/// ephemeral address outputs have been mined. The parameters are the account id and
/// the index that could not safely be reserved.
Expand Down Expand Up @@ -187,6 +190,7 @@ impl fmt::Display for SqliteClientError {
SqliteClientError::ChainHeightUnknown => write!(f, "Chain height unknown; please call `update_chain_tip`"),
SqliteClientError::UnsupportedPoolType(t) => write!(f, "Pool type is not currently supported: {}", t),
SqliteClientError::BalanceError(e) => write!(f, "Balance error: {}", e),
SqliteClientError::NoteSelectorInvalid(s) => write!(f, "Could not evaluate selection query: {:?}", s),
#[cfg(feature = "transparent-inputs")]
SqliteClientError::ReachedGapLimit(account_id, bad_index) => write!(f,
"The proposal cannot be constructed until transactions with previously reserved ephemeral address outputs have been mined. \
Expand Down
35 changes: 20 additions & 15 deletions zcash_client_sqlite/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ use zcash_client_backend::{
chain::{BlockSource, ChainState, CommitmentTreeRoot},
scanning::{ScanPriority, ScanRange},
Account, AccountBirthday, AccountPurpose, AccountSource, BlockMetadata,
DecryptedTransaction, InputSource, NullifierQuery, ScannedBlock, SeedRelevance,
SentTransaction, SpendableNotes, TransactionDataRequest, WalletCommitmentTrees, WalletMeta,
WalletRead, WalletSummary, WalletWrite, SAPLING_SHARD_HEIGHT,
DecryptedTransaction, InputSource, NoteSelector, NullifierQuery, ScannedBlock,
SeedRelevance, SentTransaction, SpendableNotes, TransactionDataRequest,
WalletCommitmentTrees, WalletMeta, WalletRead, WalletSummary, WalletWrite,
SAPLING_SHARD_HEIGHT,
},
keys::{
AddressGenerationError, UnifiedAddressRequest, UnifiedFullViewingKey, UnifiedSpendingKey,
Expand Down Expand Up @@ -128,7 +129,7 @@ pub mod error;
pub mod wallet;
use wallet::{
commitment_tree::{self, put_shard_roots},
common::count_outputs,
common::spendable_notes_meta,
SubtreeProgressEstimator,
};

Expand Down Expand Up @@ -351,35 +352,39 @@ impl<C: Borrow<rusqlite::Connection>, P: consensus::Parameters> InputSource for
fn get_wallet_metadata(
&self,
account_id: Self::AccountId,
min_value: NonNegativeAmount,
selector: &NoteSelector,
exclude: &[Self::NoteRef],
exclude: &[Self::NoteRef],
) -> Result<WalletMeta, Self::Error> {
let chain_tip_height = wallet::chain_tip_height(self.conn.borrow())?
.ok_or(SqliteClientError::ChainHeightUnknown)?;

let sapling_note_count = count_outputs(
let sapling_pool_meta = spendable_notes_meta(
self.conn.borrow(),
account_id,
min_value,
exclude,
ShieldedProtocol::Sapling,
chain_tip_height,
account_id,
selector,
exclude,
)?;

#[cfg(feature = "orchard")]
let orchard_note_count = count_outputs(
let orchard_pool_meta = spendable_notes_meta(
self.conn.borrow(),
account_id,
min_value,
exclude,
ShieldedProtocol::Orchard,
chain_tip_height,
account_id,
selector,
exclude,
)?;

Ok(WalletMeta::new(
sapling_note_count,
sapling_pool_meta.note_count,
sapling_pool_meta.total_value,
#[cfg(feature = "orchard")]
orchard_pool_meta.note_count,
#[cfg(feature = "orchard")]
orchard_note_count,
orchard_pool_meta.total_value,
))
}
}
Expand Down
Loading

0 comments on commit 9ca4f50

Please sign in to comment.