Skip to content

Commit

Permalink
zcash_client_backend: Generalize & extend wallet metadata query API
Browse files Browse the repository at this point in the history
This generalizes the previous wallet metadata query API to be able to
represent more complex queries, and also to return note totals in
addition to note counts.
  • Loading branch information
nuttycom committed Nov 1, 2024
1 parent ddf5f1b commit 600b5e6
Show file tree
Hide file tree
Showing 12 changed files with 306 additions and 90 deletions.
2 changes: 2 additions & 0 deletions zcash_client_backend/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and this library adheres to Rust's notion of
- `WalletSummary::progress`
- `WalletMeta`
- `impl Default for wallet::input_selection::GreedyInputSelector`
- `BoundedU8`
- `NoteSelector`
- `zcash_client_backend::fees`
- `SplitPolicy`
- `StandardFeeRule` has been moved here from `zcash_primitives::fees`. Relative
Expand Down
108 changes: 106 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,111 @@ 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

Check warning on line 851 in zcash_client_backend/src/data_api.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_backend/src/data_api.rs#L850-L851

Added lines #L850 - L851 were not covered by tests
}

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

Check warning on line 894 in zcash_client_backend/src/data_api.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_backend/src/data_api.rs#L892-L894

Added lines #L892 - L894 were not covered by tests
}

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

Check warning on line 902 in zcash_client_backend/src/data_api.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_backend/src/data_api.rs#L900-L902

Added lines #L900 - L902 were not covered by tests
} else {
None

Check warning on line 904 in zcash_client_backend/src/data_api.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_backend/src/data_api.rs#L904

Added line #L904 was not covered by tests
}
}

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

Check warning on line 910 in zcash_client_backend/src/data_api.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_backend/src/data_api.rs#L909-L910

Added lines #L909 - L910 were not covered by tests
}
}

/// 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 {
/// Constructs a [`NoteSelector::And`] query node.
pub fn and(l: NoteSelector, r: NoteSelector) -> Self {
Self::And(Box::new(l), Box::new(r))

Check warning on line 944 in zcash_client_backend/src/data_api.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_backend/src/data_api.rs#L943-L944

Added lines #L943 - L944 were not covered by tests
}

/// Constructs a [`NoteSelector::Attempt`] query node.
pub fn attempt(condition: NoteSelector, fallback: NoteSelector) -> Self {

Check warning on line 948 in zcash_client_backend/src/data_api.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_backend/src/data_api.rs#L948

Added line #L948 was not covered by tests
Self::Attempt {
condition: Box::new(condition),
fallback: Box::new(fallback),

Check warning on line 951 in zcash_client_backend/src/data_api.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_backend/src/data_api.rs#L950-L951

Added lines #L950 - L951 were not covered by tests
}
}
}

/// A trait representing the capability to query a data store for unspent transaction outputs
Expand Down Expand Up @@ -900,12 +1001,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 @@ -362,7 +362,7 @@ pub fn send_with_multiple_change_outputs<T: ShieldedPoolTester>(
Some(change_memo.clone().into()),
T::SHIELDED_PROTOCOL,
DustOutputPolicy::default(),
SplitPolicy::new(
SplitPolicy::with_min_output_value(
NonZeroUsize::new(2).unwrap(),
NonNegativeAmount::const_from_u64(100_0000),
),
Expand Down Expand Up @@ -465,7 +465,7 @@ pub fn send_with_multiple_change_outputs<T: ShieldedPoolTester>(
Some(change_memo.into()),
T::SHIELDED_PROTOCOL,
DustOutputPolicy::default(),
SplitPolicy::new(
SplitPolicy::with_min_output_value(
NonZeroUsize::new(8).unwrap(),
NonNegativeAmount::const_from_u64(10_0000),
),
Expand Down
100 changes: 75 additions & 25 deletions zcash_client_backend/src/fees.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ use zcash_primitives::{
components::{amount::NonNegativeAmount, OutPoint},
fees::{
transparent::{self, InputSize},
zip317 as prim_zip317, FeeRule,
zip317::{self as prim_zip317},
FeeRule,
},
},
};
use zcash_protocol::{PoolType, ShieldedProtocol};

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

pub mod common;
#[cfg(feature = "non-standard-fees")]
Expand Down Expand Up @@ -355,63 +356,112 @@ impl Default for DustOutputPolicy {
#[derive(Clone, Copy, Debug)]
pub struct SplitPolicy {
target_output_count: NonZeroUsize,
min_split_output_size: NonNegativeAmount,
min_split_output_value: 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(
/// In the case that no other conditions provided by the user are available to fall back on,
/// a default value of [`MARGINAL_FEE`] * 100 will be used as the "minimum usable note value"
/// when retrieving wallet metadata.
pub(crate) const MIN_NOTE_VALUE: NonNegativeAmount = NonNegativeAmount::const_from_u64(500000);

/// Constructs a new [`SplitPolicy`] that splits using a fixed minimum note value.
pub fn with_min_output_value(
target_output_count: NonZeroUsize,
min_split_output_size: NonNegativeAmount,
min_split_output_value: NonNegativeAmount,
) -> Self {
Self {
target_output_count,
min_split_output_size,
min_split_output_value: Some(min_split_output_value),
notes_must_exceed_prior_send_percentile: None,
notes_must_exceed_balance_percentage: None,
}
}

/// 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_value: 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 {
self.min_split_output_size
pub fn min_split_output_value(&self) -> Option<NonNegativeAmount> {
self.min_split_output_value
}

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

Check warning on line 406 in zcash_client_backend/src/fees.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_backend/src/fees.rs#L405-L406

Added lines #L405 - L406 were not covered by tests
}

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

Check warning on line 414 in zcash_client_backend/src/fees.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_backend/src/fees.rs#L413-L414

Added lines #L413 - L414 were not covered by tests
}

/// 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_value = self.min_split_output_value.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),

Check warning on line 440 in zcash_client_backend/src/fees.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_backend/src/fees.rs#L437-L440

Added lines #L437 - L440 were not covered by tests
))
.quotient()

Check warning on line 442 in zcash_client_backend/src/fees.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_backend/src/fees.rs#L442

Added line #L442 was not covered by tests
})
});

if let Some(min_split_output_value) = min_split_output_value {
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_value {
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;

Check warning on line 456 in zcash_client_backend/src/fees.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_backend/src/fees.rs#L456

Added line #L456 was not covered by tests
}
}
} 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;

Check warning on line 464 in zcash_client_backend/src/fees.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_backend/src/fees.rs#L464

Added line #L464 was not covered by tests
}
}
}
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 600b5e6

Please sign in to comment.