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 28, 2024
1 parent ddf5f1b commit 37f993e
Show file tree
Hide file tree
Showing 9 changed files with 292 additions and 83 deletions.
102 changes: 100 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,105 @@ 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 `u8` value in the range 0..=MAX
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct BoundedU8<const MAX: u8>(u8);

impl<const MAX: u8> BoundedU8<MAX> {
/// Creates a constant `BoundedU8` from a [`u8`] value.
///
/// Panics: if the value is outside the range `0..=100`.
pub const fn new_const(value: u8) -> Self {
assert!(value <= MAX);
Self(value)
}

/// Creates a `BoundedU8` from a [`u8`] value.
///
/// Returns `None` if the provided value is outside the range `0..=100`.
pub fn new(value: u8) -> Option<Self> {
if value <= MAX {
Some(Self(value))
} else {
None
}
}

/// Returns the wrapped [`u8`] value.
pub fn value(&self) -> u8 {
self.0
}
}

/// 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.
ExceedsMinValue(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`
ExceedsPriorSendPercentile(BoundedU8<100>),
/// 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`
ExceedsBalancePercentage(BoundedU8<100>),
/// A note will be selected if it satisfies both of the specified conditions.
///
/// If it is not possible to evaluate one of the conditions (for example,
/// [`NoteSelector::ExceedsPriorSendPercentile`] cannot be evaluated if no sends have been
/// performed) then that condition will be ignored.
And(Box<NoteSelector>, Box<NoteSelector>),
/// A note will be selected if it satisfies the first condition; if it is not possible to
/// evaluate that condition (for example, [`NoteSelector::ExceedsPriorSendPercentile`] cannot
/// be evaluated if no sends have been performed) then the second condition will be used for
/// evaluation.
Attempt {
condition: Box<NoteSelector>,
fallback: Box<NoteSelector>,
},
}

impl NoteSelector {
pub fn attempt(condition: NoteSelector, fallback: NoteSelector) -> Self {
Self::Attempt {
condition: Box::new(condition),
fallback: Box::new(fallback),
}
}
}

/// A trait representing the capability to query a data store for unspent transaction outputs
Expand Down Expand Up @@ -900,12 +995,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
4 changes: 2 additions & 2 deletions zcash_client_backend/src/data_api/testing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ use crate::{
ShieldedProtocol,
};

use super::error::Error;
use super::{
chain::{scan_cached_blocks, BlockSource, ChainState, CommitmentTreeRoot, ScanSummary},
scanning::ScanRange,
Expand All @@ -74,6 +73,7 @@ use super::{
WalletCommitmentTrees, WalletMeta, WalletRead, WalletSummary, WalletTest, WalletWrite,
SAPLING_SHARD_HEIGHT,
};
use super::{error::Error, NoteSelector};

#[cfg(feature = "transparent-inputs")]
use {
Expand Down Expand Up @@ -2354,7 +2354,7 @@ impl InputSource for MockWalletDb {
fn get_wallet_metadata(
&self,
_account: Self::AccountId,
_min_value: NonNegativeAmount,
_selector: &NoteSelector,
_exclude: &[Self::NoteRef],
) -> Result<WalletMeta, Self::Error> {
Err(())
Expand Down
4 changes: 2 additions & 2 deletions zcash_client_backend/src/data_api/testing/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ pub fn send_with_multiple_change_outputs<T: ShieldedPoolTester>(
DustOutputPolicy::default(),
SplitPolicy::new(
NonZeroUsize::new(2).unwrap(),
NonNegativeAmount::const_from_u64(100_0000),
Some(NonNegativeAmount::const_from_u64(100_0000)),
),
);

Expand Down Expand Up @@ -467,7 +467,7 @@ pub fn send_with_multiple_change_outputs<T: ShieldedPoolTester>(
DustOutputPolicy::default(),
SplitPolicy::new(
NonZeroUsize::new(8).unwrap(),
NonNegativeAmount::const_from_u64(10_0000),
Some(NonNegativeAmount::const_from_u64(10_0000)),
),
);

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

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

pub mod common;
#[cfg(feature = "non-standard-fees")]
Expand Down Expand Up @@ -355,63 +355,109 @@ 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>,
notes_must_exceed_prior_send_percentile: Option<BoundedU8<100>>,
notes_must_exceed_balance_percentage: Option<BoundedU8<100>>,
}

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>,
notes_must_exceed_prior_send_percentile: Option<BoundedU8<100>>,
notes_must_exceed_balance_percentage: Option<BoundedU8<100>>,
) -> Self {
Self {
target_output_count,
min_split_output_size,
notes_must_exceed_prior_send_percentile,
notes_must_exceed_balance_percentage,
}
}

/// Constructs a [`SplitPolicy`] that prescribes a single output (no splitting).
pub fn single_output() -> Self {
Self {
target_output_count: NonZeroUsize::MIN,
min_split_output_size: NonNegativeAmount::ZERO,
min_split_output_size: None,
notes_must_exceed_prior_send_percentile: None,
notes_must_exceed_balance_percentage: 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
}

/// Returns the bound on output size that is used to evaluate against prior send behavior.
///
/// If splitting change would result in notes of value less than the `n`'th percentile of prior
/// send values, a smaller number of splits should be chosen.
pub fn notes_must_exceed_prior_send_percentile(&self) -> Option<BoundedU8<100>> {
self.notes_must_exceed_prior_send_percentile
}

/// Returns the bound on output size that is used to evaluate against wallet balance.
///
/// If splitting change would result in notes of value less than `n` percent of the wallet
/// balance, a smaller number of splits should be chosen.
pub fn notes_must_exceed_balance_percentage(&self) -> Option<BoundedU8<100>> {
self.notes_must_exceed_balance_percentage
}

/// Returns the number of output notes to produce from the given total change value, given the
/// number of existing unspent notes in the account and this policy.
/// total value and number of existing unspent notes in the account and this policy.
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() >= 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
7 changes: 5 additions & 2 deletions zcash_client_backend/src/fees/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -429,8 +429,11 @@ where
// available in the wallet, irrespective of pool. If we don't have any wallet metadata
// available, we fall back to generating a single change output.
let split_count = wallet_meta.map_or(NonZeroUsize::MIN, |wm| {
cfg.split_policy
.split_count(wm.total_note_count(), proposed_change)
cfg.split_policy.split_count(
wm.total_note_count(),
wm.total_value(),
proposed_change,
)
});
let per_output_change = proposed_change.div_with_remainder(
NonZeroU64::new(
Expand Down
Loading

0 comments on commit 37f993e

Please sign in to comment.