Skip to content

Commit

Permalink
zcash_client_backend: Add fees::zip317::MultiOutputChangeStrategy.
Browse files Browse the repository at this point in the history
  • Loading branch information
nuttycom committed Oct 17, 2024
1 parent 5738990 commit e6c1fa3
Show file tree
Hide file tree
Showing 13 changed files with 630 additions and 73 deletions.
4 changes: 4 additions & 0 deletions components/zcash_protocol/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this library adheres to Rust's notion of

## [Unreleased]

### Added
- `zcash_protocol::value::DivRem`
- `zcash_protocol::value::Zatoshis::div_with_remainder`

## [0.4.0] - 2024-10-02
### Added
- `impl Sub<BlockHeight> for BlockHeight` unlike the implementation that was
Expand Down
28 changes: 28 additions & 0 deletions components/zcash_protocol/src/value.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::convert::{Infallible, TryFrom};
use std::error;
use std::iter::Sum;
use std::num::NonZeroUsize;
use std::ops::{Add, Mul, Neg, Sub};

use memuse::DynamicUsage;
Expand Down Expand Up @@ -229,6 +230,24 @@ impl Mul<usize> for ZatBalance {
#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Eq, Ord)]
pub struct Zatoshis(u64);

/// A struct that provides both the dividend and remainder of a division operation.
pub struct DivRem<A> {
dividend: A,
remainder: A,
}

impl<A> DivRem<A> {
/// Returns the dividend portion of the value.
pub fn dividend(&self) -> &A {
&self.dividend
}

/// Returns the remainder portion of the value.
pub fn remainder(&self) -> &A {
&self.remainder
}
}

impl Zatoshis {
/// Returns the identity `Zatoshis`
pub const ZERO: Self = Zatoshis(0);
Expand Down Expand Up @@ -298,6 +317,15 @@ impl Zatoshis {
pub fn is_positive(&self) -> bool {
self > &Zatoshis::ZERO
}

/// Divides this `Zatoshis` value by the given divisor and returns the dividend and remainder.
pub fn div_with_remainder(&self, divisor: NonZeroUsize) -> DivRem<Zatoshis> {
let divisor = u64::try_from(usize::from(divisor)).expect("divisor fits into a u64");
DivRem {
dividend: Zatoshis(self.0 / divisor),
remainder: Zatoshis(self.0 % divisor),
}
}
}

impl From<Zatoshis> for ZatBalance {
Expand Down
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`
- `zcash_client_backend::fees::SplitPolicy`
- `zcash_client_backend::fees::zip317::MultiOutputChangeStrategy`

### Changed
- `zcash_client_backend::data_api`:
Expand Down
2 changes: 1 addition & 1 deletion zcash_client_backend/src/data_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -794,7 +794,7 @@ impl<NoteRef> SpendableNotes<NoteRef> {
}
}

/// Metadata about the structure of the wallet for a particular account.
/// Metadata about the structure of the wallet for a particular account.
///
/// At present this just contains counts of unspent outputs in each pool, but it may be extended in
/// the future to contain note values or other more detailed information about wallet structure.
Expand Down
179 changes: 174 additions & 5 deletions zcash_client_backend/src/data_api/testing/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::{
cmp::Eq,
convert::Infallible,
hash::Hash,
num::{NonZeroU32, NonZeroU8},
num::{NonZeroU32, NonZeroU8, NonZeroUsize},
};

use assert_matches::assert_matches;
Expand All @@ -17,7 +17,7 @@ use zcash_primitives::{
legacy::TransparentAddress,
transaction::{
components::amount::NonNegativeAmount,
fees::{fixed::FeeRule as FixedFeeRule, StandardFeeRule},
fees::{fixed::FeeRule as FixedFeeRule, zip317::FeeRule as Zip317FeeRule, StandardFeeRule},
Transaction,
},
};
Expand Down Expand Up @@ -48,9 +48,9 @@ use crate::{
},
decrypt_transaction,
fees::{
fixed,
self,
standard::{self, SingleOutputChangeStrategy},
DustOutputPolicy,
DustOutputPolicy, SplitPolicy,
},
scanning::ScanError,
wallet::{Note, NoteId, OvkPolicy, ReceivedNote},
Expand Down Expand Up @@ -316,6 +316,175 @@ pub fn send_single_step_proposed_transfer<T: ShieldedPoolTester>(
);
}

pub fn send_with_multiple_change_outputs<T: ShieldedPoolTester>(
dsf: impl DataStoreFactory,
cache: impl TestCache,
) {
let mut st = TestBuilder::new()
.with_data_store_factory(dsf)
.with_block_cache(cache)
.with_account_from_sapling_activation(BlockHash([0; 32]))
.build();

let account = st.test_account().cloned().unwrap();
let dfvk = T::test_account_fvk(&st);

// Add funds to the wallet in a single note
let value = Zatoshis::const_from_u64(650_0000);
let (h, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value);
st.scan_cached_blocks(h, 1);

// Spendable balance matches total balance
assert_eq!(st.get_total_balance(account.id()), value);
assert_eq!(st.get_spendable_balance(account.id(), 1), value);

assert_eq!(
st.wallet()
.block_max_scanned()
.unwrap()
.unwrap()
.block_height(),
h
);

let to_extsk = T::sk(&[0xf5; 32]);
let to: Address = T::sk_default_address(&to_extsk);
let request = zip321::TransactionRequest::new(vec![Payment::without_memo(
to.to_zcash_address(st.network()),
Zatoshis::const_from_u64(100_0000),
)])
.unwrap();

let input_selector = GreedyInputSelector::new();
let change_memo = "Test change memo".parse::<Memo>().unwrap();
let change_strategy = fees::zip317::MultiOutputChangeStrategy::new(
Zip317FeeRule::standard(),
Some(change_memo.clone().into()),
T::SHIELDED_PROTOCOL,
DustOutputPolicy::default(),
SplitPolicy::new(
NonZeroUsize::new(2).unwrap(),
NonNegativeAmount::const_from_u64(100_0000),
),
);

let proposal = st
.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
request.clone(),
NonZeroU32::new(1).unwrap(),
)
.unwrap();

let step = &proposal.steps().head;
assert_eq!(step.balance().proposed_change().len(), 2);

let create_proposed_result = st.create_proposed_transactions::<Infallible, _, Infallible>(
account.usk(),
OvkPolicy::Sender,
&proposal,
);
assert_matches!(&create_proposed_result, Ok(txids) if txids.len() == 1);

let sent_tx_id = create_proposed_result.unwrap()[0];

// Verify that the sent transaction was stored and that we can decrypt the memos
let tx = st
.wallet()
.get_transaction(sent_tx_id)
.unwrap()
.expect("Created transaction was stored.");
let ufvks = [(account.id(), account.usk().to_unified_full_viewing_key())]
.into_iter()
.collect();
let d_tx = decrypt_transaction(st.network(), h + 1, &tx, &ufvks);
assert_eq!(T::decrypted_pool_outputs_count(&d_tx), 3);

let mut found_tx_change_memo = false;
let mut found_tx_empty_memo = false;
T::with_decrypted_pool_memos(&d_tx, |memo| {
if Memo::try_from(memo).unwrap() == change_memo {
found_tx_change_memo = true
}
if Memo::try_from(memo).unwrap() == Memo::Empty {
found_tx_empty_memo = true
}
});
assert!(found_tx_change_memo);
assert!(found_tx_empty_memo);

// Verify that the stored sent notes match what we're expecting
let sent_note_ids = st
.wallet()
.get_sent_note_ids(&sent_tx_id, T::SHIELDED_PROTOCOL)
.unwrap();
assert_eq!(sent_note_ids.len(), 3);

// The sent memo should be the empty memo for the sent output, and the
// change output's memo should be as specified.
let mut change_memo_count = 0;
let mut found_sent_empty_memo = false;
for sent_note_id in sent_note_ids {
match st
.wallet()
.get_memo(sent_note_id)
.expect("Note id is valid")
.as_ref()
{
Some(m) if m == &change_memo => {
change_memo_count += 1;
}
Some(m) if m == &Memo::Empty => {
found_sent_empty_memo = true;
}
Some(other) => panic!("Unexpected memo value: {:?}", other),
None => panic!("Memo should not be stored as NULL"),

Check warning on line 443 in zcash_client_backend/src/data_api/testing/pool.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_backend/src/data_api/testing/pool.rs#L442-L443

Added lines #L442 - L443 were not covered by tests
}
}
assert_eq!(change_memo_count, 2);
assert!(found_sent_empty_memo);

let tx_history = st.wallet().get_tx_history().unwrap();
assert_eq!(tx_history.len(), 2);

let network = *st.network();
assert_matches!(

Check warning on line 453 in zcash_client_backend/src/data_api/testing/pool.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_backend/src/data_api/testing/pool.rs#L453

Added line #L453 was not covered by tests
decrypt_and_store_transaction(&network, st.wallet_mut(), &tx, None),
Ok(_)

Check warning on line 455 in zcash_client_backend/src/data_api/testing/pool.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_backend/src/data_api/testing/pool.rs#L455

Added line #L455 was not covered by tests
);

let (h, _) = st.generate_next_block_including(sent_tx_id);
st.scan_cached_blocks(h, 1);

// Now, create another proposal with more outputs requested. We have two change notes;
// we'll spend one of them, and then we'll generate 7 splits.
let change_strategy = fees::zip317::MultiOutputChangeStrategy::new(
Zip317FeeRule::standard(),
Some(change_memo.into()),
T::SHIELDED_PROTOCOL,
DustOutputPolicy::default(),
SplitPolicy::new(
NonZeroUsize::new(8).unwrap(),
NonNegativeAmount::const_from_u64(10_0000),
),
);

let proposal = st
.propose_transfer(
account.id(),
&input_selector,
&change_strategy,
request,
NonZeroU32::new(1).unwrap(),
)
.unwrap();

let step = &proposal.steps().head;
assert_eq!(step.balance().proposed_change().len(), 7);
}

#[cfg(feature = "transparent-inputs")]
pub fn send_multi_step_proposed_transfer<T: ShieldedPoolTester, DSF>(
ds_factory: DSF,
Expand Down Expand Up @@ -1414,7 +1583,7 @@ pub fn external_address_change_spends_detected_in_restore_from_seed<T: ShieldedP

#[allow(deprecated)]
let fee_rule = FixedFeeRule::standard();
let change_strategy = fixed::SingleOutputChangeStrategy::new(
let change_strategy = fees::fixed::SingleOutputChangeStrategy::new(
fee_rule,
None,
T::SHIELDED_PROTOCOL,
Expand Down
66 changes: 65 additions & 1 deletion zcash_client_backend/src/fees.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use std::fmt::{self, Debug, Display};
use std::{
fmt::{self, Debug, Display},
num::NonZeroUsize,
};

use zcash_primitives::{
consensus::{self, BlockHeight},
Expand Down Expand Up @@ -336,6 +339,67 @@ impl Default for DustOutputPolicy {
}
}

/// A policy that describes how change output should be split into multiple notes for the purpose
/// of note management.
#[derive(Clone, Copy, Debug)]
pub struct SplitPolicy {
target_output_count: NonZeroUsize,
min_split_output_size: NonNegativeAmount,
}

impl SplitPolicy {
/// Constructs a new [`SplitPolicy`] from its constituent parts.
pub fn new(
target_output_count: NonZeroUsize,
min_split_output_size: NonNegativeAmount,
) -> Self {
Self {
target_output_count,
min_split_output_size,
}
}

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

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

/// 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.
pub fn split_count(
&self,
existing_notes: usize,
total_change: NonNegativeAmount,
) -> 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(split_count);
if split_count > NonZeroUsize::MIN
&& *per_output_change.dividend() < self.min_split_output_size
{
// safety: `split_count` has just been verified to be > 1
split_count = unsafe { NonZeroUsize::new_unchecked(usize::from(split_count) - 1) };
} else {
return split_count;
}
}
}
}

/// `EphemeralBalance` describes the ephemeral input or output value for a transaction. It is used
/// in fee computation for series of transactions that use an ephemeral transparent output in an
/// intermediate step, such as when sending from a shielded pool to a [ZIP 320] "TEX" address.
Expand Down
Loading

0 comments on commit e6c1fa3

Please sign in to comment.