use zcash_protocol::value::Zatoshis;
use super::params::MigrationParams;
use super::quantize::decompose;
use crate::wallet::utils;
pub(crate) const MARGINAL_FEE: u64 = 5_000;
pub(crate) fn side_budget(params: &MigrationParams) -> usize {
params.max_actions_per_split_tx.saturating_sub(1).max(1)
}
pub(crate) const SWEEP_MIN: u64 = 2 * MARGINAL_FEE;
const MIN_BUNDLE_ACTIONS: u64 = 2;
pub(crate) const CANONICAL_PART_FEE: u64 = MARGINAL_FEE * 2 * MIN_BUNDLE_ACTIONS;
pub(in crate::wallet::migration) fn bundle_actions(
version: orchard::bundle::BundleVersion,
n_in: usize,
n_out: usize,
) -> usize {
orchard::builder::BundleType::DEFAULT
.num_actions(version.default_flags(), n_in, n_out)
.expect("the default bundle type permits both spends and outputs")
}
pub(in crate::wallet::migration) fn zip317_fee(
orchard_actions: usize,
ironwood_actions: usize,
) -> u64 {
use zcash_primitives::transaction::fees::{FeeRule as _, transparent, zip317};
zip317::FeeRule::standard()
.fee_required(
&zcash_protocol::consensus::MAIN_NETWORK,
zcash_protocol::consensus::BlockHeight::from_u32(1),
std::iter::empty::<transparent::InputSize>(),
std::iter::empty::<usize>(),
0,
0,
orchard_actions,
ironwood_actions,
)
.expect("a shielded action count cannot overflow the fee")
.into_u64()
}
fn orchard_version(post_activation: bool) -> orchard::bundle::BundleVersion {
if post_activation {
orchard::bundle::BundleVersion::orchard_v3()
} else {
orchard::bundle::BundleVersion::orchard_v2()
}
}
pub(crate) fn note_split_fee(n_in: usize, n_out: usize, post_activation: bool) -> u64 {
zip317_fee(
bundle_actions(orchard_version(post_activation), n_in, n_out),
0,
)
}
pub(crate) fn part_denomination(value: u64, params: &MigrationParams) -> Option<u64> {
value
.checked_sub(params.part_fee)
.filter(|d| params.denominations.contains(d))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NoteSplitTx {
pub inputs: Vec<u64>,
pub outputs: Vec<u64>,
}
impl NoteSplitTx {
pub fn fee(&self) -> u64 {
self.inputs.iter().sum::<u64>() - self.outputs.iter().sum::<u64>()
}
}
#[derive(Debug, Clone, Copy)]
pub(in crate::wallet::migration) enum MigrationOutputs<'a> {
Orchard(&'a [u64]),
Ironwood(u64),
}
impl MigrationOutputs<'_> {
fn total(&self) -> u64 {
match self {
MigrationOutputs::Orchard(values) => values.iter().sum(),
MigrationOutputs::Ironwood(value) => *value,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationPlan {
pub split_rounds: Vec<Vec<NoteSplitTx>>,
pub parts: Vec<u64>,
pub residual: u64,
}
impl MigrationPlan {
pub fn is_split(&self) -> bool {
self.split_rounds.is_empty()
}
pub fn split_fee(&self) -> u64 {
self.split_rounds
.iter()
.flatten()
.map(NoteSplitTx::fee)
.sum()
}
pub fn parts_fee(&self, params: &MigrationParams) -> u64 {
self.parts.len() as u64 * params.part_fee
}
}
pub fn plan_hash(plan: &MigrationPlan) -> [u8; 32] {
let mut hasher = blake2b_simd::Params::new()
.hash_length(32)
.personal(b"ZingoMigPlanV0__")
.to_state();
hasher.update(&(plan.split_rounds.len() as u64).to_le_bytes());
for round in &plan.split_rounds {
hasher.update(&(round.len() as u64).to_le_bytes());
for tx in round {
hasher.update(&(tx.inputs.len() as u64).to_le_bytes());
for input in &tx.inputs {
hasher.update(&input.to_le_bytes());
}
hasher.update(&(tx.outputs.len() as u64).to_le_bytes());
for output in &tx.outputs {
hasher.update(&output.to_le_bytes());
}
}
}
hasher.update(&(plan.parts.len() as u64).to_le_bytes());
for denomination in &plan.parts {
hasher.update(&denomination.to_le_bytes());
}
hasher.update(&plan.residual.to_le_bytes());
hasher
.finalize()
.as_bytes()
.try_into()
.expect("hash length is 32")
}
pub fn plan_migration(
note_values: &[u64],
post_activation: bool,
params: &MigrationParams,
) -> MigrationPlan {
let mut parts: Vec<u64> = Vec::new();
let mut pool: Vec<u64> = Vec::new();
let mut residual: u64 = 0;
let max_notes = side_budget(params);
for &value in note_values {
if let Some(denomination) = part_denomination(value, params) {
parts.push(denomination);
} else if value > params.sweep_min {
pool.push(value);
} else {
residual += value;
}
}
let mut split_rounds: Vec<Vec<NoteSplitTx>> = Vec::new();
while pool.len() > max_notes {
pool.sort_unstable();
let mut round = Vec::new();
let mut next = Vec::new();
for group in pool.chunks(max_notes) {
if group.len() < 2 {
next.push(group[0]);
continue;
}
let merged =
group.iter().sum::<u64>() - note_split_fee(group.len(), 1, post_activation);
if group.len() < max_notes && merged <= params.sweep_min {
next.extend_from_slice(group);
continue;
}
round.push(NoteSplitTx {
inputs: group.to_vec(),
outputs: vec![merged],
});
next.push(merged);
}
split_rounds.push(round);
pool = next;
}
if !pool.is_empty() {
let total: u64 = pool.iter().sum();
let mut decomposable = total;
let denominations = loop {
let candidate = decompose(Zatoshis::const_from_u64(decomposable), params);
let denominations: Vec<u64> =
candidate.outputs().iter().map(|z| u64::from(*z)).collect();
if denominations.is_empty() {
break denominations;
}
let overhead = denominations.len() as u64 * params.part_fee
+ split_into_fee(pool.len(), denominations.len(), post_activation, params);
let denomination_sum: u64 = denominations.iter().sum();
if denomination_sum + overhead <= total {
break denominations;
}
let Some(next) = total.checked_sub(overhead) else {
break Vec::new();
};
decomposable = next;
};
if denominations.is_empty() {
residual += total
+ split_rounds
.iter()
.flatten()
.map(NoteSplitTx::fee)
.sum::<u64>();
split_rounds.clear();
} else {
let targets: Vec<u64> = denominations.iter().map(|d| d + params.part_fee).collect();
split_rounds.extend(split_into(pool, &targets, post_activation, params));
parts.extend(denominations);
}
}
parts.sort_unstable_by(|a, b| b.cmp(a));
MigrationPlan {
split_rounds,
parts,
residual,
}
}
fn merge_group_len(n_in: usize, m: usize, params: &MigrationParams) -> usize {
(n_in + m + 1 - params.max_actions_per_split_tx)
.max(3)
.min(n_in)
}
fn split_into_fee(n_in: usize, m: usize, post_activation: bool, params: &MigrationParams) -> u64 {
let max_notes = side_budget(params);
if m <= max_notes {
if n_in + m > params.max_actions_per_split_tx {
let group_len = merge_group_len(n_in, m, params);
note_split_fee(group_len, 1, post_activation)
+ note_split_fee(n_in - group_len + 1, m, post_activation)
} else {
note_split_fee(n_in, m, post_activation)
}
} else {
let chunks = m.div_ceil(max_notes);
let mut total = 0;
let mut remaining = m;
for _ in 0..chunks {
let len = remaining.min(max_notes);
total += note_split_fee(1, len, post_activation);
remaining -= len;
}
total + split_into_fee(n_in, chunks, post_activation, params)
}
}
fn split_into(
mut inputs: Vec<u64>,
targets: &[u64],
post_activation: bool,
params: &MigrationParams,
) -> Vec<Vec<NoteSplitTx>> {
let max_notes = side_budget(params);
if targets.len() <= max_notes {
let mut rounds = Vec::new();
if inputs.len() + targets.len() > params.max_actions_per_split_tx {
let group_len = merge_group_len(inputs.len(), targets.len(), params);
inputs.sort_unstable();
let group: Vec<u64> = inputs.drain(..group_len).collect();
let merged =
group.iter().sum::<u64>() - note_split_fee(group.len(), 1, post_activation);
rounds.push(vec![NoteSplitTx {
inputs: group,
outputs: vec![merged],
}]);
inputs.push(merged);
}
rounds.push(vec![NoteSplitTx {
inputs,
outputs: targets.to_vec(),
}]);
return rounds;
}
let chunks: Vec<&[u64]> = targets.chunks(max_notes).collect();
let intermediates: Vec<u64> = chunks
.iter()
.map(|chunk| chunk.iter().sum::<u64>() + note_split_fee(1, chunk.len(), post_activation))
.collect();
let mut rounds = split_into(inputs, &intermediates, post_activation, params);
rounds.push(
intermediates
.iter()
.zip(&chunks)
.map(|(&intermediate, chunk)| NoteSplitTx {
inputs: vec![intermediate],
outputs: chunk.to_vec(),
})
.collect(),
);
rounds
}
impl crate::wallet::LightWallet {
#[allow(clippy::result_large_err)]
pub(crate) fn migration_note_values(
&self,
account: zip32::AccountId,
) -> Result<Vec<u64>, crate::wallet::error::WalletError> {
let (_, anchor_height) = self
.get_migration_heights()?
.ok_or(crate::wallet::error::WalletError::NoSyncData)?;
let known_unspent = self.anchored_v2_known_unspent(account, anchor_height)?;
let not_known_spent = self.anchored_v2_not_known_spent(account, anchor_height)?;
if known_unspent.is_empty() && !not_known_spent.is_empty() {
return Err(crate::wallet::error::WalletError::SyncIncomplete);
}
Ok(known_unspent)
}
#[allow(clippy::result_large_err)]
fn anchored_v2_known_unspent(
&self,
account: zip32::AccountId,
anchor_height: zcash_protocol::consensus::BlockHeight,
) -> Result<Vec<u64>, crate::wallet::error::WalletError> {
self.anchored_v2_values(account, anchor_height, false)
}
#[allow(clippy::result_large_err)]
fn anchored_v2_not_known_spent(
&self,
account: zip32::AccountId,
anchor_height: zcash_protocol::consensus::BlockHeight,
) -> Result<Vec<u64>, crate::wallet::error::WalletError> {
self.anchored_v2_values(account, anchor_height, true)
}
#[allow(clippy::result_large_err)]
fn anchored_v2_values(
&self,
account: zip32::AccountId,
anchor_height: zcash_protocol::consensus::BlockHeight,
include_potentially_spent: bool,
) -> Result<Vec<u64>, crate::wallet::error::WalletError> {
use pepper_sync::wallet::{NoteInterface as _, OutputInterface as _};
Ok(self
.spendable_notes::<pepper_sync::wallet::OrchardNote>(
anchor_height,
&[],
account,
include_potentially_spent,
)?
.into_iter()
.filter(|note| note.note().version() == orchard::note::NoteVersion::V2)
.map(|note| note.value())
.collect())
}
pub(crate) fn live_v2_note_values(&self, account: zip32::AccountId) -> Vec<u64> {
use pepper_sync::wallet::{KeyIdInterface as _, NoteInterface as _, OutputInterface as _};
self.wallet_transactions
.values()
.filter(|transaction| !transaction.status().is_failed())
.flat_map(|transaction| {
pepper_sync::wallet::OrchardNote::transaction_outputs(transaction)
})
.filter(|note| {
note.key_id().account_id() == account
&& note.note().version() == orchard::note::NoteVersion::V2
&& note.spending_transaction().is_none()
})
.map(|note| note.value())
.collect()
}
#[allow(clippy::result_large_err)]
pub(crate) fn unanchored_v2_outputs(
&self,
account: zip32::AccountId,
) -> Result<bool, crate::wallet::error::WalletError> {
use pepper_sync::wallet::{KeyIdInterface as _, NoteInterface as _, OutputInterface as _};
let (_, anchor_height) = self
.get_migration_heights()?
.ok_or(crate::wallet::error::WalletError::NoSyncData)?;
Ok(self
.wallet_transactions
.values()
.filter(|transaction| {
transaction
.status()
.get_confirmed_height()
.is_some_and(|height| height > anchor_height)
})
.flat_map(|transaction| {
pepper_sync::wallet::OrchardNote::transaction_outputs(transaction)
})
.any(|note| {
note.key_id().account_id() == account
&& note.note().version() == orchard::note::NoteVersion::V2
&& note.spending_transaction().is_none()
}))
}
pub(crate) fn note_split_in_flight(&self, account: zip32::AccountId) -> bool {
use pepper_sync::wallet::{KeyIdInterface as _, NoteInterface as _, OutputInterface as _};
self.wallet_transactions
.values()
.filter(|transaction| transaction.status().is_pending())
.flat_map(|transaction| {
pepper_sync::wallet::OrchardNote::transaction_outputs(transaction)
})
.any(|note| {
note.key_id().account_id() == account
&& note.note().version() == orchard::note::NoteVersion::V2
})
}
#[allow(clippy::result_large_err)]
pub(crate) fn build_note_split_transaction(
&mut self,
account: zip32::AccountId,
planned: &NoteSplitTx,
) -> Result<zcash_primitives::transaction::TxId, crate::wallet::error::WalletError> {
self.build_migration_transaction_inner(
account,
&planned.inputs,
MigrationOutputs::Orchard(&planned.outputs),
)
}
pub(crate) fn get_migration_heights(
&self,
) -> Result<
Option<(
zcash_protocol::consensus::BlockHeight,
zcash_protocol::consensus::BlockHeight,
)>,
crate::wallet::error::WalletError,
> {
use zcash_client_backend::data_api::WalletRead as _;
Ok(self
.get_target_and_anchor_heights(self.wallet_settings.min_confirmations)
.expect("infallible")
.map(|(target, anchor)| (target.into(), anchor)))
}
#[allow(clippy::result_large_err)]
pub(in crate::wallet::migration) fn build_migration_transaction_inner(
&mut self,
account: zip32::AccountId,
input_values: &[u64],
outputs: MigrationOutputs<'_>,
) -> Result<zcash_primitives::transaction::TxId, crate::wallet::error::WalletError> {
use pepper_sync::wallet::{NoteInterface as _, OutputInterface as _};
use zcash_primitives::transaction::builder::{BuildConfig, Builder, BundlePadding};
use zcash_protocol::memo::MemoBytes;
use crate::wallet::error::WalletError;
let (target_height, anchor_height) = self
.get_migration_heights()?
.ok_or(WalletError::NoSyncData)?;
let (notes, positions): (Vec<orchard::Note>, Vec<incrementalmerkletree::Position>) = {
let mut candidates: Vec<&pepper_sync::wallet::OrchardNote> = self
.spendable_notes::<pepper_sync::wallet::OrchardNote>(
anchor_height,
&[],
account,
false,
)?
.into_iter()
.filter(|note| note.note().version() == orchard::note::NoteVersion::V2)
.collect();
let mut notes = Vec::with_capacity(input_values.len());
let mut positions = Vec::with_capacity(input_values.len());
for &value in input_values {
let index = candidates
.iter()
.position(|note| note.value() == value)
.ok_or(WalletError::MigrationNoteNotFound(value))?;
let selected = candidates.swap_remove(index);
notes.push(*selected.note());
positions.push(selected.position().expect("spendable notes have positions"));
}
(notes, positions)
};
let orchard_anchor: orchard::Anchor = self
.shard_trees
.orchard
.root_at_checkpoint_id(&anchor_height)?
.ok_or(WalletError::CheckpointNotFound {
shielded_protocol: zcash_protocol::ShieldedPool::Orchard,
height: anchor_height,
})?
.into();
let merkle_paths = positions
.into_iter()
.map(|position| {
self.shard_trees
.orchard
.witness_at_checkpoint_id_caching(position, &anchor_height)?
.ok_or(WalletError::CheckpointNotFound {
shielded_protocol: zcash_protocol::ShieldedPool::Orchard,
height: anchor_height,
})
.map(orchard::tree::MerklePath::from)
})
.collect::<Result<Vec<_>, _>>()?;
let usk: zcash_keys::keys::UnifiedSpendingKey = self
.unified_key_store
.get(&account)
.ok_or(crate::wallet::error::KeyError::NoAccountKeys)?
.try_into()?;
let orchard_fvk = orchard::keys::FullViewingKey::from(usk.orchard());
let recipient = orchard_fvk.address_at(0u32, zip32::Scope::Internal);
let internal_ovk = orchard_fvk.to_ovk(zip32::Scope::Internal);
let input_sum: u64 = input_values.iter().sum();
let output_sum: u64 = outputs.total();
let fee_rule = zcash_primitives::transaction::fees::fixed::FeeRule::non_standard(
Zatoshis::from_u64(input_sum.checked_sub(output_sum).ok_or(
WalletError::MigrationDeviation(format!(
"planned outputs {output_sum} exceed planned inputs {input_sum}"
)),
)?)?,
);
let build_config = BuildConfig::Standard {
sapling_anchor: None,
orchard_anchor: Some(orchard_anchor),
ironwood_anchor: match outputs {
MigrationOutputs::Orchard(_) => None,
MigrationOutputs::Ironwood(_) => Some(orchard::Anchor::empty_tree()),
},
orchard_padding: BundlePadding::DEFAULT,
ironwood_padding: BundlePadding::DEFAULT,
};
let mut builder =
Builder::new(self.chain_type, target_height, build_config).with_expiry_height(
crate::wallet::expiry::tx_expiry_height(&self.chain_type, target_height),
);
for (note, merkle_path) in notes.into_iter().zip(merkle_paths) {
builder
.add_orchard_spend::<std::convert::Infallible>(
orchard_fvk.clone(),
note,
merkle_path,
)
.map_err(|e| WalletError::MigrationBuild(format!("{e}")))?;
}
match outputs {
MigrationOutputs::Orchard(output_values) => {
for &value in output_values {
builder
.add_orchard_change_output::<std::convert::Infallible>(
orchard_fvk.clone(),
Some(internal_ovk.clone()),
recipient,
Zatoshis::from_u64(value)?,
MemoBytes::empty(),
)
.map_err(|e| WalletError::MigrationBuild(format!("{e}")))?;
}
}
MigrationOutputs::Ironwood(value) => {
builder
.add_ironwood_output::<std::convert::Infallible>(
Some(internal_ovk.clone()),
recipient,
Zatoshis::from_u64(value)?,
MemoBytes::empty(),
)
.map_err(|e| WalletError::MigrationBuild(format!("{e}")))?;
}
}
let sapling_prover = utils::LazySaplingProver::default();
let build_result = builder
.build(
&zcash_transparent::builder::TransparentSigningSet::new(),
&[usk.sapling().clone()],
&[usk.orchard().into()],
crate::utils::system_rng(),
&sapling_prover,
&sapling_prover,
&fee_rule,
)
.map_err(|e| WalletError::MigrationBuild(format!("{e:?}")))?;
let mut raw_tx = Vec::new();
build_result
.transaction()
.write(&mut raw_tx)
.map_err(WalletError::TransactionWrite)?;
self.record_migration_transaction(&raw_tx, target_height)
}
#[allow(clippy::result_large_err)]
pub(in crate::wallet::migration) fn record_migration_transaction(
&mut self,
raw_tx: &[u8],
target_height: zcash_protocol::consensus::BlockHeight,
) -> Result<zcash_primitives::transaction::TxId, crate::wallet::error::WalletError> {
use crate::wallet::error::WalletError;
let transaction = zcash_primitives::transaction::Transaction::read(
raw_tx,
zcash_protocol::consensus::BranchId::for_height(&self.chain_type, target_height),
)
.map_err(WalletError::TransactionRead)?;
let txid = transaction.txid();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock after unix epoch")
.as_secs() as u32;
let chain_type = self.chain_type;
let ufvks = pepper_sync::wallet::traits::SyncWallet::get_unified_full_viewing_keys(self)?;
match pepper_sync::scan_pending_transaction(
&chain_type,
&ufvks,
self,
transaction,
zingo_status::confirmation_status::ConfirmationStatus::Calculated(target_height),
timestamp,
) {
Ok(()) => (),
Err(pepper_sync::error::SyncError::ScanError(e)) => return Err(e.into()),
Err(pepper_sync::error::SyncError::WalletError(e)) => return Err(e),
Err(_) => {
panic!("`scan_pending_transaction` should only return scan or wallet errors")
}
}
self.save_required = true;
Ok(txid)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ChainType;
use proptest::prelude::*;
use zcash_protocol::value::COIN;
fn params() -> MigrationParams {
MigrationParams::provisional(ChainType::Mainnet)
}
#[test]
fn mirrored_constants_match_the_crates() {
use orchard::bundle::BundleVersion;
use zcash_primitives::transaction::fees::zip317;
assert_eq!(MARGINAL_FEE, zip317::MARGINAL_FEE.into_u64());
assert_eq!(
bundle_actions(BundleVersion::orchard_v3(), 1, 0) as u64,
MIN_BUNDLE_ACTIONS
);
assert_eq!(
bundle_actions(BundleVersion::ironwood_v3(), 0, 1) as u64,
MIN_BUNDLE_ACTIONS
);
}
#[test]
fn sweep_min_covers_the_marginal_input_cost() {
assert!(params().sweep_min >= MARGINAL_FEE);
}
#[test]
fn note_split_fee_follows_the_activation_boundary() {
assert_eq!(note_split_fee(1, 1, false), 10_000);
assert_eq!(note_split_fee(32, 1, false), 160_000);
assert_eq!(note_split_fee(32, 1, true), 165_000);
}
#[test]
fn part_fee_matches_the_crates() {
use orchard::bundle::BundleVersion;
assert_eq!(
CANONICAL_PART_FEE,
zip317_fee(
bundle_actions(BundleVersion::orchard_v3(), 1, 0),
bundle_actions(BundleVersion::ironwood_v3(), 0, 1),
)
);
assert_eq!(CANONICAL_PART_FEE, 20_000);
assert_eq!(params().part_fee, CANONICAL_PART_FEE);
}
#[test]
fn part_denomination_requires_exact_sizing() {
let params = params();
let fee = params.part_fee;
assert_eq!(part_denomination(1_000_000 + fee, ¶ms), Some(1_000_000));
assert_eq!(
part_denomination(100 * COIN + fee, ¶ms),
Some(100 * COIN)
);
assert_eq!(part_denomination(1_000_000, ¶ms), None); assert_eq!(part_denomination(1_000_000 + fee + 1, ¶ms), None);
assert_eq!(part_denomination(0, ¶ms), None);
}
#[test]
fn plan_hash_binds_the_plan_content() {
let plan = plan_migration(&[123_456_789], true, ¶ms());
let hash = plan_hash(&plan);
assert_eq!(hash, plan_hash(&plan.clone()));
let mut altered = plan.clone();
altered.residual += 1;
assert_ne!(hash, plan_hash(&altered));
}
fn assert_plan_executes(notes: &[u64], params: &MigrationParams) -> MigrationPlan {
assert_plan_executes_in_era(notes, false, params);
assert_plan_executes_in_era(notes, true, params)
}
fn assert_plan_executes_in_era(
notes: &[u64],
post_activation: bool,
params: &MigrationParams,
) -> MigrationPlan {
use std::collections::HashMap;
let max_notes = side_budget(params);
let plan = plan_migration(notes, post_activation, params);
let mut available: HashMap<u64, i64> = HashMap::new();
for ¬e in notes {
if part_denomination(note, params).is_none() && note <= params.sweep_min {
continue; }
if plan.split_rounds.is_empty()
&& plan.parts.is_empty()
&& part_denomination(note, params).is_none()
{
continue;
}
*available.entry(note).or_insert(0) += 1;
}
for round in &plan.split_rounds {
for tx in round {
assert!((2..=max_notes).contains(&tx.inputs.len()) || tx.inputs.len() == 1);
assert!(tx.inputs.len() <= max_notes, "too many inputs");
assert!(!tx.outputs.is_empty() && tx.outputs.len() <= max_notes);
assert!(
tx.inputs.len() + tx.outputs.len() <= params.max_actions_per_split_tx,
"spends plus outputs ({} + {}) exceed the total budget of {}",
tx.inputs.len(),
tx.outputs.len(),
params.max_actions_per_split_tx
);
let min_fee = note_split_fee(tx.inputs.len(), tx.outputs.len(), post_activation);
assert!(tx.fee() >= min_fee, "fee below ZIP-317 conventional");
for input in &tx.inputs {
let count = available.entry(*input).or_insert(0);
assert!(*count > 0, "spends a note the wallet does not hold");
*count -= 1;
}
}
for tx in round {
for output in &tx.outputs {
assert!(*output > params.sweep_min, "note splitting created dust");
*available.entry(*output).or_insert(0) += 1;
}
}
}
let mut expected: HashMap<u64, i64> = HashMap::new();
for &denomination in &plan.parts {
*expected.entry(denomination + params.part_fee).or_insert(0) += 1;
}
available.retain(|_, count| *count != 0);
expected.retain(|_, count| *count != 0);
assert_eq!(available, expected, "post-splitting notes ≠ part set");
plan
}
fn assert_planned_inputs_exceed_sweep_min(plan: &MigrationPlan, params: &MigrationParams) {
for (round, transactions) in plan.split_rounds.iter().enumerate() {
for tx in transactions {
for &input in &tx.inputs {
assert!(
input > params.sweep_min,
"round {round} spends a {input}-zatoshi note, \
at or below sweep_min ({})",
params.sweep_min
);
}
}
}
}
#[test]
fn sizing_respects_the_total_action_budget() {
let params = params();
let notes = vec![1_100_000_007u64; 15];
for post_activation in [false, true] {
let plan = assert_plan_executes_in_era(¬es, post_activation, ¶ms);
assert!(
plan.parts.len() >= 2,
"the shape under test needs several targets, got {:?}",
plan.parts
);
assert!(
plan.split_rounds.len() >= 2,
"expected consolidation + sizing"
);
assert!(
plan.split_rounds
.iter()
.flatten()
.any(|tx| tx.inputs.len() >= 2 && tx.outputs.len() >= 2),
"the plan under test must exercise a mixed spend/output transaction"
);
}
}
#[test]
fn notes_at_or_below_sweep_min_are_never_selected() {
let params = params();
let dust = [1, MARGINAL_FEE, params.sweep_min - 1, params.sweep_min];
let mut notes = dust.to_vec();
notes.extend([params.sweep_min + 1, 2_000_000, 3_000_000]);
for post_activation in [false, true] {
let plan = plan_migration(¬es, post_activation, ¶ms);
assert_planned_inputs_exceed_sweep_min(&plan, ¶ms);
assert_eq!(plan.residual, dust.iter().sum::<u64>());
assert!(
plan.split_rounds[0]
.iter()
.any(|tx| tx.inputs.contains(&(params.sweep_min + 1)))
);
}
}
#[test]
fn consolidation_never_creates_a_sub_sweep_min_intermediate() {
let params = params();
let notes = vec![12_000u64; 12 * side_budget(¶ms) + 2];
for post_activation in [false, true] {
let plan = assert_plan_executes_in_era(¬es, post_activation, ¶ms);
assert_planned_inputs_exceed_sweep_min(&plan, ¶ms);
}
let plan = plan_migration(¬es, true, ¶ms);
assert_eq!(plan.split_rounds[0].len(), 12);
assert_eq!(
plan.split_rounds[1][0]
.inputs
.iter()
.filter(|&&value| value == 12_000)
.count(),
2
);
}
#[test]
fn already_split_wallet_skips_splitting() {
let params = params();
let fee = params.part_fee;
let notes = vec![COIN + fee, COIN / 10 + fee, 1_000_000 + fee];
let plan = assert_plan_executes(¬es, ¶ms);
assert!(plan.is_split());
assert_eq!(plan.parts, vec![COIN, COIN / 10, 1_000_000]);
assert_eq!(plan.residual, 0);
}
#[test]
fn dust_only_wallet_leaves_everything_residual() {
let params = params();
let notes = vec![5_000, params.sweep_min, 1];
let plan = assert_plan_executes(¬es, ¶ms);
assert!(plan.split_rounds.is_empty());
assert!(plan.parts.is_empty());
assert_eq!(plan.residual, 5_000 + params.sweep_min + 1);
}
#[test]
fn sub_denomination_pool_is_residual_without_splitting() {
let notes = vec![20_000, 30_000];
let plan = assert_plan_executes(¬es, ¶ms());
assert!(plan.split_rounds.is_empty());
assert!(plan.parts.is_empty());
assert_eq!(plan.residual, 50_000);
}
#[test]
fn single_note_is_split_in_one_transaction() {
let params = params();
let notes = vec![123_456_789]; let plan = assert_plan_executes(¬es, ¶ms);
assert_eq!(plan.split_rounds.len(), 1);
assert_eq!(plan.split_rounds[0].len(), 1);
let tx = &plan.split_rounds[0][0];
assert_eq!(tx.inputs, vec![123_456_789]);
let denomination_sum: u64 = plan.parts.iter().sum();
assert_eq!(
123_456_789,
denomination_sum + plan.parts_fee(¶ms) + plan.split_fee() + plan.residual
);
}
#[test]
fn fragmented_wallet_consolidates_in_rounds() {
let params = params();
let notes = vec![150_000u64; 500];
let plan = assert_plan_executes(¬es, ¶ms);
assert!(
plan.split_rounds.len() >= 2,
"expected consolidation + sizing rounds"
);
let max_notes = side_budget(¶ms);
assert_eq!(plan.split_rounds[0].len(), 500usize.div_ceil(max_notes));
for tx in &plan.split_rounds[0] {
assert!(tx.inputs.len() <= max_notes);
assert_eq!(tx.outputs.len(), 1);
}
assert!(!plan.parts.is_empty());
}
#[test]
fn whale_balance_splits_through_intermediates() {
let params = params();
let notes = vec![500_000 * COIN];
let plan = assert_plan_executes(¬es, ¶ms);
assert!(plan.parts.len() >= 49);
for round in &plan.split_rounds {
for tx in round {
assert!(tx.outputs.len() <= side_budget(¶ms));
}
}
}
#[test]
fn very_large_balance_recurses_splitting() {
let params = params();
let notes = vec![12_000_000 * COIN];
let plan = assert_plan_executes(¬es, ¶ms);
assert!(plan.parts.len() > params.max_actions_per_split_tx);
assert!(plan.split_rounds.len() >= 3);
}
#[test]
fn mixed_wallet_leaves_ready_notes_untouched() {
let params = params();
let mut notes = vec![10 * COIN + params.part_fee];
notes.extend(vec![200_000u64; 50]);
let plan = assert_plan_executes(¬es, ¶ms);
assert!(plan.parts.contains(&(10 * COIN)));
}
proptest! {
#[test]
fn plan_executes_and_conserves_value(
notes in proptest::collection::vec(1u64..=10_000_000_000, 0..300)
) {
let params = params();
let plan = assert_plan_executes(¬es, ¶ms);
let total: u64 = notes.iter().sum();
let denomination_sum: u64 = plan.parts.iter().sum();
prop_assert_eq!(
total,
denomination_sum + plan.parts_fee(¶ms) + plan.split_fee() + plan.residual
);
}
#[test]
fn fragmented_plans_execute(
notes in proptest::collection::vec(10_001u64..=500_000, 100..400)
) {
let params = params();
let plan = assert_plan_executes(¬es, ¶ms);
let total: u64 = notes.iter().sum();
let denomination_sum: u64 = plan.parts.iter().sum();
prop_assert_eq!(
total,
denomination_sum + plan.parts_fee(¶ms) + plan.split_fee() + plan.residual
);
prop_assert!(plan.split_rounds.len() <= 4);
}
#[test]
fn planner_honors_any_action_budget(
notes in proptest::collection::vec(10_001u64..=500_000, 0..200),
budget in prop_oneof![Just(8usize), Just(32usize), Just(100usize)],
) {
let mut params = params();
params.max_actions_per_split_tx = budget;
assert_plan_executes(¬es, ¶ms);
}
}
}