use zcash_address::ZcashAddress;
use zcash_client_backend::{
data_api::{
MaxSpendMode,
wallet::{
ConfirmationsPolicy,
input_selection::{GreedyInputSelector, LockedInputPolicy, SpendPolicy},
propose_send_max_transfer,
},
},
fees::{DustAction, DustOutputPolicy},
zip321::TransactionRequest,
};
use zcash_protocol::{
ShieldedPool,
consensus::{BlockHeight, NetworkUpgrade, Parameters},
memo::{Memo, MemoBytes},
value::Zatoshis,
};
use super::{
LightWallet,
error::{ProposeSendError, ProposeShieldError, WalletError},
};
use crate::{
config::ChainType,
data::{
proposal::{ProportionalFeeProposal, ZingoProposal},
receivers::{Receiver, transaction_request_from_receivers},
},
};
use pepper_sync::sync::{ScanPriority, ScanRange};
const SEND_ALL_PROPOSAL_ATTEMPTS: u32 = 4;
impl LightWallet {
pub(crate) fn create_send_proposal(
&mut self,
request: TransactionRequest,
account_id: zip32::AccountId,
) -> Result<ProportionalFeeProposal, ProposeSendError> {
let memo = self.change_memo_from_transaction_request(&request);
let input_selector = GreedyInputSelector::new();
let chain_height =
self.sync_state
.last_known_chain_height()
.ok_or(ProposeSendError::Proposal(
zcash_client_backend::data_api::error::Error::ScanRequired,
))?;
let change_strategy = zcash_client_backend::fees::zip317::SingleOutputChangeStrategy::new(
zcash_primitives::transaction::fees::zip317::FeeRule::standard(),
Some(memo),
if self
.chain_type
.activation_height(NetworkUpgrade::Nu6_3)
.is_some_and(|ironwood_height| chain_height >= ironwood_height)
{
ShieldedPool::Ironwood
} else {
ShieldedPool::Orchard
},
DustOutputPolicy::new(DustAction::AllowDustChange, None),
);
let chain_type = self.chain_type;
zcash_client_backend::data_api::wallet::propose_transfer::<
LightWallet,
ChainType,
GreedyInputSelector<LightWallet>,
zcash_client_backend::fees::zip317::SingleOutputChangeStrategy<
zcash_primitives::transaction::fees::zip317::FeeRule,
LightWallet,
>,
WalletError,
>(
self,
&chain_type,
account_id,
&input_selector,
&change_strategy,
request,
ConfirmationsPolicy::new_symmetrical(self.wallet_settings.min_confirmations, false),
&SpendPolicy::default(),
None,
None,
)
.map_err(ProposeSendError::Proposal)
}
pub(crate) fn create_send_all_proposal(
&mut self,
address: ZcashAddress,
memo: Option<MemoBytes>,
account_id: zip32::AccountId,
) -> Result<ProportionalFeeProposal, ProposeSendError> {
let sizing = self.propose_send_max(address.clone(), memo.clone(), account_id)?;
let mut amount = recipient_amount(&sizing);
let request = |amount: Zatoshis| {
transaction_request_from_receivers(vec![Receiver::new(
address.clone(),
amount,
memo.clone(),
)])
};
for _ in 0..SEND_ALL_PROPOSAL_ATTEMPTS {
let attempt = self.create_send_proposal(request(amount)?, account_id);
let Err(ProposeSendError::Proposal(
zcash_client_backend::data_api::error::Error::InsufficientFunds {
required,
available,
},
)) = &attempt
else {
return attempt;
};
match send_all_correction(amount, *required, *available) {
SendAllCorrection::Retry(corrected_amount) => amount = corrected_amount,
SendAllCorrection::NothingToSend => return attempt,
SendAllCorrection::Unbalanced => break,
}
}
Ok(sizing)
}
fn propose_send_max(
&mut self,
address: ZcashAddress,
memo: Option<MemoBytes>,
account_id: zip32::AccountId,
) -> Result<ProportionalFeeProposal, ProposeSendError> {
let chain_type = self.chain_type;
let confirmations_policy =
ConfirmationsPolicy::new_symmetrical(self.wallet_settings.min_confirmations, false);
propose_send_max_transfer::<
LightWallet,
ChainType,
zcash_primitives::transaction::fees::zip317::FeeRule,
WalletError,
>(
self,
&chain_type,
account_id,
&[
ShieldedPool::Ironwood,
ShieldedPool::Orchard,
ShieldedPool::Sapling,
],
&zcash_primitives::transaction::fees::zip317::FeeRule::standard(),
address,
memo,
MaxSpendMode::MaxSpendable,
confirmations_policy,
&LockedInputPolicy::Exclude,
None,
)
.map_err(ProposeSendError::Proposal)
}
pub(crate) fn create_shield_proposal(
&mut self,
account_id: zip32::AccountId,
) -> Result<crate::data::proposal::ProportionalFeeShieldProposal, ProposeShieldError> {
let input_selector = GreedyInputSelector::new();
let change_strategy = zcash_client_backend::fees::zip317::SingleOutputChangeStrategy::new(
zcash_primitives::transaction::fees::zip317::FeeRule::standard(),
None,
ShieldedPool::Orchard,
DustOutputPolicy::new(DustAction::AllowDustChange, None),
);
let chain_type = self.chain_type;
let transparent_addresses = self
.transparent_addresses
.values()
.map(|address| {
Ok(zcash_address::ZcashAddress::try_from_encoded(address)?
.convert_if_network::<zcash_transparent::address::TransparentAddress>(
self.chain_type.network_type(),
)
.expect("incorrect network should be checked on wallet load"))
})
.collect::<Result<Vec<_>, zcash_address::ParseError>>()?;
let proposed_shield = zcash_client_backend::data_api::wallet::propose_shielding::<
LightWallet,
ChainType,
GreedyInputSelector<LightWallet>,
zcash_client_backend::fees::zip317::SingleOutputChangeStrategy<
zcash_primitives::transaction::fees::zip317::FeeRule,
LightWallet,
>,
WalletError,
>(
self,
&chain_type,
&input_selector,
&change_strategy,
Zatoshis::const_from_u64(10_000),
&transparent_addresses,
account_id,
ConfirmationsPolicy::new_symmetrical(self.wallet_settings.min_confirmations, false),
zcash_client_backend::data_api::CoinbaseFilter::AllTransparentOutputs,
None,
)
.map_err(ProposeShieldError::Component)?;
for step in proposed_shield.steps().iter() {
if step
.balance()
.proposed_change()
.iter()
.fold(0, |total_out, output| total_out + output.value().into_u64())
== 0
{
return Err(ProposeShieldError::InsufficientFunds);
}
}
Ok(proposed_shield)
}
pub(crate) fn store_proposal(&mut self, proposal: ZingoProposal) {
self.send_proposal = Some(proposal);
}
pub(crate) fn take_proposal(&mut self) -> Option<ZingoProposal> {
self.send_proposal.take()
}
fn change_memo_from_transaction_request(&self, request: &TransactionRequest) -> MemoBytes {
let recipient_uas = request
.payments()
.values()
.filter_map(|payment| {
payment
.recipient_address()
.clone()
.convert_if_network::<zcash_keys::address::Address>(
self.chain_type.network_type(),
)
.ok()
})
.filter_map(|address| match address {
zcash_keys::address::Address::Unified(unified_address) => Some(*unified_address),
_ => None,
})
.collect::<Vec<_>>();
let uas_bytes = match zingo_memo::create_wallet_internal_memo(
&self.chain_type,
recipient_uas.as_slice(),
) {
Ok(packed) => {
if packed.recorded < recipient_uas.len() {
log::error!(
"The memo field holds {} of the {} recipient addresses of this send. \
After a rescan the wallet will not display the sent-to address of the \
rest. This is a visual error only. The correct addresses were sent to.",
packed.recorded,
recipient_uas.len()
);
}
packed.bytes
}
Err(e) => {
log::error!(
"Could not write uas to memo field: {e}\n\
Your wallet will display an incorrect sent-to address. This is a visual error only.\n\
The correct address was sent to."
);
[0; zingo_memo::MEMO_LEN]
}
};
MemoBytes::from(Memo::Arbitrary(Box::new(uas_bytes)))
}
pub(crate) fn spend_horizon(&self, all_spends_known: bool) -> Option<BlockHeight> {
let mut scan_ranges_top_to_bottom = self.sync_state.scan_ranges().iter().rev();
let awaits_spend_detection = |scan_range: &&ScanRange| {
if all_spends_known {
scan_range.priority() >= ScanPriority::FoundNote
|| scan_range.priority() == ScanPriority::Scanning
} else {
!scan_range.priority().is_scanned()
}
};
let highest_range_awaiting_detection =
scan_ranges_top_to_bottom.find(awaits_spend_detection);
highest_range_awaiting_detection
.map(|awaiting_range| awaiting_range.block_range().end)
.or_else(|| self.sync_state.wallet_birthday())
}
pub(crate) fn note_spends_confirmed(
&self,
note_height: BlockHeight,
spend_horizon: BlockHeight,
refetch_nullifier_ranges: &[std::ops::Range<BlockHeight>],
) -> bool {
note_height >= spend_horizon
&& refetch_nullifier_ranges.iter().all(|refetch_range| {
self.sync_state.scan_ranges().iter().any(|scan_range| {
scan_range.priority() == ScanPriority::Scanned
&& scan_range.block_range().contains(&refetch_range.start)
&& scan_range.block_range().contains(&(refetch_range.end - 1))
})
})
}
}
pub(crate) fn recipient_amount(proposal: &ProportionalFeeProposal) -> Zatoshis {
proposal
.steps()
.last()
.transaction_request()
.payments()
.get(&0)
.and_then(|payment| payment.amount())
.unwrap_or(Zatoshis::ZERO)
}
#[derive(Debug, PartialEq, Eq)]
enum SendAllCorrection {
Retry(Zatoshis),
NothingToSend,
Unbalanced,
}
fn send_all_correction(
amount: Zatoshis,
required: Zatoshis,
available: Zatoshis,
) -> SendAllCorrection {
let Some(shortfall) = (required - available).filter(|shortfall| *shortfall > Zatoshis::ZERO)
else {
return SendAllCorrection::Unbalanced;
};
match (amount - shortfall).filter(|corrected| *corrected > Zatoshis::ZERO) {
Some(corrected) => SendAllCorrection::Retry(corrected),
None => SendAllCorrection::NothingToSend,
}
}
#[cfg(test)]
mod send_all_correction {
use zcash_protocol::value::Zatoshis;
use super::{SendAllCorrection, send_all_correction};
fn zats(value: u64) -> Zatoshis {
Zatoshis::const_from_u64(value)
}
#[test]
fn a_shortfall_below_the_amount_retries_with_the_amount_reduced() {
assert_eq!(
send_all_correction(zats(125_000), zats(155_000), zats(150_000)),
SendAllCorrection::Retry(zats(120_000))
);
}
#[test]
fn a_shortfall_equal_to_the_amount_leaves_nothing_to_send() {
assert_eq!(
send_all_correction(zats(5_000), zats(155_000), zats(150_000)),
SendAllCorrection::NothingToSend
);
}
#[test]
fn a_shortfall_above_the_amount_leaves_nothing_to_send() {
assert_eq!(
send_all_correction(zats(25_000), zats(1_035_000), zats(1_000_000)),
SendAllCorrection::NothingToSend
);
}
#[test]
fn a_refusal_with_no_shortfall_is_unbalanced() {
assert_eq!(
send_all_correction(zats(76_000), zats(101_000), zats(160_000)),
SendAllCorrection::Unbalanced
);
assert_eq!(
send_all_correction(zats(76_000), zats(160_000), zats(160_000)),
SendAllCorrection::Unbalanced
);
}
}
#[cfg(test)]
mod test {
use zcash_protocol::{PoolType, ShieldedPool};
use crate::{
testutils::lightclient::from_inputs::transaction_request_from_send_inputs,
testutils::synthetic_wallet::SyntheticWalletBuilder,
wallet::keys::unified::ReceiverSelection,
};
#[test]
fn proposal_targets_best_pool_per_unified_address() {
let mut wallet =
SyntheticWalletBuilder::new(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED)
.ironwood_note(100_000)
.build();
let chain = wallet.chain_type;
let (_, orchard_only) = wallet
.generate_unified_address(ReceiverSelection::orchard_only(), zip32::AccountId::ZERO)
.unwrap();
let (_, all_shielded) = wallet
.generate_unified_address(ReceiverSelection::all_shielded(), zip32::AccountId::ZERO)
.unwrap();
let (_, sapling_only) = wallet
.generate_unified_address(ReceiverSelection::sapling_only(), zip32::AccountId::ZERO)
.unwrap();
let orchard_only = orchard_only.encode(&chain);
let all_shielded = all_shielded.encode(&chain);
let sapling_only = sapling_only.encode(&chain);
let request = transaction_request_from_send_inputs(vec![
(orchard_only.as_str(), 10_000, None),
(all_shielded.as_str(), 10_000, None),
(sapling_only.as_str(), 10_000, None),
])
.expect("valid send inputs form a request");
let proposal = wallet
.create_send_proposal(request, zip32::AccountId::ZERO)
.expect("synthetic wallet data supports proposing");
let step = proposal.steps().first();
let pools = step.payment_pools();
assert_eq!(
pools[&0],
PoolType::Shielded(ShieldedPool::Ironwood),
"orchard-only UA must be paid in ironwood"
);
assert_eq!(
pools[&1],
PoolType::Shielded(ShieldedPool::Ironwood),
"all-shielded UA must be paid in its best pool, ironwood"
);
assert_eq!(
pools[&2],
PoolType::Shielded(ShieldedPool::Sapling),
"sapling-only UA must be paid in sapling"
);
}
#[test]
fn propose_orchard_dust_to_sapling() {
let mut wallet =
SyntheticWalletBuilder::new(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED)
.orchard_note(100_000)
.orchard_note(4_000)
.build();
let mut external_wallet =
SyntheticWalletBuilder::new(zingo_test_vectors::seeds::ABANDON_ART_SEED).build();
let (_, sapling_destination) = external_wallet
.generate_unified_address(ReceiverSelection::sapling_only(), zip32::AccountId::ZERO)
.unwrap();
let sapling_destination = sapling_destination.encode(&external_wallet.chain_type());
let request = transaction_request_from_send_inputs(vec![(
sapling_destination.as_str(),
10_000,
None,
)])
.expect("valid send inputs form a request");
wallet
.create_send_proposal(request, zip32::AccountId::ZERO)
.expect("orchard funds propose cleanly to a sapling destination");
}
#[test]
fn propose_100_000_to_self() {
let mut wallet =
SyntheticWalletBuilder::new(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED)
.orchard_note(200_000)
.build();
let pool = PoolType::Shielded(ShieldedPool::Orchard);
let self_address = wallet.get_address(pool);
let receivers = vec![(self_address.as_str(), 100_000, None)];
let request = transaction_request_from_send_inputs(receivers)
.expect("actually all of this logic oughta be internal to propose");
wallet
.create_send_proposal(request, zip32::AccountId::ZERO)
.expect("can propose from existing data");
}
}