use pepper_sync::sync::SHARDTREE_CHECKPOINT_ROLLING_WINDOW_SIZE;
use pepper_sync::wallet::IronwoodNote;
use shardtree::store::ShardStore;
use zcash_address::ZcashAddress;
use zcash_protocol::PoolType;
use zcash_protocol::ShieldedPool;
use zcash_protocol::consensus::BlockHeight;
use zcash_protocol::value::Zatoshis;
use crate::check_client_balances;
use crate::testutils::lightclient::{from_inputs, get_base_address};
use crate::testutils::mock_indexer::{MockNet, faucet_funding_transaction};
use crate::testutils::synthetic_wallet::SyntheticWalletBuilder;
use crate::wallet::error::ProposeSendError;
use crate::wallet::keys::unified::ReceiverSelection;
fn external_address(pool: PoolType) -> String {
let mut external_wallet =
SyntheticWalletBuilder::new(zingo_test_vectors::seeds::ABANDON_ART_SEED).build();
let selection = match pool {
PoolType::ORCHARD | PoolType::IRONWOOD => ReceiverSelection::orchard_only(),
PoolType::SAPLING => ReceiverSelection::sapling_only(),
_ => unimplemented!("only shielded external destinations are needed here"),
};
let (_, unified_address) = external_wallet
.generate_unified_address(selection, zip32::AccountId::ZERO)
.unwrap();
unified_address.encode(&external_wallet.chain_type())
}
fn external_tex_address() -> String {
use pepper_sync::keys::decode_address;
use zcash_client_backend::address::Address;
use zcash_transparent::address::TransparentAddress;
let external_wallet =
SyntheticWalletBuilder::new(zingo_test_vectors::seeds::ABANDON_ART_SEED).build();
let taddr = external_wallet
.transparent_addresses()
.values()
.next()
.unwrap()
.clone();
let Address::Transparent(TransparentAddress::PublicKeyHash(taddr_bytes)) =
decode_address(&external_wallet.chain_type(), &taddr).unwrap()
else {
panic!("a wallet-generated first taddr is p2pkh")
};
crate::testutils::interpret_taddr_as_tex_addr(taddr_bytes, &external_wallet.chain_type())
}
async fn fund_mirrored(
nets: &[&MockNet],
receivers: Vec<(&str, u64, Option<&str>)>,
extra_blocks: u32,
) {
let funding = faucet_funding_transaction(receivers).await;
for net in nets {
let mut chain = net.chain.write().await;
chain.mine_empty_blocks(1);
chain.mine_block(vec![funding.clone()]);
chain.mine_empty_blocks(extra_blocks);
}
}
async fn fund(net: &MockNet, receivers: Vec<(&str, u64, Option<&str>)>, extra_blocks: u32) {
let funding = faucet_funding_transaction(receivers).await;
let mut chain = net.chain.write().await;
chain.mine_block(vec![funding]);
chain.mine_empty_blocks(extra_blocks);
}
#[tokio::test]
async fn funded_send_confirms_on_the_mock_chain() {
let mut net = MockNet::launch().await;
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let recipient_ua =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
net.chain.write().await.mine_empty_blocks(1);
fund(&net, vec![(&recipient_ua, 100_000, None)], 1).await;
recipient.sync_and_await().await.unwrap();
check_client_balances!(recipient, i: 100_000 o: 0 s: 0 t: 0);
from_inputs::quick_send(
&mut recipient,
vec![(&external_address(PoolType::ORCHARD), 20_000, None)],
)
.await
.unwrap();
net.chain.write().await.mine_mempool();
recipient.sync_and_await().await.unwrap();
check_client_balances!(recipient, i: 70_000 o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn max_send_value_to_tex_empties_the_wallet() {
let funding = 100_000;
let mut net = MockNet::launch().await;
let mut sender = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let sender_ua = get_base_address(&sender, PoolType::Shielded(ShieldedPool::Orchard)).await;
net.chain.write().await.mine_empty_blocks(1);
fund(&net, vec![(&sender_ua, funding, None)], 1).await;
sender.sync_and_await().await.unwrap();
check_client_balances!(sender, i: funding o: 0 s: 0 t: 0);
let tex_address = external_tex_address();
let max_send_value = sender
.max_send_value(
ZcashAddress::try_from_encoded(&tex_address).unwrap(),
zip32::AccountId::ZERO,
)
.await
.unwrap();
assert!(
max_send_value > Zatoshis::ZERO,
"a funded wallet can send to a TEX address"
);
from_inputs::quick_send(
&mut sender,
vec![(&tex_address, max_send_value.into_u64(), None)],
)
.await
.unwrap();
net.chain.write().await.mine_mempool();
sender.sync_and_await().await.unwrap();
check_client_balances!(sender, i: 0 o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn max_send_value_to_shielded_empties_the_wallet() {
let ironwood_funding = 100_000;
let sapling_funding = 50_000;
let mut net = MockNet::launch().await;
let mut sender = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let sender_ua = get_base_address(&sender, PoolType::Shielded(ShieldedPool::Orchard)).await;
let sender_sapling = get_base_address(&sender, PoolType::Shielded(ShieldedPool::Sapling)).await;
net.chain.write().await.mine_empty_blocks(1);
fund(
&net,
vec![
(&sender_ua, ironwood_funding, None),
(&sender_sapling, sapling_funding, None),
],
1,
)
.await;
sender.sync_and_await().await.unwrap();
check_client_balances!(sender, i: ironwood_funding o: 0 s: sapling_funding t: 0);
let recipient = external_address(PoolType::ORCHARD);
let max_send_value = sender
.max_send_value(
ZcashAddress::try_from_encoded(&recipient).unwrap(),
zip32::AccountId::ZERO,
)
.await
.unwrap();
assert!(
max_send_value > Zatoshis::ZERO,
"a funded wallet can send to a shielded address"
);
let one_zat = Zatoshis::const_from_u64(1);
let past_max = (max_send_value + one_zat).unwrap();
assert!(matches!(
from_inputs::propose(&mut sender, vec![(&recipient, past_max.into_u64(), None)]).await,
Err(ProposeSendError::Proposal(
zcash_client_backend::data_api::error::Error::InsufficientFunds { .. }
))
));
from_inputs::quick_send(
&mut sender,
vec![(&recipient, max_send_value.into_u64(), None)],
)
.await
.unwrap();
net.chain.write().await.mine_mempool();
sender.sync_and_await().await.unwrap();
check_client_balances!(sender, i: 0 o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn list_value_transfers_check_fees() {
let mut net = MockNet::launch().await;
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let recipient_ua =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
let recipient_taddr = get_base_address(&recipient, PoolType::Transparent).await;
let recipient_sapling =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Sapling)).await;
net.chain.write().await.mine_empty_blocks(1);
fund(&net, vec![(&recipient_ua, 100_000, None)], 1).await;
recipient.sync_and_await().await.unwrap();
check_client_balances!(recipient, i: 100_000 o: 0 s: 0 t: 0);
from_inputs::quick_send(
&mut recipient,
vec![
(recipient_taddr.as_str(), 30_000, None),
(recipient_sapling.as_str(), 30_000, None),
],
)
.await
.unwrap();
net.chain.write().await.mine_mempool();
recipient.sync_and_await().await.unwrap();
check_client_balances!(recipient, i: 15_000 o: 0 s: 30_000 t: 30_000);
}
#[tokio::test]
async fn self_send_to_t_displays_as_one_transaction() {
let mut net = MockNet::launch().await;
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let recipient_ua =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
let recipient_taddr = get_base_address(&recipient, PoolType::Transparent).await;
let recipient_zaddr =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Sapling)).await;
net.chain.write().await.mine_empty_blocks(1);
fund(&net, vec![(&recipient_ua, 80_000, None)], 0).await;
recipient.sync_and_await().await.unwrap();
let sent_to_taddr_value = 5_000;
let sent_to_zaddr_value = 11_000;
let sent_to_self_orchard_value = 1_000;
from_inputs::quick_send(
&mut recipient,
vec![(recipient_taddr.as_str(), sent_to_taddr_value, None)],
)
.await
.unwrap();
net.chain.write().await.mine_mempool();
recipient.sync_and_await().await.unwrap();
from_inputs::quick_send(
&mut recipient,
vec![
(recipient_taddr.as_str(), sent_to_taddr_value, None),
(recipient_zaddr.as_str(), sent_to_zaddr_value, Some("foo")),
(
recipient_ua.as_str(),
sent_to_self_orchard_value,
Some("bar"),
),
],
)
.await
.unwrap();
let incoming = faucet_funding_transaction(vec![
(recipient_taddr.as_str(), sent_to_taddr_value, None),
(recipient_zaddr.as_str(), sent_to_zaddr_value, Some("foo2")),
(
recipient_ua.as_str(),
sent_to_self_orchard_value,
Some("bar2"),
),
])
.await;
{
let mut chain = net.chain.write().await;
chain.enter_mempool(incoming);
chain.mine_mempool();
}
recipient.sync_and_await().await.unwrap();
let txids: Vec<_> = recipient
.transaction_summaries(false)
.await
.unwrap()
.iter()
.map(|summary| summary.txid)
.collect();
let unique: std::collections::HashSet<_> = txids.iter().collect();
assert_eq!(
unique.len(),
txids.len(),
"every self-send surfaces as exactly one transaction"
);
}
#[tokio::test]
async fn send_to_transparent_and_sapling_maintain_balance() {
use zcash_protocol::consensus::BlockHeight;
use zingo_status::confirmation_status::ConfirmationStatus;
use zingo_test_vectors::TEST_TXID;
use crate::testutils::{
assert_transaction_summary_equality, assert_transaction_summary_exists,
};
use crate::utils::conversion::txid_from_hex_encoded_str;
use crate::wallet::output::SpendStatus;
use crate::wallet::summary::data::Scope as SummaryScope;
use crate::wallet::summary::data::{
BasicNoteSummary, OutgoingNoteSummary, SendType, TransactionKind, TransactionSummary,
};
let recipient_initial_funds = 100_000_000;
let mut net = MockNet::launch().await;
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let recipient_ua = get_base_address(&recipient, PoolType::IRONWOOD).await;
let external_sapling = external_address(PoolType::SAPLING);
let external_taddr = {
let external_wallet =
SyntheticWalletBuilder::new(zingo_test_vectors::seeds::ABANDON_ART_SEED).build();
external_wallet
.transparent_addresses()
.values()
.next()
.unwrap()
.clone()
};
net.chain.write().await.mine_empty_blocks(1);
fund(
&net,
vec![(&recipient_ua, recipient_initial_funds, None)],
0,
)
.await;
recipient.sync_and_await().await.unwrap();
let placeholder_txid = txid_from_hex_encoded_str(TEST_TXID).unwrap();
let summary_orchard_receipt = TransactionSummary {
txid: placeholder_txid,
datetime: 0,
status: ConfirmationStatus::Confirmed(BlockHeight::from_u32(2)),
blockheight: BlockHeight::from_u32(2),
kind: TransactionKind::Received,
value: recipient_initial_funds,
fee: Some(10_000),
zec_price: None,
pools_sent_from: vec![],
ironwood_notes: vec![BasicNoteSummary::from_parts(
recipient_initial_funds,
SpendStatus::Spent(placeholder_txid),
0,
None,
)],
orchard_notes: vec![],
sapling_notes: vec![],
transparent_coins: vec![],
outgoing_ironwood_notes: vec![],
outgoing_orchard_notes: vec![],
outgoing_sapling_notes: vec![],
outgoing_transparent_coins: vec![],
};
let first_send_to_sapling = 20_000;
from_inputs::quick_send(
&mut recipient,
vec![(&external_sapling, first_send_to_sapling, None)],
)
.await
.unwrap();
net.chain.write().await.mine_mempool();
recipient.sync_and_await().await.unwrap();
let summary_external_sapling = TransactionSummary {
txid: placeholder_txid,
datetime: 0,
status: ConfirmationStatus::Confirmed(BlockHeight::from_u32(3)),
blockheight: BlockHeight::from_u32(3),
kind: TransactionKind::Sent(SendType::Send),
value: first_send_to_sapling,
fee: Some(20_000),
zec_price: None,
pools_sent_from: vec![PoolType::IRONWOOD],
ironwood_notes: vec![BasicNoteSummary::from_parts(
99_960_000,
SpendStatus::TransmittedSpent(placeholder_txid),
0,
None,
)],
orchard_notes: vec![],
sapling_notes: vec![],
transparent_coins: vec![],
outgoing_ironwood_notes: vec![],
outgoing_orchard_notes: vec![],
outgoing_sapling_notes: vec![OutgoingNoteSummary {
output_index: 0,
value: first_send_to_sapling,
memo: None,
recipient: "zregtestsapling1sa4rckrf4zs6ny3l3ljnezupacvxfnjjn90lpeaa4ddtjeyww2ypzqr3jxfsta3t8dn3jk8cm4f".to_string(),
recipient_unified_address: Some("uregtest183rtm3qhxxermx3nxwa706va0xnypt3td648tayetchlp28hue08vrcnwq02ryyk5rh3y0xhftay8a5ynjdg8kr3juq5x0d9ygd5ffht".to_string()),
account_id: zip32::AccountId::ZERO,
scope: SummaryScope::from(zip32::Scope::External),
}],
outgoing_transparent_coins: vec![],
};
let first_send_to_transparent = 20_000;
let summary_external_transparent = TransactionSummary {
txid: placeholder_txid,
datetime: 0,
status: ConfirmationStatus::Transmitted(BlockHeight::from_u32(4)),
blockheight: BlockHeight::from_u32(4),
kind: TransactionKind::Sent(SendType::Send),
value: first_send_to_transparent,
fee: Some(15_000),
zec_price: None,
pools_sent_from: vec![PoolType::IRONWOOD],
ironwood_notes: vec![BasicNoteSummary::from_parts(
99_925_000,
SpendStatus::Unspent,
0,
None,
)],
orchard_notes: vec![],
sapling_notes: vec![],
transparent_coins: vec![],
outgoing_ironwood_notes: vec![],
outgoing_orchard_notes: vec![],
outgoing_sapling_notes: vec![],
outgoing_transparent_coins: vec![],
};
from_inputs::quick_send(
&mut recipient,
vec![(&external_taddr, first_send_to_transparent, None)],
)
.await
.unwrap();
assert_transaction_summary_equality(
&recipient.transaction_summaries(false).await.unwrap().0[0],
&summary_orchard_receipt,
);
assert_transaction_summary_equality(
&recipient.transaction_summaries(false).await.unwrap().0[1],
&summary_external_sapling,
);
assert_transaction_summary_equality(
&recipient.transaction_summaries(false).await.unwrap().0[2],
&summary_external_transparent,
);
let expected_funds = recipient_initial_funds
- first_send_to_sapling
- 20_000
- first_send_to_transparent
- 15_000;
{
let recipient_wallet = recipient.wallet();
let recipient_wallet = recipient_wallet.read().await;
assert_eq!(
recipient_wallet
.unconfirmed_balance::<IronwoodNote>(zip32::AccountId::ZERO)
.unwrap(),
expected_funds.try_into().unwrap()
);
assert_eq!(
recipient_wallet
.confirmed_balance::<IronwoodNote>(zip32::AccountId::ZERO)
.unwrap(),
0.try_into().unwrap()
);
}
net.chain.write().await.mine_mempool();
recipient.sync_and_await().await.unwrap();
let recipient_second_funding = 1_000_000;
fund(
&net,
vec![(
&recipient_ua,
recipient_second_funding,
Some("Second wave incoming"),
)],
0,
)
.await;
recipient.sync_and_await().await.unwrap();
let summary_orchard_receipt_2 = TransactionSummary {
txid: placeholder_txid,
datetime: 0,
status: ConfirmationStatus::Confirmed(BlockHeight::from_u32(5)),
blockheight: BlockHeight::from_u32(5),
kind: TransactionKind::Received,
value: recipient_second_funding,
fee: Some(10_000),
zec_price: None,
pools_sent_from: vec![],
ironwood_notes: vec![BasicNoteSummary::from_parts(
recipient_second_funding,
SpendStatus::Spent(placeholder_txid),
0,
Some("Second wave incoming".to_string()),
)],
orchard_notes: vec![],
sapling_notes: vec![],
transparent_coins: vec![],
outgoing_ironwood_notes: vec![],
outgoing_orchard_notes: vec![],
outgoing_sapling_notes: vec![],
outgoing_transparent_coins: vec![],
};
let second_send_to_transparent = 20_000;
let second_send_to_sapling = 20_000;
from_inputs::quick_send(
&mut recipient,
vec![(&external_taddr, second_send_to_transparent, None)],
)
.await
.unwrap();
from_inputs::quick_send(
&mut recipient,
vec![(&external_sapling, second_send_to_sapling, None)],
)
.await
.unwrap();
net.chain.write().await.mine_mempool();
recipient.sync_and_await().await.unwrap();
let summary_external_transparent_2 = TransactionSummary {
txid: placeholder_txid,
datetime: 0,
status: ConfirmationStatus::Confirmed(BlockHeight::from_u32(6)),
blockheight: BlockHeight::from_u32(6),
kind: TransactionKind::Sent(SendType::Send),
value: second_send_to_transparent,
fee: Some(15_000),
zec_price: None,
pools_sent_from: vec![PoolType::IRONWOOD],
ironwood_notes: vec![BasicNoteSummary::from_parts(
965_000,
SpendStatus::Spent(placeholder_txid),
0,
None,
)],
orchard_notes: vec![],
sapling_notes: vec![],
transparent_coins: vec![],
outgoing_ironwood_notes: vec![],
outgoing_orchard_notes: vec![],
outgoing_sapling_notes: vec![],
outgoing_transparent_coins: vec![],
};
let summary_external_sapling_2 = TransactionSummary {
txid: placeholder_txid,
datetime: 0,
status: ConfirmationStatus::Confirmed(BlockHeight::from_u32(6)),
blockheight: BlockHeight::from_u32(6),
kind: TransactionKind::Sent(SendType::Send),
value: second_send_to_sapling,
fee: Some(20_000),
zec_price: None,
pools_sent_from: vec![PoolType::IRONWOOD],
ironwood_notes: vec![BasicNoteSummary::from_parts(
99_885_000,
SpendStatus::Unspent,
0,
None,
)],
orchard_notes: vec![],
sapling_notes: vec![],
transparent_coins: vec![],
outgoing_ironwood_notes: vec![],
outgoing_orchard_notes: vec![],
outgoing_sapling_notes: vec![OutgoingNoteSummary {
output_index: 0,
value: second_send_to_sapling,
memo: None,
recipient: "zregtestsapling1sa4rckrf4zs6ny3l3ljnezupacvxfnjjn90lpeaa4ddtjeyww2ypzqr3jxfsta3t8dn3jk8cm4f".to_string(),
recipient_unified_address: Some("uregtest183rtm3qhxxermx3nxwa706va0xnypt3td648tayetchlp28hue08vrcnwq02ryyk5rh3y0xhftay8a5ynjdg8kr3juq5x0d9ygd5ffht".to_string()),
account_id: zip32::AccountId::ZERO,
scope: SummaryScope::from(zip32::Scope::External),
}],
outgoing_transparent_coins: vec![],
};
let external_transparent_3 = 20_000;
from_inputs::quick_send(
&mut recipient,
vec![(&external_taddr, external_transparent_3, None)],
)
.await
.unwrap();
net.chain.write().await.mine_mempool();
recipient.sync_and_await().await.unwrap();
let summary_external_transparent_3 = TransactionSummary {
txid: placeholder_txid,
datetime: 0,
status: ConfirmationStatus::Confirmed(BlockHeight::from_u32(7)),
blockheight: BlockHeight::from_u32(7),
kind: TransactionKind::Sent(SendType::Send),
value: external_transparent_3,
fee: Some(15_000),
zec_price: None,
pools_sent_from: vec![PoolType::IRONWOOD],
ironwood_notes: vec![BasicNoteSummary::from_parts(
930_000,
SpendStatus::Unspent,
0,
None,
)],
orchard_notes: vec![],
sapling_notes: vec![],
transparent_coins: vec![],
outgoing_ironwood_notes: vec![],
outgoing_orchard_notes: vec![],
outgoing_sapling_notes: vec![],
outgoing_transparent_coins: vec![],
};
assert_transaction_summary_equality(
&recipient.transaction_summaries(false).await.unwrap().0[3],
&summary_orchard_receipt_2,
);
assert_transaction_summary_exists(&recipient, &summary_external_transparent_2).await;
assert_transaction_summary_exists(&recipient, &summary_external_sapling_2).await;
assert_transaction_summary_equality(
&recipient.transaction_summaries(false).await.unwrap().0[6],
&summary_external_transparent_3,
);
let second_wave_expected_funds = expected_funds + recipient_second_funding
- second_send_to_sapling
- second_send_to_transparent
- external_transparent_3
- 50_000;
assert_eq!(
recipient
.wallet()
.read()
.await
.confirmed_balance::<IronwoodNote>(zip32::AccountId::ZERO)
.unwrap(),
second_wave_expected_funds.try_into().unwrap(),
);
}
#[ignore = "The ledger's fees and amounts predate V6. Re-derive every step per ADR 0009 \
before un-ignoring (step 10 under-drains, stranding 10_000 in sapling, because \
V6's two-bundle fees lead the planner to leave the sapling note unspent)"]
#[tokio::test]
async fn from_t_z_o_tz_to_zo_tzo_to_orchard() {
use crate::lightclient::error::{LightClientError, SendError};
use crate::testutils::lightclient::get_fees_paid_by_client;
use crate::wallet::error::ProposeSendError;
use zcash_protocol::value::Zatoshis;
let mut net = MockNet::launch().await;
let mut client = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let pmc_unified = get_base_address(&client, PoolType::Shielded(ShieldedPool::Orchard)).await;
let pmc_taddr = get_base_address(&client, PoolType::Transparent).await;
let pmc_sapling = get_base_address(&client, PoolType::Shielded(ShieldedPool::Sapling)).await;
net.chain.write().await.mine_empty_blocks(1);
macro_rules! bump_and_check {
(o: $o:tt i: $i:tt s: $s:tt t: $t:tt) => {
net.chain.write().await.mine_mempool();
client.sync_and_await().await.unwrap();
check_client_balances!(client, i: $i o:$o s:$s t:$t);
};
}
let mut total_expected_fee = 0;
fund(&net, vec![(&pmc_taddr, 50_000, None)], 0).await;
net.chain.write().await.mine_mempool();
client.sync_and_await().await.unwrap();
{
let chain = net.chain.read().await;
eprintln!("step-1 diagnostics: mock tip {}", chain.tip());
eprintln!("taddr requests served: {:#?}", chain.taddr_request_log());
let wallet = client.wallet();
let wallet = wallet.read().await;
eprintln!("wallet transactions: {}", wallet.wallet_transactions.len());
}
check_client_balances!(client, i: 0 o: 0 s: 0 t: 50_000);
assert_eq!(get_fees_paid_by_client(&client).await, total_expected_fee);
client.quick_shield(zip32::AccountId::ZERO).await.unwrap();
bump_and_check!(o: 0 i: 35_000 s: 0 t: 0);
total_expected_fee += 15_000;
assert_eq!(get_fees_paid_by_client(&client).await, total_expected_fee);
fund(&net, vec![(&pmc_sapling, 50_000, None)], 0).await;
bump_and_check!(o: 0 i: 35_000 s: 50_000 t: 0);
assert_eq!(get_fees_paid_by_client(&client).await, total_expected_fee);
from_inputs::quick_send(&mut client, vec![(&pmc_unified, 30_000, None)])
.await
.unwrap();
bump_and_check!(o: 0 i: 65_000 s: 0 t: 0);
total_expected_fee += 20_000;
assert_eq!(get_fees_paid_by_client(&client).await, total_expected_fee);
from_inputs::quick_send(&mut client, vec![(&pmc_unified, 55_000, None)])
.await
.unwrap();
bump_and_check!(o: 0 i: 55_000 s: 0 t: 0);
total_expected_fee += 10_000;
assert_eq!(get_fees_paid_by_client(&client).await, total_expected_fee);
from_inputs::quick_send(
&mut client,
vec![(&pmc_taddr, 10_000, None), (&pmc_sapling, 10_000, None)],
)
.await
.unwrap();
bump_and_check!(o: 0 i: 10_000 s: 10_000 t: 10_000);
total_expected_fee += 25_000;
assert_eq!(get_fees_paid_by_client(&client).await, total_expected_fee);
fund(&net, vec![(&pmc_taddr, 500_000, None)], 0).await;
bump_and_check!(o: 0 i: 10_000 s: 10_000 t: 510_000);
assert_eq!(get_fees_paid_by_client(&client).await, total_expected_fee);
client.quick_shield(zip32::AccountId::ZERO).await.unwrap();
bump_and_check!(o: 0 i: 500_000 s: 10_000 t: 0);
total_expected_fee += 20_000;
assert_eq!(get_fees_paid_by_client(&client).await, total_expected_fee);
from_inputs::quick_send(&mut client, vec![(&pmc_unified, 30_000, None)])
.await
.unwrap();
bump_and_check!(o: 0 i: 490_000 s: 10_000 t: 0);
total_expected_fee += 10_000;
assert_eq!(get_fees_paid_by_client(&client).await, total_expected_fee);
from_inputs::quick_send(&mut client, vec![(&pmc_taddr, 465_000, None)])
.await
.unwrap();
bump_and_check!(o: 0 i: 10_000 s: 10_000 t: 465_000);
total_expected_fee += 15_000;
assert_eq!(get_fees_paid_by_client(&client).await, total_expected_fee);
match from_inputs::quick_send(&mut client, vec![(&pmc_taddr, 10_000, None)]).await {
Err(LightClientError::SendError(SendError::ProposeSendError(
ProposeSendError::Proposal(
zcash_client_backend::data_api::error::Error::InsufficientFunds {
available,
required,
},
),
))) => {
assert_eq!(available, Zatoshis::from_u64(20_000).unwrap());
assert_eq!(required, Zatoshis::from_u64(35_000).unwrap());
}
other => panic!("expected InsufficientFunds, got {other:?}"),
}
bump_and_check!(o: 0 i: 10_000 s: 10_000 t: 465_000);
assert_eq!(get_fees_paid_by_client(&client).await, total_expected_fee);
match from_inputs::quick_send(&mut client, vec![(&pmc_sapling, 50_000, None)]).await {
Err(LightClientError::SendError(SendError::ProposeSendError(
ProposeSendError::Proposal(
zcash_client_backend::data_api::error::Error::InsufficientFunds {
available,
required,
},
),
))) => {
assert_eq!(available, Zatoshis::from_u64(20_000).unwrap());
assert_eq!(required, Zatoshis::from_u64(70_000).unwrap());
}
other => panic!("expected InsufficientFunds, got {other:?}"),
}
bump_and_check!(o: 0 i: 10_000 s: 10_000 t: 465_000);
assert_eq!(get_fees_paid_by_client(&client).await, total_expected_fee);
client.quick_shield(zip32::AccountId::ZERO).await.unwrap();
bump_and_check!(o: 0 i: 460_000 s: 10_000 t: 0);
total_expected_fee += 15_000;
assert_eq!(get_fees_paid_by_client(&client).await, total_expected_fee);
from_inputs::quick_send(&mut client, vec![(&pmc_sapling, 10_000, None)])
.await
.unwrap();
bump_and_check!(o: 0 i: 430_000 s: 20_000 t: 0);
total_expected_fee += 20_000;
assert_eq!(get_fees_paid_by_client(&client).await, total_expected_fee);
from_inputs::quick_send(&mut client, vec![(&pmc_unified, 20_000, None)])
.await
.unwrap();
bump_and_check!(o: 0 i: 420_000 s: 20_000 t: 0);
total_expected_fee += 10_000;
assert_eq!(get_fees_paid_by_client(&client).await, total_expected_fee);
from_inputs::quick_send(&mut client, vec![(&pmc_sapling, 400_000, None)])
.await
.unwrap();
bump_and_check!(o: 0 i: 20_000 s: 400_000 t: 0);
total_expected_fee += 20_000;
assert_eq!(get_fees_paid_by_client(&client).await, total_expected_fee);
from_inputs::quick_send(&mut client, vec![(&pmc_sapling, 350_000, None)])
.await
.unwrap();
bump_and_check!(o: 0 i: 20_000 s: 390_000 t: 0);
total_expected_fee += 10_000;
assert_eq!(get_fees_paid_by_client(&client).await, total_expected_fee);
}
#[tokio::test]
async fn send_survives_lost_response_and_duplicate_rejection() {
use crate::testutils::mock_indexer::LostSendDestination;
use zingo_status::confirmation_status::ConfirmationStatus;
let mut net = MockNet::launch().await;
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
recipient.set_transmit_retry_interval(std::time::Duration::ZERO);
let recipient_ua =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
net.chain.write().await.mine_empty_blocks(1);
fund(&net, vec![(&recipient_ua, 100_000, None)], 1).await;
recipient.sync_and_await().await.unwrap();
check_client_balances!(recipient, i: 100_000 o: 0 s: 0 t: 0);
net.chain.write().await.lose_next_send_response = Some(LostSendDestination::Mempool);
let txids = from_inputs::quick_send(
&mut recipient,
vec![(&external_address(PoolType::ORCHARD), 20_000, None)],
)
.await
.expect("a duplicate-in-mempool rejection proves transmission succeeded");
{
let wallet = recipient.wallet().read().await;
for txid in txids.iter() {
let status = wallet
.wallet_transactions
.get(txid)
.expect("the transmitted transaction stays in the wallet")
.status();
assert!(
!matches!(status, ConfirmationStatus::Failed(_)),
"transaction {txid} marked Failed while live in the mempool"
);
}
}
net.chain.write().await.mine_mempool();
recipient.sync_and_await().await.unwrap();
check_client_balances!(recipient, i: 70_000 o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn send_survives_lost_response_and_queued_duplicate_rejection() {
use crate::testutils::mock_indexer::LostSendDestination;
use zingo_status::confirmation_status::ConfirmationStatus;
let mut net = MockNet::launch().await;
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
recipient.set_transmit_retry_interval(std::time::Duration::ZERO);
let recipient_ua =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
net.chain.write().await.mine_empty_blocks(1);
fund(&net, vec![(&recipient_ua, 100_000, None)], 1).await;
recipient.sync_and_await().await.unwrap();
check_client_balances!(recipient, i: 100_000 o: 0 s: 0 t: 0);
{
let mut chain = net.chain.write().await;
chain.lose_next_send_response = Some(LostSendDestination::DownloadQueue);
chain.queued_rejections_before_promotion = 2;
}
let txids = from_inputs::quick_send(
&mut recipient,
vec![(&external_address(PoolType::ORCHARD), 20_000, None)],
)
.await
.expect("probing resubmissions reach the storage-backed verdict");
{
let wallet = recipient.wallet().read().await;
for txid in txids.iter() {
let status = wallet
.wallet_transactions
.get(txid)
.expect("the transmitted transaction stays in the wallet")
.status();
assert!(
!matches!(status, ConfirmationStatus::Failed(_)),
"transaction {txid} marked Failed while queued for download"
);
}
}
net.chain.write().await.mine_mempool();
recipient.sync_and_await().await.unwrap();
check_client_balances!(recipient, i: 70_000 o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn failed_split_round_transmit_strands_calculated_transactions() {
use zcash_primitives::transaction::TxId;
use zip32::AccountId;
use pepper_sync::wallet::{OrchardNote, OutputInterface as _};
use zingo_status::confirmation_status::ConfirmationStatus;
use crate::testutils::mock_indexer::LostSendDestination;
use crate::testutils::synthetic_wallet::inject_confirmed_orchard_notes;
const NOTES: u32 = 17;
const NOTE_VALUE: u64 = 120_000;
const TIP: u32 = 41;
let mut net = MockNet::launch().await;
{
let mut chain = net.chain.write().await;
chain.rules.anchors = false;
chain.mine_empty_blocks(TIP);
}
let mut client = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
client.set_transmit_retry_interval(std::time::Duration::ZERO);
client
.sync_and_await()
.await
.expect("initial sync succeeds");
{
let wallet_lock = client.wallet().clone();
let mut wallet = wallet_lock.write().await;
inject_confirmed_orchard_notes(&mut wallet, NOTES, NOTE_VALUE, TIP);
}
{
let mut chain = net.chain.write().await;
chain.lose_next_send_response = Some(LostSendDestination::DownloadQueue);
chain.queued_rejections_before_promotion = u8::MAX;
}
let err = client
.migrate_to_ironwood(AccountId::ZERO)
.await
.expect_err("the first split transaction's transmit fails");
eprintln!("migrate_to_ironwood returned: {err:?}");
let wallet = client.wallet().read().await;
let mut failed = Vec::new();
let mut calculated = Vec::new();
for tx in wallet.wallet_transactions.values() {
match tx.status() {
ConfirmationStatus::Calculated(_) => {
let spent_inputs: Vec<(TxId, u64)> = wallet
.wallet_transactions
.values()
.flat_map(OrchardNote::transaction_outputs)
.filter(|note| note.spending_transaction() == Some(tx.txid()))
.map(|note| (note.output_id().txid(), note.value()))
.collect();
eprintln!(
"stranded Calculated transaction {} spends {} notes still \
marked spent: {spent_inputs:?}",
tx.txid(),
spent_inputs.len(),
);
calculated.push(tx.txid());
}
ConfirmationStatus::Failed(_) => failed.push(tx.txid()),
_ => (),
}
}
assert!(
!failed.is_empty(),
"precondition: the transmit stage must have failed the first split \
transaction (otherwise this test failed before transmit)"
);
assert!(
calculated.is_empty(),
"a failed note-split round stranded {} transaction(s) in Calculated \
with their input notes marked spent: {calculated:?}",
calculated.len()
);
}
fn continuous_sync_wallet_settings() -> crate::wallet::WalletSettings {
let mut settings = crate::testutils::default_test_wallet_settings();
settings.sync_config.shutdown_on_completion = false;
settings
}
#[tokio::test]
async fn sync_to_tip_and_await_returns_under_continuous_sync() {
let mut net = MockNet::launch().await;
net.chain.write().await.mine_empty_blocks(10);
let mut client = net
.client(
zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED,
Some(continuous_sync_wallet_settings()),
)
.await;
tokio::time::timeout(
std::time::Duration::from_secs(60),
client.sync_to_tip_and_await(),
)
.await
.expect("sync returns at the chain tip")
.expect("sync succeeds");
assert_eq!(
client.sync_mode(),
pepper_sync::wallet::SyncMode::NotRunning
);
assert!(
!client
.wallet()
.read()
.await
.wallet_settings
.sync_config
.shutdown_on_completion,
"the stored sync config must not be modified"
);
}
#[tokio::test]
async fn sync_to_tip_and_await_stops_running_continuous_sync() {
let mut net = MockNet::launch().await;
net.chain.write().await.mine_empty_blocks(10);
let mut client = net
.client(
zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED,
Some(continuous_sync_wallet_settings()),
)
.await;
client.sync().await.expect("continuous sync launches");
tokio::time::timeout(
std::time::Duration::from_secs(60),
client.sync_to_tip_and_await(),
)
.await
.expect("sync returns at the chain tip")
.expect("sync succeeds");
assert_eq!(
client.sync_mode(),
pepper_sync::wallet::SyncMode::NotRunning
);
}
#[tokio::test]
async fn transparent_funds_mined_during_sync_session_are_detected() {
let mut net = MockNet::launch().await;
net.chain.write().await.mine_empty_blocks(10);
let mut client = net
.client(
zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED,
Some(continuous_sync_wallet_settings()),
)
.await;
let taddr = get_base_address(&client, PoolType::Transparent).await;
let funding = faucet_funding_transaction(vec![(&taddr, 50_000, None)]).await;
client.sync().await.expect("continuous sync launches");
tokio::time::timeout(
std::time::Duration::from_secs(60),
client.subscribe_sync_status().wait_for(Option::is_some),
)
.await
.expect("sync publishes its status")
.expect("sync status channel open");
{
let mut chain = net.chain.write().await;
chain.mine_block(vec![funding]);
chain.mine_empty_blocks(1);
}
tokio::time::timeout(std::time::Duration::from_secs(60), async {
loop {
if let crate::data::PollReport::Ready(result) = client.poll_sync() {
panic!("sync returned before the transparent funds were detected: {result:?}");
}
let transparent_balance = client
.account_balance(zip32::AccountId::ZERO)
.await
.unwrap()
.confirmed_transparent_balance
.unwrap()
.into_u64();
if transparent_balance == 50_000 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
})
.await
.expect("transparent funds mined during the sync session are detected");
client.stop_sync().unwrap();
client.await_sync().await.unwrap();
}
#[tokio::test]
async fn transparent_funds_in_blocks_unscanned_by_previous_sync_session_are_detected() {
use crate::testutils::mock_indexer::{Fault, Rpc};
const INITIAL_CHAIN_HEIGHT: u32 = 10;
const BLOCKS_MINED_ABOVE_FUNDS: u32 = pepper_sync::sync::MAX_REORG_ALLOWANCE + 50;
const FETCH_HOLD: std::time::Duration = std::time::Duration::from_secs(5);
let mut net = MockNet::launch().await;
net.chain
.write()
.await
.mine_empty_blocks(INITIAL_CHAIN_HEIGHT);
let mut client = net
.client(
zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED,
Some(continuous_sync_wallet_settings()),
)
.await;
let taddr = get_base_address(&client, PoolType::Transparent).await;
client.sync().await.expect("continuous sync launches");
wait_until_scanned_to(&client, INITIAL_CHAIN_HEIGHT).await;
net.chain
.write()
.await
.faults
.inject(Rpc::BlockRange, Fault::Delay(FETCH_HOLD));
fund(&net, vec![(&taddr, 50_000, None)], BLOCKS_MINED_ABOVE_FUNDS).await;
let chain_height = net.chain.read().await.tip();
tokio::time::timeout(std::time::Duration::from_secs(60), async {
while client.latest_sync_status().is_none_or(|status| {
status.scan_ranges.last().is_none_or(|range| {
range.block_range().end != BlockHeight::from_u32(chain_height + 1)
})
}) {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
})
.await
.expect("the sync session creates the scan range of the new blocks");
client.stop_sync().unwrap();
client.await_sync().await.unwrap();
assert_eq!(
client
.wallet()
.read()
.await
.sync_state
.fully_scanned_height(),
Some(BlockHeight::from_u32(INITIAL_CHAIN_HEIGHT)),
"the sync session is stopped before the new blocks are scanned"
);
client.sync_to_tip_and_await().await.unwrap();
check_client_balances!(client, i: 0 o: 0 s: 0 t: 50_000);
}
enum ReorgDetection {
Continuity,
LowerChainHeight,
}
async fn transparent_funds_remined_at_transparent_scan_floor(
reorg_detection: ReorgDetection,
) -> crate::lightclient::LightClient {
const FUNDING_HEIGHT: u32 = 20;
let mut net = MockNet::launch().await;
net.chain
.write()
.await
.mine_empty_blocks(FUNDING_HEIGHT - 1);
let mut client = net
.client(
zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED,
Some(continuous_sync_wallet_settings()),
)
.await;
let taddr = get_base_address(&client, PoolType::Transparent).await;
let funding = faucet_funding_transaction(vec![(&taddr, 50_000, None)]).await;
net.chain.write().await.mine_block(vec![funding.clone()]);
assert_eq!(net.chain.read().await.tip(), FUNDING_HEIGHT);
client.sync().await.expect("continuous sync launches");
wait_until_scanned_to(&client, FUNDING_HEIGHT).await;
check_client_balances!(client, i: 0 o: 0 s: 0 t: 50_000);
match reorg_detection {
ReorgDetection::Continuity => {
let mut chain = net.chain.write().await;
chain.reorg_to(FUNDING_HEIGHT - 1);
chain.mine_block(vec![funding]);
chain.mine_empty_blocks(1);
}
ReorgDetection::LowerChainHeight => {
net.chain.write().await.reorg_to(FUNDING_HEIGHT - 1);
wait_until_scanned_to(&client, FUNDING_HEIGHT - 1).await;
let mut chain = net.chain.write().await;
chain.mine_block(vec![funding]);
chain.mine_empty_blocks(1);
}
}
wait_until_scanned_to(&client, FUNDING_HEIGHT + 1).await;
client.stop_sync().unwrap();
client.await_sync().await.unwrap();
client
}
#[tokio::test]
async fn transparent_funds_remined_at_transparent_scan_floor_are_detected_after_continuity_reorg() {
let client =
transparent_funds_remined_at_transparent_scan_floor(ReorgDetection::Continuity).await;
check_client_balances!(client, i: 0 o: 0 s: 0 t: 50_000);
}
#[tokio::test]
async fn transparent_funds_remined_at_transparent_scan_floor_are_detected_after_lower_chain_height()
{
let client =
transparent_funds_remined_at_transparent_scan_floor(ReorgDetection::LowerChainHeight).await;
check_client_balances!(client, i: 0 o: 0 s: 0 t: 50_000);
}
#[tokio::test]
async fn scan_results_in_flight_when_chain_height_drops_are_discarded_and_rescanned() {
use crate::testutils::mock_indexer::{Fault, Rpc};
const BLOCKS_BELOW_FUNDS: u32 = 10;
const BLOCKS_ABOVE_FUNDS: u32 = 9;
const CHAIN_HEIGHT: u32 = BLOCKS_BELOW_FUNDS + 1 + BLOCKS_ABOVE_FUNDS;
const FUNDING_TRANSACTIONS: usize = 2;
const FETCH_HOLD: std::time::Duration =
std::time::Duration::from_secs(pepper_sync::sync::CHECK_NEW_BLOCKS_INTERVAL / 2 + 1);
let mut net = MockNet::launch().await;
net.chain
.write()
.await
.mine_empty_blocks(BLOCKS_BELOW_FUNDS);
let mut client = net
.client(
zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED,
Some(continuous_sync_wallet_settings()),
)
.await;
let ua = get_base_address(&client, PoolType::Shielded(ShieldedPool::Orchard)).await;
let mut funding = Vec::new();
for _ in 0..FUNDING_TRANSACTIONS {
funding.push(faucet_funding_transaction(vec![(&ua, 100_000, None)]).await);
net.chain
.write()
.await
.faults
.inject(Rpc::Transaction, Fault::Delay(FETCH_HOLD));
}
{
let mut chain = net.chain.write().await;
chain.mine_block(funding);
chain.mine_empty_blocks(BLOCKS_ABOVE_FUNDS);
}
assert_eq!(net.chain.read().await.tip(), CHAIN_HEIGHT);
client.sync().await.expect("continuous sync launches");
tokio::time::timeout(std::time::Duration::from_secs(60), async {
while net.chain.read().await.faults.pending(Rpc::Transaction) != 0 {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
})
.await
.expect("the scan fetches the funding transactions");
net.chain.write().await.reorg_to(CHAIN_HEIGHT - 1);
tokio::time::timeout(std::time::Duration::from_secs(60), async {
loop {
if let crate::data::PollReport::Ready(result) = client.poll_sync() {
panic!("sync returned before the lower chain was scanned: {result:?}");
}
{
let wallet = client.wallet();
let wallet = wallet.read().await;
let scan_ranges = wallet.sync_state.scan_ranges();
if scan_ranges.last().is_some_and(|range| {
range.block_range().end == BlockHeight::from_u32(CHAIN_HEIGHT)
}) && scan_ranges
.iter()
.all(|range| range.priority() == pepper_sync::sync::ScanPriority::Scanned)
{
break;
}
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
})
.await
.expect("the wallet scans to the lower chain height");
client.stop_sync().unwrap();
client.await_sync().await.unwrap();
check_client_balances!(client, i: 200_000 o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn fetch_under_a_lowered_chain_height_recommends_syncing_again() {
use pepper_sync::error::{ServerError, SyncError, SyncRecoveryObservables};
use crate::lightclient::error::LightClientError;
use crate::testutils::mock_indexer::{Fault, Rpc};
const CHAIN_HEIGHT: u32 = 10;
const FETCH_HOLD: std::time::Duration = std::time::Duration::from_secs(3);
let mut net = MockNet::launch().await;
net.chain.write().await.mine_empty_blocks(CHAIN_HEIGHT);
let mut client = net
.client(
zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED,
Some(continuous_sync_wallet_settings()),
)
.await;
client.sync().await.expect("continuous sync launches");
wait_until_scanned_to(&client, CHAIN_HEIGHT).await;
{
let mut chain = net.chain.write().await;
chain
.faults
.inject(Rpc::BlockRange, Fault::Delay(FETCH_HOLD));
chain.mine_empty_blocks(1);
}
tokio::time::timeout(std::time::Duration::from_secs(60), async {
while client.latest_sync_status().is_none_or(|status| {
status.scan_ranges.last().is_none_or(|range| {
range.block_range().end != BlockHeight::from_u32(CHAIN_HEIGHT + 2)
})
}) {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
})
.await
.expect("the sync session creates the scan range of the new block");
net.chain.write().await.reorg_to(CHAIN_HEIGHT);
let LightClientError::SyncError(error) = client
.await_sync()
.await
.expect_err("the fetch of the removed block fails")
else {
panic!("the sync session ends with a sync error");
};
assert!(
matches!(
error,
SyncError::ServerError(ServerError::ChainHeightBelowScanRange {
chain_height,
scan_range_end,
}) if chain_height == BlockHeight::from_u32(CHAIN_HEIGHT)
&& scan_range_end == BlockHeight::from_u32(CHAIN_HEIGHT + 1)
),
"{error:?}"
);
assert_eq!(
error.recovery_recommendation(),
SyncRecoveryObservables::MaybeRecoverableServer
);
let sync_result = client.sync_to_tip_and_await().await.unwrap();
assert_eq!(
sync_result.sync_end_height,
BlockHeight::from_u32(CHAIN_HEIGHT)
);
}
#[tokio::test]
async fn session_start_requests_release_the_wallet_lock() {
use crate::testutils::mock_indexer::{Fault, Rpc};
const CHAIN_HEIGHT: u32 = 10;
const FETCH_HOLD: std::time::Duration = std::time::Duration::from_secs(5);
const LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(1);
let mut net = MockNet::launch().await;
{
let mut chain = net.chain.write().await;
chain.mine_empty_blocks(CHAIN_HEIGHT);
chain
.faults
.inject(Rpc::TreeState, Fault::Delay(FETCH_HOLD));
chain
.faults
.inject(Rpc::SubtreeRoots, Fault::Delay(FETCH_HOLD));
}
let mut client = net
.client(
zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED,
Some(continuous_sync_wallet_settings()),
)
.await;
client.sync().await.expect("continuous sync launches");
for held_request in ["tree state", "subtree roots"] {
tokio::time::sleep(FETCH_HOLD / 2).await;
let wallet = client.wallet();
let wallet_guard = tokio::time::timeout(LOCK_WAIT, wallet.read())
.await
.unwrap_or_else(|_| {
panic!("the wallet lock is held across the {held_request} request")
});
assert!(
wallet_guard
.sync_state
.fully_scanned_height()
.is_some_and(|height| height < BlockHeight::from_u32(CHAIN_HEIGHT)),
"no block is scanned while the {held_request} request is held"
);
drop(wallet_guard);
tokio::time::sleep(FETCH_HOLD / 2).await;
}
client.sync_to_tip_and_await().await.unwrap();
assert_eq!(
client
.wallet()
.read()
.await
.sync_state
.fully_scanned_height(),
Some(BlockHeight::from_u32(CHAIN_HEIGHT))
);
}
#[tokio::test]
async fn stop_ends_the_session_during_a_held_session_start_request() {
use crate::testutils::mock_indexer::{Fault, Rpc};
const CHAIN_HEIGHT: u32 = 10;
const FETCH_HOLD: std::time::Duration = std::time::Duration::from_secs(30);
const STOP_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
let mut net = MockNet::launch().await;
{
let mut chain = net.chain.write().await;
chain.mine_empty_blocks(CHAIN_HEIGHT);
chain
.faults
.inject(Rpc::SubtreeRoots, Fault::Delay(FETCH_HOLD));
}
let mut client = net
.client(
zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED,
Some(continuous_sync_wallet_settings()),
)
.await;
client.sync().await.expect("continuous sync launches");
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
client.stop_sync().unwrap();
let sync_result = tokio::time::timeout(STOP_WAIT, client.await_sync())
.await
.expect("the session ends while the server still holds the request")
.expect("a stopped session returns its result");
assert_eq!(sync_result.blocks_scanned, 0);
assert!(
client
.wallet()
.read()
.await
.sync_state
.fully_scanned_height()
.is_some_and(|height| height < BlockHeight::from_u32(CHAIN_HEIGHT)),
"the session ended before scanning began"
);
}
#[tokio::test]
async fn sync_status_holds_while_a_reorg_truncation_awaits_the_tree_bounds() {
use pepper_sync::sync::ScanPriority;
use crate::testutils::mock_indexer::{Fault, Rpc};
const CHAIN_HEIGHT: u32 = 20;
const FETCH_HOLD: std::time::Duration = std::time::Duration::from_secs(5);
let mut net = MockNet::launch().await;
net.chain.write().await.mine_empty_blocks(CHAIN_HEIGHT);
let mut client = net
.client(
zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED,
Some(continuous_sync_wallet_settings()),
)
.await;
client.sync().await.expect("continuous sync launches");
wait_until_scanned_to(&client, CHAIN_HEIGHT).await;
client.stop_sync().unwrap();
client.await_sync().await.unwrap();
{
let mut chain = net.chain.write().await;
chain.reorg_to(CHAIN_HEIGHT - 1);
chain.mine_empty_blocks(1);
chain
.faults
.inject(Rpc::TreeState, Fault::Delay(FETCH_HOLD));
}
client.sync().await.expect("the next session launches");
let status_in_the_window = tokio::time::timeout(FETCH_HOLD, async {
loop {
let wallet = client.wallet();
let wallet_guard = wallet.read().await;
let status = pepper_sync::sync_status(&*wallet_guard)
.await
.expect("the status is computable while the tree bounds are fetched");
if status
.scan_ranges
.iter()
.any(|range| range.priority() == ScanPriority::Verify)
{
break status;
}
drop(wallet_guard);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
})
.await
.expect("the re-org truncation is read while the tree state request is held");
assert!(
status_in_the_window.total_blocks_scanned < CHAIN_HEIGHT,
"the truncation has removed scanned blocks: {status_in_the_window:?}"
);
assert_eq!(
status_in_the_window.session_blocks_scanned, 0,
"the live count below the previous session's snapshot saturates"
);
wait_until_scanned_to(&client, CHAIN_HEIGHT).await;
client.stop_sync().unwrap();
let sync_result = client.await_sync().await.unwrap();
assert!(
sync_result.blocks_scanned > 0,
"the replacing blocks are scanned"
);
}
#[tokio::test]
async fn first_session_of_a_wallet_born_after_sapling_inserts_its_frontier_and_scans() {
const BIRTHDAY: u32 = 5;
const CHAIN_HEIGHT: u32 = 20;
let mut net = MockNet::launch().await;
net.chain.write().await.mine_empty_blocks(CHAIN_HEIGHT);
let mut client = net
.client_with_birthday(
zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED,
BIRTHDAY,
Some(continuous_sync_wallet_settings()),
)
.await;
client.sync().await.expect("continuous sync launches");
wait_until_scanned_to(&client, CHAIN_HEIGHT).await;
client.stop_sync().unwrap();
let sync_result = client.await_sync().await.unwrap();
assert_eq!(
sync_result.sync_start_height,
BlockHeight::from_u32(BIRTHDAY)
);
assert_eq!(
sync_result.blocks_scanned,
CHAIN_HEIGHT - BIRTHDAY + 1,
"every block from the birthday to the tip is scanned"
);
}
#[tokio::test]
async fn stop_during_the_first_chain_height_request_returns_an_empty_session() {
use crate::testutils::mock_indexer::{Fault, Rpc};
const CHAIN_HEIGHT: u32 = 10;
const FETCH_HOLD: std::time::Duration = std::time::Duration::from_secs(30);
const STOP_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
let mut net = MockNet::launch().await;
{
let mut chain = net.chain.write().await;
chain.mine_empty_blocks(CHAIN_HEIGHT);
chain
.faults
.inject(Rpc::LatestBlock, Fault::Delay(FETCH_HOLD));
}
let mut client = net
.client(
zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED,
Some(continuous_sync_wallet_settings()),
)
.await;
client.sync().await.expect("continuous sync launches");
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
client.stop_sync().unwrap();
let sync_result = tokio::time::timeout(STOP_WAIT, client.await_sync())
.await
.expect("the session ends while the server still holds the request")
.expect("a session stopped before its first scan range returns its result");
assert_eq!(sync_result.blocks_scanned, 0);
assert_eq!(sync_result.sync_start_height, sync_result.sync_end_height);
assert!(
client
.wallet()
.read()
.await
.sync_state
.scan_ranges()
.is_empty(),
"no scan range was created before the stop"
);
client.sync_to_tip_and_await().await.unwrap();
assert_eq!(
client
.wallet()
.read()
.await
.sync_state
.fully_scanned_height(),
Some(BlockHeight::from_u32(CHAIN_HEIGHT)),
"the next session syncs the wallet from scratch"
);
}
#[tokio::test]
async fn sync_rejects_server_below_minimum_lightwallet_protocol_version() {
use crate::lightclient::error::LightClientError;
use pepper_sync::error::{ServerError, SyncError, SyncRecoveryObservables};
for reported_version in ["v0.4.0", ""] {
let mut net = MockNet::launch().await;
{
let mut chain = net.chain.write().await;
chain.lightwallet_protocol_version = reported_version.to_string();
chain.mine_empty_blocks(10);
}
let mut client = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let error = client
.sync_and_await()
.await
.expect_err("sync against an old server fails");
let LightClientError::SyncError(sync_error) = &error else {
panic!("expected a sync error, got {error:?}");
};
assert!(
matches!(
sync_error,
SyncError::ServerError(ServerError::UnsupportedProtocolVersion { version })
if version == reported_version
),
"expected UnsupportedProtocolVersion for '{reported_version}', got {sync_error:?}"
);
assert_eq!(
sync_error.recovery_recommendation(),
SyncRecoveryObservables::ServerUnavailable
);
}
}
async fn wait_until_scanned_to(client: &crate::lightclient::LightClient, height: u32) {
tokio::time::timeout(std::time::Duration::from_secs(60), async {
loop {
{
let wallet = client.wallet();
let wallet = wallet.read().await;
let scan_ranges = wallet.sync_state.scan_ranges();
if scan_ranges.last().is_some_and(|range| {
range.block_range().end == BlockHeight::from_u32(height + 1)
}) && scan_ranges
.iter()
.all(|range| range.priority() == pepper_sync::sync::ScanPriority::Scanned)
{
return;
}
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
})
.await
.unwrap_or_else(|_| panic!("blocks up to {height} were not scanned"));
}
async fn continuous_sync_with_refetched_nullifiers() -> (
MockNet,
crate::lightclient::LightClient,
crate::lightclient::LightClient,
) {
use crate::testutils::mock_indexer::{Fault, Rpc};
let mut net = MockNet::launch().await;
net.chain.write().await.mine_empty_blocks(10);
let mut settings = continuous_sync_wallet_settings();
settings.sync_config.performance_level = pepper_sync::config::PerformanceLevel::Low;
let mut client = net
.client(
zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED,
Some(settings),
)
.await;
client.sync_to_tip_and_await().await.unwrap();
let mut spender = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let ua = get_base_address(&client, PoolType::Shielded(ShieldedPool::Orchard)).await;
net.chain.write().await.mine_empty_blocks(14);
fund(&net, vec![(&ua, 100_000, None)], 15).await;
spender.sync_and_await().await.unwrap();
from_inputs::quick_send(
&mut spender,
vec![(&external_address(PoolType::ORCHARD), 20_000, None)],
)
.await
.unwrap();
let spend = {
let mut chain = net.chain.write().await;
chain.mine_mempool();
chain.reorg_to(40)
};
assert_eq!(net.chain.read().await.tip(), 40);
net.chain.write().await.faults.inject(
Rpc::BlockRange,
Fault::Delay(
std::time::Duration::from_secs(pepper_sync::sync::CHECK_NEW_BLOCKS_INTERVAL)
+ std::time::Duration::from_secs(2),
),
);
client.sync().await.expect("continuous sync launches");
tokio::time::timeout(
std::time::Duration::from_secs(60),
client.subscribe_sync_status().wait_for(Option::is_some),
)
.await
.expect("sync publishes its status")
.expect("sync status channel open");
{
let mut chain = net.chain.write().await;
chain.mine_block(spend);
chain.mine_empty_blocks(4);
}
wait_until_scanned_to(&client, 45).await;
(net, client, spender)
}
#[tokio::test]
async fn spend_in_range_scanned_without_mapping_is_detected_from_refetched_nullifiers() {
let (_net, mut client, _spender) = continuous_sync_with_refetched_nullifiers().await;
client.stop_sync().unwrap();
client.await_sync().await.unwrap();
check_client_balances!(client, i: 70_000 o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn gap_address_funds_are_detected_after_nullifiers_are_refetched() {
use zcash_keys::encoding::AddressCodec;
let (net, mut client, spender) = continuous_sync_with_refetched_nullifiers().await;
let (_, gap_taddr) = spender
.wallet()
.write()
.await
.generate_transparent_address(zip32::AccountId::ZERO, false)
.unwrap();
fund(
&net,
vec![(&gap_taddr.encode(&client.chain_type()), 50_000, None)],
0,
)
.await;
wait_until_scanned_to(&client, 46).await;
client.stop_sync().unwrap();
client.await_sync().await.unwrap();
check_client_balances!(client, i: 70_000 o: 0 s: 0 t: 50_000);
}
async fn wait_until_open_mempool_streams(net: &MockNet, expected: usize) {
tokio::time::timeout(std::time::Duration::from_secs(60), async {
while net.chain.read().await.open_mempool_streams() != expected {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
})
.await
.unwrap_or_else(|_| panic!("the indexer never served {expected} open mempool streams"));
}
#[tokio::test]
async fn mempool_stream_closes_when_sync_returns_an_error() {
use zaino_proto::tonic::Code;
use crate::testutils::mock_indexer::{Fault, Rpc};
let mut net = MockNet::launch().await;
net.chain.write().await.mine_empty_blocks(2);
let mut client = net
.client(
zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED,
Some(continuous_sync_wallet_settings()),
)
.await;
client.sync().await.expect("continuous sync launches");
wait_until_open_mempool_streams(&net, 1).await;
net.chain.write().await.faults.inject(
Rpc::LatestBlock,
Fault::Fail(Code::Unavailable, "mock outage".to_string()),
);
client
.await_sync()
.await
.expect_err("the failed chain tip request ends the session");
wait_until_open_mempool_streams(&net, 0).await;
}
#[tokio::test]
async fn sync_returns_while_the_indexer_refuses_the_mempool_stream() {
use zaino_proto::tonic::Code;
use crate::testutils::mock_indexer::{Fault, Rpc};
const QUEUED_REFUSALS: usize = 100;
let mut net = MockNet::launch().await;
let mut client = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
{
let mut chain = net.chain.write().await;
chain.mine_empty_blocks(2);
for _ in 0..QUEUED_REFUSALS {
chain.faults.inject(
Rpc::MempoolStream,
Fault::Fail(Code::ResourceExhausted, "mock refusal".to_string()),
);
}
}
tokio::time::timeout(std::time::Duration::from_secs(30), client.sync_and_await())
.await
.expect("sync returns while the mempool stream is refused")
.unwrap();
assert!(
net.chain.read().await.faults.pending(Rpc::MempoolStream) > 0,
"the indexer was still refusing the mempool stream when sync returned"
);
}
#[tokio::test]
async fn migrate_to_ironwood_returns_under_continuous_sync() {
use zip32::AccountId;
use zingo_status::confirmation_status::ConfirmationStatus;
use crate::testutils::mock_indexer::LostSendDestination;
use crate::testutils::synthetic_wallet::inject_confirmed_orchard_notes;
const NOTES: u32 = 17;
const NOTE_VALUE: u64 = 120_000;
const TIP: u32 = 41;
let mut net = MockNet::launch().await;
{
let mut chain = net.chain.write().await;
chain.rules.anchors = false;
chain.mine_empty_blocks(TIP);
}
let mut client = net
.client(
zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED,
Some(continuous_sync_wallet_settings()),
)
.await;
client.set_transmit_retry_interval(std::time::Duration::ZERO);
client
.sync_to_tip_and_await()
.await
.expect("initial sync succeeds");
{
let wallet_lock = client.wallet().clone();
let mut wallet = wallet_lock.write().await;
inject_confirmed_orchard_notes(&mut wallet, NOTES, NOTE_VALUE, TIP);
}
{
let mut chain = net.chain.write().await;
chain.lose_next_send_response = Some(LostSendDestination::DownloadQueue);
chain.queued_rejections_before_promotion = u8::MAX;
}
client.sync().await.expect("continuous sync launches");
tokio::time::timeout(
std::time::Duration::from_secs(120),
client.migrate_to_ironwood(AccountId::ZERO),
)
.await
.expect("migrate_to_ironwood returns under continuous sync")
.expect_err("the first split transaction's transmit fails");
let wallet = client.wallet().read().await;
assert!(
wallet
.wallet_transactions
.values()
.any(|tx| matches!(tx.status(), ConfirmationStatus::Failed(_))),
"the round reached its transmit stage, so its sync returned"
);
assert!(!wallet.wallet_settings.sync_config.shutdown_on_completion);
}
#[cfg(feature = "perspective")]
mod perspective {
use std::num::NonZeroU32;
use std::time::{Duration, Instant};
use pepper_sync::config::{
PerformanceLevel, SyncConfig, TransparentAddressDiscovery,
TransparentAddressDiscoveryScopes,
};
use tracing_subscriber::EnvFilter;
use zcash_keys::encoding::AddressCodec;
use zip32::AccountId;
use crate::lightclient::LightClient;
use crate::perspective::value_transfer::{
SelfSendValueTransfer, SentValueTransfer, ValueTransfer, ValueTransferKind, ValueTransfers,
};
use crate::testutils::synthetic_wallet::inject_confirmed_orchard_notes;
use crate::wallet::WalletSettings;
use super::*;
const NOTE_VALUE: u64 = 1_000_000;
const TIP: u32 = 41;
async fn orchard_funded_client() -> (MockNet, LightClient) {
let mut net = MockNet::launch().await;
{
let mut chain = net.chain.write().await;
chain.rules.anchors = false;
chain.mine_empty_blocks(TIP);
}
let mut client = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
client
.sync_and_await()
.await
.expect("initial sync succeeds");
{
let wallet_lock = client.wallet().clone();
let mut wallet = wallet_lock.write().await;
inject_confirmed_orchard_notes(&mut wallet, 1, NOTE_VALUE, TIP);
}
(net, client)
}
fn migration_transfer(value_transfers: &ValueTransfers) -> Option<&ValueTransfer> {
value_transfers.iter().find(|vt| {
vt.kind
== ValueTransferKind::Sent(SentValueTransfer::SendToSelf(
SelfSendValueTransfer::Migration,
))
})
}
#[tokio::test]
async fn zero_value_receipts() {
let mut net = MockNet::launch().await;
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let recipient_ua = get_base_address(&recipient, PoolType::IRONWOOD).await;
net.chain.write().await.mine_empty_blocks(1);
fund(&net, vec![(&recipient_ua, 100_000, None)], 1).await;
fund(&net, vec![(&recipient_ua, 0, None)], 1).await;
recipient.sync_and_await().await.unwrap();
from_inputs::quick_send(
&mut recipient,
vec![(&external_address(PoolType::IRONWOOD), 1_000, None)],
)
.await
.unwrap();
net.chain.write().await.mine_mempool();
net.chain.write().await.mine_empty_blocks(1);
recipient.sync_and_await().await.unwrap();
check_client_balances!(recipient, i: 89_000 o: 0 s: 0 t: 0);
let value_transfers = recipient.value_transfers(true).await.unwrap();
assert!(
value_transfers
.iter()
.any(|vt| vt.kind == ValueTransferKind::Received && vt.value == 100_000)
);
assert_eq!(
value_transfers
.iter()
.filter(|vt| vt.kind == ValueTransferKind::Received
&& vt.value == 0
&& vt.pools_received == [PoolType::IRONWOOD])
.count(),
1
);
assert!(value_transfers.iter().any(|vt| {
vt.kind == ValueTransferKind::Sent(SentValueTransfer::Send)
&& vt.value == 1_000
&& vt.transaction_fee == Some(10_000)
}));
}
#[tokio::test]
async fn immediate_migration_is_a_migration_value_transfer() {
use zip32::AccountId;
let (net, mut client) = orchard_funded_client().await;
let summary = client
.migrate_immediately(AccountId::ZERO)
.await
.expect("the immediate migration builds and transmits");
assert_eq!(
summary.txids.len(),
1,
"one note migrates in one transaction"
);
net.chain.write().await.mine_mempool();
client.sync_and_await().await.unwrap();
let value_transfers = client.value_transfers(false).await.unwrap();
assert!(
migration_transfer(&value_transfers).is_some(),
"the immediate migration transaction must classify as a migration value transfer; got {:?}",
value_transfers.iter().map(|vt| vt.kind).collect::<Vec<_>>(),
);
}
#[tokio::test]
async fn migration_with_memo_is_still_a_migration_value_transfer() {
const MEMO: &str = "moving my own funds";
let (_net, mut client) = orchard_funded_client().await;
let own_ua = get_base_address(&client, PoolType::Shielded(ShieldedPool::Orchard)).await;
from_inputs::quick_send(&mut client, vec![(&own_ua, 50_000, Some(MEMO))])
.await
.unwrap();
let value_transfers = client.value_transfers(false).await.unwrap();
let migration = migration_transfer(&value_transfers).unwrap_or_else(|| {
panic!(
"the memo-carrying Orchard→Ironwood self-send must classify as a \
migration value transfer; got {:?}",
value_transfers.iter().map(|vt| vt.kind).collect::<Vec<_>>(),
)
});
assert!(
migration.memos.iter().any(|memo| memo == MEMO),
"the migration value transfer must keep its memo; got {:?}",
migration.memos,
);
}
#[tokio::test]
async fn gap_address_compact_block_scanning() {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let mut net = MockNet::launch().await;
let mut recipient = net
.client(
zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED,
Some(WalletSettings {
sync_config: SyncConfig {
transparent_address_discovery: TransparentAddressDiscovery {
gap_limit: 3,
scopes: TransparentAddressDiscoveryScopes::default(),
},
performance_level: PerformanceLevel::High,
shutdown_on_completion: false,
},
min_confirmations: NonZeroU32::try_from(1)
.expect("hard-coded non-zero integer"),
}),
)
.await;
net.chain.write().await.mine_empty_blocks(10);
recipient.sync().await.unwrap();
while recipient
.latest_sync_status()
.is_none_or(|status| !status.is_complete())
{
tokio::time::sleep(Duration::from_millis(100)).await;
}
let recipient_clone = net
.client(
zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED,
Some(WalletSettings {
sync_config: SyncConfig {
transparent_address_discovery: TransparentAddressDiscovery {
gap_limit: 3,
scopes: TransparentAddressDiscoveryScopes::default(),
},
performance_level: PerformanceLevel::High,
shutdown_on_completion: false,
},
min_confirmations: NonZeroU32::try_from(1)
.expect("hard-coded non-zero integer"),
}),
)
.await;
let mut gap_taddrs = Vec::new();
let mut funding_txs = Vec::new();
for _ in 0..5 {
let gap_taddr = recipient_clone
.wallet()
.write()
.await
.generate_transparent_address(AccountId::ZERO, false)
.unwrap();
gap_taddrs.push(gap_taddr);
let funding_tx = faucet_funding_transaction(vec![(
&gap_taddr.1.encode(&recipient.chain_type()),
100_000,
None,
)])
.await;
funding_txs.push(funding_tx);
}
net.chain.write().await.mine_block(funding_txs);
let timeout = Duration::from_secs(150);
let assert_start = Instant::now();
while !(recipient
.latest_sync_status()
.unwrap()
.scan_ranges
.last()
.unwrap()
.block_range()
.end
== 12.into()
&& recipient.latest_sync_status().unwrap().is_complete())
{
if assert_start.elapsed() >= timeout {
panic!("test time exceeded expected time to scan newly mined blocks");
}
tokio::time::sleep(Duration::from_millis(2_000)).await;
}
recipient.stop_sync().unwrap();
recipient.await_sync().await.unwrap();
check_client_balances!(recipient, i: 0 o: 0 s: 0 t: 500_000);
for (id, addr) in gap_taddrs {
assert!(
recipient
.wallet()
.read()
.await
.transparent_addresses()
.iter()
.any(|(wallet_addr_id, wallet_addr)| *wallet_addr_id == id
&& *wallet_addr == addr.encode(&recipient.chain_type()))
);
}
}
}
#[cfg(feature = "nym")]
#[tokio::test]
async fn a_mock_chain_send_reports_the_mixnet_route() {
use crate::lightclient::send::TransmitRoute;
let mut net = MockNet::launch().await;
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let recipient_ua =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
net.chain.write().await.mine_empty_blocks(1);
fund(&net, vec![(&recipient_ua, 100_000, None)], 1).await;
recipient.sync_and_await().await.unwrap();
check_client_balances!(recipient, i: 100_000 o: 0 s: 0 t: 0);
let reports = from_inputs::quick_send_reported(
&mut recipient,
vec![(&external_address(PoolType::ORCHARD), 20_000, None)],
)
.await
.unwrap();
for report in &reports {
match &report.route {
TransmitRoute::Mixnet {
destination,
via_socks5,
} => {
assert_eq!(
via_socks5,
&crate::mocks::transmission::MOCK_SOCKS5_ADDR.to_string()
);
assert_eq!(
destination,
net.indexer_uri()
.host()
.expect("the mock indexer has a host"),
"a regtest draw names the sync indexer alone"
);
}
TransmitRoute::Nakednet { destination } => {
panic!("a mixnet-on session leaked the transmission to nakednet at {destination}")
}
}
}
net.chain.write().await.mine_mempool();
recipient.sync_and_await().await.unwrap();
check_client_balances!(recipient, i: 70_000 o: 0 s: 0 t: 0);
}
#[cfg(feature = "nym")]
#[tokio::test]
async fn the_nakednet_policy_reports_the_nakednet_route_over_a_ready_mixnet() {
use crate::lightclient::send::TransmitRoute;
let mut net = MockNet::launch().await;
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let recipient_ua =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
net.chain.write().await.mine_empty_blocks(1);
fund(&net, vec![(&recipient_ua, 100_000, None)], 1).await;
recipient.sync_and_await().await.unwrap();
assert!(recipient.read_mixnet_indicator().is_ready());
recipient.set_transmit_policy(crate::mixnet::TransmitPolicy::Nakednet);
let reports = from_inputs::quick_send_reported(
&mut recipient,
vec![(&external_address(PoolType::ORCHARD), 20_000, None)],
)
.await
.unwrap();
for report in &reports {
assert!(
matches!(report.route, TransmitRoute::Nakednet { .. }),
"a nakednet-policy session reported {:?} instead of nakednet",
report.route
);
}
}
fn checkpoint_exists<S>(store: &S, height: u32) -> bool
where
S: ShardStore<CheckpointId = BlockHeight>,
{
store
.get_checkpoint(&BlockHeight::from_u32(height))
.expect("infallible")
.is_some()
}
#[tokio::test]
async fn shardtree_roundtrip_restores_retained_checkpoints() {
fn all_checkpoints_stored<S>(store: &S, chain_height: u32) -> bool
where
S: ShardStore<CheckpointId = BlockHeight>,
{
for boundary in [144, 288] {
if !checkpoint_exists(store, boundary) {
return false;
}
}
for rolling_window in
(chain_height - SHARDTREE_CHECKPOINT_ROLLING_WINDOW_SIZE + 1)..=chain_height
{
if !checkpoint_exists(store, rolling_window) {
return false;
}
}
true
}
fn all_boundaries_retained<S>(store: &S, chain_height: u32) -> bool
where
S: ShardStore<CheckpointId = BlockHeight>,
{
let retained = store.retained_checkpoints().unwrap();
let no_of_boundaries = chain_height / 144;
for boundary_index in 0..no_of_boundaries {
let boundary = (boundary_index + 1) * 144;
if !retained.contains(&BlockHeight::from_u32(boundary)) {
return false;
}
}
true
}
let mut chain_height = 500;
let mut net = MockNet::launch().await;
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
net.chain.write().await.mine_empty_blocks(chain_height - 2);
recipient.sync_and_await().await.unwrap();
let recipient_ua =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
fund(&net, vec![(&recipient_ua, 100_000, None)], 1).await;
recipient.sync_and_await().await.unwrap();
{
let shard_trees = &recipient.wallet().read().await.shard_trees;
assert!(all_checkpoints_stored(
shard_trees.sapling.store(),
chain_height
));
assert!(all_checkpoints_stored(
shard_trees.orchard.store(),
chain_height
));
assert!(all_checkpoints_stored(
shard_trees.ironwood.store(),
chain_height
));
assert!(all_boundaries_retained(
shard_trees.sapling.store(),
chain_height
));
assert!(all_boundaries_retained(
shard_trees.orchard.store(),
chain_height
));
assert!(all_boundaries_retained(
shard_trees.ironwood.store(),
chain_height
));
}
recipient.save_task().await;
recipient.wait_for_save().await;
recipient.shutdown_save_task().await.unwrap();
drop(recipient);
let mut reloaded_recipient = net.client_from_file(0).await;
fund(&net, vec![(&recipient_ua, 100_000, None)], 1).await;
chain_height += 2;
reloaded_recipient.sync_and_await().await.unwrap();
{
let shard_trees = &reloaded_recipient.wallet().read().await.shard_trees;
assert!(all_checkpoints_stored(
shard_trees.sapling.store(),
chain_height
));
assert!(all_checkpoints_stored(
shard_trees.orchard.store(),
chain_height
));
assert!(all_checkpoints_stored(
shard_trees.ironwood.store(),
chain_height
));
assert!(all_boundaries_retained(
shard_trees.sapling.store(),
chain_height
));
assert!(all_boundaries_retained(
shard_trees.orchard.store(),
chain_height
));
assert!(all_boundaries_retained(
shard_trees.ironwood.store(),
chain_height
));
}
}
#[tokio::test]
async fn store_all_checkpoints_in_verification_window_on_the_mock_chain() {
const PAYMENT: u64 = 10_000;
const DENSE_CYCLES: u32 = 3;
const EMPTY_MIDDLE_BLOCKS: u32 = 86;
async fn dense_cycle(net: &MockNet, orchard_address: &str, sapling_address: &str) {
fund(net, vec![(orchard_address, PAYMENT, None)], 0).await;
fund(net, vec![(sapling_address, PAYMENT, None)], 0).await;
fund(
net,
vec![
(orchard_address, PAYMENT, None),
(sapling_address, PAYMENT, None),
],
1,
)
.await;
}
let mut net = MockNet::launch().await;
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let orchard_address =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
let sapling_address =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Sapling)).await;
net.chain.write().await.mine_empty_blocks(1);
for _ in 0..DENSE_CYCLES {
dense_cycle(&net, &orchard_address, &sapling_address).await;
}
net.chain
.write()
.await
.mine_empty_blocks(EMPTY_MIDDLE_BLOCKS);
for _ in 0..DENSE_CYCLES {
dense_cycle(&net, &orchard_address, &sapling_address).await;
}
recipient.sync_and_await().await.unwrap();
let tip = net.chain.read().await.tip();
let window_start = tip - pepper_sync::sync::MAX_REORG_ALLOWANCE + 1;
let wallet = recipient.wallet().read().await;
let shard_trees = &wallet.shard_trees;
for height in window_start..=tip {
assert!(
checkpoint_exists(shard_trees.sapling.store(), height),
"missing sapling checkpoint at height {height}"
);
assert!(
checkpoint_exists(shard_trees.orchard.store(), height),
"missing orchard checkpoint at height {height}"
);
assert!(
checkpoint_exists(shard_trees.ironwood.store(), height),
"missing ironwood checkpoint at height {height}"
);
}
}
mod strict_chain {
use std::time::Duration;
use zaino_proto::tonic::Code;
use zcash_protocol::consensus::COINBASE_MATURITY_BLOCKS;
use zcash_protocol::value::Zatoshis;
use zingo_status::confirmation_status::ConfirmationStatus;
use pepper_sync::wallet::KeyIdInterface;
use pepper_sync::wallet::traits::SyncWallet;
use crate::lightclient::LightClient;
use crate::testutils::chain_generics::fixtures;
use crate::testutils::mock_indexer::{Fault, LostSendDestination, Rpc};
use super::*;
const FUNDING: u64 = 100_000;
const PAYMENT: u64 = 10_000;
const REWARD_ZATS: u64 = 1_000_000;
const REWARD: Zatoshis = Zatoshis::const_from_u64(REWARD_ZATS);
async fn funded_sender(net: &mut MockNet) -> LightClient {
let mut sender = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
sender.set_transmit_retry_interval(Duration::ZERO);
let sender_ua = get_base_address(&sender, PoolType::Shielded(ShieldedPool::Orchard)).await;
fund(net, vec![(&sender_ua, FUNDING, None)], 1).await;
sender.sync_and_await().await.unwrap();
check_client_balances!(sender, i: FUNDING o: 0 s: 0 t: 0);
sender
}
#[tokio::test]
async fn rejected_transaction_gets_failed_status() {
let mut net = MockNet::launch().await;
let mut sender = funded_sender(&mut net).await;
from_inputs::propose(
&mut sender,
vec![(&external_address(PoolType::ORCHARD), PAYMENT, None)],
)
.await
.unwrap();
let calculated = sender.calculate_stored_proposal().await.unwrap();
let expiry = {
let wallet = sender.wallet().read().await;
wallet
.wallet_transactions
.get(&calculated[0])
.unwrap()
.transaction()
.expiry_height()
};
{
let mut chain = net.chain.write().await;
let tip = chain.tip();
chain.mine_empty_blocks(u32::from(expiry) - tip);
}
let err = sender
.transmit_calculated(calculated.clone())
.await
.unwrap_err();
assert!(format!("{err:?}").contains("expired"), "{err:?}");
let wallet = sender.wallet().read().await;
assert!(matches!(
wallet
.wallet_transactions
.get(&calculated[0])
.unwrap()
.status(),
ConfirmationStatus::Failed(_)
));
assert_eq!(net.chain.read().await.mempool_len(), 0);
}
#[tokio::test]
async fn transmitted_transaction_has_mempool_status_before_mining() {
let mut net = MockNet::launch().await;
let mut sender = funded_sender(&mut net).await;
let txids = from_inputs::quick_send(
&mut sender,
vec![(&external_address(PoolType::ORCHARD), PAYMENT, None)],
)
.await
.unwrap();
assert_eq!(net.chain.read().await.mempool_len(), 1);
sender.sync_and_await().await.unwrap();
let summaries = sender.transaction_summaries(false).await.unwrap();
let sent = summaries
.iter()
.find(|summary| summary.txid == txids[0])
.unwrap();
assert!(
matches!(sent.status, ConfirmationStatus::Mempool(_)),
"{:?}",
sent.status
);
net.chain.write().await.mine_mempool();
sender.sync_and_await().await.unwrap();
let summaries = sender.transaction_summaries(false).await.unwrap();
let sent = summaries
.iter()
.find(|summary| summary.txid == txids[0])
.unwrap();
assert!(matches!(sent.status, ConfirmationStatus::Confirmed(_)));
}
#[tokio::test]
async fn sync_recovers_from_a_truncated_block_stream() {
let mut net = MockNet::launch().await;
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let recipient_ua =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
fund(&net, vec![(&recipient_ua, FUNDING, None)], 3).await;
net.chain
.write()
.await
.faults
.inject(Rpc::BlockRange, Fault::TruncateStream { after: 1 });
recipient.sync_and_await().await.unwrap();
assert_eq!(net.chain.read().await.faults.pending(Rpc::BlockRange), 0);
check_client_balances!(recipient, i: FUNDING o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn wait_for_sync_on_a_shared_reference_closes_the_status_channel() {
let mut net = MockNet::launch().await;
let mut client = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
net.chain.write().await.mine_empty_blocks(2);
client.sync().await.unwrap();
let mut status = client.subscribe_sync_status();
let shared = &client;
shared.wait_for_sync().await;
assert!(
status.changed().await.is_ok(),
"the sync engine pushed a status update"
);
assert!(status.borrow_and_update().is_some());
assert!(
status.changed().await.is_err(),
"the watch channel closed when sync finished"
);
client.await_sync().await.unwrap();
assert_eq!(
client.sync_mode(),
pepper_sync::wallet::SyncMode::NotRunning
);
}
#[tokio::test]
async fn sync_fails_while_the_indexer_is_unavailable() {
let mut net = MockNet::launch().await;
let mut client = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
net.chain.write().await.mine_empty_blocks(2);
{
let mut chain = net.chain.write().await;
for _ in 0..5 {
chain.faults.inject(
Rpc::LatestBlock,
Fault::Fail(Code::Unavailable, "mock outage".to_string()),
);
}
}
assert!(client.sync_and_await().await.is_err());
}
#[tokio::test]
async fn coinbase_reward_becomes_spendable_after_maturity() {
let mut net = MockNet::launch().await;
let mut miner = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
miner.set_transmit_retry_interval(Duration::ZERO);
let miner_taddr = get_base_address(&miner, PoolType::Transparent).await;
let coinbase = {
let mut chain = net.chain.write().await;
let coinbase = chain.mine_block_rewarding(&miner_taddr, REWARD, vec![]);
chain.mine_empty_blocks(1);
coinbase
};
miner.sync_and_await().await.unwrap();
check_client_balances!(miner, i: 0 o: 0 s: 0 t: 0);
let summaries = miner.transaction_summaries(false).await.unwrap();
assert!(summaries.iter().any(|summary| summary.value == REWARD_ZATS
&& summary.status == ConfirmationStatus::Confirmed(BlockHeight::from_u32(1))));
assert!(!coinbase.is_empty());
net.chain
.write()
.await
.mine_empty_blocks(COINBASE_MATURITY_BLOCKS);
miner.sync_and_await().await.unwrap();
check_client_balances!(miner, i: 0 o: 0 s: 0 t: REWARD_ZATS);
miner.quick_shield(zip32::AccountId::ZERO).await.unwrap();
assert_eq!(net.chain.read().await.mempool_len(), 1);
net.chain.write().await.mine_mempool();
miner.sync_and_await().await.unwrap();
let balance = miner.account_balance(zip32::AccountId::ZERO).await.unwrap();
assert_eq!(balance.confirmed_transparent_balance.unwrap().into_u64(), 0);
assert!(balance.total_ironwood_balance.unwrap().into_u64() > 0);
}
#[tokio::test]
async fn generate_a_range_of_value_transfers_on_the_mock_chain() {
fixtures::create_various_value_transfers::<MockNet>().await;
}
#[tokio::test]
async fn send_shield_cycle_on_the_mock_chain() {
fixtures::send_shield_cycle::<MockNet>(1).await;
}
use nonempty::NonEmpty;
use pepper_sync::keys::transparent::TransparentScope;
use pepper_sync::wallet::OutputInterface;
use zcash_client_backend::data_api::OutputLockStore;
use zcash_client_backend::zip321::{Payment, TransactionRequest};
use zcash_primitives::transaction::TxId;
use crate::wallet::LightWallet;
const RETRIES_BEFORE_DELIVERY_CHECK: usize = 4;
const ONE_INPUT_SEND_FEE: u64 = 10_000;
const QUEUED_REJECTIONS: usize = 40;
const ZAINO_EXPIRED_MESSAGE: &str = "unhandled rpc-specific zaino_fetch::jsonrpsee::response::SendTransactionError error: RPC Error (code: -26): transaction has expired";
fn expected_after_one_send() -> u64 {
FUNDING - PAYMENT - ONE_INPUT_SEND_FEE
}
fn ironwood_notes_unspent(wallet: &LightWallet) -> bool {
wallet
.wallet_transactions
.values()
.flat_map(IronwoodNote::transaction_outputs)
.all(|note| note.spending_transaction().is_none())
}
fn refund_address_count(wallet: &LightWallet) -> usize {
wallet
.transparent_addresses()
.keys()
.filter(|id| id.scope() == TransparentScope::Refund)
.count()
}
async fn status_of(client: &LightClient, txid: &TxId) -> ConfirmationStatus {
client
.wallet()
.read()
.await
.wallet_transactions
.get(txid)
.unwrap()
.status()
}
async fn calculated_send(sender: &mut LightClient) -> NonEmpty<TxId> {
from_inputs::propose(
sender,
vec![(&external_address(PoolType::ORCHARD), PAYMENT, None)],
)
.await
.unwrap();
sender.calculate_stored_proposal().await.unwrap()
}
async fn expiry_of(sender: &LightClient, txid: &TxId) -> BlockHeight {
sender
.wallet()
.read()
.await
.wallet_transactions
.get(txid)
.unwrap()
.transaction()
.expiry_height()
}
async fn transmit_after_expiry(net: &MockNet, sender: &mut LightClient) -> NonEmpty<TxId> {
let calculated = calculated_send(sender).await;
let expiry = expiry_of(sender, &calculated[0]).await;
{
let mut chain = net.chain.write().await;
let tip = chain.tip();
chain.mine_empty_blocks(u32::from(expiry) - tip);
}
sender
.transmit_calculated(calculated.clone())
.await
.unwrap_err();
assert!(matches!(
status_of(sender, &calculated[0]).await,
ConfirmationStatus::Failed(_)
));
calculated
}
fn tex_request() -> TransactionRequest {
use pepper_sync::keys::decode_address;
use zcash_client_backend::address::Address;
use zcash_transparent::address::TransparentAddress;
let external_wallet =
SyntheticWalletBuilder::new(zingo_test_vectors::seeds::ABANDON_ART_SEED).build();
let taddr = external_wallet
.transparent_addresses()
.values()
.next()
.unwrap()
.clone();
let Address::Transparent(TransparentAddress::PublicKeyHash(taddr_bytes)) =
decode_address(&external_wallet.chain_type(), &taddr).unwrap()
else {
panic!("a wallet-generated first taddr is p2pkh")
};
let tex_address = crate::testutils::interpret_taddr_as_tex_addr(
taddr_bytes,
&external_wallet.chain_type(),
);
TransactionRequest::new(vec![Payment::without_memo(
zcash_address::ZcashAddress::try_from_encoded(&tex_address).unwrap(),
Zatoshis::const_from_u64(PAYMENT),
)])
.unwrap()
}
async fn calculated_tex_send(sender: &mut LightClient) -> NonEmpty<TxId> {
sender
.propose_send(tex_request(), zip32::AccountId::ZERO)
.await
.unwrap();
let calculated = sender.calculate_stored_proposal().await.unwrap();
assert_eq!(calculated.len(), 2);
calculated
}
fn inject_send_failures(chain: &mut crate::testutils::mock_indexer::MockChain, count: usize) {
for _ in 0..count {
chain.faults.inject(
Rpc::SendTransaction,
Fault::Fail(Code::Unavailable, "mock outage".to_string()),
);
}
}
#[tokio::test]
async fn accepted_send_marked_failed_is_confirmed_after_mining() {
let mut net = MockNet::launch().await;
let mut sender = funded_sender(&mut net).await;
let calculated = calculated_send(&mut sender).await;
{
let mut chain = net.chain.write().await;
chain.lose_next_send_response = Some(LostSendDestination::Mempool);
chain
.faults
.inject(Rpc::SendTransaction, Fault::Delay(Duration::ZERO));
inject_send_failures(&mut chain, QUEUED_REJECTIONS);
}
sender
.transmit_calculated(calculated.clone())
.await
.unwrap_err();
assert!(matches!(
status_of(&sender, &calculated[0]).await,
ConfirmationStatus::Failed(_)
));
assert_eq!(net.chain.read().await.mempool_len(), 1);
assert!(ironwood_notes_unspent(&*sender.wallet().read().await));
net.chain.write().await.mine_mempool();
sender.sync_and_await().await.unwrap();
assert!(matches!(
status_of(&sender, &calculated[0]).await,
ConfirmationStatus::Confirmed(_)
));
let expected = expected_after_one_send();
check_client_balances!(sender, i: expected o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn released_inputs_are_reselected_after_a_rejection() {
let mut net = MockNet::launch().await;
let mut sender = funded_sender(&mut net).await;
transmit_after_expiry(&net, &mut sender).await;
assert!(ironwood_notes_unspent(&*sender.wallet().read().await));
sender.sync_and_await().await.unwrap();
let resent = from_inputs::quick_send(
&mut sender,
vec![(&external_address(PoolType::ORCHARD), PAYMENT, None)],
)
.await
.unwrap();
assert_eq!(net.chain.read().await.mempool_len(), 1);
net.chain.write().await.mine_mempool();
sender.sync_and_await().await.unwrap();
assert!(matches!(
status_of(&sender, &resent[0]).await,
ConfirmationStatus::Confirmed(_)
));
let expected = expected_after_one_send();
check_client_balances!(sender, i: expected o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn later_step_rejected_leaves_earlier_step_transmitted() {
let mut net = MockNet::launch().await;
let mut sender = funded_sender(&mut net).await;
let calculated = calculated_tex_send(&mut sender).await;
{
let mut chain = net.chain.write().await;
chain
.faults
.inject(Rpc::SendTransaction, Fault::Delay(Duration::ZERO));
inject_send_failures(&mut chain, QUEUED_REJECTIONS);
}
sender
.transmit_calculated(calculated.clone())
.await
.unwrap_err();
assert!(matches!(
status_of(&sender, &calculated[0]).await,
ConfirmationStatus::Transmitted(_)
));
assert!(matches!(
status_of(&sender, &calculated[1]).await,
ConfirmationStatus::Failed(_)
));
assert_eq!(net.chain.read().await.mempool_len(), 1);
net.chain.write().await.mine_mempool();
sender.sync_and_await().await.unwrap();
assert!(matches!(
status_of(&sender, &calculated[0]).await,
ConfirmationStatus::Confirmed(_)
));
assert!(matches!(
status_of(&sender, &calculated[1]).await,
ConfirmationStatus::Failed(_)
));
let balance = sender
.account_balance(zip32::AccountId::ZERO)
.await
.unwrap();
assert!(balance.confirmed_transparent_balance.unwrap().into_u64() > 0);
}
async fn failed_first_step_via_quick_send(net: &MockNet, sender: &mut LightClient) {
inject_send_failures(&mut *net.chain.write().await, QUEUED_REJECTIONS);
sender
.quick_send(tex_request(), zip32::AccountId::ZERO, true)
.await
.unwrap_err();
}
fn steps_by_status(wallet: &LightWallet) -> (Vec<TxId>, Vec<TxId>) {
let mut failed = Vec::new();
let mut calculated = Vec::new();
for transaction in wallet.wallet_transactions.values() {
match transaction.status() {
ConfirmationStatus::Failed(_) => failed.push(transaction.txid()),
ConfirmationStatus::Calculated(_) => calculated.push(transaction.txid()),
_ => (),
}
}
(failed, calculated)
}
fn refund_address_ids(
wallet: &LightWallet,
account_id: zip32::AccountId,
) -> Vec<pepper_sync::keys::transparent::TransparentAddressId> {
wallet
.transparent_addresses()
.keys()
.filter(|id| id.scope() == TransparentScope::Refund && id.account_id() == account_id)
.copied()
.collect()
}
async fn confirmed_tex_send(net: &MockNet, sender: &mut LightClient) -> NonEmpty<TxId> {
let steps = sender
.quick_send(tex_request(), zip32::AccountId::ZERO, true)
.await
.unwrap();
assert_eq!(steps.len(), 2);
assert_eq!(net.chain.read().await.mempool_len(), 2);
net.chain.write().await.mine_mempool();
sender.sync_and_await().await.unwrap();
steps
}
#[tokio::test]
async fn tex_send_confirms_on_the_strict_mock_chain() {
let mut net = MockNet::launch().await;
let mut sender = funded_sender(&mut net).await;
let steps = confirmed_tex_send(&net, &mut sender).await;
for txid in &steps {
assert!(matches!(
status_of(&sender, txid).await,
ConfirmationStatus::Confirmed(_)
));
}
let wallet = sender.wallet().read().await;
assert_eq!(refund_address_ids(&wallet, zip32::AccountId::ZERO).len(), 1);
assert_eq!(net.chain.read().await.mempool_len(), 0);
}
#[tokio::test]
async fn failed_send_in_one_account_keeps_refund_addresses_of_other_accounts() {
let mut net = MockNet::launch().await;
let mut sender = funded_sender(&mut net).await;
confirmed_tex_send(&net, &mut sender).await;
let other_account = zip32::AccountId::try_from(1).unwrap();
let other_ua = {
let mut wallet = sender.wallet().write().await;
wallet.create_new_account().unwrap();
let (_, address) = wallet
.generate_unified_address(ReceiverSelection::all_shielded(), other_account)
.unwrap();
address.encode(&wallet.chain_type())
};
fund(&net, vec![(&other_ua, FUNDING, None)], 1).await;
sender.sync_and_await().await.unwrap();
assert_eq!(
sender
.account_balance(other_account)
.await
.unwrap()
.confirmed_ironwood_balance
.unwrap()
.into_u64(),
FUNDING
);
let first_account_refunds_before =
refund_address_ids(&*sender.wallet().read().await, zip32::AccountId::ZERO);
assert_eq!(first_account_refunds_before.len(), 1);
inject_send_failures(&mut *net.chain.write().await, QUEUED_REJECTIONS);
sender
.quick_send(tex_request(), other_account, true)
.await
.unwrap_err();
let wallet = sender.wallet().read().await;
assert_eq!(
refund_address_ids(&wallet, zip32::AccountId::ZERO),
first_account_refunds_before
);
assert!(refund_address_ids(&wallet, other_account).is_empty());
}
#[tokio::test]
async fn failed_first_step_via_quick_send_truncates_refund_addresses() {
let mut net = MockNet::launch().await;
let mut sender = funded_sender(&mut net).await;
let refund_addresses_before = refund_address_count(&*sender.wallet().read().await);
failed_first_step_via_quick_send(&net, &mut sender).await;
let wallet = sender.wallet().read().await;
assert!(ironwood_notes_unspent(&wallet));
assert_eq!(refund_address_count(&wallet), refund_addresses_before);
assert_eq!(net.chain.read().await.mempool_len(), 0);
}
#[tokio::test]
async fn failed_first_step_via_quick_send_fails_the_second_step() {
let mut net = MockNet::launch().await;
let mut sender = funded_sender(&mut net).await;
failed_first_step_via_quick_send(&net, &mut sender).await;
let (failed, calculated) = steps_by_status(&*sender.wallet().read().await);
assert_eq!(failed.len(), 2);
assert!(
calculated.is_empty(),
"the second step must not stay Calculated after the first step failed: {calculated:?}"
);
}
#[tokio::test]
async fn failed_first_step_via_transmit_calculated_truncates_refund_addresses() {
let mut net = MockNet::launch().await;
let mut sender = funded_sender(&mut net).await;
let refund_addresses_before = refund_address_count(&*sender.wallet().read().await);
let calculated = calculated_tex_send(&mut sender).await;
inject_send_failures(&mut *net.chain.write().await, QUEUED_REJECTIONS);
sender
.transmit_calculated(calculated.clone())
.await
.unwrap_err();
assert!(matches!(
status_of(&sender, &calculated[0]).await,
ConfirmationStatus::Failed(_)
));
let wallet = sender.wallet().read().await;
assert!(ironwood_notes_unspent(&wallet));
assert_eq!(refund_address_count(&wallet), refund_addresses_before);
}
#[tokio::test]
async fn output_locks_are_released_after_a_rejection() {
let mut net = MockNet::launch().await;
let mut sender = funded_sender(&mut net).await;
transmit_after_expiry(&net, &mut sender).await;
let wallet = sender.wallet().read().await;
assert!(
wallet
.get_locked_outputs(zip32::AccountId::ZERO)
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn failed_send_survives_a_wallet_file_round_trip() {
let mut net = MockNet::launch().await;
let mut sender = funded_sender(&mut net).await;
let calculated = transmit_after_expiry(&net, &mut sender).await;
sender.save_task().await;
sender.wait_for_save().await;
sender.shutdown_save_task().await.unwrap();
drop(sender);
let mut reloaded = net.client_from_file(0).await;
reloaded.set_transmit_retry_interval(Duration::ZERO);
assert!(matches!(
status_of(&reloaded, &calculated[0]).await,
ConfirmationStatus::Failed(_)
));
assert!(ironwood_notes_unspent(&*reloaded.wallet().read().await));
reloaded.sync_and_await().await.unwrap();
from_inputs::quick_send(
&mut reloaded,
vec![(&external_address(PoolType::ORCHARD), PAYMENT, None)],
)
.await
.unwrap();
net.chain.write().await.mine_mempool();
reloaded.sync_and_await().await.unwrap();
let expected = expected_after_one_send();
check_client_balances!(reloaded, i: expected o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn a_rejection_is_retried_three_times_before_failing() {
let mut net = MockNet::launch().await;
let mut sender = funded_sender(&mut net).await;
let calculated = calculated_send(&mut sender).await;
{
let mut chain = net.chain.write().await;
for _ in 0..QUEUED_REJECTIONS {
chain.faults.inject(
Rpc::SendTransaction,
Fault::Fail(Code::Internal, ZAINO_EXPIRED_MESSAGE.to_string()),
);
}
}
sender
.transmit_calculated(calculated.clone())
.await
.unwrap_err();
let attempts =
QUEUED_REJECTIONS - net.chain.read().await.faults.pending(Rpc::SendTransaction);
assert!(attempts > 0);
assert_eq!(attempts % RETRIES_BEFORE_DELIVERY_CHECK, 0);
assert!(matches!(
status_of(&sender, &calculated[0]).await,
ConfirmationStatus::Failed(_)
));
}
use zcash_primitives::transaction::Transaction;
use zcash_protocol::consensus::BranchId;
const BLOCK_FETCH_FAILURES: usize = 8;
fn expiry_of_bytes(net: &MockNet, bytes: &[u8]) -> BlockHeight {
Transaction::read(
bytes,
BranchId::for_height(&net.chain_type(), BlockHeight::from_u32(1)),
)
.unwrap()
.expiry_height()
}
async fn pending_txid(client: &LightClient) -> TxId {
client
.wallet()
.read()
.await
.wallet_transactions
.values()
.find(|transaction| transaction.status().is_pending())
.unwrap()
.txid()
}
async fn mine_past_expiry(net: &MockNet, expiry: BlockHeight) {
let mut chain = net.chain.write().await;
chain.mine_mempool();
let tip = chain.tip();
chain.mine_empty_blocks(u32::from(expiry) - tip);
}
async fn fail_next_block_fetches(net: &MockNet) {
let mut chain = net.chain.write().await;
for _ in 0..BLOCK_FETCH_FAILURES {
chain.faults.inject(
Rpc::BlockRange,
Fault::Fail(Code::Unavailable, "mock outage".to_string()),
);
}
}
#[tokio::test]
async fn received_transaction_is_not_failed_by_a_session_that_never_scans_its_block() {
let mut net = MockNet::launch().await;
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let recipient_ua =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
net.chain.write().await.mine_empty_blocks(1);
recipient.sync_and_await().await.unwrap();
let funding = faucet_funding_transaction(vec![(&recipient_ua, FUNDING, None)]).await;
let expiry = expiry_of_bytes(&net, &funding);
net.chain.write().await.enter_mempool(funding);
recipient.sync_and_await().await.unwrap();
let txid = pending_txid(&recipient).await;
assert!(matches!(
status_of(&recipient, &txid).await,
ConfirmationStatus::Mempool(_)
));
mine_past_expiry(&net, expiry).await;
fail_next_block_fetches(&net).await;
assert!(recipient.sync_and_await().await.is_err());
assert!(
!matches!(
status_of(&recipient, &txid).await,
ConfirmationStatus::Failed(_)
),
"a received transaction mined in an unscanned block was marked Failed"
);
net.chain.write().await.faults.clear(Rpc::BlockRange);
recipient.sync_and_await().await.unwrap();
assert!(matches!(
status_of(&recipient, &txid).await,
ConfirmationStatus::Confirmed(_)
));
check_client_balances!(recipient, i: FUNDING o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn received_transaction_that_expires_unmined_is_failed() {
let mut net = MockNet::launch().await;
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let recipient_ua =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
net.chain.write().await.mine_empty_blocks(1);
recipient.sync_and_await().await.unwrap();
let funding = faucet_funding_transaction(vec![(&recipient_ua, FUNDING, None)]).await;
let expiry = expiry_of_bytes(&net, &funding);
net.chain.write().await.enter_mempool(funding);
recipient.sync_and_await().await.unwrap();
let txid = pending_txid(&recipient).await;
assert!(matches!(
status_of(&recipient, &txid).await,
ConfirmationStatus::Mempool(_)
));
{
let mut chain = net.chain.write().await;
let tip = chain.tip();
chain.mine_empty_blocks(u32::from(expiry) - tip);
assert_eq!(chain.mempool_len(), 0);
}
recipient.sync_and_await().await.unwrap();
assert!(matches!(
status_of(&recipient, &txid).await,
ConfirmationStatus::Failed(_)
));
check_client_balances!(recipient, i: 0 o: 0 s: 0 t: 0);
net.chain.write().await.mine_empty_blocks(1);
recipient.sync_and_await().await.unwrap();
assert!(matches!(
status_of(&recipient, &txid).await,
ConfirmationStatus::Failed(_)
));
}
#[tokio::test]
async fn mined_send_inputs_stay_spent_through_a_session_that_never_scans_its_block() {
let mut net = MockNet::launch().await;
let mut sender = funded_sender(&mut net).await;
let sent = from_inputs::quick_send(
&mut sender,
vec![(&external_address(PoolType::ORCHARD), PAYMENT, None)],
)
.await
.unwrap();
let expiry = expiry_of(&sender, &sent[0]).await;
mine_past_expiry(&net, expiry).await;
fail_next_block_fetches(&net).await;
assert!(sender.sync_and_await().await.is_err());
assert!(
!matches!(
status_of(&sender, &sent[0]).await,
ConfirmationStatus::Failed(_)
),
"a mined send in an unscanned block was marked Failed"
);
assert!(!ironwood_notes_unspent(&*sender.wallet().read().await));
let expected = expected_after_one_send();
check_client_balances!(sender, i: expected o: 0 s: 0 t: 0);
net.chain.write().await.faults.clear(Rpc::BlockRange);
sender.sync_and_await().await.unwrap();
assert!(matches!(
status_of(&sender, &sent[0]).await,
ConfirmationStatus::Confirmed(_)
));
check_client_balances!(sender, i: expected o: 0 s: 0 t: 0);
}
use crate::wallet::summary::data::{SendType, TransactionKind, TransactionSummary};
async fn summary_of(client: &LightClient, txid: &TxId) -> TransactionSummary {
client
.transaction_summaries(false)
.await
.unwrap()
.iter()
.find(|summary| summary.txid == *txid)
.unwrap()
.clone()
}
#[tokio::test]
async fn failed_send_reads_as_sent() {
let mut net = MockNet::launch().await;
let mut sender = funded_sender(&mut net).await;
let calculated = transmit_after_expiry(&net, &mut sender).await;
assert!(ironwood_notes_unspent(&*sender.wallet().read().await));
let failed = summary_of(&sender, &calculated[0]).await;
assert!(matches!(failed.status, ConfirmationStatus::Failed(_)));
assert_eq!(failed.kind, TransactionKind::Sent(SendType::Send));
assert_eq!(failed.value, PAYMENT);
assert_eq!(failed.fee, Some(ONE_INPUT_SEND_FEE));
assert_eq!(failed.pools_sent_from, vec![PoolType::IRONWOOD]);
sender.sync_and_await().await.unwrap();
let failed = summary_of(&sender, &calculated[0]).await;
assert_eq!(failed.kind, TransactionKind::Sent(SendType::Send));
}
#[tokio::test]
async fn failed_shield_reads_as_shield() {
let mut net = MockNet::launch().await;
let mut shielder = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
shielder.set_transmit_retry_interval(Duration::ZERO);
let taddr = get_base_address(&shielder, PoolType::Transparent).await;
fund(&net, vec![(&taddr, FUNDING, None)], 1).await;
shielder.sync_and_await().await.unwrap();
check_client_balances!(shielder, i: 0 o: 0 s: 0 t: FUNDING);
shielder
.propose_shield(zip32::AccountId::ZERO)
.await
.unwrap();
let calculated = shielder.calculate_stored_proposal().await.unwrap();
let expiry = expiry_of(&shielder, &calculated[0]).await;
mine_past_expiry(&net, expiry).await;
shielder
.transmit_calculated(calculated.clone())
.await
.unwrap_err();
let failed = summary_of(&shielder, &calculated[0]).await;
assert!(matches!(failed.status, ConfirmationStatus::Failed(_)));
assert_eq!(failed.kind, TransactionKind::Sent(SendType::Shield));
assert!(failed.fee.is_some());
assert_eq!(failed.pools_sent_from, vec![PoolType::TRANSPARENT]);
assert_eq!(failed.outgoing_transparent_coins, vec![]);
shielder.sync_and_await().await.unwrap();
check_client_balances!(shielder, i: 0 o: 0 s: 0 t: FUNDING);
}
const HELD_FETCH: Duration = Duration::from_secs(8);
const TRANSACTION_FETCH_FAILURES: usize = 8;
const HOLD_POLL: Duration = Duration::from_millis(10);
const HOLD_WAIT_LIMIT: Duration = Duration::from_secs(4);
const BLOCKS_BELOW_EXPIRY_AT_LAST_SYNC: u32 = 5;
const REORG_VERIFY_BLOCKS: u32 = 10;
async fn fully_scanned_height(client: &LightClient) -> BlockHeight {
client
.wallet()
.read()
.await
.get_sync_state()
.unwrap()
.fully_scanned_height()
.unwrap()
}
async fn wait_until_scanned_through(client: &LightClient, height: BlockHeight) {
let started = tokio::time::Instant::now();
while fully_scanned_height(client).await < height {
assert!(
started.elapsed() < HOLD_WAIT_LIMIT,
"the sync engine did not scan through {height} in time"
);
tokio::time::sleep(HOLD_POLL).await;
}
}
async fn wait_until_fetch_is_held(net: &MockNet) {
let started = tokio::time::Instant::now();
while net.chain.read().await.faults.pending(Rpc::Transaction) > 0 {
assert!(
started.elapsed() < HOLD_WAIT_LIMIT,
"the sync engine did not request the transaction in time"
);
tokio::time::sleep(HOLD_POLL).await;
}
}
async fn mempool_transaction_near_expiry(
net: &mut MockNet,
) -> (LightClient, String, TxId, BlockHeight) {
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let recipient_ua =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
net.chain.write().await.mine_empty_blocks(1);
recipient.sync_and_await().await.unwrap();
let funding = faucet_funding_transaction(vec![(&recipient_ua, FUNDING, None)]).await;
let expiry = expiry_of_bytes(net, &funding);
{
let mut chain = net.chain.write().await;
chain.enter_mempool(funding);
let last_sync_height = u32::from(expiry) - BLOCKS_BELOW_EXPIRY_AT_LAST_SYNC;
let tip = chain.tip();
assert!(last_sync_height > tip);
chain.mine_empty_blocks(last_sync_height - tip);
}
recipient.sync_and_await().await.unwrap();
let txid = pending_txid(&recipient).await;
assert!(matches!(
status_of(&recipient, &txid).await,
ConfirmationStatus::Mempool(_)
));
(recipient, recipient_ua, txid, expiry)
}
async fn mine_past_expiry_and_fund_above_verify_range(
net: &MockNet,
recipient_ua: &str,
expiry: BlockHeight,
) {
{
let mut chain = net.chain.write().await;
let verify_end =
u32::from(expiry) - BLOCKS_BELOW_EXPIRY_AT_LAST_SYNC + REORG_VERIFY_BLOCKS;
let tip = chain.tip();
chain.mine_empty_blocks(verify_end - tip);
assert_eq!(chain.mempool_len(), 0);
}
fund(net, vec![(recipient_ua, FUNDING, None)], 0).await;
}
#[tokio::test]
async fn stopped_session_that_scanned_past_expiry_fails_the_transaction() {
let mut net = MockNet::launch().await;
let (mut recipient, recipient_ua, txid, expiry) =
mempool_transaction_near_expiry(&mut net).await;
mine_past_expiry_and_fund_above_verify_range(&net, &recipient_ua, expiry).await;
net.chain
.write()
.await
.faults
.inject(Rpc::Transaction, Fault::Delay(HELD_FETCH));
recipient.sync().await.unwrap();
wait_until_scanned_through(&recipient, expiry).await;
recipient.stop_sync().unwrap();
recipient.await_sync().await.unwrap();
assert!(fully_scanned_height(&recipient).await >= expiry);
assert!(
matches!(
status_of(&recipient, &txid).await,
ConfirmationStatus::Failed(_)
),
"a stopped session scanned past the expiry height without failing the transaction"
);
recipient.sync_and_await().await.unwrap();
assert!(matches!(
status_of(&recipient, &txid).await,
ConfirmationStatus::Failed(_)
));
check_client_balances!(recipient, i: FUNDING o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn failed_session_that_scanned_past_expiry_fails_the_transaction() {
let mut net = MockNet::launch().await;
let (mut recipient, recipient_ua, txid, expiry) =
mempool_transaction_near_expiry(&mut net).await;
mine_past_expiry_and_fund_above_verify_range(&net, &recipient_ua, expiry).await;
{
let mut chain = net.chain.write().await;
for _ in 0..TRANSACTION_FETCH_FAILURES {
chain.faults.inject(
Rpc::Transaction,
Fault::Fail(Code::Unavailable, "mock outage".to_string()),
);
}
}
assert!(recipient.sync_and_await().await.is_err());
assert!(fully_scanned_height(&recipient).await >= expiry);
assert!(
matches!(
status_of(&recipient, &txid).await,
ConfirmationStatus::Failed(_)
),
"a failed session scanned past the expiry height without failing the transaction"
);
net.chain.write().await.faults.clear(Rpc::Transaction);
recipient.sync_and_await().await.unwrap();
assert!(matches!(
status_of(&recipient, &txid).await,
ConfirmationStatus::Failed(_)
));
check_client_balances!(recipient, i: FUNDING o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn stopped_session_does_not_fail_a_transaction_mined_in_an_unscanned_block() {
let mut net = MockNet::launch().await;
let (mut recipient, _, txid, expiry) = mempool_transaction_near_expiry(&mut net).await;
{
let mut chain = net.chain.write().await;
let tip = chain.tip();
chain.mine_empty_blocks(u32::from(expiry) - 1 - tip);
chain.mine_mempool();
assert_eq!(chain.tip(), u32::from(expiry));
}
net.chain
.write()
.await
.faults
.inject(Rpc::Transaction, Fault::Delay(HELD_FETCH));
recipient.sync().await.unwrap();
wait_until_fetch_is_held(&net).await;
recipient.stop_sync().unwrap();
recipient.await_sync().await.unwrap();
assert!(fully_scanned_height(&recipient).await < expiry);
assert!(
!matches!(
status_of(&recipient, &txid).await,
ConfirmationStatus::Failed(_)
),
"a transaction mined in an unscanned block was marked Failed by a stopped session"
);
recipient.sync_and_await().await.unwrap();
assert!(matches!(
status_of(&recipient, &txid).await,
ConfirmationStatus::Confirmed(_)
));
check_client_balances!(recipient, i: FUNDING o: 0 s: 0 t: 0);
}
}
mod mainnet_broadcast_offline {
use super::*;
use crate::destination::servers::{DestinationServerSet, IndexerConfig, Location, Role, Trust};
use crate::lightclient::LightClient;
use crate::lightclient::error::LightClientError;
use crate::lightclient::send::{TransmitReport, TransmitRoute};
use nonempty::NonEmpty;
const SUPPRESSOR: &str = "suppressor.example";
const ACCEPTOR: &str = "acceptor.example";
struct Stage {
sync: MockNet,
suppressing: MockNet,
accepting: MockNet,
}
impl Stage {
fn set_over(&self, entries: &[(&MockNet, &str)]) -> DestinationServerSet {
DestinationServerSet::registry_for_tests(
Trust::Untrusted,
entries
.iter()
.map(|(net, operator)| (net.indexer_uri(), *operator)),
)
.with_indexer(IndexerConfig::new(self.sync.indexer_uri()).location(Location::Remote))
}
async fn received(&self) -> (usize, usize, u32) {
(
self.sync.chain.read().await.mempool_len(),
self.accepting.chain.read().await.mempool_len(),
self.suppressing.chain.read().await.rejected_sends,
)
}
}
async fn stage() -> (Stage, LightClient) {
let mut sync = MockNet::launch().await;
let suppressing = MockNet::launch().await;
suppressing.chain.write().await.reject_all_sends = true;
let accepting = MockNet::launch().await;
let mut recipient = sync
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let recipient_ua =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
fund_mirrored(
&[&sync, &suppressing, &accepting],
vec![(&recipient_ua, 100_000, None)],
1,
)
.await;
recipient.sync_and_await().await.unwrap();
recipient.set_transmit_retry_interval(std::time::Duration::from_millis(10));
(
Stage {
sync,
suppressing,
accepting,
},
recipient,
)
}
async fn send(
recipient: &mut LightClient,
) -> Result<NonEmpty<TransmitReport>, LightClientError> {
from_inputs::quick_send_reported(
recipient,
vec![(&external_address(PoolType::ORCHARD), 20_000, None)],
)
.await
}
#[tokio::test]
async fn a_nakednet_send_goes_to_the_untrusted_sync_indexer_alone() {
let (stage, mut recipient) = stage().await;
#[cfg(feature = "nym")]
recipient.set_transmit_policy(crate::mixnet::TransmitPolicy::Nakednet);
recipient.set_destination_servers_for_tests(stage.set_over(&[
(&stage.suppressing, SUPPRESSOR),
(&stage.accepting, ACCEPTOR),
]));
let reports = send(&mut recipient).await.unwrap();
assert!(
reports
.iter()
.all(|report| matches!(report.route, TransmitRoute::Nakednet { .. }))
);
assert_eq!(stage.received().await, (reports.len(), 0, 0));
}
#[tokio::test]
async fn a_trusted_broadcast_indexer_receives_the_send_alone() {
let (stage, mut recipient) = stage().await;
#[cfg(feature = "nym")]
recipient.set_transmit_policy(crate::mixnet::TransmitPolicy::Nakednet);
recipient.set_destination_servers_for_tests(
stage
.set_over(&[(&stage.suppressing, SUPPRESSOR)])
.with_indexer(
IndexerConfig::new(stage.accepting.indexer_uri())
.role(Role::Broadcast)
.trust(Trust::Trusted)
.location(Location::Remote),
),
);
let reports = send(&mut recipient).await.unwrap();
assert_eq!(stage.received().await, (0, reports.len(), 0));
}
#[cfg(feature = "nym")]
#[tokio::test]
async fn a_mixnet_send_routes_around_the_suppressor_and_never_the_sync_indexer() {
let (stage, mut recipient) = stage().await;
recipient.set_destination_servers_for_tests(stage.set_over(&[
(&stage.suppressing, SUPPRESSOR),
(&stage.accepting, ACCEPTOR),
]));
let reports = send(&mut recipient).await.unwrap();
assert!(
reports
.iter()
.all(|report| matches!(report.route, TransmitRoute::Mixnet { .. }))
);
let (sync, accepting, _) = stage.received().await;
assert_eq!((sync, accepting), (0, reports.len()));
}
#[cfg(feature = "nym")]
#[tokio::test]
async fn an_all_suppressing_mixnet_draw_fails_closed() {
let (stage, mut recipient) = stage().await;
recipient
.set_destination_servers_for_tests(stage.set_over(&[(&stage.suppressing, SUPPRESSOR)]));
let refused = send(&mut recipient)
.await
.expect_err("a suppressed transmission surfaces");
assert!(
matches!(
refused,
LightClientError::SendError(
crate::lightclient::error::SendError::TransmissionError(_)
)
),
"the refusal is the typed transmission failure: {refused}"
);
let (sync, _, rejected) = stage.received().await;
assert_eq!(
sync, 0,
"a refused draw never falls back to the sync indexer"
);
assert!(rejected > 0, "the suppressing Destination was contacted");
}
}
#[cfg(feature = "nym")]
mod mixnet_wire_offline {
use super::*;
use crate::destination::health::FaultDomain;
use crate::destination::servers::{DestinationServerSet, IndexerConfig, Location, Role, Trust};
use crate::lightclient::LightClient;
use crate::lightclient::error::LightClientError;
use crate::lightclient::migrate::transmission_route::{
MigrationWire, RoutedTransmissionClient,
};
use crate::lightclient::send::{TransmitReport, TransmitRoute};
use crate::testutils::mock_indexer::Rules;
use crate::testutils::socks5_relay::{Destination, Socks5Relay, destination_of};
use crate::wallet::migration::transmission::{
PartTransmissionError, TransmissionClient as _, TransmissionRoute,
};
use nonempty::NonEmpty;
const ACCEPTOR_URI: &str = "https://localhost:443";
const SUPPRESSOR_URI: &str = "https://127.0.0.1:443";
const UNROUTED_URI: &str = "https://unrouted.example:443";
const ACCEPTOR: &str = "acceptor.example";
const SUPPRESSOR: &str = "suppressor.example";
const UNROUTED: &str = "unrouted.example";
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
fn uri(text: &str) -> http::Uri {
text.parse().expect("a static uri")
}
struct Stage {
sync: MockNet,
accepting: MockNet,
suppressing: MockNet,
relay: Socks5Relay,
}
impl Stage {
fn set_over(&self, entries: &[(&str, &str)]) -> DestinationServerSet {
DestinationServerSet::registry_for_tests(
Trust::Untrusted,
entries
.iter()
.map(|(text, operator)| (uri(text), *operator)),
)
.with_indexer(IndexerConfig::new(self.sync.indexer_uri()).location(Location::Remote))
}
fn sync_destination(&self) -> Destination {
destination_of(&self.sync.indexer_uri())
}
}
async fn relayed() -> (Socks5Relay, MockNet, MockNet) {
let accepting = MockNet::launch_tls().await;
let suppressing = MockNet::launch_tls().await;
suppressing.chain.write().await.reject_all_sends = true;
let relay = Socks5Relay::launch().await;
relay.route(&uri(ACCEPTOR_URI), accepting.addr());
relay.route(&uri(SUPPRESSOR_URI), suppressing.addr());
(relay, accepting, suppressing)
}
async fn stage() -> (Stage, LightClient) {
let mut sync = MockNet::launch().await;
let (relay, accepting, suppressing) = relayed().await;
let mut recipient = sync
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let recipient_ua =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
fund_mirrored(
&[&sync, &accepting, &suppressing],
vec![(&recipient_ua, 100_000, None)],
1,
)
.await;
recipient.sync_and_await().await.unwrap();
recipient.set_transmit_retry_interval(std::time::Duration::from_millis(10));
recipient
.switch_on_mixnet_through_for_tests(relay.addr())
.await;
(
Stage {
sync,
accepting,
suppressing,
relay,
},
recipient,
)
}
async fn send(
recipient: &mut LightClient,
) -> Result<NonEmpty<TransmitReport>, LightClientError> {
from_inputs::quick_send_reported(
recipient,
vec![(&external_address(PoolType::ORCHARD), 20_000, None)],
)
.await
}
#[tokio::test]
async fn a_mixnet_send_crosses_the_tunnel_to_a_registry_destination() {
let (stage, mut recipient) = stage().await;
recipient.set_destination_servers_for_tests(
stage.set_over(&[(ACCEPTOR_URI, ACCEPTOR), (SUPPRESSOR_URI, SUPPRESSOR)]),
);
let reports = send(&mut recipient).await.unwrap();
for report in &reports {
assert_eq!(
report.route,
TransmitRoute::Mixnet {
destination: "localhost".to_string(),
via_socks5: stage.relay.addr().to_string(),
}
);
}
assert_eq!(
stage.accepting.chain.read().await.mempool_len(),
reports.len()
);
assert_eq!(stage.sync.chain.read().await.mempool_len(), 0);
let allowed = [
destination_of(&uri(ACCEPTOR_URI)),
destination_of(&uri(SUPPRESSOR_URI)),
];
let requested = stage.relay.requested();
assert!(!requested.is_empty());
assert!(
requested
.iter()
.all(|destination| allowed.contains(destination)),
"the exit learned a destination outside the registry: {requested:?}"
);
assert!(!requested.contains(&stage.sync_destination()));
}
#[tokio::test]
async fn failed_arms_are_attributed_to_the_component_that_failed() {
let (stage, mut recipient) = stage().await;
recipient.set_destination_servers_for_tests(
stage.set_over(&[(SUPPRESSOR_URI, SUPPRESSOR), (UNROUTED_URI, UNROUTED)]),
);
let refused = send(&mut recipient)
.await
.expect_err("no Destination accepts");
assert!(
matches!(
refused,
LightClientError::SendError(
crate::lightclient::error::SendError::TransmissionError(_)
)
),
"{refused}"
);
let attempts = recipient.indexer_history_handle().load();
let fault_of = |host: &str| {
attempts
.iter()
.find(|attempt| attempt.host.as_str() == host)
.unwrap_or_else(|| panic!("no attempt against {host}: {attempts:?}"))
.fault_domain
};
assert_eq!(fault_of("127.0.0.1"), Some(FaultDomain::Destination));
assert_eq!(fault_of("unrouted.example"), Some(FaultDomain::Tunnel));
assert!(stage.suppressing.chain.read().await.rejected_sends > 0);
assert!(
stage
.relay
.requested()
.contains(&destination_of(&uri(UNROUTED_URI))),
"the unroutable arm asked the exit for its Destination"
);
}
#[tokio::test]
async fn a_trusted_broadcast_indexer_receives_the_mixnet_send_alone() {
let (stage, mut recipient) = stage().await;
recipient.set_destination_servers_for_tests(
stage
.set_over(&[(SUPPRESSOR_URI, SUPPRESSOR)])
.with_indexer(
IndexerConfig::new(uri(ACCEPTOR_URI))
.role(Role::Broadcast)
.trust(Trust::Trusted)
.location(Location::Remote),
),
);
let reports = send(&mut recipient).await.unwrap();
assert_eq!(
stage.accepting.chain.read().await.mempool_len(),
reports.len()
);
assert_eq!(stage.suppressing.chain.read().await.rejected_sends, 0);
let acceptor = destination_of(&uri(ACCEPTOR_URI));
assert!(
stage
.relay
.requested()
.iter()
.all(|destination| *destination == acceptor),
"{:?}",
stage.relay.requested()
);
}
#[tokio::test]
async fn the_network_probe_measures_the_registry_through_the_tunnel() {
let (stage, mut recipient) = stage().await;
recipient.set_destination_servers_for_tests(
stage.set_over(&[(ACCEPTOR_URI, ACCEPTOR), (SUPPRESSOR_URI, SUPPRESSOR)]),
);
let probes = recipient
.probe_destinations(None, PROBE_TIMEOUT)
.await
.expect("a ready mixnet probes");
let mut hosts: Vec<&str> = probes.iter().map(|probe| probe.host.as_str()).collect();
hosts.sort_unstable();
assert_eq!(hosts, vec!["127.0.0.1", "localhost"]);
for probe in &probes {
assert!(probe.leg.outcome.is_ok(), "{probe:?}");
}
assert!(!stage.relay.requested().contains(&stage.sync_destination()));
}
#[tokio::test]
async fn a_migration_part_crosses_the_tunnel() {
const REJECTION_CODE: i32 = -26;
const REJECTOR_URI: &str = "https://localhost:8443";
let (relay, accepting, suppressing) = relayed().await;
let rejecting = MockNet::launch_tls().await;
for net in [&accepting, &suppressing, &rejecting] {
net.chain.write().await.rules = Rules::LAX;
}
rejecting.chain.write().await.answer_sends_with_error_code = Some(REJECTION_CODE);
relay.route(&uri(REJECTOR_URI), rejecting.addr());
let part =
faucet_funding_transaction(vec![(&external_address(PoolType::ORCHARD), 20_000, None)])
.await;
let expiry = BlockHeight::from_u32(1);
let client_for = |target: &str| {
RoutedTransmissionClient::new(
MigrationWire::Mixnet(crate::mixnet::MixnetConduit::over(relay.addr()).dial()),
vec![uri(target)],
)
};
let receipt = client_for(ACCEPTOR_URI)
.submit(part.clone(), expiry)
.await
.expect("the acceptor takes the part");
assert_eq!(
receipt.route,
TransmissionRoute::Mixnet {
destination: "localhost".to_string(),
via_socks5: relay.addr().to_string(),
}
);
assert_eq!(accepting.chain.read().await.mempool_len(), 1);
assert!(matches!(
client_for(REJECTOR_URI).submit(part.clone(), expiry).await,
Err(PartTransmissionError::Rejected(_))
));
assert!(matches!(
client_for(SUPPRESSOR_URI)
.submit(part.clone(), expiry)
.await,
Err(PartTransmissionError::Transport(_))
));
assert!(matches!(
client_for(UNROUTED_URI).submit(part, expiry).await,
Err(PartTransmissionError::Transport(_))
));
}
}
mod shielded_coinbase {
use zcash_protocol::PoolType;
use zcash_protocol::ShieldedPool;
use zcash_protocol::value::Zatoshis;
use crate::check_client_balances;
use crate::testutils::lightclient::get_base_address;
use crate::testutils::mock_activation_heights_deferring_nu6_3;
use crate::testutils::mock_indexer::{MockChain, MockNet};
const REWARD_ZATS: u64 = 625_000;
const REWARD: Zatoshis = Zatoshis::const_from_u64(REWARD_ZATS);
const DEFERRED_NU6_3: u32 = 130;
#[tokio::test]
async fn a_shielded_coinbase_pays_the_miner_in_ironwood_after_the_activation() {
let mut net = MockNet::launch().await;
let mut miner = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let miner_ua = get_base_address(&miner, PoolType::Shielded(ShieldedPool::Orchard)).await;
net.chain.write().await.mine_empty_blocks(1);
let coinbase = net
.mine_block_rewarding_shielded(&miner_ua, REWARD, vec![])
.await;
assert!(!coinbase.is_empty());
net.chain.write().await.mine_empty_blocks(1);
miner.sync_and_await().await.unwrap();
check_client_balances!(miner, i: REWARD_ZATS o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn a_shielded_coinbase_pays_the_miner_in_orchard_before_the_activation() {
let mut net = MockNet::launch_with(MockChain::with_activation_heights(
mock_activation_heights_deferring_nu6_3(DEFERRED_NU6_3),
))
.await;
let mut miner = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let miner_ua = get_base_address(&miner, PoolType::Shielded(ShieldedPool::Orchard)).await;
net.chain.write().await.mine_empty_blocks(1);
net.mine_block_rewarding_shielded(&miner_ua, REWARD, vec![])
.await;
net.chain.write().await.mine_empty_blocks(1);
miner.sync_and_await().await.unwrap();
check_client_balances!(miner, i: 0 o: REWARD_ZATS s: 0 t: 0);
}
}
mod boundary_pruning {
use incrementalmerkletree::Position;
use pepper_sync::sync::MAX_SHARDTREE_CHECKPOINTS;
use pepper_sync::wallet::{NoteInterface, OrchardNote, OutputId, OutputInterface};
use shardtree::store::ShardStore as _;
use zaino_proto::tonic::Code;
use zcash_client_backend::data_api::anchor_retention::{
AnchorRetention, AnchorRetentionInterval,
};
use zcash_protocol::PoolType;
use zcash_protocol::ShieldedPool;
use zcash_protocol::consensus::BlockHeight;
use crate::check_client_balances;
use crate::lightclient::LightClient;
use crate::testutils::lightclient::get_base_address;
use crate::testutils::migration::inject_scheduled_migration;
use crate::testutils::mock_activation_heights_deferring_nu6_3;
use crate::testutils::mock_indexer::{
Fault, MockChain, MockNet, Rpc, faucet_funding_transaction_on,
};
use crate::wallet::LightWallet;
use crate::wallet::migration::parts::SkipReason;
use crate::wallet::migration::{MigrationParams, PartState, PrepareResult, bucket_index};
const HIDDEN_BLOCKS: u32 = 10;
const PRUNED_BUCKET_MODULUS: u32 = (MAX_SHARDTREE_CHECKPOINTS + 1).next_power_of_two();
const ANCHOR_BUCKET: u64 = 2;
const WINDOW_BUCKET: u64 = ANCHOR_BUCKET + 1;
const ANCHOR_BOUNDARY: u32 = ANCHOR_BUCKET as u32 * PRUNED_BUCKET_MODULUS;
const DEFERRED_NU6_3: u32 = 200;
const BIRTHDAY_CHECKPOINT_RUNWAY: u32 = MAX_SHARDTREE_CHECKPOINTS;
const _: () = assert!(
BIRTHDAY_CHECKPOINT_RUNWAY + 2 < DEFERRED_NU6_3,
"the funding must sit above every checkpoint the birthday verification leaves and still confirm before NU6.3 activates"
);
const TARGET_TIP: u32 =
WINDOW_BUCKET as u32 * PRUNED_BUCKET_MODULUS + (PRUNED_BUCKET_MODULUS - HIDDEN_BLOCKS) / 2;
const _: () = assert!(
ANCHOR_BOUNDARY >= DEFERRED_NU6_3,
"the transmit path skips an anchor below the NU6.3 activation before it looks at the tree"
);
const _: () = assert!(
TARGET_TIP - ANCHOR_BOUNDARY > MAX_SHARDTREE_CHECKPOINTS,
"the leap must leave the anchor boundary below pepper-sync's rolling checkpoint window"
);
const _: () = assert!(
TARGET_TIP + HIDDEN_BLOCKS < (WINDOW_BUCKET as u32 + 1) * PRUNED_BUCKET_MODULUS,
"the hidden blocks must keep the tip inside the transmission window"
);
async fn funding_note(
client: &LightClient,
funding: u64,
) -> ((u64, OutputId, [u8; 32]), Position) {
let wallet = client.wallet().read().await;
wallet
.wallet_transactions
.values()
.flat_map(|transaction| OrchardNote::transaction_outputs(transaction).to_vec())
.find(|note| note.value() == funding)
.map(|note| {
(
(
note.value(),
note.output_id(),
note.nullifier()
.expect("scanned wallet notes carry nullifiers")
.to_bytes(),
),
note.position()
.expect("scanned wallet notes carry tree positions"),
)
})
.expect("the recipient holds its funding note")
}
fn orchard_checkpoints_holding(
wallet: &LightWallet,
at_or_below: BlockHeight,
position: Position,
) -> Vec<BlockHeight> {
let store = wallet.shard_trees.orchard.store();
let count = store.checkpoint_count().expect("infallible");
(0..count)
.filter_map(|depth| store.get_checkpoint_at_depth(depth).expect("infallible"))
.filter(|(id, checkpoint)| {
*id <= at_or_below && checkpoint.position().is_some_and(|held| held >= position)
})
.map(|(id, _)| id)
.collect()
}
#[tokio::test]
async fn unavailable_boundary_tree_state_skips_without_sync_on_the_mock_chain() {
let anchor_boundary = BlockHeight::from_u32(ANCHOR_BOUNDARY);
let retention = AnchorRetention::new(
BlockHeight::from_u32(DEFERRED_NU6_3),
AnchorRetentionInterval::default(),
);
assert!(
retention
.retained_in_range(BlockHeight::from_u32(0)..=anchor_boundary)
.is_empty(),
"the canonical retention grid must pin no checkpoint at or below the anchor boundary"
);
let mut net = MockNet::launch_with(MockChain::with_activation_heights(
mock_activation_heights_deferring_nu6_3(DEFERRED_NU6_3),
))
.await;
let mut params = MigrationParams::provisional(net.chain_type());
params.bucket_modulus = PRUNED_BUCKET_MODULUS;
let funding = params.max_residual_value + params.part_fee;
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let recipient_ua =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
{
let mut chain = net.chain.write().await;
chain.mine_empty_blocks(BIRTHDAY_CHECKPOINT_RUNWAY);
let funding_transaction = faucet_funding_transaction_on(
chain.activation_heights(),
chain.next_height(),
vec![(&recipient_ua, funding, None)],
)
.await;
chain.mine_block(vec![funding_transaction]);
let funded_tip = chain.tip();
assert!(
funded_tip < DEFERRED_NU6_3,
"the funding must confirm before NU6.3 activates, but the chain is at {funded_tip}"
);
chain.mine_empty_blocks(TARGET_TIP - funded_tip);
}
recipient.sync_and_await().await.unwrap();
check_client_balances!(recipient, i: 0 o: funding s: 0 t: 0);
let known_height = recipient
.wallet()
.read()
.await
.sync_state
.last_known_chain_height()
.expect("the recipient has synced");
assert_eq!(
bucket_index(known_height, PRUNED_BUCKET_MODULUS),
WINDOW_BUCKET,
"the chain must sit inside the transmission window"
);
let (bound, position) = funding_note(&recipient, funding).await;
assert_eq!(
orchard_checkpoints_holding(
&*recipient.wallet().read().await,
anchor_boundary,
position
),
Vec::<BlockHeight>::new(),
"no checkpoint at or below the anchor boundary may hold the bound note"
);
let (funding_value, output_id, nullifier) = bound;
let denomination = funding_value - params.part_fee;
inject_scheduled_migration(
&recipient,
params,
vec![(denomination, output_id, nullifier)],
Some(WINDOW_BUCKET),
Some(ANCHOR_BUCKET),
)
.await;
{
let mut chain = net.chain.write().await;
chain.mine_empty_blocks(HIDDEN_BLOCKS);
chain.faults.inject(
Rpc::BlockRange,
Fault::Fail(
Code::Unavailable,
"a hidden sync fetched blocks".to_string(),
),
);
}
#[cfg(feature = "nym")]
recipient.set_transmit_policy(crate::mixnet::TransmitPolicy::Nakednet);
let sent = recipient.transmit_due_parts().await.unwrap();
assert!(sent.is_empty(), "nothing must be transmitted: {sent:?}");
assert_eq!(
net.chain.read().await.faults.pending(Rpc::BlockRange),
1,
"the transmit path must never fetch blocks"
);
let mut wallet = recipient.wallet().write().await;
let state = wallet.migration.as_ref().unwrap();
let part = &state.parts[0];
assert_eq!(part.state, PartState::Assigned, "a skip writes nothing");
assert_eq!(part.attempts, 0, "a skip records no attempt");
assert!(part.anchor_witness.is_none());
assert_eq!(
wallet.sync_state.last_known_chain_height(),
Some(known_height),
"the transmit path must never synchronize"
);
let (account, params, mut probe) = (state.account, state.params.clone(), part.clone());
match wallet
.prepare_part(account, &mut probe, ¶ms)
.expect("an unavailable boundary is a skip, not an error")
{
PrepareResult::Skip(SkipReason::MissedBoundary { boundary }) => {
assert_eq!(boundary, anchor_boundary);
}
PrepareResult::Skip(other) => {
panic!("the part skipped for the wrong reason: {other:?}")
}
PrepareResult::Ready { .. } => {
panic!("the anchor boundary's tree state must be unavailable")
}
}
}
}
mod moved_from_regtest {
use std::time::Duration;
use zcash_primitives::transaction::fees::zip317::MARGINAL_FEE;
use zcash_protocol::PoolType;
use zcash_protocol::consensus::COINBASE_MATURITY_BLOCKS;
use zcash_protocol::value::Zatoshis;
use zip32::AccountId;
use zcash_primitives::transaction::fees::zip317::MINIMUM_FEE;
use zcash_protocol::ShieldedPool;
use super::{external_address, fund};
use crate::check_client_balances;
use crate::testutils::lightclient::{from_inputs, get_base_address, get_fees_paid_by_client};
use crate::testutils::mock_activation_heights_deferring_nu6_3;
use crate::testutils::mock_indexer::{MockChain, MockNet};
const REWARD_ZATS: u64 = 625_000;
const REWARD: Zatoshis = Zatoshis::const_from_u64(REWARD_ZATS);
const COINBASES: u64 = 4;
const DEFERRED_NU6_3: u32 = 130;
const SHIELD_LOGICAL_ACTIONS: u64 = 6;
#[tokio::test]
async fn mine_to_transparent_and_shield() {
let mut net = MockNet::launch().await;
let mut miner = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
miner.set_transmit_retry_interval(Duration::ZERO);
let miner_taddr = get_base_address(&miner, PoolType::Transparent).await;
{
let mut chain = net.chain.write().await;
for _ in 0..COINBASES {
chain.mine_block_rewarding(&miner_taddr, REWARD, vec![]);
}
chain.mine_empty_blocks(COINBASE_MATURITY_BLOCKS);
}
miner.sync_and_await().await.unwrap();
check_client_balances!(miner, i: 0 o: 0 s: 0 t: (COINBASES * REWARD_ZATS));
miner.quick_shield(AccountId::ZERO).await.unwrap();
net.chain.write().await.mine_mempool();
miner.sync_and_await().await.unwrap();
let fee = get_fees_paid_by_client(&miner).await;
assert_eq!(
fee,
Option::unwrap(MARGINAL_FEE * SHIELD_LOGICAL_ACTIONS).into_u64()
);
check_client_balances!(miner, i: (COINBASES * REWARD_ZATS - fee) o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn sapling_dust_fee_collection() {
const FEES_FOR_ORCHARD: u64 = 10;
const DUST_DIVISOR: u64 = 10;
const FEES_SENT: u64 = 5;
let fee = u64::from(MINIMUM_FEE);
let for_orchard = fee * FEES_FOR_ORCHARD;
let for_sapling = fee / DUST_DIVISOR;
let mut net = MockNet::launch().await;
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let recipient_sapling =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Sapling)).await;
let recipient_unified =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
check_client_balances!(recipient, i: 0 o: 0 s: 0 t: 0);
net.chain.write().await.mine_empty_blocks(1);
fund(
&net,
vec![
(&recipient_unified, for_orchard, Some("Plenty for orchard.")),
(&recipient_sapling, for_sapling, Some("Dust for sapling.")),
],
0,
)
.await;
recipient.sync_and_await().await.unwrap();
check_client_balances!(recipient, i: for_orchard o: 0 s: 0 t: 0);
from_inputs::quick_send(
&mut recipient,
vec![(
&external_address(PoolType::ORCHARD),
fee * FEES_SENT,
Some("Five times fee."),
)],
)
.await
.unwrap();
net.chain.write().await.mine_mempool();
recipient.sync_and_await().await.unwrap();
let remaining_ironwood = for_orchard - (FEES_SENT + 1) * fee;
check_client_balances!(recipient, i: remaining_ironwood o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn send_and_sync_with_multiple_notes_no_panic() {
const NOTE: u64 = 40_000;
const PAYMENT: u64 = 50_000;
let fee = u64::from(MINIMUM_FEE);
let mut net = MockNet::launch().await;
let mut recipient = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let recipient_ua =
get_base_address(&recipient, PoolType::Shielded(ShieldedPool::Orchard)).await;
net.chain.write().await.mine_empty_blocks(1);
fund(&net, vec![(&recipient_ua, NOTE, None)], 0).await;
fund(&net, vec![(&recipient_ua, NOTE, None)], 0).await;
recipient.sync_and_await().await.unwrap();
check_client_balances!(recipient, i: (2 * NOTE) o: 0 s: 0 t: 0);
from_inputs::quick_send(
&mut recipient,
vec![(&external_address(PoolType::ORCHARD), PAYMENT, None)],
)
.await
.unwrap();
net.chain.write().await.mine_mempool();
recipient.sync_and_await().await.unwrap();
check_client_balances!(recipient, i: (2 * NOTE - PAYMENT - fee) o: 0 s: 0 t: 0);
}
#[tokio::test]
async fn mine_to_transparent() {
use crate::lightclient::LightClient;
use pepper_sync::wallet::TransparentCoin;
const FIRST_BLOCKS: u64 = 3;
let mut net = MockNet::launch().await;
let mut miner = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let miner_taddr = get_base_address(&miner, PoolType::Transparent).await;
{
let mut chain = net.chain.write().await;
for _ in 0..FIRST_BLOCKS {
chain.mine_block_rewarding(&miner_taddr, REWARD, vec![]);
}
}
miner.sync_and_await().await.unwrap();
async fn unfiltered(miner: &LightClient) -> u64 {
miner
.wallet()
.read()
.await
.get_filtered_balance::<TransparentCoin, _>(|_, _| true, AccountId::ZERO)
.unwrap()
.into_u64()
}
assert_eq!(unfiltered(&miner).await, FIRST_BLOCKS * REWARD_ZATS);
net.chain
.write()
.await
.mine_block_rewarding(&miner_taddr, REWARD, vec![]);
miner.sync_and_await().await.unwrap();
assert_eq!(unfiltered(&miner).await, (FIRST_BLOCKS + 1) * REWARD_ZATS);
}
#[tokio::test]
async fn mine_to_transparent_coinbase_maturity() {
use crate::lightclient::LightClient;
use pepper_sync::wallet::TransparentCoin;
const FIRST_BLOCKS: u64 = 3;
let mut net = MockNet::launch().await;
let mut miner = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let miner_taddr = get_base_address(&miner, PoolType::Transparent).await;
{
let mut chain = net.chain.write().await;
for _ in 0..FIRST_BLOCKS {
chain.mine_block_rewarding(&miner_taddr, REWARD, vec![]);
}
}
miner.sync_and_await().await.unwrap();
async fn mature(miner: &LightClient) -> u64 {
miner
.wallet()
.read()
.await
.confirmed_balance_excluding_dust::<TransparentCoin>(AccountId::ZERO)
.unwrap()
.into_u64()
}
check_client_balances!(miner, i: 0 o: 0 s: 0 t: 0);
assert_eq!(mature(&miner).await, 0);
net.chain
.write()
.await
.mine_empty_blocks(COINBASE_MATURITY_BLOCKS);
miner.sync_and_await().await.unwrap();
assert_eq!(mature(&miner).await, FIRST_BLOCKS * REWARD_ZATS);
}
#[tokio::test]
async fn mine_to_orchard() {
const ORCHARD_BLOCKS: u64 = 3;
let mut net = MockNet::launch_with(MockChain::with_activation_heights(
mock_activation_heights_deferring_nu6_3(DEFERRED_NU6_3),
))
.await;
let mut miner = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let miner_sapling =
get_base_address(&miner, PoolType::Shielded(ShieldedPool::Sapling)).await;
let miner_ua = get_base_address(&miner, PoolType::Shielded(ShieldedPool::Orchard)).await;
net.mine_block_rewarding_shielded(&miner_sapling, REWARD, vec![])
.await;
for _ in 0..ORCHARD_BLOCKS {
net.mine_block_rewarding_shielded(&miner_ua, REWARD, vec![])
.await;
}
miner.sync_and_await().await.unwrap();
check_client_balances!(miner, i: 0 o: (ORCHARD_BLOCKS * REWARD_ZATS) s: REWARD_ZATS t: 0);
net.mine_block_rewarding_shielded(&miner_ua, REWARD, vec![])
.await;
miner.sync_and_await().await.unwrap();
check_client_balances!(miner, i: 0 o: ((ORCHARD_BLOCKS + 1) * REWARD_ZATS) s: REWARD_ZATS t: 0);
}
#[tokio::test]
async fn mine_to_ironwood() {
const IRONWOOD_BLOCKS: u64 = 3;
let mut net = MockNet::launch().await;
let mut miner = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let miner_sapling =
get_base_address(&miner, PoolType::Shielded(ShieldedPool::Sapling)).await;
let miner_ua = get_base_address(&miner, PoolType::Shielded(ShieldedPool::Orchard)).await;
net.mine_block_rewarding_shielded(&miner_sapling, REWARD, vec![])
.await;
for _ in 0..IRONWOOD_BLOCKS {
net.mine_block_rewarding_shielded(&miner_ua, REWARD, vec![])
.await;
}
miner.sync_and_await().await.unwrap();
check_client_balances!(miner, i: (IRONWOOD_BLOCKS * REWARD_ZATS) o: 0 s: REWARD_ZATS t: 0);
net.mine_block_rewarding_shielded(&miner_ua, REWARD, vec![])
.await;
miner.sync_and_await().await.unwrap();
check_client_balances!(miner, i: ((IRONWOOD_BLOCKS + 1) * REWARD_ZATS) o: 0 s: REWARD_ZATS t: 0);
}
#[tokio::test]
async fn ironwood_miner_coinbase_distribution() {
use crate::testutils::mock_activation_heights_with;
use crate::testutils::mock_indexer::MockChain;
const NU6_3: u32 = 5;
const ORCHARD_BLOCKS: u64 = (NU6_3 - 2) as u64;
let mut net = MockNet::launch_with(MockChain::with_activation_heights(
mock_activation_heights_with(|era| era.set_nu6_3(Some(NU6_3)).set_nu7(None)),
))
.await;
let mut miner = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let miner_sapling =
get_base_address(&miner, PoolType::Shielded(ShieldedPool::Sapling)).await;
let miner_ua = get_base_address(&miner, PoolType::Shielded(ShieldedPool::Orchard)).await;
net.mine_block_rewarding_shielded(&miner_sapling, REWARD, vec![])
.await;
for _ in 0..ORCHARD_BLOCKS {
net.mine_block_rewarding_shielded(&miner_ua, REWARD, vec![])
.await;
}
assert_eq!(net.chain.read().await.tip(), NU6_3 - 1);
miner.sync_and_await().await.unwrap();
check_client_balances!(miner, i: 0 o: (ORCHARD_BLOCKS * REWARD_ZATS) s: REWARD_ZATS t: 0);
net.mine_block_rewarding_shielded(&miner_ua, REWARD, vec![])
.await;
miner.sync_and_await().await.unwrap();
check_client_balances!(miner, i: REWARD_ZATS o: (ORCHARD_BLOCKS * REWARD_ZATS) s: REWARD_ZATS t: 0);
}
#[tokio::test]
async fn send_mined_ironwood_to_ironwood() {
const IRONWOOD_BLOCKS: u64 = 3;
const AMOUNT_TO_SEND: u64 = 10_000;
let fee = u64::from(MINIMUM_FEE);
let mut net = MockNet::launch().await;
let mut miner = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let miner_ua = get_base_address(&miner, PoolType::Shielded(ShieldedPool::Orchard)).await;
for _ in 0..IRONWOOD_BLOCKS {
net.mine_block_rewarding_shielded(&miner_ua, REWARD, vec![])
.await;
}
miner.sync_and_await().await.unwrap();
let funded = IRONWOOD_BLOCKS * REWARD_ZATS;
check_client_balances!(miner, i: funded o: 0 s: 0 t: 0);
from_inputs::quick_send(
&mut miner,
vec![(&miner_ua, AMOUNT_TO_SEND, Some("Scenario test: engage!"))],
)
.await
.unwrap();
let reward_with_fee = Zatoshis::from_u64(REWARD_ZATS + fee).unwrap();
net.mine_mempool_rewarding_shielded(&miner_ua, reward_with_fee)
.await;
miner.sync_and_await().await.unwrap();
let balance = miner.account_balance(AccountId::ZERO).await.unwrap();
assert_eq!(balance.unconfirmed_ironwood_balance, Some(Zatoshis::ZERO));
assert_eq!(
balance.confirmed_ironwood_balance.unwrap().into_u64(),
funded + REWARD_ZATS
);
}
#[tokio::test]
async fn verify_old_wallet_uses_server_height_in_send() {
const FUNDING: u64 = 100_000;
const SYNCED_BLOCKS: u32 = 5;
const HIDDEN_BLOCKS: u32 = 2;
const PAYMENT: u64 = 10_000;
let mut net = MockNet::launch().await;
let mut sender = net
.client(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED, None)
.await;
let sender_ua = get_base_address(&sender, PoolType::Shielded(ShieldedPool::Orchard)).await;
net.chain.write().await.mine_empty_blocks(1);
fund(&net, vec![(&sender_ua, FUNDING, None)], SYNCED_BLOCKS).await;
sender.sync_and_await().await.unwrap();
let synced_height = sender
.wallet()
.read()
.await
.sync_state
.fully_scanned_height()
.unwrap();
assert_eq!(u32::from(synced_height), net.chain.read().await.tip());
net.chain.write().await.mine_empty_blocks(HIDDEN_BLOCKS);
let stale_height = sender
.wallet()
.read()
.await
.sync_state
.fully_scanned_height()
.unwrap();
assert_eq!(stale_height, synced_height);
assert_eq!(
u32::from(stale_height) + HIDDEN_BLOCKS,
net.chain.read().await.tip()
);
from_inputs::quick_send(
&mut sender,
vec![(
&external_address(PoolType::ORCHARD),
PAYMENT,
Some("Interrupting sync!!"),
)],
)
.await
.unwrap();
assert_eq!(net.chain.read().await.mempool_len(), 1);
}
}