use std::convert::Infallible;
use std::future::Future;
use nonempty::NonEmpty;
use zcash_client_backend::data_api::wallet::TargetHeight;
use zcash_client_backend::proposal::{Proposal, ProposalError};
use zcash_client_backend::zip321::TransactionRequest;
use zcash_primitives::transaction::{TxId, fees::zip317};
use zcash_protocol::consensus::{BlockHeight, BranchId};
use zingo_netutils::Indexer as _;
use zingo_netutils::lightwallet_protocol::{RawTransaction, TxFilter};
use zingo_status::confirmation_status::ConfirmationStatus;
use crate::config::ChainType;
use crate::data::proposal::{OpReturnProposal, ZingoProposal};
use crate::lightclient::error::{LightClientError, SendError, TransmissionError};
use crate::lightclient::indexer_history::{
AttemptKind, AttemptRoute, FailureKind, IndexerAttempt, IndexerHistoryHandle, now_unix_secs,
};
use crate::lightclient::transmit::{
TransmitFailed, TransmitProgressHandle, TransmitProgressScope, TransmitTarget,
resilient_transmit,
};
fn record_send_attempt(
history: &IndexerHistoryHandle,
host: &crate::destination::Host,
route: AttemptRoute,
started: std::time::Instant,
outcome: &Result<String, zingo_net_diag::NetOpFailure>,
fault_domain: Option<crate::destination::health::FaultDomain>,
) {
history.record(&IndexerAttempt {
unix_secs: now_unix_secs(),
host: host.clone(),
route,
kind: AttemptKind::Send,
millis: started.elapsed().as_millis().try_into().unwrap_or(u64::MAX),
fault_domain,
outcome: match outcome {
Ok(_) => Ok(()),
Err(failure) => Err(FailureKind::classify(&failure.to_string())),
},
});
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum TransmitError {
#[error(transparent)]
Draw(#[from] crate::destination::servers::NoEligibleDestinations),
#[error("{0}")]
Escalation(crate::destination::rotation::EscalationError<zingo_net_diag::NetOpFailure>),
}
use crate::lightclient::{DEFAULT_REQUEST_TIMEOUT, LightClient};
use crate::wallet::LightWallet;
use crate::wallet::error::WalletError;
use crate::wallet::output::OutputRef;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TransmitReport {
pub txid: TxId,
pub route: TransmitRoute,
pub round_trip: std::time::Duration,
}
#[cfg(feature = "nym")]
fn resolve_transmit_route(
has_indexer: bool,
route: Result<crate::mixnet::MixnetRoute, crate::mixnet::MixnetNotReady>,
) -> Result<Option<zingo_netutils::conduit::ConduitDial>, LightClientError> {
use crate::mixnet::{MixnetNotReady, MixnetRoute};
match (has_indexer, route) {
(_, Ok(MixnetRoute::Mixnet(conduit))) => Ok(Some(conduit.dial())),
(true, Ok(MixnetRoute::Nakednet)) => Ok(None),
(false, Ok(MixnetRoute::Nakednet)) => Err(LightClientError::Offline),
(false, Err(MixnetNotReady::Unattached)) => Err(LightClientError::Offline),
(_, Err(e)) => Err(LightClientError::MixnetNotReady(e)),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TransmitRoute {
Nakednet {
destination: String,
},
Mixnet {
destination: String,
via_socks5: String,
},
}
pub(crate) const ZIP_203_EXPIRY_HEIGHT_THRESHOLD: u32 = 500_000_000;
fn retarget_for_offline_signing<NoteRef: Clone>(
proposal: &Proposal<zip317::FeeRule, NoteRef>,
chain_type: &ChainType,
) -> Result<Proposal<zip317::FeeRule, NoteRef>, ProposalError> {
let stored_target = proposal.min_target_height();
let epoch = BranchId::for_height(chain_type, stored_target.into());
let cap = match epoch.height_bounds(chain_type) {
Some((_, Some(next_activation))) => u32::from(next_activation) - 1,
_ => {
let highest_encodable = ZIP_203_EXPIRY_HEIGHT_THRESHOLD - 1;
highest_encodable
- crate::wallet::expiry::tx_expiry_delta(
chain_type,
BlockHeight::from_u32(highest_encodable),
)
}
};
let lifted_target = cap.max(u32::from(stored_target));
Proposal::multi_step(
proposal.fee_rule().clone(),
TargetHeight::from(lifted_target),
proposal.confirmations_policy(),
proposal.steps().clone(),
)
}
struct NakednetTarget(zingo_netutils::GrpcIndexer);
impl NakednetTarget {
fn lazy(destination: http::Uri) -> Result<Self, zingo_net_diag::NetOpFailure> {
let host = crate::destination::Host::of_uri(&destination);
zingo_netutils::GrpcIndexer::new_lazy(destination)
.map(NakednetTarget)
.map_err(|error| {
zingo_net_diag::NetOpFailure::from_error(
zingo_net_diag::NetOpStage::RouteResolution,
&host,
&error,
)
})
}
fn failure(
status: &zingo_netutils::Status,
host: &crate::destination::Host,
) -> zingo_net_diag::NetOpFailure {
zingo_net_diag::NetOpFailure::from_error(
zingo_net_diag::NetOpStage::RemoteHttp,
host,
status,
)
}
}
impl TransmitTarget for NakednetTarget {
type Failure = zingo_netutils::Status;
fn submit(
&self,
raw_tx: &[u8],
height: u64,
) -> impl Future<Output = Result<String, zingo_netutils::Status>> + Send {
let mut client = self.0.clone();
let data = raw_tx.to_vec();
async move {
client
.send_transaction(RawTransaction { data, height }, DEFAULT_REQUEST_TIMEOUT)
.await
}
}
fn knows_transaction(&self, txid: &TxId) -> impl Future<Output = bool> + Send {
let mut client = self.0.clone();
let hash = txid.as_ref().to_vec();
async move {
client
.get_transaction(
TxFilter {
block: None,
index: 0,
hash,
},
DEFAULT_REQUEST_TIMEOUT,
)
.await
.is_ok()
}
}
}
#[cfg(feature = "nym")]
impl TransmitTarget for zingo_netutils::Socks5Indexer {
type Failure = zingo_netutils::Socks5TransmitError;
async fn submit(
&self,
raw_tx: &[u8],
height: u64,
) -> Result<String, zingo_netutils::Socks5TransmitError> {
self.send_transaction(raw_tx, height).await
}
fn knows_transaction(&self, txid: &TxId) -> impl Future<Output = bool> + Send {
let hash = txid.as_ref().to_vec();
async move { self.transaction_known(&hash).await }
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) enum Wire {
Nakednet,
#[cfg(feature = "nym")]
Mixnet { shared_socks5: std::net::SocketAddr },
#[cfg(all(feature = "nym", any(test, feature = "testutils")))]
MixnetOverMock { shared_socks5: std::net::SocketAddr },
}
impl Wire {
fn transport(self) -> crate::destination::servers::Transport {
use crate::destination::servers::Transport;
match self {
Wire::Nakednet => Transport::Nakednet,
#[cfg(feature = "nym")]
Wire::Mixnet { .. } => Transport::Mixnet,
#[cfg(all(feature = "nym", any(test, feature = "testutils")))]
Wire::MixnetOverMock { .. } => Transport::Mixnet,
}
}
fn reach(self) -> crate::destination::servers::Transport {
match self {
#[cfg(all(feature = "nym", any(test, feature = "testutils")))]
Wire::MixnetOverMock { .. } => crate::destination::servers::Transport::Nakednet,
other => other.transport(),
}
}
fn attempt_route(self) -> AttemptRoute {
match self {
Wire::Nakednet => AttemptRoute::Nakednet,
#[cfg(feature = "nym")]
Wire::Mixnet { .. } => AttemptRoute::Mixnet,
#[cfg(all(feature = "nym", any(test, feature = "testutils")))]
Wire::MixnetOverMock { .. } => AttemptRoute::Mixnet,
}
}
fn is_mixnet(self) -> bool {
!matches!(self, Wire::Nakednet)
}
fn route_to(self, destination: &crate::destination::Host) -> TransmitRoute {
match self {
Wire::Nakednet => TransmitRoute::Nakednet {
destination: destination.to_string(),
},
#[cfg(feature = "nym")]
Wire::Mixnet { shared_socks5 } => TransmitRoute::Mixnet {
destination: destination.to_string(),
via_socks5: shared_socks5.to_string(),
},
#[cfg(all(feature = "nym", any(test, feature = "testutils")))]
Wire::MixnetOverMock { shared_socks5 } => TransmitRoute::Mixnet {
destination: destination.to_string(),
via_socks5: shared_socks5.to_string(),
},
}
}
}
struct TransmitContext<'a> {
progress: &'a TransmitProgressHandle,
history: &'a IndexerHistoryHandle,
retry_interval: std::time::Duration,
}
async fn transmit_one_transaction(
wire: Wire,
servers: &crate::destination::servers::DestinationServerSet,
sync_indexer: Option<&http::Uri>,
tx_bytes: &[u8],
height: u64,
txid: &TxId,
context: &TransmitContext<'_>,
) -> Result<(String, TransmitRoute), TransmitError> {
let draw = servers.draw_reaching(
wire.transport(),
wire.reach(),
sync_indexer,
&context.history.health().lock().expect("health mutex"),
)?;
match wire {
Wire::Nakednet => {
rotate_transmit(
wire,
&draw,
NakednetTarget::lazy,
NakednetTarget::failure,
tx_bytes,
height,
txid,
context,
)
.await
}
#[cfg(feature = "nym")]
Wire::Mixnet { shared_socks5 } => {
rotate_transmit(
wire,
&draw,
|destination| {
Ok(zingo_netutils::Socks5Indexer::new(
shared_socks5,
destination,
DEFAULT_REQUEST_TIMEOUT,
))
},
|error, host| crate::mixnet::socks5_transmit_failure(error, host),
tx_bytes,
height,
txid,
context,
)
.await
}
#[cfg(all(feature = "nym", any(test, feature = "testutils")))]
Wire::MixnetOverMock { .. } => {
rotate_transmit(
wire,
&draw,
NakednetTarget::lazy,
NakednetTarget::failure,
tx_bytes,
height,
txid,
context,
)
.await
}
}
}
#[allow(clippy::too_many_arguments)]
async fn rotate_transmit<T, M, F>(
wire: Wire,
draw: &crate::destination::servers::Draw,
make_target: M,
describe_failure: F,
tx_bytes: &[u8],
height: u64,
txid: &TxId,
context: &TransmitContext<'_>,
) -> Result<(String, TransmitRoute), TransmitError>
where
T: TransmitTarget + Sync,
M: Fn(http::Uri) -> Result<T, zingo_net_diag::NetOpFailure>,
F: Fn(&T::Failure, &crate::destination::Host) -> zingo_net_diag::NetOpFailure,
{
use crate::destination::rotation::{MAX_TRANSMISSION_DESTINATIONS, escalating_transmit};
let run_pull = |destination: http::Uri| {
let host = crate::destination::Host::of_uri(&destination);
let target = make_target(destination);
let describe_failure = &describe_failure;
let txid = *txid;
async move {
let started = std::time::Instant::now();
let outcome = match target {
Ok(target) => resilient_transmit(
&target,
tx_bytes,
height,
&txid,
move |_| tokio::time::sleep(context.retry_interval),
|event| context.progress.set(format!("destination {host}: {event}")),
)
.await
.map_err(|TransmitFailed(failure)| describe_failure(&failure, &host)),
Err(failure) => Err(failure),
};
record_send_attempt(
context.history,
&host,
wire.attempt_route(),
started,
&outcome,
outcome
.as_ref()
.err()
.map(|failure| crate::destination::health::fault_domain(&failure.stage)),
);
outcome.map(|server_txid| (server_txid, wire.route_to(&host)))
}
};
escalating_transmit(
draw.destinations(),
draw.preferred(),
&mut crate::utils::system_rng(),
MAX_TRANSMISSION_DESTINATIONS,
run_pull,
|line| context.progress.set(format!("escalation: {line}")),
)
.await
.map_err(TransmitError::Escalation)
}
fn fail_unsent(wallet: &mut LightWallet, calculated_txids: &NonEmpty<TxId>, from: usize) {
let unsent: Vec<TxId> = calculated_txids.iter().skip(from).copied().collect();
pepper_sync::set_transactions_failed(&mut wallet.wallet_transactions, unsent);
wallet.truncate_failed_refund_addresses();
wallet.save_required = true;
}
impl LightClient {
async fn send(
&mut self,
proposal: Proposal<zip317::FeeRule, OutputRef>,
sending_account: zip32::AccountId,
) -> Result<NonEmpty<TransmitReport>, LightClientError> {
self.preflight_transmit()?;
let indexerless = self.indexer.is_none();
let mut wallet = self.wallet().write().await;
let proposal = if indexerless {
let chain_type = wallet.chain_type();
retarget_for_offline_signing(&proposal, &chain_type)
.map_err(SendError::RetargetError)?
} else {
proposal
};
let highest_refund_address_index = wallet.highest_refund_address_index();
let calculated_txids = wallet
.calculate_transactions(proposal, sending_account)
.await
.map_err(|e| {
wallet.truncate_refund_addresses(highest_refund_address_index);
SendError::CalculateSendError(e)
})?;
drop(wallet);
self.transmit_transactions(calculated_txids).await
}
async fn shield(
&mut self,
proposal: Proposal<zip317::FeeRule, Infallible>,
shielding_account: zip32::AccountId,
) -> Result<NonEmpty<TransmitReport>, LightClientError> {
let calculated_txids = self
.wallet()
.write()
.await
.calculate_transactions(proposal, shielding_account)
.await
.map_err(SendError::CalculateShieldError)?;
self.transmit_transactions(calculated_txids).await
}
pub async fn send_stored_proposal(
&mut self,
resume_sync: bool,
) -> Result<NonEmpty<TxId>, LightClientError> {
self.require_indexer()?;
let opt_proposal = self.wallet().write().await.take_proposal();
if let Some(proposal) = opt_proposal {
let reports = match proposal {
ZingoProposal::Send {
proposal,
sending_account,
} => self.send(proposal, sending_account).await,
ZingoProposal::Shield {
proposal,
shielding_account,
} => self.shield(proposal, shielding_account).await,
ZingoProposal::OpReturn(proposal) => self.send_op_return(proposal).await,
};
self.release_proposal_pause(resume_sync);
reports.map(|reports| reports.map(|report| report.txid))
} else {
Err(SendError::NoStoredProposal.into())
}
}
pub async fn calculate_stored_proposal(&mut self) -> Result<NonEmpty<TxId>, LightClientError> {
let indexerless = self.indexer.is_none();
let mut wallet = self.wallet().write().await;
let opt_proposal = wallet.take_proposal();
let Some(proposal) = opt_proposal else {
return Err(SendError::NoStoredProposal.into());
};
let chain_type = wallet.chain_type();
let result = match proposal {
ZingoProposal::Send {
proposal,
sending_account,
} => {
let retargeted = if indexerless {
retarget_for_offline_signing(&proposal, &chain_type)
.map_err(SendError::RetargetError)
} else {
Ok(proposal)
};
match retargeted {
Ok(proposal) => wallet
.calculate_transactions(proposal, sending_account)
.await
.map_err(SendError::CalculateSendError),
Err(e) => Err(e),
}
}
ZingoProposal::Shield {
proposal,
shielding_account,
} => {
let retargeted = if indexerless {
retarget_for_offline_signing(&proposal, &chain_type)
.map_err(SendError::RetargetError)
} else {
Ok(proposal)
};
match retargeted {
Ok(proposal) => wallet
.calculate_transactions(proposal, shielding_account)
.await
.map_err(SendError::CalculateShieldError),
Err(e) => Err(e),
}
}
ZingoProposal::OpReturn(proposal) => {
wallet.store_proposal(ZingoProposal::OpReturn(proposal));
return Err(SendError::OpReturnNotCalculable.into());
}
};
drop(wallet);
self.release_proposal_pause(true);
result.map_err(LightClientError::from)
}
fn preflight_transmit(&self) -> Result<(), LightClientError> {
#[cfg(feature = "nym")]
{
resolve_transmit_route(self.indexer.is_some(), self.send_route()).map(|_| ())
}
#[cfg(not(feature = "nym"))]
{
self.require_indexer().map(|_| ())
}
}
pub async fn transmit_calculated(
&mut self,
calculated_txids: NonEmpty<TxId>,
) -> Result<NonEmpty<TxId>, LightClientError> {
self.transmit_transactions(calculated_txids)
.await
.map(|reports| reports.map(|report| report.txid))
}
pub async fn quick_send(
&mut self,
request: TransactionRequest,
account_id: zip32::AccountId,
resume_sync: bool,
) -> Result<NonEmpty<TxId>, LightClientError> {
self.quick_send_reported(request, account_id, resume_sync)
.await
.map(|reports| reports.map(|report| report.txid))
}
pub async fn quick_send_reported(
&mut self,
request: TransactionRequest,
account_id: zip32::AccountId,
resume_sync: bool,
) -> Result<NonEmpty<TransmitReport>, LightClientError> {
let guard = self.pause_sync_scoped().ok();
let proposal_result = self
.wallet()
.write()
.await
.create_send_proposal(request, account_id)
.map_err(SendError::ProposeSendError);
let reports = match proposal_result {
Ok(proposal) => self.send(proposal, account_id).await,
Err(e) => Err(e.into()),
};
if let Some(guard) = guard
&& !resume_sync
{
guard.disarm();
}
reports
}
pub async fn quick_send_with_op_return(
&mut self,
recipient: &str,
amount: zcash_protocol::value::Zatoshis,
data: crate::wallet::transparent::OpReturnData,
account_id: zip32::AccountId,
resume_sync: bool,
) -> Result<NonEmpty<TransmitReport>, LightClientError> {
let guard = self.pause_sync_scoped().ok();
let reports = match self
.create_op_return_proposal(recipient, amount, data, account_id)
.await
{
Ok(proposal) => self.send_op_return(proposal).await,
Err(e) => Err(e),
};
if let Some(guard) = guard
&& !resume_sync
{
guard.disarm();
}
reports
}
async fn send_op_return(
&mut self,
proposal: OpReturnProposal,
) -> Result<NonEmpty<TransmitReport>, LightClientError> {
let account = proposal.sending_account();
let (deshield_reports, deshield_txid) = match proposal.deshield_txid() {
Some(txid) => (None, txid),
None => {
let highest_before = {
let mut wallet = self.wallet().write().await;
let (next_id, next_address) = wallet
.derive_refund_addresses(1, account)
.map_err(|e| SendError::OpReturn(WalletError::from(e)))?[0];
if next_id != proposal.source_address_id()
|| next_address != *proposal.source_address()
{
return Err(SendError::OpReturnSourceAddressStale.into());
}
let highest_before = wallet.highest_refund_address_index();
wallet
.generate_refund_addresses(1, account)
.map_err(|e| SendError::OpReturn(WalletError::from(e)))?;
highest_before
};
match self.send(proposal.deshield().clone(), account).await {
Ok(reports) => {
let txid = reports.first().txid;
(Some(reports), txid)
}
Err(e) => {
let transmitted = matches!(
e,
LightClientError::SendError(SendError::TransmissionError(_))
);
if !transmitted {
self.wallet()
.write()
.await
.truncate_refund_addresses(highest_before);
}
return Err(e);
}
}
}
};
let op_return_txid = {
let mut wallet = self.wallet().write().await;
let built = wallet
.get_migration_heights()
.map_err(SendError::OpReturn)
.and_then(|heights| {
heights
.map(|(target, _)| target)
.ok_or(SendError::OpReturn(WalletError::NoSyncData))
})
.and_then(|target_height| {
wallet
.find_transparent_output(deshield_txid, proposal.source_address())
.map(|(outpoint, txout)| (outpoint, txout, target_height))
.map_err(SendError::OpReturn)
})
.and_then(|(source_outpoint, source_txout, target_height)| {
wallet
.build_op_return_send(
account,
proposal.source_address_id(),
source_outpoint,
source_txout,
proposal.recipient(),
proposal.amount(),
proposal.data(),
target_height,
)
.map_err(SendError::OpReturn)
});
match built {
Ok(txid) => txid,
Err(e) => {
wallet.store_proposal(ZingoProposal::OpReturn(
proposal.with_deshield_txid(deshield_txid),
));
return Err(SendError::OpReturnAfterDeshield {
deshield_txid,
op_return_txid: None,
source: Box::new(e.into()),
}
.into());
}
}
};
let op_return_reports = self
.transmit_transactions(NonEmpty::new(op_return_txid))
.await
.map_err(|e| SendError::OpReturnAfterDeshield {
deshield_txid,
op_return_txid: Some(op_return_txid),
source: Box::new(e),
})?;
Ok(match deshield_reports {
Some(mut reports) => {
for report in op_return_reports {
reports.push(report);
}
reports
}
None => op_return_reports,
})
}
pub async fn quick_shield(
&mut self,
account_id: zip32::AccountId,
) -> Result<NonEmpty<TxId>, LightClientError> {
let _guard = self.pause_sync_scoped().ok();
let proposal = self
.wallet()
.write()
.await
.create_shield_proposal(account_id)
.map_err(SendError::ProposeShieldError)?;
self.shield(proposal, account_id)
.await
.map(|reports| reports.map(|report| report.txid))
}
pub(crate) async fn transmit_transactions(
&mut self,
calculated_txids: NonEmpty<TxId>,
) -> Result<NonEmpty<TransmitReport>, LightClientError> {
let indexer = self.indexer.clone();
#[cfg(feature = "nym")]
let transmit_dial = resolve_transmit_route(indexer.is_some(), self.send_route())?;
#[cfg(all(feature = "nym", any(test, feature = "testutils")))]
let mock_arms = matches!(
*self.mixnet_slot.lock().expect("mixnet slot mutex"),
crate::mixnet::MixnetSlot::AttachedForTests {
mock_arms: true,
..
}
);
#[cfg(feature = "nym")]
let wire = match transmit_dial.as_ref().map(|dial| dial.socks5()) {
None => Wire::Nakednet,
#[cfg(any(test, feature = "testutils"))]
Some(shared_socks5) if mock_arms => Wire::MixnetOverMock { shared_socks5 },
Some(shared_socks5) => Wire::Mixnet { shared_socks5 },
};
#[cfg(not(feature = "nym"))]
let wire = Wire::Nakednet;
if !wire.is_mixnet() && indexer.is_none() {
return Err(LightClientError::Offline);
}
let sync_indexer = indexer.as_ref().map(|indexer| indexer.uri());
let progress = self.transmit_progress.clone();
let _progress_scope = TransmitProgressScope(progress.clone());
let history = self.indexer_history.clone();
let total = calculated_txids.len();
let mut reports: Vec<TransmitReport> = Vec::with_capacity(total);
let mut wallet = self.wallet().write().await;
for (index, txid) in calculated_txids.iter().enumerate() {
progress.set(format!("transaction {} of {total}: preparing", index + 1));
let calculated_transaction = wallet
.wallet_transactions
.get(txid)
.ok_or(WalletError::TransactionNotFound(*txid))?;
let height = calculated_transaction.status().get_height();
if !matches!(
calculated_transaction.status(),
ConfirmationStatus::Calculated(_)
) {
return Err(SendError::TransmissionError(
TransmissionError::IncorrectTransactionStatus(*txid),
)
.into());
}
let mut transaction_bytes = vec![];
calculated_transaction
.transaction()
.write(&mut transaction_bytes)
.map_err(|e| {
fail_unsent(&mut wallet, &calculated_txids, index);
WalletError::TransactionWrite(e)
})?;
let dispatched = std::time::Instant::now();
let transmit_context = TransmitContext {
progress: &progress,
history: &history,
retry_interval: self.transmit_retry_interval,
};
let transmit_outcome = transmit_one_transaction(
wire,
&self.destination_servers,
sync_indexer,
&transaction_bytes,
height.into(),
txid,
&transmit_context,
)
.await;
let (txid_from_server, route) = match transmit_outcome {
Ok(server_txid_and_route) => {
#[cfg(feature = "nym")]
if matches!(server_txid_and_route.1, TransmitRoute::Mixnet { .. }) {
self.note_standing_round_trip();
}
server_txid_and_route
}
Err(failure) => {
#[cfg(feature = "nym")]
if wire.is_mixnet() {
self.note_standing_exit_suspicion();
}
fail_unsent(&mut wallet, &calculated_txids, index);
return Err(SendError::TransmissionError(
TransmissionError::TransmissionFailed(failure.to_string()),
)
.into());
}
};
wallet
.wallet_transactions
.get_mut(txid)
.ok_or(WalletError::TransactionNotFound(*txid))?
.update_status(
ConfirmationStatus::Transmitted(height),
crate::utils::now(),
false,
);
wallet.save_required = true;
let txid_from_server =
crate::utils::conversion::txid_from_hex_encoded_str(txid_from_server.as_str())
.map_err(WalletError::ConversionFailed)?;
if txid_from_server != *txid {
return Err(SendError::TransmissionError(
TransmissionError::IncorrectTxidFromServer(*txid, txid_from_server),
)
.into());
}
self.immediate_migration_progress.set_sent(index as u32 + 1);
self.split_progress.set_sent(index as u32 + 1);
reports.push(TransmitReport {
txid: *txid,
route,
round_trip: dispatched.elapsed(),
});
}
Ok(NonEmpty::from_vec(reports).expect("one report per calculated transaction"))
}
}
#[cfg(test)]
mod transmit_error_seam {
use super::*;
#[cfg(feature = "nym")]
#[test]
fn the_route_resolver_fails_closed_in_every_state() {
use crate::mixnet::{MixnetNotReady, MixnetRoute};
let socks5 = crate::mocks::transmission::MOCK_SOCKS5_ADDR;
let ready = || {
Ok(MixnetRoute::Mixnet(crate::mixnet::MixnetConduit::over(
socks5,
)))
};
for has_indexer in [true, false] {
let dial = resolve_transmit_route(has_indexer, ready())
.expect("a ready conduit carries the send")
.expect("the mixnet route dials the conduit");
assert_eq!(dial.socks5(), socks5);
}
assert!(matches!(
resolve_transmit_route(true, Ok(MixnetRoute::Nakednet)),
Ok(None)
));
assert!(matches!(
resolve_transmit_route(false, Ok(MixnetRoute::Nakednet)),
Err(LightClientError::Offline)
));
assert!(matches!(
resolve_transmit_route(false, Err(MixnetNotReady::Unattached)),
Err(LightClientError::Offline)
));
assert!(matches!(
resolve_transmit_route(true, Err(MixnetNotReady::Unattached)),
Err(LightClientError::MixnetNotReady(MixnetNotReady::Unattached))
));
for has_indexer in [true, false] {
for not_ready in [MixnetNotReady::Bootstrapping, MixnetNotReady::Died] {
assert!(
matches!(
resolve_transmit_route(has_indexer, Err(not_ready)),
Err(LightClientError::MixnetNotReady(refusal)) if refusal == not_ready
),
"{not_ready:?} with an indexer: {has_indexer}"
);
}
}
}
#[test]
fn each_wire_draws_reaches_records_and_reports_its_own_transport() {
use crate::destination::servers::Transport;
let host = crate::destination::Host::of_host_str("node.example");
assert_eq!(Wire::Nakednet.transport(), Transport::Nakednet);
assert_eq!(Wire::Nakednet.reach(), Transport::Nakednet);
assert_eq!(Wire::Nakednet.attempt_route(), AttemptRoute::Nakednet);
assert!(!Wire::Nakednet.is_mixnet());
assert_eq!(
Wire::Nakednet.route_to(&host),
TransmitRoute::Nakednet {
destination: "node.example".to_string()
}
);
#[cfg(feature = "nym")]
{
let shared_socks5 = crate::mocks::transmission::MOCK_SOCKS5_ADDR;
let mixnet = Wire::Mixnet { shared_socks5 };
assert_eq!(mixnet.transport(), Transport::Mixnet);
assert_eq!(mixnet.reach(), Transport::Mixnet);
assert_eq!(mixnet.attempt_route(), AttemptRoute::Mixnet);
assert!(mixnet.is_mixnet());
let mock = Wire::MixnetOverMock { shared_socks5 };
assert_eq!(mock.transport(), Transport::Mixnet);
assert_eq!(mock.reach(), Transport::Nakednet);
assert_eq!(mock.attempt_route(), AttemptRoute::Mixnet);
for wire in [mixnet, mock] {
assert_eq!(
wire.route_to(&host),
TransmitRoute::Mixnet {
destination: "node.example".to_string(),
via_socks5: shared_socks5.to_string(),
}
);
}
}
}
const ARBITRARY_HEIGHT: u64 = 0;
#[test]
fn send_attempt_failure_is_classified_from_the_record() {
let history = IndexerHistoryHandle::default();
let failure = zingo_net_diag::NetOpFailure::message(
zingo_net_diag::NetOpStage::RemoteConnect,
"indexer.example",
"connection refused",
);
record_send_attempt(
&history,
&crate::destination::Host::of_host_str("indexer.example"),
AttemptRoute::Nakednet,
std::time::Instant::now(),
&Err(failure),
None,
);
let recorded = history.load();
assert_eq!(recorded.len(), 1, "one attempt is recorded");
assert_eq!(
recorded[0].outcome,
Err(crate::lightclient::indexer_history::FailureKind::Unreachable),
"the category comes from the typed record"
);
}
#[tokio::test]
async fn an_empty_draw_refuses_typed() {
let history = IndexerHistoryHandle::default();
let progress = TransmitProgressHandle::default();
let servers = crate::destination::servers::DestinationServerSet::for_chain(
&ChainType::Regtest(crate::ActivationHeights::default()),
None,
Vec::new(),
);
let refusal = transmit_one_transaction(
Wire::Nakednet,
&servers,
None,
&[],
ARBITRARY_HEIGHT,
&TxId::from_bytes([0u8; 32]),
&TransmitContext {
progress: &progress,
history: &history,
retry_interval: zingo_netutils::time::TRANSMIT_RETRY_INTERVAL,
},
)
.await
.expect_err("no indexer must refuse");
assert!(matches!(
refusal,
TransmitError::Draw(crate::destination::servers::NoEligibleDestinations::Empty(
crate::destination::servers::Transport::Nakednet
))
));
}
}
#[cfg(test)]
mod built_transaction_shape {
use zcash_protocol::consensus::{BlockHeight, BranchId};
use zingo_common_components::protocol::ActivationHeights;
use zingo_status::confirmation_status::ConfirmationStatus;
use crate::lightclient::LightClient;
use crate::testutils::synthetic_wallet::{self, SyntheticWalletBuilder};
use crate::wallet::LightWallet;
async fn build_one_send(wallet: LightWallet) -> (u32, u32, BranchId) {
let mut client = LightClient::new_for_test(wallet).await;
let proposal = client
.propose_send_all(
synthetic_wallet::external_orchard_zcash_address(),
None,
zip32::AccountId::ZERO,
)
.await
.unwrap();
let txids = client
.wallet()
.write()
.await
.calculate_transactions(proposal, zip32::AccountId::ZERO)
.await
.unwrap();
assert_eq!(txids.len(), 1);
let wallet = client.wallet();
let wallet = wallet.read().await;
let record = wallet.wallet_transactions.get(&txids[0]).unwrap();
let ConfirmationStatus::Calculated(target) = record.status() else {
panic!("a built, untransmitted transaction is stored as Calculated");
};
let transaction = record.transaction();
(
u32::from(target),
u32::from(transaction.expiry_height()),
transaction.consensus_branch_id(),
)
}
#[tokio::test]
async fn expiry_and_branch_id_derive_from_synced_height() {
let tip = 20;
let wallet = SyntheticWalletBuilder::new(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED)
.orchard_note(100_000)
.tip(tip)
.build();
let chain = wallet.chain_type();
let (target, expiry, branch_id) = build_one_send(wallet).await;
assert_eq!(target, tip + 1);
assert_eq!(
expiry,
target + crate::wallet::expiry::NU7_TX_EXPIRY_DELTA,
"the in-process era is NU7-active, so the NU7 expiry delta applies"
);
assert_eq!(
branch_id,
BranchId::for_height(&chain, BlockHeight::from_u32(tip + 1))
);
}
#[tokio::test]
async fn four_coin_shield_builds_and_nets_input_minus_fee() {
let coin_value = 1_000_000u64;
let wallet = SyntheticWalletBuilder::new(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED)
.transparent_coin(coin_value)
.transparent_coin(coin_value)
.transparent_coin(coin_value)
.transparent_coin(coin_value)
.build();
let mut client = LightClient::new_for_test(wallet).await;
let proposal = client.propose_shield(zip32::AccountId::ZERO).await.unwrap();
let txids = client
.wallet()
.write()
.await
.calculate_transactions(proposal, zip32::AccountId::ZERO)
.await
.unwrap();
assert_eq!(txids.len(), 1);
let wallet = client.wallet();
let wallet = wallet.read().await;
let transaction = wallet
.wallet_transactions
.get(&txids[0])
.unwrap()
.transaction();
let transparent = transaction
.transparent_bundle()
.expect("a shield spends transparent coins");
assert_eq!(transparent.vin.len(), 4, "all four coins consumed");
assert!(
transparent.vout.is_empty(),
"a shield pays no transparent outputs"
);
let ironwood = transaction
.ironwood_bundle()
.expect("a V6 shield produces ironwood change");
assert_eq!(
i64::from(ironwood.value_balance()),
-i64::try_from(4 * coin_value - 30_000).unwrap()
);
}
#[tokio::test]
async fn two_input_sapling_spend_with_sapling_change_builds_offline() {
use zcash_client_backend::zip321::{Payment, TransactionRequest};
use zcash_protocol::value::Zatoshis;
let wallet = SyntheticWalletBuilder::new(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED)
.sapling_note(20_000)
.sapling_note(30_000)
.build();
let mut client = LightClient::new_for_test(wallet).await;
let request = TransactionRequest::new(vec![Payment::without_memo(
synthetic_wallet::external_orchard_zcash_address(),
Zatoshis::const_from_u64(25_000),
)])
.unwrap();
let proposal = client
.propose_send(request, zip32::AccountId::ZERO)
.await
.unwrap();
let step = proposal.steps().first();
let change = step.balance().proposed_change();
assert_eq!(change.len(), 1);
assert_eq!(u64::from(change[0].value()), 5_000);
assert_eq!(change[0].output_pool(), zcash_protocol::PoolType::IRONWOOD,);
let txids = client
.wallet()
.write()
.await
.calculate_transactions(proposal, zip32::AccountId::ZERO)
.await
.unwrap();
assert_eq!(txids.len(), 1);
let wallet = client.wallet();
let wallet = wallet.read().await;
let transaction = wallet
.wallet_transactions
.get(&txids[0])
.unwrap()
.transaction();
let sapling_bundle = transaction
.sapling_bundle()
.expect("spending sapling notes produces a sapling bundle");
assert_eq!(
sapling_bundle.shielded_spends().len(),
2,
"both fabricated sapling notes are spent"
);
assert!(
transaction.orchard_bundle().is_none(),
"no orchard flow, no orchard bundle"
);
let ironwood_bundle = transaction
.ironwood_bundle()
.expect("the payment to the orchard receiver produces an ironwood bundle");
assert_eq!(
ironwood_bundle.actions().len(),
2,
"payment plus dummy padding, the bundle minimum"
);
}
#[tokio::test]
async fn orchard_note_send_keeps_change_in_orchard_pool_post_nu6_3() {
use zcash_client_backend::zip321::{Payment, TransactionRequest};
use zcash_protocol::{PoolType, value::Zatoshis};
let note_value = 200_000u64;
let sent_value = 50_000u64;
let wallet = SyntheticWalletBuilder::new(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED)
.orchard_note(note_value)
.build();
let mut client = LightClient::new_for_test(wallet).await;
let request = TransactionRequest::new(vec![Payment::without_memo(
synthetic_wallet::external_orchard_zcash_address(),
Zatoshis::const_from_u64(sent_value),
)])
.unwrap();
let proposal = client
.propose_send(request, zip32::AccountId::ZERO)
.await
.unwrap();
let step = proposal.steps().first();
let change = step.balance().proposed_change();
assert_eq!(change.len(), 1);
let change_value = u64::from(change[0].value());
assert!(change_value > 0, "the send must leave real change to place");
assert_eq!(
change[0].output_pool(),
PoolType::ORCHARD,
"the proposer must select the Orchard pool for change when spending an Orchard note"
);
let txids = client
.wallet()
.write()
.await
.calculate_transactions(proposal, zip32::AccountId::ZERO)
.await
.unwrap();
assert_eq!(txids.len(), 1);
let wallet = client.wallet();
let wallet = wallet.read().await;
let transaction = wallet
.wallet_transactions
.get(&txids[0])
.unwrap()
.transaction();
let orchard = transaction
.orchard_bundle()
.expect("spending a legacy Orchard note keeps a real Orchard bundle");
assert_eq!(
i64::from(*orchard.value_balance()),
i64::try_from(note_value - change_value).unwrap(),
"Orchard value balance must net out only payment + fee, leaving the change in Orchard"
);
let ironwood = transaction
.ironwood_bundle()
.expect("the payment to an Orchard receiver routes via Ironwood post-NU6.3");
assert_eq!(
i64::from(*ironwood.value_balance()),
-i64::try_from(sent_value).unwrap(),
"Ironwood carries only the payment value, not the change"
);
}
#[tokio::test]
async fn tex_two_step_chains_ephemeral_output_offline() {
use pepper_sync::keys::decode_address;
use zcash_client_backend::address::Address;
use zcash_client_backend::zip321::{Payment, TransactionRequest};
use zcash_protocol::value::Zatoshis;
use zcash_transparent::address::TransparentAddress;
let payment_value = 100_000;
let wallet = SyntheticWalletBuilder::new(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED)
.orchard_note(5_000_000)
.build();
let mut client = LightClient::new_for_test(wallet).await;
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(),
);
let request = TransactionRequest::new(vec![Payment::without_memo(
zcash_address::ZcashAddress::try_from_encoded(&tex_address).unwrap(),
Zatoshis::from_u64(payment_value).unwrap(),
)])
.unwrap();
let proposal = client
.propose_send(request, zip32::AccountId::ZERO)
.await
.unwrap();
let txids = client
.wallet()
.write()
.await
.calculate_transactions(proposal, zip32::AccountId::ZERO)
.await
.unwrap();
assert_eq!(txids.len(), 2, "the ZIP-320 pair builds as two steps");
let wallet = client.wallet();
let wallet = wallet.read().await;
let step_one = wallet.wallet_transactions.get(&txids[0]).unwrap();
let step_two = wallet.wallet_transactions.get(&txids[1]).unwrap();
let step_two_transparent = step_two
.transaction()
.transparent_bundle()
.expect("the transparent leg carries a transparent bundle");
assert_eq!(step_two_transparent.vin.len(), 1);
let prevout = step_two_transparent.vin[0].prevout();
assert_eq!(
*prevout.txid(),
txids[0],
"step two's sole input spends step one"
);
let step_one_bundle = step_one
.transaction()
.transparent_bundle()
.expect("the shield leg pays out an ephemeral transparent output");
let ephemeral_output = step_one_bundle
.vout
.get(prevout.n() as usize)
.expect("the spent index exists among step one's outputs");
assert_eq!(
step_two_transparent.vout.len(),
1,
"the transparent leg pays the TEX destination and nothing else"
);
assert_eq!(
u64::from(ephemeral_output.value()),
payment_value + 10_000,
"step one's ephemeral output funds step two's payment plus its fee exactly"
);
let expected_script: zcash_transparent::address::Script =
TransparentAddress::PublicKeyHash(taddr_bytes)
.script()
.into();
let tex_payment = step_two_transparent
.vout
.iter()
.find(|out| *out.script_pubkey() == expected_script)
.expect("one of step two's outputs pays the TEX-decoded p2pkh");
assert_eq!(u64::from(tex_payment.value()), payment_value);
}
async fn assert_boundary_adjacent_build_uses_post_activation_branch_id(
heights: ActivationHeights,
boundary: u32,
expected_delta: u32,
wallet: SyntheticWalletBuilder,
) {
let wallet = wallet.tip(boundary - 1).activation_heights(heights).build();
let chain = wallet.chain_type();
let pre_activation = BranchId::for_height(&chain, BlockHeight::from_u32(boundary - 1));
let post_activation = BranchId::for_height(&chain, BlockHeight::from_u32(boundary));
assert_ne!(
pre_activation, post_activation,
"the cell must sit on a real branch boundary"
);
let (target, expiry, branch_id) = build_one_send(wallet).await;
assert_eq!(target, boundary);
assert_eq!(branch_id, post_activation);
assert_eq!(expiry, target + expected_delta);
}
#[tokio::test]
async fn boundary_adjacent_build_uses_post_activation_branch_id() {
let boundary = 10;
let heights = crate::testutils::mock_activation_heights_with(|era| {
era.set_nu6_2(Some(boundary)).set_nu6_3(None).set_nu7(None)
});
assert_boundary_adjacent_build_uses_post_activation_branch_id(
heights,
boundary,
zcash_primitives::transaction::builder::DEFAULT_TX_EXPIRY_DELTA,
SyntheticWalletBuilder::new(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED)
.orchard_note(100_000),
)
.await;
}
#[tokio::test]
async fn nu7_boundary_adjacent_build_uses_post_activation_branch_id() {
let boundary = 10;
let heights =
crate::testutils::mock_activation_heights_with(|era| era.set_nu7(Some(boundary)));
assert_boundary_adjacent_build_uses_post_activation_branch_id(
heights,
boundary,
crate::wallet::expiry::NU7_TX_EXPIRY_DELTA,
SyntheticWalletBuilder::new(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED)
.ironwood_note(100_000),
)
.await;
}
}
#[cfg(test)]
mod test {
use zingo_test_vectors::seeds;
use crate::{
config::{ClientConfig, WalletConfig},
lightclient::{LightClient, error::LightClientError, sync::test::sync_example_wallet},
mocks::proposal::ProposalBuilder,
testutils::{
chain_generics::{
conduct_chain::ConductChain as _, networked::NetworkedTestEnvironment,
with_assertions,
},
default_test_wallet_settings,
},
wallet::disk::testing::examples,
};
async fn create_basic_client() -> LightClient {
let config = ClientConfig::builder()
.set_wallet_config(WalletConfig::MnemonicPhrase {
mnemonic_phrase: seeds::HOSPITAL_MUSEUM_SEED.to_string(),
no_of_accounts: 1.try_into().unwrap(),
birthday: 419200,
wallet_settings: default_test_wallet_settings(),
})
.build()
.unwrap();
LightClient::new(config, true).await.unwrap()
}
#[tokio::test]
async fn complete_and_transmit_unconnected_error() {
let mut lc = create_basic_client().await;
let proposal = ProposalBuilder::default().build();
let err = lc.send(proposal, zip32::AccountId::ZERO).await.unwrap_err();
assert!(matches!(err, LightClientError::Offline));
}
mod testnet {
use zcash_protocol::{PoolType, ShieldedPool};
use crate::testutils::lightclient::get_base_address;
use super::*;
#[ignore = "only one test can be run per testnet wallet at a time"]
#[tokio::test]
async fn testnet_send_to_self_orchard_glory_goddess() {
let case =
examples::NetworkSeedVersion::Testnet(examples::TestnetSeedVersion::GloryGoddess);
let mut client = sync_example_wallet(case).await;
let client_addr =
get_base_address(&client, PoolType::Shielded(ShieldedPool::Orchard)).await;
with_assertions::assure_propose_send_bump_sync_all_recipients(
&mut NetworkedTestEnvironment::setup().await,
&mut client,
vec![(&client_addr, 20_000, None)],
vec![],
true,
)
.await
.unwrap();
}
#[ignore = "only one test can be run per testnet wallet at a time"]
#[tokio::test]
async fn testnet_send_to_self_sapling_glory_goddess() {
let case =
examples::NetworkSeedVersion::Testnet(examples::TestnetSeedVersion::GloryGoddess);
let mut client = sync_example_wallet(case).await;
let client_addr =
get_base_address(&client, PoolType::Shielded(ShieldedPool::Sapling)).await;
with_assertions::assure_propose_send_bump_sync_all_recipients(
&mut NetworkedTestEnvironment::setup().await,
&mut client,
vec![(&client_addr, 20_000, None)],
vec![],
true,
)
.await
.unwrap();
}
#[ignore = "only one test can be run per testnet wallet at a time"]
#[tokio::test]
async fn testnet_send_to_self_transparent_and_then_shield_glory_goddess() {
let case =
examples::NetworkSeedVersion::Testnet(examples::TestnetSeedVersion::GloryGoddess);
let mut client = sync_example_wallet(case).await;
let client_addr = get_base_address(&client, PoolType::Transparent).await;
let environment = &mut NetworkedTestEnvironment::setup().await;
with_assertions::assure_propose_send_bump_sync_all_recipients(
environment,
&mut client,
vec![(&client_addr, 100_001, None)],
vec![],
true,
)
.await
.unwrap();
let _ =
with_assertions::assure_propose_shield_bump_sync(environment, &mut client, true)
.await
.unwrap();
}
#[ignore = "this needs to pass CI, but we arent there with testnet"]
#[tokio::test]
async fn testnet_send_to_self_all_pools_glory_goddess() {
let case =
examples::NetworkSeedVersion::Testnet(examples::TestnetSeedVersion::GloryGoddess);
let mut client = sync_example_wallet(case).await;
let environment = &mut NetworkedTestEnvironment::setup().await;
let client_addr =
get_base_address(&client, PoolType::Shielded(ShieldedPool::Orchard)).await;
with_assertions::assure_propose_send_bump_sync_all_recipients(
&mut NetworkedTestEnvironment::setup().await,
&mut client,
vec![(&client_addr, 14_000, None)],
vec![],
true,
)
.await
.unwrap();
let client_addr =
get_base_address(&client, PoolType::Shielded(ShieldedPool::Sapling)).await;
with_assertions::assure_propose_send_bump_sync_all_recipients(
&mut NetworkedTestEnvironment::setup().await,
&mut client,
vec![(&client_addr, 15_000, None)],
vec![],
true,
)
.await
.unwrap();
let client_addr = get_base_address(&client, PoolType::Transparent).await;
with_assertions::assure_propose_send_bump_sync_all_recipients(
environment,
&mut client,
vec![(&client_addr, 100_000, None)],
vec![],
true,
)
.await
.unwrap();
let _ =
with_assertions::assure_propose_shield_bump_sync(environment, &mut client, true)
.await
.unwrap();
}
}
}
#[cfg(test)]
mod transparent_policy {
use crate::{
lightclient::LightClient,
lightclient::error::{LightClientError, SendError},
testutils::lightclient::from_inputs,
testutils::synthetic_wallet::SyntheticWalletBuilder,
wallet::error::ProposeSendError,
};
#[tokio::test]
async fn t_incoming_t_outgoing_disallowed() {
let value = 100_000;
let wallet = SyntheticWalletBuilder::new(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED)
.transparent_coin(value)
.build();
let mut client = LightClient::new_for_test(wallet).await;
let transaction = client
.wallet()
.read()
.await
.transaction_summaries(false)
.await
.unwrap()
.0
.first()
.unwrap()
.clone();
assert_eq!(transaction.blockheight, 2.into());
assert_eq!(transaction.value, value);
let sent_value = 20_000;
let sent_transaction_error = from_inputs::quick_send(
&mut client,
vec![(zingo_test_vectors::EXT_TADDR, sent_value, None)],
)
.await
.unwrap_err();
assert!(matches!(
sent_transaction_error,
LightClientError::SendError(SendError::ProposeSendError(ProposeSendError::Proposal(
zcash_client_backend::data_api::error::Error::InsufficientFunds {
available: _,
required: _
}
)))
));
}
}
#[cfg(test)]
mod op_return {
use pepper_sync::keys::transparent::TransparentScope;
use zcash_primitives::transaction::TxId;
use zcash_protocol::value::Zatoshis;
use crate::data::proposal::{OpReturnProposal, ZingoProposal};
use crate::lightclient::LightClient;
use crate::lightclient::error::{LightClientError, SendError};
use crate::testutils::synthetic_wallet::SyntheticWalletBuilder;
use crate::wallet::error::WalletError;
use crate::wallet::transparent::OpReturnData;
const ACCOUNT: zip32::AccountId = zip32::AccountId::ZERO;
async fn client() -> LightClient {
let wallet = SyntheticWalletBuilder::new(zingo_test_vectors::seeds::HOSPITAL_MUSEUM_SEED)
.orchard_note(1_000_000)
.build();
LightClient::new_for_test(wallet).await
}
async fn proposal(client: &mut LightClient) -> OpReturnProposal {
client
.create_op_return_proposal(
zingo_test_vectors::EXT_TADDR,
Zatoshis::const_from_u64(100_000),
OpReturnData::new(b"payload".to_vec()).unwrap(),
ACCOUNT,
)
.await
.unwrap()
}
async fn reserved_refund_addresses(client: &LightClient) -> usize {
client
.wallet()
.read()
.await
.transparent_addresses()
.keys()
.filter(|id| id.scope() == TransparentScope::Refund)
.count()
}
#[tokio::test]
async fn stale_source_address_is_refused() {
let mut client = client().await;
let proposal = proposal(&mut client).await;
client
.wallet()
.write()
.await
.generate_refund_addresses(1, ACCOUNT)
.unwrap();
let result = client.send_op_return(proposal).await;
assert!(matches!(
result,
Err(LightClientError::SendError(
SendError::OpReturnSourceAddressStale
))
));
assert_eq!(reserved_refund_addresses(&client).await, 1);
}
#[tokio::test]
async fn deshield_failure_before_transmission_releases_the_reservation() {
let mut client = client().await;
let proposal = proposal(&mut client).await;
let result = client.send_op_return(proposal).await;
assert!(matches!(result, Err(LightClientError::Offline)));
assert_eq!(reserved_refund_addresses(&client).await, 0);
}
#[tokio::test]
async fn resume_skips_the_deshield_and_reports_the_txid_on_failure() {
let mut client = client().await;
let deshield_txid = TxId::from_bytes([9u8; 32]);
let proposal = proposal(&mut client)
.await
.with_deshield_txid(deshield_txid);
let result = client.send_op_return(proposal).await;
match result {
Err(LightClientError::SendError(SendError::OpReturnAfterDeshield {
deshield_txid: reported,
op_return_txid,
source,
})) => {
assert_eq!(reported, deshield_txid);
assert_eq!(op_return_txid, None);
assert!(matches!(
*source,
LightClientError::SendError(SendError::OpReturn(
WalletError::TransactionNotFound(_)
))
));
}
other => panic!("expected OpReturnAfterDeshield, got {other:?}"),
}
assert_eq!(reserved_refund_addresses(&client).await, 0);
match client.wallet().write().await.take_proposal() {
Some(ZingoProposal::OpReturn(stored)) => {
assert_eq!(stored.deshield_txid(), Some(deshield_txid));
}
other => panic!("expected the proposal stored again, got {other:?}"),
}
}
#[tokio::test]
async fn resume_ignores_the_stale_check() {
let mut client = client().await;
let proposal = proposal(&mut client)
.await
.with_deshield_txid(TxId::from_bytes([9u8; 32]));
client
.wallet()
.write()
.await
.generate_refund_addresses(1, ACCOUNT)
.unwrap();
let result = client.send_op_return(proposal).await;
assert!(matches!(
result,
Err(LightClientError::SendError(
SendError::OpReturnAfterDeshield { .. }
))
));
assert_eq!(reserved_refund_addresses(&client).await, 1);
}
}