use orchard::bundle::BundleVersion;
use zcash_primitives::transaction::TxId;
use super::params::MigrationParams;
use super::split::{MigrationOutputs, bundle_actions, side_budget, zip317_fee};
use crate::wallet::error::WalletError;
fn immediate_migration_fee(n_in: usize) -> u64 {
zip317_fee(
bundle_actions(BundleVersion::orchard_v3(), n_in, 0),
bundle_actions(BundleVersion::ironwood_v3(), 0, 1),
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImmediateMigrationTx {
pub inputs: Vec<u64>,
pub output: u64,
}
impl ImmediateMigrationTx {
pub fn fee(&self) -> u64 {
self.inputs.iter().sum::<u64>() - self.output
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ImmediateMigrationPlan {
pub transactions: Vec<ImmediateMigrationTx>,
pub migrated: u64,
pub fee: u64,
pub residual: u64,
}
impl ImmediateMigrationPlan {
pub fn is_empty(&self) -> bool {
self.transactions.is_empty()
}
}
pub(crate) fn plan_immediate_migration(
note_values: &[u64],
params: &MigrationParams,
) -> ImmediateMigrationPlan {
let mut plan = ImmediateMigrationPlan::default();
let sweep_min = params.sweep_min;
let (spendable, dust): (Vec<u64>, Vec<u64>) =
note_values.iter().partition(|&&value| value > sweep_min);
plan.residual = dust.iter().sum();
for chunk in spendable.chunks(side_budget(params)) {
let total: u64 = chunk.iter().sum();
let fee = immediate_migration_fee(chunk.len());
match total.checked_sub(fee) {
Some(output) if output > sweep_min => {
plan.migrated += output;
plan.fee += fee;
plan.transactions.push(ImmediateMigrationTx {
inputs: chunk.to_vec(),
output,
});
}
_ => plan.residual += total,
}
}
plan
}
impl crate::wallet::LightWallet {
#[allow(clippy::result_large_err)]
pub(crate) fn plan_immediate_migration(
&self,
account: zip32::AccountId,
) -> Result<ImmediateMigrationPlan, WalletError> {
Ok(plan_immediate_migration(
&self.migration_note_values(account)?,
&MigrationParams::provisional(self.chain_type()),
))
}
#[allow(clippy::result_large_err)]
pub(crate) fn build_immediate_migration_transaction(
&mut self,
account: zip32::AccountId,
planned: &ImmediateMigrationTx,
) -> Result<TxId, WalletError> {
self.build_migration_transaction_inner(
account,
&planned.inputs,
MigrationOutputs::Ironwood(planned.output),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ChainType;
use crate::wallet::migration::split::{CANONICAL_PART_FEE, MARGINAL_FEE, SWEEP_MIN};
fn params() -> MigrationParams {
MigrationParams::provisional(ChainType::Mainnet)
}
#[test]
fn immediate_migration_fee_is_era_independent() {
for n_in in [1usize, 2, 3, 5, side_budget(¶ms())] {
assert_eq!(
bundle_actions(BundleVersion::orchard_v2(), n_in, 0),
bundle_actions(BundleVersion::orchard_v3(), n_in, 0),
"a spend-only Orchard bundle changed action count across the NU6.3 boundary"
);
}
}
#[test]
fn single_input_immediate_tx_costs_a_canonical_part_fee() {
assert_eq!(immediate_migration_fee(1), CANONICAL_PART_FEE);
assert_eq!(immediate_migration_fee(2), CANONICAL_PART_FEE);
assert_eq!(
immediate_migration_fee(3),
CANONICAL_PART_FEE + MARGINAL_FEE
);
}
#[test]
fn plan_conserves_value() {
let cases: [&[u64]; 5] = [
&[],
&[100_000],
&[100_000, 200_000, 300_000],
&[1, 5_000, 10_000, 100_000, 1_000_000],
&[1, 2, 3],
];
for note_values in cases {
let plan = plan_immediate_migration(note_values, ¶ms());
let total: u64 = note_values.iter().sum();
assert_eq!(
plan.migrated + plan.fee + plan.residual,
total,
"plan lost value for {note_values:?}: {plan:?}"
);
for transaction in &plan.transactions {
assert_eq!(
transaction.fee(),
immediate_migration_fee(transaction.inputs.len())
);
}
}
}
#[test]
fn chunks_at_the_action_bound() {
let params = params();
let max = side_budget(¶ms);
for (note_count, expected_txs) in [
(0, 0),
(1, 1),
(max, 1),
(max + 1, 2),
(max * 2, 2),
(max * 2 + 1, 3),
] {
let note_values = vec![1_000_000u64; note_count];
let plan = plan_immediate_migration(¬e_values, ¶ms);
assert_eq!(
plan.transactions.len(),
expected_txs,
"{note_count} notes should chunk into {expected_txs} transaction(s)"
);
assert!(
plan.transactions.iter().all(|transaction| {
let actions = transaction.inputs.len() + 1;
actions <= params.max_actions_per_split_tx
}),
"a chunk's spends and single output together exceeded the total action budget"
);
}
}
#[test]
fn all_dust_leaves_everything_residual() {
let note_values = vec![SWEEP_MIN, SWEEP_MIN - 1, 1];
let plan = plan_immediate_migration(¬e_values, ¶ms());
assert!(plan.is_empty());
assert_eq!(plan.migrated, 0);
assert_eq!(plan.fee, 0);
assert_eq!(plan.residual, note_values.iter().sum::<u64>());
}
#[test]
fn notes_at_or_below_sweep_min_are_never_migrated() {
let dust = [1, SWEEP_MIN - 1, SWEEP_MIN];
let mut note_values = dust.to_vec();
note_values.extend([SWEEP_MIN + 1, 1_000_000]);
let plan = plan_immediate_migration(¬e_values, ¶ms());
assert_eq!(plan.residual, dust.iter().sum::<u64>());
for transaction in &plan.transactions {
for &input in &transaction.inputs {
assert!(
input > SWEEP_MIN,
"migration spends a {input}-zatoshi note, at or below sweep_min ({})",
SWEEP_MIN
);
}
}
assert!(
plan.transactions
.iter()
.any(|tx| tx.inputs.contains(&(SWEEP_MIN + 1)))
);
}
#[test]
fn output_at_or_below_sweep_min_leaves_the_chunk_residual() {
let plan = plan_immediate_migration(&[25_000], ¶ms());
assert!(plan.is_empty());
assert_eq!(plan.residual, 25_000);
assert_eq!(plan.fee, 0);
let boundary = immediate_migration_fee(1) + SWEEP_MIN;
let plan = plan_immediate_migration(&[boundary], ¶ms());
assert!(plan.is_empty());
assert_eq!(plan.residual, boundary);
let plan = plan_immediate_migration(&[boundary + 1], ¶ms());
assert_eq!(plan.transactions.len(), 1);
assert_eq!(plan.transactions[0].output, SWEEP_MIN + 1);
assert_eq!(plan.residual, 0);
}
proptest::proptest! {
#[test]
fn immediate_migration_inputs_always_exceed_sweep_min(
note_values in proptest::collection::vec(1u64..=10_000_000_000, 0..300)
) { let plan = plan_immediate_migration(¬e_values, ¶ms());
for transaction in &plan.transactions {
for &input in &transaction.inputs {
proptest::prop_assert!(input > SWEEP_MIN);
}
}
}
#[test]
fn immediate_migration_outputs_always_exceed_sweep_min(
note_values in proptest::collection::vec(1u64..=10_000_000_000, 0..300)
) { let plan = plan_immediate_migration(¬e_values, ¶ms());
for transaction in &plan.transactions {
proptest::prop_assert!(transaction.output > SWEEP_MIN);
}
}
}
#[test]
fn replanning_covers_only_the_remaining_notes() {
let all = vec![1_000_000u64, 2_000_000, 3_000_000, 4_000_000];
let full = plan_immediate_migration(&all, ¶ms());
let remaining = &all[2..];
let replan = plan_immediate_migration(remaining, ¶ms());
assert_eq!(replan.transactions.len(), 1);
assert_eq!(replan.transactions[0].inputs, remaining.to_vec());
assert!(replan.migrated < full.migrated);
assert_eq!(
replan.migrated + replan.fee + replan.residual,
remaining.iter().sum::<u64>()
);
}
}