Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,24 @@ jobs:
- name: Install JS dependencies
run: pnpm install --frozen-lockfile

# Vite inlines VITE_* at build time — an empty value silently bakes a
# broken config into the signed APK with a fully green pipeline. Fail
# loudly here instead. These are repo Variables (Settings → Secrets and
# variables → Actions → Variables), not Secrets — `vars.*` only sees
# the Variables tab.
- name: Verify release build env is populated
env:
VITE_WALLETCONNECT_PROJECT_ID: ${{ vars.VITE_WALLETCONNECT_PROJECT_ID }}
VITE_DEFAULT_ASP_URL: ${{ vars.VITE_DEFAULT_ASP_URL }}
run: |
missing=()
[ -n "$VITE_WALLETCONNECT_PROJECT_ID" ] || missing+=(VITE_WALLETCONNECT_PROJECT_ID)
[ -n "$VITE_DEFAULT_ASP_URL" ] || missing+=(VITE_DEFAULT_ASP_URL)
if [ "${#missing[@]}" -gt 0 ]; then
echo "::error::Release build env vars are empty: ${missing[*]}. Add them in repo Settings → Secrets and variables → Actions → Variables tab (not Secrets — vars.* can't read Secrets)."
exit 1
fi

- name: Write release keystore
env:
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
Expand Down
34 changes: 24 additions & 10 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 9 additions & 4 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ thiserror = { version = "2", default-features = false }
tokio = { version = "1", default-features = false, features = ["sync", "fs", "time"] }

# Ark protocol
ark-client = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.8.0", default-features = false, features = ["tls-webpki-roots", "sqlite"] }
ark-core = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.8.0" }
ark-bdk-wallet = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.8.0" }
ark-grpc = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.8.0" }
ark-client = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.0", default-features = false, features = ["tls-webpki-roots", "sqlite"] }
ark-core = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.0" }
ark-bdk-wallet = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.0" }
ark-grpc = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.0" }

# Bitcoin / BIP39
bip39 = { version = "2", features = ["rand", "zeroize"] }
Expand Down Expand Up @@ -71,6 +71,11 @@ keyring = { version = "3", features = ["sync-secret-service", "apple-native", "w
jni = "0.21"
ndk-context = "0.1"

[dev-dependencies]
# Async test harness for the esplora retry layer. The runtime/macros features
# aren't needed at runtime — Tauri supplies the tokio runtime in production.
tokio = { version = "1", features = ["macros", "rt", "time"] }

[features]
vendored-openssl = ["dep:openssl"]

Expand Down
195 changes: 176 additions & 19 deletions src-tauri/src/ark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,90 @@ use esplora_client::OutputStatus;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::RwLock;
use std::time::Duration;
use zeroize::Zeroize;

/// Total attempts (first try included) for a transient esplora request.
const ESPLORA_MAX_ATTEMPTS: usize = 3;
/// Backoff before the first retry; doubles for each subsequent retry.
const ESPLORA_RETRY_BASE_DELAY: Duration = Duration::from_millis(300);
/// Socket timeout for esplora HTTP requests. Without it a stalled connection
/// hangs an entire sync cycle indefinitely.
const ESPLORA_TIMEOUT_SECS: u64 = 30;

pub struct EsploraBlockchain {
client: esplora_client::AsyncClient,
}

impl EsploraBlockchain {
pub fn new(url: &str) -> Result<Self, esplora_client::Error> {
let client = esplora_client::Builder::new(url).build_async_with_sleeper()?;
let client = esplora_client::Builder::new(url)
.timeout(ESPLORA_TIMEOUT_SECS)
.build_async_with_sleeper()?;
Ok(Self { client })
}
}

/// Whether an esplora error is a transport-level failure worth retrying.
///
/// `Reqwest` covers connection resets, incomplete responses, mid-handshake TLS
/// aborts and sporadic DNS failures — rampant on hostile mobile networks and
/// almost always gone on the next attempt. `HttpResponse` is deliberately
/// excluded: esplora-client already retries 429/500/503 internally, and other
/// status codes are not transient.
fn is_transient_esplora_error(e: &esplora_client::Error) -> bool {
matches!(e, esplora_client::Error::Reqwest(_))
}

/// Retry an idempotent async operation while `is_transient` keeps returning
/// true, up to `max_attempts` total, with exponential backoff from `base_delay`.
async fn retry_transient<T, E, F, Fut>(
max_attempts: usize,
base_delay: Duration,
is_transient: impl Fn(&E) -> bool,
mut op: F,
) -> Result<T, E>
where
E: std::fmt::Display,
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, E>>,
{
let mut attempt = 1usize;
loop {
match op().await {
Ok(value) => return Ok(value),
Err(e) if attempt < max_attempts && is_transient(&e) => {
let delay = base_delay * 2u32.pow((attempt - 1) as u32);
tracing::debug!(attempt, %e, "transient esplora error; retrying after {delay:?}");
tokio::time::sleep(delay).await;
attempt += 1;
}
Err(e) => return Err(e),
}
}
}

/// [`retry_transient`] specialized for esplora reads. Never use this for
/// `broadcast`: a lost response on an already-accepted submission would re-POST
/// the transaction and surface a spurious "already known" error.
async fn esplora_retry<T, F, Fut>(op: F) -> Result<T, esplora_client::Error>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, esplora_client::Error>>,
{
retry_transient(
ESPLORA_MAX_ATTEMPTS,
ESPLORA_RETRY_BASE_DELAY,
is_transient_esplora_error,
op,
)
.await
}

impl Blockchain for EsploraBlockchain {
async fn find_outpoints(&self, address: &Address) -> Result<Vec<ExplorerUtxo>, Error> {
let script_pubkey = address.script_pubkey();
let txs = self
.client
.scripthash_txs(&script_pubkey, None)
let txs = esplora_retry(|| self.client.scripthash_txs(&script_pubkey, None))
.await
.map_err(Error::consumer)?;

Expand All @@ -44,18 +109,27 @@ impl Blockchain for EsploraBlockchain {
},
amount: Amount::from_sat(v.value),
confirmation_blocktime: block_time,
// v0.9.0 added this field for block-based unilateral
// exit delay support. We don't currently surface
// confirmation count from esplora here — `0` is safe
// for time-based exit delays (the only kind avark
// currently exits against). If we ever need to
// support block-based exit-delay ASPs, fetch the
// chain tip and compute `tip_height - block_height + 1`.
confirmations: 0,
is_spent: false,
})
})
.collect();

let mut utxos = Vec::with_capacity(candidates.len());
for output in candidates {
let status = self
.client
.get_output_status(&output.outpoint.txid, output.outpoint.vout as u64)
.await
.map_err(Error::consumer)?;
let status = esplora_retry(|| {
self.client
.get_output_status(&output.outpoint.txid, output.outpoint.vout as u64)
})
.await
.map_err(Error::consumer)?;

utxos.push(match status {
Some(OutputStatus { spent: true, .. }) => ExplorerUtxo {
Expand All @@ -70,13 +144,13 @@ impl Blockchain for EsploraBlockchain {
}

async fn find_tx(&self, txid: &Txid) -> Result<Option<Transaction>, Error> {
self.client.get_tx(txid).await.map_err(Error::consumer)
esplora_retry(|| self.client.get_tx(txid))
.await
.map_err(Error::consumer)
}

async fn get_tx_status(&self, txid: &Txid) -> Result<TxStatus, Error> {
let info = self
.client
.get_tx_info(txid)
let info = esplora_retry(|| self.client.get_tx_info(txid))
.await
.map_err(Error::consumer)?;

Expand All @@ -86,9 +160,7 @@ impl Blockchain for EsploraBlockchain {
}

async fn get_output_status(&self, txid: &Txid, vout: u32) -> Result<SpendStatus, Error> {
let status = self
.client
.get_output_status(txid, vout as u64)
let status = esplora_retry(|| self.client.get_output_status(txid, vout as u64))
.await
.map_err(Error::consumer)?;

Expand All @@ -102,9 +174,7 @@ impl Blockchain for EsploraBlockchain {
}

async fn get_fee_rate(&self) -> Result<f64, Error> {
let estimates = self
.client
.get_fee_estimates()
let estimates = esplora_retry(|| self.client.get_fee_estimates())
.await
.map_err(Error::consumer)?;
// Target ~6 blocks confirmation, fall back to 1.0 sat/vB
Expand Down Expand Up @@ -648,4 +718,91 @@ mod tests {
fn esplora_url_empty_custom_uses_default() {
assert!(esplora_url(bitcoin::Network::Bitcoin, Some("")).contains("blockstream.info"));
}

#[test]
fn http_response_errors_are_not_transient() {
// esplora-client already retries 429/500/503 internally — re-retrying
// here would just double the load. Other status codes aren't transient.
let e = esplora_client::Error::HttpResponse {
status: 503,
message: "service unavailable".into(),
};
assert!(!is_transient_esplora_error(&e));
}

#[tokio::test]
async fn retry_transient_succeeds_without_retrying() {
let attempts = std::cell::Cell::new(0);
let result: Result<&str, String> = retry_transient(
3,
Duration::ZERO,
|_| true,
|| {
attempts.set(attempts.get() + 1);
async { Ok("ok") }
},
)
.await;
assert_eq!(result.unwrap(), "ok");
assert_eq!(attempts.get(), 1, "a first-try success must not retry");
}

#[tokio::test]
async fn retry_transient_recovers_after_transient_failures() {
let attempts = std::cell::Cell::new(0);
let result: Result<&str, String> = retry_transient(
3,
Duration::ZERO,
|_| true,
|| {
let n = attempts.get() + 1;
attempts.set(n);
async move {
if n < 3 {
Err(format!("transient {n}"))
} else {
Ok("recovered")
}
}
},
)
.await;
assert_eq!(result.unwrap(), "recovered");
assert_eq!(attempts.get(), 3);
}

#[tokio::test]
async fn retry_transient_stops_at_max_attempts() {
let attempts = std::cell::Cell::new(0);
let result: Result<&str, String> = retry_transient(
3,
Duration::ZERO,
|_| true,
|| {
let n = attempts.get() + 1;
attempts.set(n);
async move { Err(format!("fail {n}")) }
},
)
.await;
assert_eq!(result.unwrap_err(), "fail 3");
assert_eq!(attempts.get(), 3, "must give up after max_attempts");
}

#[tokio::test]
async fn retry_transient_does_not_retry_non_transient_errors() {
let attempts = std::cell::Cell::new(0);
let result: Result<&str, String> = retry_transient(
3,
Duration::ZERO,
|_| false,
|| {
attempts.set(attempts.get() + 1);
async { Err("permanent".to_string()) }
},
)
.await;
assert_eq!(result.unwrap_err(), "permanent");
assert_eq!(attempts.get(), 1, "non-transient errors fail immediately");
}
}
Loading
Loading