use pepper_sync::wallet::OutputId;
use zcash_primitives::transaction::TxId;
use zcash_protocol::consensus::BlockHeight;
use super::parts::{PartId, PartRecord, PartState};
use super::{MigrationPhase, MigrationState};
const SLIP_TOLERANCE_BLOCKS: u32 = 96;
pub trait ChainView {
fn chain_tip(&self) -> Option<BlockHeight>;
fn spend_evidence_height(&self) -> Option<BlockHeight>;
fn note_spend(&self, output_id: OutputId) -> Option<(TxId, Option<BlockHeight>)>;
fn transaction_confirmed_height(&self, txid: &TxId) -> Option<BlockHeight>;
fn transaction_failed(&self, txid: &TxId) -> bool;
fn orchard_confirmed_spendable(&self, account: zip32::AccountId) -> u64;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PartClass {
OnTrack,
SlippedWithinTolerance,
Overdue,
AwaitingExpiry {
expiry: BlockHeight,
},
Expired,
Invalidated,
Confirmed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RecommendedAction {
AwaitSplitConfirmation,
RetrySplit {
txid: TxId,
},
ContinueNoteSplitting,
PromoteConfirmed {
part: PartId,
height: BlockHeight,
},
Rebuild {
part: PartId,
},
MarkInvalidated {
part: PartId,
},
ReplanRemainder,
PromptCatchUp {
parts: Vec<PartId>,
disclosure_required: bool,
},
MarkComplete {
residual: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PartAssessment {
pub id: PartId,
pub class: PartClass,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ReconcileReport {
pub assessments: Vec<PartAssessment>,
pub actions: Vec<RecommendedAction>,
}
pub fn reconcile(state: &MigrationState, chain: &impl ChainView) -> ReconcileReport {
let mut report = ReconcileReport::default();
match &state.phase {
MigrationPhase::Planned => {
report
.actions
.push(RecommendedAction::ContinueNoteSplitting);
return report;
}
MigrationPhase::NoteSplitting { pending_txids, .. } => {
let mut all_confirmed = true;
for txid in pending_txids {
if chain.transaction_failed(txid) {
report
.actions
.push(RecommendedAction::RetrySplit { txid: *txid });
all_confirmed = false;
} else if chain.transaction_confirmed_height(txid).is_none() {
all_confirmed = false;
}
}
if all_confirmed {
report
.actions
.push(RecommendedAction::ContinueNoteSplitting);
} else if report.actions.is_empty() {
report
.actions
.push(RecommendedAction::AwaitSplitConfirmation);
}
return report;
}
MigrationPhase::Complete { .. } => return report,
MigrationPhase::PartsScheduled => (),
}
let chain_tip = chain.chain_tip();
let mut overdue = Vec::new();
let mut any_invalidated = false;
let all_terminal = state.parts.iter().all(|part| {
matches!(
part.state,
PartState::Confirmed { .. } | PartState::Invalidated
)
});
for part in &state.parts {
let class = classify(part, state, chain, chain_tip, &mut report);
match class {
PartClass::Overdue => overdue.push(part.id),
PartClass::Invalidated => any_invalidated = true,
_ => (),
}
report
.assessments
.push(PartAssessment { id: part.id, class });
}
if !overdue.is_empty() {
report.actions.push(RecommendedAction::PromptCatchUp {
parts: overdue,
disclosure_required: true,
});
}
let spendable = chain.orchard_confirmed_spendable(state.account);
if any_invalidated && spendable > state.params.sweep_min {
report.actions.push(RecommendedAction::ReplanRemainder);
}
if all_terminal && !state.parts.is_empty() {
if spendable <= state.params.sweep_min {
report.actions.push(RecommendedAction::MarkComplete {
residual: spendable,
});
} else if !any_invalidated {
report.actions.push(RecommendedAction::ReplanRemainder);
}
}
report
}
pub fn due_now_parts(
parts: &[PartRecord],
report: &ReconcileReport,
now_height: BlockHeight,
params: &super::params::MigrationParams,
) -> Vec<PartId> {
let current_bucket = super::schedule::bucket_index(now_height, params.bucket_modulus);
let overdue: std::collections::HashSet<PartId> = report
.actions
.iter()
.find_map(|action| match action {
RecommendedAction::PromptCatchUp { parts, .. } => Some(parts.iter().copied().collect()),
_ => None,
})
.unwrap_or_default();
let on_track: std::collections::HashSet<PartId> = report
.assessments
.iter()
.filter(|assessment| assessment.class == PartClass::OnTrack)
.map(|assessment| assessment.id)
.collect();
parts
.iter()
.filter(|part| {
if overdue.contains(&part.id) {
part.state == PartState::Assigned
} else {
on_track.contains(&part.id)
&& super::schedule::part_in_current_bucket(part, current_bucket)
}
})
.map(|part| part.id)
.collect()
}
fn classify(
part: &super::parts::PartRecord,
state: &MigrationState,
chain: &impl ChainView,
chain_tip: Option<BlockHeight>,
report: &mut ReconcileReport,
) -> PartClass {
match part.state {
PartState::Confirmed { .. } => return PartClass::Confirmed,
PartState::Invalidated => return PartClass::Invalidated,
PartState::Expired => {
report
.actions
.push(RecommendedAction::Rebuild { part: part.id });
return PartClass::Expired;
}
PartState::Bound | PartState::Assigned | PartState::Signed | PartState::Broadcast => (),
}
if let Some(bound) = part.note
&& let Some((spending_txid, height)) = chain.note_spend(bound.output_id)
{
if part.txid == Some(spending_txid) {
if let Some(height) = height {
report.actions.push(RecommendedAction::PromoteConfirmed {
part: part.id,
height,
});
return PartClass::Confirmed;
}
return PartClass::OnTrack;
}
report
.actions
.push(RecommendedAction::MarkInvalidated { part: part.id });
return PartClass::Invalidated;
}
let Some(tip) = chain_tip else {
return PartClass::OnTrack;
};
if matches!(part.state, PartState::Signed | PartState::Broadcast)
&& part.expiry_height.is_some_and(|expiry_height| {
chain
.spend_evidence_height()
.is_some_and(|evidence| evidence >= expiry_height)
})
{
report
.actions
.push(RecommendedAction::Rebuild { part: part.id });
return PartClass::Expired;
}
if matches!(part.state, PartState::Assigned | PartState::Signed)
&& let Some(bucket) = part.bucket_index
{
let window_end = super::schedule::boundary_of(bucket + 1, state.params.bucket_modulus);
if tip >= window_end {
let blocks_past = u32::from(tip) - u32::from(window_end);
return if blocks_past <= SLIP_TOLERANCE_BLOCKS {
PartClass::SlippedWithinTolerance
} else if part.state == PartState::Signed {
match part.expiry_height {
Some(expiry) => PartClass::AwaitingExpiry { expiry },
None => PartClass::Overdue,
}
} else {
PartClass::Overdue
};
}
}
PartClass::OnTrack
}
impl ChainView for crate::wallet::LightWallet {
fn chain_tip(&self) -> Option<BlockHeight> {
self.sync_state.last_known_chain_height()
}
fn spend_evidence_height(&self) -> Option<BlockHeight> {
self.sync_state.fully_scanned_height()
}
fn note_spend(&self, output_id: OutputId) -> Option<(TxId, Option<BlockHeight>)> {
use pepper_sync::wallet::OutputInterface as _;
let note = self
.wallet_transactions
.get(&output_id.txid())
.and_then(|transaction| {
pepper_sync::wallet::OrchardNote::transaction_outputs(transaction)
.iter()
.find(|note| note.output_id() == output_id)
})?;
let spending_txid = note.spending_transaction()?;
Some((
spending_txid,
self.transaction_confirmed_height(&spending_txid),
))
}
fn transaction_confirmed_height(&self, txid: &TxId) -> Option<BlockHeight> {
self.wallet_transactions
.get(txid)
.and_then(|transaction| transaction.status().get_confirmed_height())
}
fn transaction_failed(&self, txid: &TxId) -> bool {
self.wallet_transactions
.get(txid)
.is_some_and(|transaction| transaction.status().is_failed())
}
fn orchard_confirmed_spendable(&self, account: zip32::AccountId) -> u64 {
use pepper_sync::wallet::{KeyIdInterface as _, NoteInterface as _, OutputInterface as _};
self.wallet_transactions
.values()
.filter(|transaction| transaction.status().is_confirmed())
.flat_map(|transaction| {
pepper_sync::wallet::OrchardNote::transaction_outputs(transaction)
})
.filter(|note| {
note.spending_transaction().is_none()
&& note.key_id().account_id() == account
&& note.note().version() == orchard::note::NoteVersion::V2
})
.map(pepper_sync::wallet::OutputInterface::value)
.sum()
}
}
#[cfg(test)]
mod tests {
use super::super::parts::{BoundNote, PartRecord};
use super::super::{ConsentBinding, MigrationParams, SigningStrategy};
use super::*;
use crate::config::ChainType;
use std::collections::HashMap;
struct MockChainView {
tip: Option<BlockHeight>,
spend_evidence: Option<BlockHeight>,
note_spends: HashMap<OutputId, (TxId, Option<BlockHeight>)>,
confirmed: HashMap<TxId, BlockHeight>,
failed: Vec<TxId>,
orchard_spendable: u64,
}
impl Default for MockChainView {
fn default() -> Self {
MockChainView {
tip: Some(BlockHeight::from_u32(10_000)),
spend_evidence: Some(BlockHeight::from_u32(10_000)),
note_spends: HashMap::new(),
confirmed: HashMap::new(),
failed: Vec::new(),
orchard_spendable: 0,
}
}
}
impl ChainView for MockChainView {
fn chain_tip(&self) -> Option<BlockHeight> {
self.tip
}
fn spend_evidence_height(&self) -> Option<BlockHeight> {
self.spend_evidence
}
fn note_spend(&self, output_id: OutputId) -> Option<(TxId, Option<BlockHeight>)> {
self.note_spends.get(&output_id).copied()
}
fn transaction_confirmed_height(&self, txid: &TxId) -> Option<BlockHeight> {
self.confirmed.get(txid).copied()
}
fn transaction_failed(&self, txid: &TxId) -> bool {
self.failed.contains(txid)
}
fn orchard_confirmed_spendable(&self, _account: zip32::AccountId) -> u64 {
self.orchard_spendable
}
}
fn params() -> MigrationParams {
MigrationParams::provisional(ChainType::Mainnet)
}
fn output_id(seed: u8) -> OutputId {
OutputId::new(TxId::from_bytes([seed; 32]), u32::from(seed))
}
fn scheduled_state(parts: Vec<PartRecord>) -> MigrationState {
let params = params();
MigrationState {
consent: ConsentBinding {
params_hash: params.params_hash(),
plan_hash: [0; 32],
consented_at: 0,
},
params,
strategy: SigningStrategy::LazyAtBoundary,
mode: crate::wallet::migration::MigrationMode::Scheduled,
account: zip32::AccountId::ZERO,
phase: MigrationPhase::PartsScheduled,
parts,
}
}
fn assigned_part(id: u32, bucket: u64) -> PartRecord {
let mut part = PartRecord::new(
PartId(id),
100_000_000,
BoundNote {
output_id: output_id(id as u8),
nullifier: [id as u8; 32],
commitment: [id as u8; 32],
},
);
part.assign(bucket).unwrap();
part
}
const M: u32 = 144;
const TIP_BUCKET: u64 = 10_000 / M as u64;
#[test]
fn terminal_parts_with_nothing_replannable_reach_complete() {
let mut confirmed = assigned_part(0, TIP_BUCKET - 2);
confirmed
.mark_confirmed(BlockHeight::from_u32(9_000))
.unwrap();
let mut invalidated = assigned_part(1, TIP_BUCKET - 2);
invalidated.mark_invalidated().unwrap();
let state = scheduled_state(vec![confirmed, invalidated]);
let report = reconcile(&state, &MockChainView::default());
assert!(
report
.actions
.iter()
.any(|action| matches!(action, RecommendedAction::MarkComplete { .. })),
"every part is terminal and nothing is replannable, yet the \
migration cannot conclude: {:?}",
report.actions,
);
}
#[test]
fn future_and_open_windows_are_on_track() {
let state = scheduled_state(vec![
assigned_part(0, TIP_BUCKET), assigned_part(1, TIP_BUCKET + 3), ]);
let report = reconcile(&state, &MockChainView::default());
assert!(
report
.assessments
.iter()
.all(|a| a.class == PartClass::OnTrack)
);
assert!(report.actions.is_empty());
}
#[test]
fn recent_slips_are_silent_and_older_ones_prompt_catch_up() {
let state = scheduled_state(vec![assigned_part(0, TIP_BUCKET - 1)]);
let mut chain = MockChainView {
tip: Some(BlockHeight::from_u32(
(TIP_BUCKET as u32) * M + SLIP_TOLERANCE_BLOCKS,
)),
..Default::default()
};
let report = reconcile(&state, &chain);
assert_eq!(
report.assessments[0].class,
PartClass::SlippedWithinTolerance
);
assert!(report.actions.is_empty(), "slips are not surfaced");
chain.tip = Some(BlockHeight::from_u32(
(TIP_BUCKET as u32) * M + SLIP_TOLERANCE_BLOCKS + 1,
));
let report = reconcile(&state, &chain);
assert_eq!(report.assessments[0].class, PartClass::Overdue);
assert_eq!(
report.actions,
vec![RecommendedAction::PromptCatchUp {
parts: vec![PartId(0)],
disclosure_required: true,
}]
);
}
#[test]
fn due_now_reports_the_current_window_regardless_of_the_target() {
let mut part = assigned_part(0, TIP_BUCKET);
part.target_height = Some(BlockHeight::from_u32(10_040)); let state = scheduled_state(vec![part]);
let chain = MockChainView::default();
let report = reconcile(&state, &chain);
assert_eq!(
due_now_parts(
&state.parts,
&report,
chain.chain_tip().unwrap(),
&state.params,
),
vec![PartId(0)],
"a current-window part is due even with its target ahead",
);
let chain = MockChainView {
tip: Some(BlockHeight::from_u32(10_060)),
..Default::default()
};
let report = reconcile(&state, &chain);
assert_eq!(
due_now_parts(
&state.parts,
&report,
BlockHeight::from_u32(10_060),
&state.params,
),
vec![PartId(0)],
);
}
#[test]
fn due_now_folds_assigned_overdue_parts_but_not_signed_ones() {
let assigned = assigned_part(0, TIP_BUCKET - 2);
let mut signed = assigned_part(1, TIP_BUCKET - 2);
signed
.mark_signed(
TxId::from_bytes([9; 32]),
BlockHeight::from_u32(20_000),
None,
)
.unwrap();
let state = scheduled_state(vec![assigned, signed]);
let chain = MockChainView::default();
let report = reconcile(&state, &chain);
assert!(
report.actions.contains(&RecommendedAction::PromptCatchUp {
parts: vec![PartId(0)],
disclosure_required: true,
}),
"only the Assigned overdue part is surfaced for catch-up",
);
assert!(
matches!(
report
.assessments
.iter()
.find(|assessment| assessment.id == PartId(1))
.map(|assessment| assessment.class),
Some(PartClass::AwaitingExpiry { .. })
),
"the signed overdue part awaits its expiry instead of catching up",
);
assert_eq!(
due_now_parts(
&state.parts,
&report,
chain.chain_tip().unwrap(),
&state.params,
),
vec![PartId(0)],
"only the Assigned overdue part is sendable this instant",
);
}
#[test]
fn due_now_excludes_future_windows() {
let part = assigned_part(0, TIP_BUCKET + 3);
let state = scheduled_state(vec![part]);
let chain = MockChainView::default();
let report = reconcile(&state, &chain);
assert!(
due_now_parts(
&state.parts,
&report,
chain.chain_tip().unwrap(),
&state.params,
)
.is_empty(),
"a part whose window has not opened is a future window, not a due batch",
);
}
#[test]
fn due_now_omits_a_current_part_reconciliation_will_invalidate() {
let mut part = assigned_part(0, TIP_BUCKET);
part.target_height = None;
let state = scheduled_state(vec![part]);
let mut chain = MockChainView::default();
chain.note_spends.insert(
output_id(0),
(
TxId::from_bytes([250; 32]),
Some(BlockHeight::from_u32(9_990)),
),
);
let report = reconcile(&state, &chain);
assert_eq!(report.assessments[0].class, PartClass::Invalidated);
assert!(
due_now_parts(
&state.parts,
&report,
chain.chain_tip().unwrap(),
&state.params,
)
.is_empty(),
"an externally-spent part is not sendable and must not be advertised",
);
}
#[test]
fn expired_transactions_are_rebuilt() {
let mut part = assigned_part(0, TIP_BUCKET - 2);
part.mark_signed(
TxId::from_bytes([9; 32]),
BlockHeight::from_u32(9_000),
None,
)
.unwrap();
part.mark_broadcast().unwrap();
let state = scheduled_state(vec![part]);
let report = reconcile(&state, &MockChainView::default());
assert_eq!(report.assessments[0].class, PartClass::Expired);
assert!(
report
.actions
.contains(&RecommendedAction::Rebuild { part: PartId(0) })
);
}
#[test]
fn external_spend_invalidates_and_replans() {
let state = scheduled_state(vec![assigned_part(0, TIP_BUCKET + 1)]);
let mut chain = MockChainView::default();
chain.note_spends.insert(
output_id(0),
(
TxId::from_bytes([250; 32]),
Some(BlockHeight::from_u32(9_990)),
),
);
chain.orchard_spendable = 50_000_000; let report = reconcile(&state, &chain);
assert_eq!(report.assessments[0].class, PartClass::Invalidated);
assert!(
report
.actions
.contains(&RecommendedAction::MarkInvalidated { part: PartId(0) })
);
assert!(report.actions.contains(&RecommendedAction::ReplanRemainder));
}
#[test]
fn crash_between_broadcast_and_record_promotes_via_nullifier() {
let own_txid = TxId::from_bytes([9; 32]);
let mut part = assigned_part(0, TIP_BUCKET - 1);
part.mark_signed(own_txid, BlockHeight::from_u32(20_000), None)
.unwrap();
let state = scheduled_state(vec![part]);
let mut chain = MockChainView::default();
chain
.note_spends
.insert(output_id(0), (own_txid, Some(BlockHeight::from_u32(9_995))));
let report = reconcile(&state, &chain);
assert_eq!(report.assessments[0].class, PartClass::Confirmed);
assert_eq!(
report.actions,
vec![RecommendedAction::PromoteConfirmed {
part: PartId(0),
height: BlockHeight::from_u32(9_995),
}]
);
}
#[test]
fn all_confirmed_with_dust_residual_completes() {
let mut part = assigned_part(0, TIP_BUCKET - 2);
part.mark_signed(
TxId::from_bytes([9; 32]),
BlockHeight::from_u32(20_000),
None,
)
.unwrap();
part.mark_broadcast().unwrap();
part.mark_confirmed(BlockHeight::from_u32(9_999)).unwrap();
let state = scheduled_state(vec![part]);
let mut chain = MockChainView {
orchard_spendable: 5_000, ..Default::default()
};
let report = reconcile(&state, &chain);
assert_eq!(
report.actions,
vec![RecommendedAction::MarkComplete { residual: 5_000 }]
);
chain.orchard_spendable = 5_000_000;
let report = reconcile(&state, &chain);
assert_eq!(report.actions, vec![RecommendedAction::ReplanRemainder]);
}
#[test]
fn split_rounds_gate_on_confirmation() {
let split_txid = TxId::from_bytes([1; 32]);
let mut state = scheduled_state(Vec::new());
state.phase = MigrationPhase::NoteSplitting {
round: 0,
pending_txids: vec![split_txid],
};
let mut chain = MockChainView::default();
let report = reconcile(&state, &chain);
assert_eq!(
report.actions,
vec![RecommendedAction::AwaitSplitConfirmation]
);
chain.failed.push(split_txid);
let report = reconcile(&state, &chain);
assert_eq!(
report.actions,
vec![RecommendedAction::RetrySplit { txid: split_txid }]
);
chain.failed.clear();
chain
.confirmed
.insert(split_txid, BlockHeight::from_u32(9_000));
let report = reconcile(&state, &chain);
assert_eq!(
report.actions,
vec![RecommendedAction::ContinueNoteSplitting],
"a fully confirmed round hands back to the splitting driver, \
which alone can tell a finished split from one needing more rounds"
);
}
#[test]
fn reinstall_offers_a_fresh_migration() {
let mut state = scheduled_state(Vec::new());
state.phase = MigrationPhase::Planned;
let report = reconcile(&state, &MockChainView::default());
assert_eq!(
report.actions,
vec![RecommendedAction::ContinueNoteSplitting]
);
}
#[test]
fn unknown_txid_after_restore_is_not_classified_failed() {
use crate::testutils::synthetic_wallet::SyntheticWalletBuilder;
let wallet =
SyntheticWalletBuilder::new(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED).build();
let in_flight_txid = TxId::from_bytes([42; 32]);
assert!(!wallet.wallet_transactions.contains_key(&in_flight_txid));
assert!(
!wallet.transaction_failed(&in_flight_txid),
"an unknown txid must not be reported as failed",
);
let mut state = scheduled_state(Vec::new());
state.phase = MigrationPhase::NoteSplitting {
round: 0,
pending_txids: vec![in_flight_txid],
};
let report = reconcile(&state, &wallet);
assert_eq!(
report.actions,
vec![RecommendedAction::AwaitSplitConfirmation],
);
}
}