use std::convert::Infallible;
use zcash_client_backend::{
data_api::{
enhance_pir::{
EnhancePirBatchResult, EnhancePirRead as _, EnhancePirRequest, EnhancePirStoreResult,
EnhancePirWork, EnhancePirWrite as _, EnhanceRecord, EnhanceRecordParts,
EnhanceTransactionMetadata, EnhancementMode, TransactionEnhancementWork,
},
testing::single_output_change_strategy,
testing::{IronwoodFvk, orchard::OrchardPoolTester, pool::ShieldedPoolTester},
transparent_ledger::{
AccountActivity, ActivityOutputs, AggregatePayment, DetailCompleteness,
EffectCompleteness, FeeState, HistoryClassification, OutputRef, PoolEffect,
TransactionHistoryDetails, TransactionMetadata, WholeTransactionFee,
},
wallet::decrypt_and_store_transaction,
wallet::{ConfirmationsPolicy, input_selection::GreedyInputSelector},
},
fees::StandardFeeRule,
wallet::OvkPolicy,
};
use zcash_keys::address::Address;
use zcash_primitives::transaction::Transaction;
use zcash_protocol::{PoolType, ShieldedPool, local_consensus::LocalNetwork};
use zip321::{Payment, TransactionRequest};
use super::*;
const SPENT: u64 = 107_485_000;
const CHANGE: u64 = 107_220_000;
const SENT: u64 = 250_000;
const FEE: u64 = 15_000;
fn zat(value: u64) -> Zatoshis {
Zatoshis::const_from_u64(value)
}
fn ironwood_network() -> LocalNetwork {
let activation = BlockHeight::from_u32(100_000);
LocalNetwork {
nu6: Some(activation),
nu6_1: Some(activation),
nu6_2: Some(activation),
nu6_3: Some(activation),
..TestBuilder::<(), ()>::DEFAULT_NETWORK
}
}
fn funded(prepare: impl FnOnce(&mut State)) -> State {
let mut st = TestBuilder::new()
.with_network(ironwood_network())
.with_data_store_factory(TestDbFactory::default())
.with_block_cache(BlockCache::new())
.with_account_from_sapling_activation(BlockHash([0; 32]))
.build();
scan_new_blocks(&mut st, 10);
prepare(&mut st);
let fvk = IronwoodFvk(OrchardPoolTester::test_account_fvk(&st));
let (height, _, _) = st.generate_next_block(&fvk, AddressType::DefaultExternal, zat(SPENT));
st.scan_cached_blocks(height, 1);
st
}
#[derive(Clone, Copy)]
enum Destination {
OwnTransparent,
ExternalTransparent,
ExternalShieldedWithoutOvk,
}
fn own_transparent(st: &State) -> TransparentAddress {
let account = st.test_account().unwrap().id();
external(&watch(st, account))
}
fn sent_transaction(destination: Destination) -> Transaction {
let mut st = funded(|st| set_policy(st, Public));
let to = match destination {
Destination::OwnTransparent => Address::from(own_transparent(&st)),
Destination::ExternalTransparent => {
Address::from(TransparentAddress::PublicKeyHash([7; 20]))
}
Destination::ExternalShieldedWithoutOvk => {
OrchardPoolTester::sk_default_address(&OrchardPoolTester::sk(&[0xf5; 32]))
}
};
let request = TransactionRequest::new(vec![Payment::without_memo(
to.to_zcash_address(st.network()),
zat(SENT),
)])
.unwrap();
let account = st.test_account().cloned().unwrap();
let proposal = st
.propose_transfer(
account.id(),
&GreedyInputSelector::new(),
&single_output_change_strategy(StandardFeeRule::Zip317, None, ShieldedPool::Orchard),
request,
ConfirmationsPolicy::MIN,
)
.unwrap();
let ovk_policy = match destination {
Destination::ExternalShieldedWithoutOvk => OvkPolicy::Discard,
_ => OvkPolicy::Sender,
};
let txid = *st
.create_proposed_transactions::<Infallible, _, Infallible, _>(
account.usk(),
ovk_policy,
&proposal,
)
.unwrap()
.first();
st.wallet().get_transaction(txid).unwrap().unwrap()
}
struct Restored {
st: State,
account: AccountUuid,
tx: Transaction,
tx_ref: i64,
height: BlockHeight,
}
impl Restored {
fn history(&self) -> TransactionHistoryDetails {
let mut entries = self
.st
.wallet()
.db()
.transaction_history_details(self.account, &[self.tx.txid()])
.unwrap();
assert_eq!(entries.len(), 1);
entries.remove(0)
}
fn count(&self, sql: &str) -> i64 {
conn(&self.st)
.query_row(sql, [self.tx_ref], |row| row.get(0))
.unwrap()
}
fn recorded_transparent_sends(&self) -> u64 {
u64::try_from(self.count(
"SELECT COALESCE(SUM(value), 0) FROM sent_notes
WHERE transaction_id = ?1 AND output_pool = 0",
))
.unwrap()
}
}
fn restore(tx: Transaction, prepare: impl FnOnce(&mut State)) -> Restored {
let mut st = funded(prepare);
let account = st.test_account().unwrap().id();
assert_eq!(
st.wallet()
.get_wallet_summary(ConfirmationsPolicy::MIN)
.unwrap()
.unwrap()
.account_balances()[&account]
.ironwood_balance()
.total(),
zat(SPENT),
"the restore holds the note the sender spent"
);
let (height, _) = st.generate_next_block_from_tx(1, &tx);
st.scan_cached_blocks(height, 1);
scan_new_blocks(&mut st, 2);
let tx_ref = conn(&st)
.query_row(
"SELECT id_tx FROM transactions WHERE txid = ?1",
[tx.txid().as_ref()],
|row| row.get(0),
)
.unwrap();
Restored {
st,
account,
tx,
tx_ref,
height,
}
}
fn recover_transparent(st: &mut State, receives: Vec<ReceiveEvent>) {
let account = st.test_account().unwrap().id();
let fixture = revision(1, true);
cover(st, account, &fixture, receives);
qualify(st, &fixture);
set_policy(st, PrivateRequired);
activate_recovered_ledger(st, account).unwrap();
st.wallet_mut()
.db_mut()
.set_enhancement_mode(EnhancementMode::PrivateIronwood);
}
fn record(tx: &Transaction, index: u32, outputs: bool, fee: Option<u64>) -> EnhanceRecord {
let bundle = tx.ironwood_bundle().unwrap();
let action = &bundle.actions()[usize::try_from(index).unwrap()];
let note = action.encrypted_note();
EnhanceRecord::from_parts(EnhanceRecordParts {
enc_ciphertext_suffix: note.enc_ciphertext[52..].try_into().unwrap(),
cv_net: action.cv_net().to_bytes(),
out_ciphertext: note.out_ciphertext,
has_transparent_inputs: false,
has_transparent_outputs: outputs,
metadata: EnhanceTransactionMetadata::new(u32::from(tx.expiry_height()), fee).unwrap(),
})
}
fn enhance(case: &mut Restored, outputs: bool, fee: Option<u64>) -> Vec<EnhancePirStoreResult> {
let requests: Vec<EnhancePirRequest> = case
.st
.wallet()
.transaction_enhancement_work()
.unwrap()
.into_iter()
.filter_map(|work| match work {
TransactionEnhancementWork::Private(EnhancePirWork::Query(request))
if request.request_id().txid() == case.tx.txid() =>
{
Some(request)
}
TransactionEnhancementWork::Public(request) => {
panic!("PrivateRequired exposed public payload work for {request:?}")
}
_ => None,
})
.collect();
assert!(!requests.is_empty());
let records: Vec<_> = requests
.iter()
.map(|request| {
let index = request.request_id().output_index();
(*request, record(&case.tx, index, outputs, fee))
})
.collect();
match case
.st
.wallet_mut()
.db_mut()
.apply_ironwood_enhance_records(&records)
.unwrap()
{
EnhancePirBatchResult::Committed(results) => results,
rejected => panic!("unexpected batch rejection {rejected:?}"),
}
}
fn own_output(case: &Restored, metadata: Option<TransactionMetadata>) -> ReceiveEvent {
let vout = &case.tx.transparent_bundle().unwrap().vout;
let index = vout
.iter()
.position(|out| out.value() == zat(SENT))
.unwrap();
ReceiveEvent {
metadata,
outpoint: OutPoint::new(case.tx.txid().into(), u32::try_from(index).unwrap()),
address: own_transparent(&case.st),
value: zat(SENT),
coinbase: false,
mined_height: case.height,
}
}
fn privately_recovered(
destination: Destination,
own_output_metadata: Option<Option<TransactionMetadata>>,
record_outputs: bool,
record_fee: Option<u64>,
) -> Restored {
let tx = sent_transaction(destination);
let mut case = restore(tx, |st| set_policy(st, PrivateRequired));
let receives = own_output_metadata
.map(|metadata| vec![own_output(&case, metadata)])
.unwrap_or_default();
recover_transparent(&mut case.st, receives);
let results = enhance(&mut case, record_outputs, record_fee);
assert!(
results.iter().all(|result| matches!(
result,
EnhancePirStoreResult::PrivateDetailsUnsupported
| EnhancePirStoreResult::Stored
| EnhancePirStoreResult::NotRecoverable
| EnhancePirStoreResult::AlreadyResolved
)),
"{results:?}"
);
case
}
fn publicly_recovered(destination: Destination) -> Restored {
let tx = sent_transaction(destination);
let mut case = restore(tx, |st| set_policy(st, Public));
let network = *case.st.network();
decrypt_and_store_transaction(&network, case.st.wallet_mut(), &case.tx, Some(case.height))
.unwrap();
case
}
fn effect(entry: &TransactionHistoryDetails, pool: PoolType) -> PoolEffect {
*entry.effects.iter().find(|e| e.pool == pool).unwrap()
}
fn assert_only_activity_inferred(entry: &TransactionHistoryDetails) {
assert_eq!(entry.classification, HistoryClassification::Provisional);
assert_eq!(entry.payment_details, DetailCompleteness::Incomplete);
assert_eq!(
entry.fee,
entry
.whole_fee
.map_or(FeeState::Unknown, |whole| FeeState::Unattributed { whole })
);
assert_eq!(entry.aggregate_payment, AggregatePayment::Unknown);
if entry.inferred_outgoing.is_none() {
assert_eq!(
entry.activity,
AccountActivity::MovementDebit {
amount: Zatoshis::from_u64(u64::try_from(-entry.account_movement.net()).unwrap())
.unwrap()
}
);
}
}
#[test]
fn reported_send_shows_its_outgoing_value_and_network_fee() {
let public = publicly_recovered(Destination::OwnTransparent);
let control = public.history();
assert_eq!(control.fee, FeeState::Known(zat(FEE)));
assert_eq!(control.whole_fee, Some(zat(FEE)));
assert_eq!(control.inferred_outgoing, None);
assert_eq!(public.recorded_transparent_sends(), SENT);
assert_eq!(effect(&control, PoolType::IRONWOOD).spent, zat(SPENT));
assert_eq!(effect(&control, PoolType::IRONWOOD).received, zat(CHANGE));
let own_row = Some(ActivityOutputs {
amount: zat(SENT),
outputs: vec![OutputRef {
pool: PoolType::Transparent,
index: 0,
}],
});
assert_eq!(
control.activity,
AccountActivity::Outputs {
sent: own_row.clone(),
received: own_row.clone(),
}
);
let private = privately_recovered(Destination::OwnTransparent, None, true, Some(FEE));
let entry = private.history();
for table in [
"transparent_received_outputs",
"transparent_received_output_spends",
] {
assert_eq!(
private.count(&format!(
"SELECT COUNT(*) FROM {table} WHERE transaction_id = ?1"
)),
0,
"{table}"
);
}
assert_eq!(
private.count(
"SELECT COUNT(*) FROM sent_notes s
JOIN ironwood_received_notes rn
ON rn.transaction_id = s.transaction_id AND rn.action_index = s.output_index
WHERE s.transaction_id = ?1 AND s.output_pool = 4 AND rn.value = s.value"
),
private.count("SELECT COUNT(*) FROM sent_notes WHERE transaction_id = ?1"),
"only the change is recorded as sent"
);
assert_eq!(
private.count("SELECT raw IS NOT NULL FROM transactions WHERE id_tx = ?1"),
0,
"no full transaction"
);
assert_eq!(count(&private.st, "tpir_transaction_metadata"), 0);
assert_eq!(entry.transaction_metadata, None);
assert_eq!(entry.has_transparent_outputs, Some(true));
assert_eq!(
effect(&entry, PoolType::IRONWOOD),
PoolEffect {
pool: PoolType::IRONWOOD,
received: zat(CHANGE),
spent: zat(SPENT),
completeness: EffectCompleteness::Complete,
}
);
assert_eq!(entry.whole_fee, Some(zat(FEE)));
assert_eq!(entry.inferred_outgoing, Some(zat(SENT)));
assert_eq!(
entry.inferred_outgoing.map(Zatoshis::into_u64),
Some(public.recorded_transparent_sends())
);
assert_only_activity_inferred(&entry);
assert_eq!(
entry.activity,
AccountActivity::InferredOutgoing {
outgoing: zat(SENT),
received: None,
settled: true,
}
);
let owned = privately_recovered(Destination::OwnTransparent, Some(None), true, Some(FEE));
let entry = owned.history();
assert_eq!(effect(&entry, PoolType::Transparent).received, zat(SENT));
assert_eq!(
effect(&entry, PoolType::Transparent).completeness,
EffectCompleteness::Complete
);
assert_eq!(entry.account_movement.net(), -i128::from(FEE));
assert_eq!(entry.whole_fee, Some(zat(FEE)));
assert_eq!(entry.inferred_outgoing, Some(zat(SENT)));
assert_only_activity_inferred(&entry);
let AccountActivity::InferredOutgoing {
outgoing,
received: Some(received),
settled: true,
} = &entry.activity
else {
panic!(
"expected an outgoing value and a receipt, got {:?}",
entry.activity
);
};
assert_eq!((*outgoing, received.amount), (zat(SENT), zat(SENT)));
assert_eq!(received.outputs.len(), 1);
assert_eq!(received.outputs[0].pool, PoolType::Transparent);
let public = publicly_recovered(Destination::ExternalTransparent);
assert_eq!(public.recorded_transparent_sends(), SENT);
assert_eq!(
public.history().activity,
AccountActivity::Outputs {
sent: own_row,
received: None,
}
);
let entry =
privately_recovered(Destination::ExternalTransparent, None, true, Some(FEE)).history();
assert_eq!(entry.inferred_outgoing, Some(zat(SENT)));
assert_only_activity_inferred(&entry);
assert_eq!(
entry.activity,
AccountActivity::InferredOutgoing {
outgoing: zat(SENT),
received: None,
settled: true,
}
);
}
#[test]
fn unknown_or_contradicted_fee_infers_nothing() {
let metadata = |fee, inputs| TransactionMetadata {
fee,
transparent_input_count: inputs,
has_shielded_components: true,
};
for own in [None, Some(Some(metadata(WholeTransactionFee::Unknown, 0)))] {
let entry = privately_recovered(Destination::OwnTransparent, own, true, None).history();
assert_eq!(entry.whole_fee, None);
assert_eq!(entry.inferred_outgoing, None);
assert_only_activity_inferred(&entry);
}
let entry = privately_recovered(
Destination::OwnTransparent,
Some(Some(metadata(WholeTransactionFee::Exact(zat(FEE)), 0))),
true,
None,
)
.history();
assert_eq!(entry.whole_fee, Some(zat(FEE)));
assert_eq!(entry.inferred_outgoing, Some(zat(SENT)));
let entry = privately_recovered(
Destination::OwnTransparent,
Some(Some(metadata(WholeTransactionFee::Exact(zat(20_000)), 0))),
true,
Some(FEE),
)
.history();
assert!(entry.transaction_metadata.is_some());
assert_eq!(entry.whole_fee, None);
assert_eq!(entry.inferred_outgoing, None);
assert_only_activity_inferred(&entry);
let case = privately_recovered(Destination::OwnTransparent, None, true, Some(FEE));
conn(&case.st)
.execute(
"UPDATE transactions SET fee = ?1 WHERE id_tx = ?2",
rusqlite::params![SPENT - CHANGE, case.tx_ref],
)
.unwrap();
let entry = case.history();
assert_eq!(entry.whole_fee, Some(zat(SPENT - CHANGE)));
assert_eq!(entry.inferred_outgoing, None);
}
#[test]
fn shared_transparent_funding_infers_nothing() {
let entry = privately_recovered(
Destination::OwnTransparent,
Some(Some(TransactionMetadata {
fee: WholeTransactionFee::Exact(zat(FEE)),
transparent_input_count: 1,
has_shielded_components: true,
})),
true,
Some(FEE),
)
.history();
assert_eq!(entry.whole_fee, Some(zat(FEE)));
assert_eq!(entry.inferred_outgoing, None);
assert_only_activity_inferred(&entry);
}
fn recover_contributor(case: &mut Restored, active: bool) -> RecoveryRevision {
let other = import_account(&mut case.st, 9);
let start = case.st.test_account().unwrap().birthday().height();
let end = case.height + 2;
case.st.scan_cached_blocks(
start,
usize::try_from(u32::from(end) - u32::from(start) + 1).unwrap(),
);
let initial = source(b"contributor", 1);
cover(&mut case.st, other, &initial, vec![]);
qualify(&mut case.st, &initial);
if active {
activate_recovered_ledger(&mut case.st, other).unwrap();
}
let observed = source(b"contributor-spend", 1);
qualify(&mut case.st, &observed);
let ws = watch(&case.st, other);
let mut c = commit(&ws);
c.revision = observed.clone();
c.spends = vec![SpendEvent {
metadata: None,
spending_txid: case.tx.txid(),
input_index: 0,
prevout: OutPoint::new([0xd1; 32], 0),
prevout_address: external(&ws),
mined_height: case.height,
}];
apply(&mut case.st, c).unwrap();
observed
}
#[test]
fn candidate_contributor_does_not_veto_activity() {
let mut case = privately_recovered(Destination::OwnTransparent, None, true, Some(FEE));
recover_contributor(&mut case, false);
assert_eq!(count(&case.st, "tpir_spend_events"), 1);
let entry = case.history();
assert_eq!(entry.inferred_outgoing, Some(zat(SENT)));
assert_only_activity_inferred(&entry);
}
#[test]
fn active_contributor_veto_tracks_qualified_current_evidence() {
let mut case = privately_recovered(Destination::OwnTransparent, None, true, Some(FEE));
assert_eq!(case.history().inferred_outgoing, Some(zat(SENT)));
let observed = recover_contributor(&mut case, true);
assert_eq!(
case.count(
"SELECT COUNT(*) FROM transparent_received_output_spends WHERE transaction_id = ?1"
),
0
);
let entry = case.history();
assert_eq!(entry.inferred_outgoing, None);
assert_only_activity_inferred(&entry);
conn(&case.st)
.execute(
"INSERT INTO tpir_quarantined_sources (source) VALUES (?1)",
[&observed.source],
)
.unwrap();
assert_eq!(case.history().inferred_outgoing, Some(zat(SENT)));
clear_recovery_suspension(&case.st);
assert_eq!(case.history().inferred_outgoing, None);
conn(&case.st)
.execute("INSERT INTO tpir_quarantined_accounts SELECT account_id FROM tpir_spend_events WHERE spending_txid = ?1", [case.tx.txid().as_ref()])
.unwrap();
assert_eq!(case.history().inferred_outgoing, Some(zat(SENT)));
clear_recovery_suspension(&case.st);
assert_eq!(case.history().inferred_outgoing, None);
conn(&case.st)
.execute("DELETE FROM tpir_spend_observations", [])
.unwrap();
assert_eq!(case.history().inferred_outgoing, Some(zat(SENT)));
conn(&case.st).execute("INSERT INTO tpir_spend_observations SELECT s.id, r.id FROM tpir_spend_events s CROSS JOIN tpir_revisions r WHERE r.source = ?1", [&observed.source]).unwrap();
assert_eq!(case.history().inferred_outgoing, None);
conn(&case.st).execute("DELETE FROM tpir_qualified_revisions WHERE revision_id IN (SELECT id FROM tpir_revisions WHERE source = ?1)", [&observed.source]).unwrap();
assert_eq!(case.history().inferred_outgoing, Some(zat(SENT)));
qualify(&mut case.st, &observed);
assert_eq!(case.history().inferred_outgoing, None);
conn(&case.st)
.execute(
"UPDATE tpir_spend_events SET mined_height = mined_height - 1 WHERE spending_txid = ?1",
[case.tx.txid().as_ref()],
)
.unwrap();
assert_eq!(case.history().inferred_outgoing, Some(zat(SENT)));
}
#[test]
fn absent_or_unknown_transparent_shape_infers_nothing() {
let case = privately_recovered(Destination::OwnTransparent, None, false, Some(FEE));
let entry = case.history();
assert_eq!(entry.has_transparent_outputs, None);
assert_eq!(entry.inferred_outgoing, None);
let case = privately_recovered(Destination::OwnTransparent, None, true, Some(FEE));
conn(&case.st)
.execute(
"UPDATE ironwood_enhance_routing SET has_transparent_outputs = ?1
WHERE transaction_id = ?2",
rusqlite::params![None::<bool>, case.tx_ref],
)
.unwrap();
let entry = case.history();
assert_eq!(entry.has_transparent_outputs, None);
assert_eq!(entry.inferred_outgoing, None);
assert_only_activity_inferred(&entry);
}
#[test]
fn unrecoverable_shielded_payment_infers_nothing() {
let case = privately_recovered(
Destination::ExternalShieldedWithoutOvk,
None,
false,
Some(FEE),
);
assert_eq!(
case.count(
"SELECT COUNT(*) FROM sent_notes s WHERE s.transaction_id = ?1
AND NOT EXISTS (SELECT 1 FROM ironwood_received_notes rn
WHERE rn.transaction_id = s.transaction_id AND rn.action_index = s.output_index)"
),
0
);
let entry = case.history();
assert_eq!(entry.whole_fee, Some(zat(FEE)));
assert_eq!(entry.inferred_outgoing, None);
assert_eq!(entry.classification, HistoryClassification::Provisional);
assert_eq!(entry.payment_details, DetailCompleteness::Incomplete);
}
#[test]
fn a_recorded_send_suppresses_the_inference() {
let case = privately_recovered(Destination::OwnTransparent, None, true, Some(FEE));
assert_eq!(case.history().inferred_outgoing, Some(zat(SENT)));
conn(&case.st)
.execute(
"INSERT INTO sent_notes (transaction_id, output_pool, output_index, from_account_id,
to_address, value)
SELECT ?1, 0, 0, id, 't1fixture', ?2 FROM accounts",
rusqlite::params![case.tx_ref, SENT],
)
.unwrap();
let entry = case.history();
assert_eq!(entry.inferred_outgoing, None);
assert_eq!(
entry.activity,
AccountActivity::Outputs {
sent: Some(ActivityOutputs {
amount: zat(SENT),
outputs: vec![OutputRef {
pool: PoolType::Transparent,
index: 0,
}],
}),
received: None,
}
);
}
#[test]
fn reorg_withdraws_the_inference() {
let mut case = privately_recovered(Destination::OwnTransparent, None, true, Some(FEE));
assert_eq!(case.history().inferred_outgoing, Some(zat(SENT)));
case.st.truncate_to_height(case.height - 1);
let entry = case.history();
assert_eq!(entry.mined_height, None);
assert_eq!(entry.inferred_outgoing, None);
assert_eq!(
entry.activity,
AccountActivity::MovementDebit {
amount: zat(SENT + FEE)
}
);
}