use std::future::Future;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use zcash_primitives::transaction::TxId;
pub(crate) const MAX_RETRIES: u8 = 3;
pub(crate) const MAX_QUEUED_PROBES: u8 = 30;
use zingo_netutils::time::TRANSMIT_RETRY_INTERVAL;
#[derive(Clone, Debug, Default)]
pub struct TransmitProgressHandle(Arc<Mutex<Option<String>>>);
impl TransmitProgressHandle {
pub fn latest(&self) -> Option<String> {
self.0
.lock()
.expect("transmit progress mutex poisoned")
.clone()
}
pub(crate) fn set(&self, line: String) {
*self.0.lock().expect("transmit progress mutex poisoned") = Some(line);
}
pub(crate) fn clear(&self) {
*self.0.lock().expect("transmit progress mutex poisoned") = None;
}
}
pub(crate) struct TransmitProgressScope(pub(crate) TransmitProgressHandle);
impl Drop for TransmitProgressScope {
fn drop(&mut self) {
self.0.clear();
}
}
pub(crate) trait SubmitFailure: std::fmt::Display {
fn rejection_text(&self) -> Option<&str>;
}
impl SubmitFailure for zingo_netutils::Status {
fn rejection_text(&self) -> Option<&str> {
Some(self.message())
}
}
#[cfg(feature = "nym")]
impl SubmitFailure for zingo_netutils::Socks5TransmitError {
fn rejection_text(&self) -> Option<&str> {
match self {
zingo_netutils::Socks5TransmitError::Rejected(rejection) => Some(&rejection.message),
zingo_netutils::Socks5TransmitError::Rpc { status, .. } => Some(status.message()),
_ => None,
}
}
}
pub(crate) trait TransmitTarget {
type Failure: SubmitFailure + Send;
fn submit(
&self,
raw_tx: &[u8],
height: u64,
) -> impl Future<Output = Result<String, Self::Failure>> + Send;
fn knows_transaction(&self, txid: &TxId) -> impl Future<Output = bool> + Send;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TransmitFailed<F>(pub F);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum RejectionClass {
StorageBackedDuplicate,
QueuedProbe,
Transient,
}
pub(crate) fn classify_rejection(message: &str) -> RejectionClass {
if message.contains("transaction already exists in mempool")
|| message.contains("transaction already in block chain")
{
return RejectionClass::StorageBackedDuplicate;
}
if message.contains("already queued for download") {
return RejectionClass::QueuedProbe;
}
RejectionClass::Transient
}
pub(crate) async fn resilient_transmit<T, S, F, P>(
target: &T,
raw_tx: &[u8],
height: u64,
txid: &TxId,
mut sleep: S,
report: P,
) -> Result<String, TransmitFailed<T::Failure>>
where
T: TransmitTarget + Sync,
S: FnMut(Duration) -> F,
F: Future<Output = ()>,
P: Fn(String),
{
let mut retry_count: u8 = 0;
let mut queued_probes: u8 = 0;
report("submitting".to_string());
let exhausted = loop {
let failure = match target.submit(raw_tx, height).await {
Ok(server_txid) => return Ok(server_txid),
Err(failure) => failure,
};
match classify_rejection(failure.rejection_text().unwrap_or_default()) {
RejectionClass::StorageBackedDuplicate => return Ok(txid.to_string()),
RejectionClass::QueuedProbe => {
if queued_probes >= MAX_QUEUED_PROBES {
break failure;
}
queued_probes += 1;
report(format!(
"delivered, awaiting the server's verdict (probe {queued_probes}/{MAX_QUEUED_PROBES})"
));
sleep(TRANSMIT_RETRY_INTERVAL).await;
}
RejectionClass::Transient => {
if retry_count >= MAX_RETRIES {
break failure;
}
retry_count += 1;
report(format!(
"retrying after a transient error (retry {retry_count}/{MAX_RETRIES})"
));
sleep(TRANSMIT_RETRY_INTERVAL).await;
}
}
};
report("checking whether an earlier attempt was delivered".to_string());
if target.knows_transaction(txid).await {
return Ok(txid.to_string());
}
Err(TransmitFailed(exhausted))
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
#[derive(Clone, Debug, PartialEq, Eq)]
struct TestFailure(String);
impl std::fmt::Display for TestFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl SubmitFailure for TestFailure {
fn rejection_text(&self) -> Option<&str> {
Some(&self.0)
}
}
struct ScriptedTarget {
submits: Vec<Result<String, String>>,
next: AtomicUsize,
knows: bool,
submit_calls: AtomicUsize,
knows_calls: AtomicUsize,
}
impl ScriptedTarget {
fn new(submits: Vec<Result<String, String>>, knows: bool) -> Self {
ScriptedTarget {
submits,
next: AtomicUsize::new(0),
knows,
submit_calls: AtomicUsize::new(0),
knows_calls: AtomicUsize::new(0),
}
}
fn submit_calls(&self) -> usize {
self.submit_calls.load(Ordering::Relaxed)
}
fn knows_calls(&self) -> usize {
self.knows_calls.load(Ordering::Relaxed)
}
}
impl TransmitTarget for ScriptedTarget {
type Failure = TestFailure;
fn submit(
&self,
_raw_tx: &[u8],
_height: u64,
) -> impl Future<Output = Result<String, TestFailure>> + Send {
let i = self.next.fetch_add(1, Ordering::Relaxed);
self.submit_calls.fetch_add(1, Ordering::Relaxed);
let result = self
.submits
.get(i)
.cloned()
.unwrap_or_else(|| Err("script exhausted".to_string()))
.map_err(TestFailure);
async move { result }
}
fn knows_transaction(&self, _txid: &TxId) -> impl Future<Output = bool> + Send {
self.knows_calls.fetch_add(1, Ordering::Relaxed);
let knows = self.knows;
async move { knows }
}
}
fn a_txid() -> TxId {
TxId::from_bytes([7u8; 32])
}
fn no_sleep(_: Duration) -> std::future::Ready<()> {
std::future::ready(())
}
#[tokio::test]
async fn accepts_on_first_submit() {
let target = ScriptedTarget::new(vec![Ok("server-txid".into())], false);
let out = resilient_transmit(&target, b"tx", 1, &a_txid(), no_sleep, |_| ())
.await
.expect("accepted");
assert_eq!(out, "server-txid");
assert_eq!(target.submit_calls(), 1);
assert_eq!(target.knows_calls(), 0);
}
#[tokio::test]
async fn duplicate_in_mempool_is_success() {
let target = ScriptedTarget::new(
vec![Err("error: transaction already exists in mempool".into())],
false,
);
let out = resilient_transmit(&target, b"tx", 1, &a_txid(), no_sleep, |_| ())
.await
.expect("duplicate counts as delivered");
assert_eq!(out, a_txid().to_string());
assert_eq!(target.submit_calls(), 1, "no retry on a duplicate");
}
#[tokio::test]
async fn already_in_chain_is_success() {
let target = ScriptedTarget::new(
vec![Err("transaction already in block chain".into())],
false,
);
let out = resilient_transmit(&target, b"tx", 1, &a_txid(), no_sleep, |_| ())
.await
.expect("mined duplicate counts as delivered");
assert_eq!(out, a_txid().to_string());
}
#[tokio::test]
async fn queued_probe_settles_into_acceptance() {
let target = ScriptedTarget::new(
vec![
Err("already queued for download".into()),
Err("already queued for download".into()),
Ok("server-txid".into()),
],
false,
);
let out = resilient_transmit(&target, b"tx", 1, &a_txid(), no_sleep, |_| ())
.await
.expect("queued then accepted");
assert_eq!(out, "server-txid");
assert_eq!(target.submit_calls(), 3, "two probes then acceptance");
}
#[tokio::test]
async fn queued_probe_exhaustion_runs_the_delivery_check() {
let submits = std::iter::repeat_n(
Err::<String, String>("already queued for download".into()),
(MAX_QUEUED_PROBES as usize) + 1,
)
.collect();
let target = ScriptedTarget::new(submits, false);
let err = resilient_transmit(&target, b"tx", 1, &a_txid(), no_sleep, |_| ())
.await
.expect_err("probes exhausted and the server does not know the txid");
assert!(err.0.0.contains("already queued for download"));
assert_eq!(target.submit_calls(), (MAX_QUEUED_PROBES as usize) + 1);
assert_eq!(
target.knows_calls(),
1,
"queued exhaustion ends with the delivery check"
);
}
#[tokio::test]
async fn queued_exhaustion_with_a_known_transaction_is_success() {
let submits = std::iter::repeat_n(
Err::<String, String>("already queued for download".into()),
(MAX_QUEUED_PROBES as usize) + 1,
)
.collect();
let target = ScriptedTarget::new(submits, true);
let out = resilient_transmit(&target, b"tx", 1, &a_txid(), no_sleep, |_| ())
.await
.expect("the server knows the transaction, so delivery stands");
assert_eq!(out, a_txid().to_string());
assert_eq!(target.knows_calls(), 1);
}
#[tokio::test]
async fn transient_errors_retry_then_succeed() {
let target = ScriptedTarget::new(
vec![
Err("connection reset".into()),
Err("connection reset".into()),
Ok("server-txid".into()),
],
false,
);
let out = resilient_transmit(&target, b"tx", 1, &a_txid(), no_sleep, |_| ())
.await
.expect("retried then accepted");
assert_eq!(out, "server-txid");
assert_eq!(target.submit_calls(), 3);
}
#[tokio::test]
async fn retry_exhaustion_confirmed_by_delivery_check() {
let submits = std::iter::repeat_n(
Err::<String, String>("timeout".into()),
(MAX_RETRIES as usize) + 1,
)
.collect();
let target = ScriptedTarget::new(submits, true);
let out = resilient_transmit(&target, b"tx", 1, &a_txid(), no_sleep, |_| ())
.await
.expect("delivery check rescues a lost response");
assert_eq!(out, a_txid().to_string());
assert_eq!(target.knows_calls(), 1);
}
#[tokio::test]
async fn retry_exhaustion_denied_by_delivery_check_fails() {
let submits = std::iter::repeat_n(
Err::<String, String>("timeout".into()),
(MAX_RETRIES as usize) + 1,
)
.collect();
let target = ScriptedTarget::new(submits, false);
let err = resilient_transmit(&target, b"tx", 1, &a_txid(), no_sleep, |_| ())
.await
.expect_err("server does not know it, so it failed");
assert_eq!(err.0, TestFailure("timeout".to_string()));
assert_eq!(target.knows_calls(), 1);
}
#[test]
fn rejection_messages_classify_by_the_pinned_table() {
let table = [
(
"error: transaction already exists in mempool",
RejectionClass::StorageBackedDuplicate,
),
(
"transaction already in block chain",
RejectionClass::StorageBackedDuplicate,
),
(
"tx already queued for download",
RejectionClass::QueuedProbe,
),
("connection reset", RejectionClass::Transient),
("timeout", RejectionClass::Transient),
("", RejectionClass::Transient),
];
for (message, expected) in table {
assert_eq!(
classify_rejection(message),
expected,
"message: {message:?}"
);
}
}
#[test]
fn duplicate_outranks_queued_when_both_substrings_appear() {
assert_eq!(
classify_rejection(
"transaction already exists in mempool and already queued for download"
),
RejectionClass::StorageBackedDuplicate
);
}
#[tokio::test]
async fn narrates_each_state_change_in_order() {
let target = ScriptedTarget::new(
vec![
Err("connection refused".into()),
Err("already queued for download".into()),
Ok("server-txid".into()),
],
false,
);
let lines = std::sync::Mutex::new(Vec::new());
let out = resilient_transmit(&target, b"tx", 1, &a_txid(), no_sleep, |line| {
lines.lock().expect("narration mutex poisoned").push(line);
})
.await
.expect("accepted on the third submit");
assert_eq!(out, "server-txid");
assert_eq!(
*lines.lock().expect("narration mutex poisoned"),
vec![
"submitting".to_string(),
format!("retrying after a transient error (retry 1/{MAX_RETRIES})"),
format!("delivered, awaiting the server's verdict (probe 1/{MAX_QUEUED_PROBES})"),
]
);
}
#[test]
fn progress_handle_sets_and_scope_clears() {
let handle = TransmitProgressHandle::default();
assert_eq!(handle.latest(), None);
handle.set("submitting".to_string());
assert_eq!(handle.latest(), Some("submitting".to_string()));
{
let _scope = TransmitProgressScope(handle.clone());
}
assert_eq!(handle.latest(), None, "the scope's drop clears the line");
}
}