pub(crate) mod json_patch;
pub mod time;
use std::time::Duration;
use log::trace;
use ark::VtxoId;
use server_rpc::StatusExt;
const RECONNECT_BACKOFF_BASE: Duration = Duration::from_secs(1);
const RECONNECT_BACKOFF_MAX: Duration = Duration::from_secs(30);
pub struct ReconnectBackoff {
attempts: u32,
}
impl ReconnectBackoff {
pub fn new() -> ReconnectBackoff {
ReconnectBackoff { attempts: 0 }
}
pub fn reset(&mut self) {
self.attempts = 0;
}
fn next_delay(&mut self) -> Duration {
let factor = 1u32.checked_shl(self.attempts).unwrap_or(u32::MAX);
let delay = RECONNECT_BACKOFF_BASE
.checked_mul(factor)
.unwrap_or(RECONNECT_BACKOFF_MAX)
.min(RECONNECT_BACKOFF_MAX);
self.attempts = self.attempts.saturating_add(1);
let half = delay / 2;
half + half.mul_f64(rand::random::<f64>())
}
pub async fn wait(&mut self) {
let delay = self.next_delay();
trace!("Reconnecting subscription stream in {:?}", delay);
crate::utils::time::sleep(delay).await;
}
}
pub(crate) fn is_h2_stream_error(e: &anyhow::Error) -> bool {
e.chain().any(|cause| {
cause.downcast_ref::<tonic::Status>().is_some_and(|s| {
s.code() == tonic::Code::Internal
&& s.message().starts_with("h2 protocol error")
})
})
}
pub(crate) fn url_with_default_https_scheme(url: &str) -> String {
let trimmed = url.trim();
if trimmed.contains("://") {
trimmed.to_owned()
} else {
format!("https://{}", trimmed)
}
}
#[cfg(not(feature = "wasm-web"))]
pub fn spawn<F>(fut: F) -> tokio::task::JoinHandle<F::Output>
where
F: Future<Output = ()> + Send + 'static,
{
tokio::spawn(fut)
}
#[cfg(feature = "wasm-web")]
pub fn spawn<F>(fut: F)
where
F: Future<Output = ()> + 'static,
{
wasm_bindgen_futures::spawn_local(fut)
}
pub(crate) fn rejected_vtxos_from_error(err: &anyhow::Error) -> Vec<VtxoId> {
for cause in err.chain() {
if let Some(status) = cause.downcast_ref::<tonic::Status>() {
return status.rejected_vtxos();
}
}
Vec::new()
}
#[cfg(test)]
mod test {
use super::*;
use std::str::FromStr;
const VTXO_A: &str = "0000000000000000000000000000000000000000000000000000000000000001:0";
const VTXO_B: &str = "0000000000000000000000000000000000000000000000000000000000000002:1";
#[test]
fn url_defaults_to_https() {
assert_eq!(url_with_default_https_scheme("esplora.signet.2nd.dev"), "https://esplora.signet.2nd.dev");
assert_eq!(url_with_default_https_scheme("ark.signet.2nd.dev"), "https://ark.signet.2nd.dev");
assert_eq!(url_with_default_https_scheme(" esplora.signet.2nd.dev "), "https://esplora.signet.2nd.dev");
}
#[test]
fn url_keeps_explicit_scheme() {
assert_eq!(url_with_default_https_scheme("https://esplora.signet.2nd.dev"), "https://esplora.signet.2nd.dev");
assert_eq!(url_with_default_https_scheme("http://127.0.0.1:3000"), "http://127.0.0.1:3000");
}
#[test]
fn rejected_vtxos_from_tonic_status_metadata() {
let a = VtxoId::from_str(VTXO_A).unwrap();
let b = VtxoId::from_str(VTXO_B).unwrap();
let mut status = tonic::Status::invalid_argument("input vtxo(s) not spendable");
status.metadata_mut().insert(
"identifiers", format!("{},{}", a, b).parse().unwrap(),
);
let err = anyhow::Error::new(status).context("Ark server refused our payment submission");
assert_eq!(rejected_vtxos_from_error(&err), vec![a, b]);
}
#[test]
fn rejected_vtxos_from_not_found_status() {
let a = VtxoId::from_str(VTXO_A).unwrap();
let mut status = tonic::Status::not_found("input vtxo does not exist");
status.metadata_mut().insert("identifiers", a.to_string().parse().unwrap());
let err = anyhow::Error::new(status).context("Ark server refused our payment submission");
assert_eq!(rejected_vtxos_from_error(&err), vec![a]);
}
#[test]
fn reconnect_backoff_bounds_and_reset() {
let mut b = ReconnectBackoff::new();
for _ in 0..50 {
let mut fresh = ReconnectBackoff::new();
let d = fresh.next_delay();
assert!(d >= RECONNECT_BACKOFF_BASE / 2, "delay {d:?} below floor");
assert!(d <= RECONNECT_BACKOFF_BASE, "delay {d:?} above base");
}
for _ in 0..40 {
let d = b.next_delay();
assert!(d <= RECONNECT_BACKOFF_MAX, "delay {d:?} above cap");
}
let capped = b.next_delay();
assert!(capped >= RECONNECT_BACKOFF_MAX / 2, "delay {capped:?} not near cap");
b.reset();
let after_reset = b.next_delay();
assert!(after_reset <= RECONNECT_BACKOFF_BASE, "reset didn't shrink delay");
}
#[test]
fn rejected_vtxos_empty_for_unrelated_error() {
assert!(rejected_vtxos_from_error(&anyhow::anyhow!("boom")).is_empty());
let mut status = tonic::Status::internal("h2 protocol error");
status.metadata_mut().insert(
"identifiers", VtxoId::from_str(VTXO_A).unwrap().to_string().parse().unwrap(),
);
let err = anyhow::Error::new(status);
assert!(rejected_vtxos_from_error(&err).is_empty());
}
}