Skip to main content

decdn_client/
provider.rs

1//! HTTP provider builders for the `decdn` CLI and SDK clients — a
2//! wallet-filled builder that signs and sends transactions, and a wallet-free
3//! builder for reads that run before a keystore is unlocked — plus the
4//! retryable-vs-permanent classifier for a failed chain read
5//! ([`is_permanent_contract_error`](crate::provider::is_permanent_contract_error),
6//! [`is_permanent_rpc_error`](crate::provider::is_permanent_rpc_error)).
7//!
8//! It lives in the SDK so any client of the `cdn/client/v1` data plane — not
9//! just the `decdn` CLI bin crate — can build the signing provider it opens and
10//! settles payment channels with. The daemon's boot chain reads and the
11//! client's registry discovery classify a typed chain error with the same
12//! classifier.
13
14use alloy::network::EthereumWallet;
15use alloy::providers::{Provider, ProviderBuilder};
16use alloy::signers::local::PrivateKeySigner;
17use anyhow::Context;
18
19/// Build a wallet-filled HTTP provider that signs and sends transactions as
20/// `signer`. It reads the pending nonce on every send, so it stays correct
21/// after a send that pins its own nonce ([`crate::buyer_pool::top_up`]) or a
22/// transaction sent from elsewhere.
23///
24/// # Errors
25///
26/// Fails if `rpc_url` is not a valid URL. The error never echoes the value,
27/// which can carry an API key.
28// `use<>` pins the returned provider to capture *no* input lifetimes: it owns
29// a clone of `signer` and the parsed URL, so it is genuinely `'static`. Without
30// the precise-capturing bound, Rust 2024's RPIT rules over-capture `&signer`,
31// which would stop callers (e.g. `setup`'s USDC swap venue) from handing the
32// provider to APIs that need `P: 'static` (`DynProvider::erased`).
33pub fn build_provider(
34    rpc_url: &str,
35    signer: &PrivateKeySigner,
36) -> anyhow::Result<impl Provider + Clone + use<>> {
37    Ok(ProviderBuilder::new()
38        .with_simple_nonce_management()
39        .wallet(EthereumWallet::from(signer.clone()))
40        .connect_http(parse_rpc_url(rpc_url)?))
41}
42
43/// Build a wallet-free HTTP provider for read-only calls. It lets a command
44/// read chain state before it unlocks a keystore, so a check that fails asks
45/// for no password.
46///
47/// # Errors
48///
49/// Fails if `rpc_url` is not a valid URL. The error never echoes the value,
50/// which can carry an API key.
51pub fn build_read_provider(rpc_url: &str) -> anyhow::Result<impl Provider + Clone + use<>> {
52    Ok(ProviderBuilder::new().connect_http(parse_rpc_url(rpc_url)?))
53}
54
55/// Parse the configured RPC endpoint into the type the caller's
56/// `connect_http` takes. It is generic so that type is inferred at each call
57/// site; naming `Url` would need `alloy::transports`, which this crate's alloy
58/// feature set does not expose.
59///
60/// # Errors
61///
62/// Fails if `rpc_url` is not a valid URL. The value is never echoed into the
63/// error: an `rpc_url` secret commonly lives in the path/query, which userinfo
64/// redaction wouldn't scrub, so the value is hidden entirely (in the style of
65/// `config validate`'s `<redacted> (N chars)`).
66fn parse_rpc_url<U: std::str::FromStr>(rpc_url: &str) -> anyhow::Result<U>
67where
68    U::Err: std::error::Error + Send + Sync + 'static,
69{
70    rpc_url.parse().with_context(|| {
71        format!(
72            "rpc_url is not a valid URL (<redacted>, {} chars)",
73            rpc_url.len()
74        )
75    })
76}
77
78/// Whether a failed contract call is deterministic — the same call fails the
79/// same way however often it is repeated, so retrying it only spends a retry
80/// budget before reporting the error it was always going to report.
81///
82/// Only a transport failure can be transient; [`is_permanent_rpc_error`]
83/// decides those. Every other variant is a contract-level fault that repeats
84/// identically: no contract at the configured address
85/// ([`ZeroData`](alloy::contract::Error::ZeroData) — the typo'd-address case),
86/// an ABI that does not match the binding, an unknown function or selector, a
87/// failed deployment.
88///
89/// The classifier is for reads: it calls every non-transport failure permanent,
90/// including a pending-transaction error, which a write path must judge on its
91/// own.
92#[must_use]
93#[doc(hidden)]
94pub fn is_permanent_contract_error(err: &alloy::contract::Error) -> bool {
95    match err {
96        alloy::contract::Error::TransportError(e) => is_permanent_rpc_error(e),
97        _ => true,
98    }
99}
100
101/// Whether a failed JSON-RPC request is deterministic, so a retry cannot
102/// succeed.
103///
104/// This is a denylist — retry unless the failure is known to repeat. The
105/// permanent failures are the ones a request carries with it:
106///
107/// - a JSON-RPC error response that is a revert (code `3`, or a message that
108///   names a revert, in any case), or an invalid request, unknown method or
109///   invalid params (`-32600`, `-32601`, `-32602`);
110/// - an HTTP 4xx other than 408 and 429 — a wrong path, an unauthorized or
111///   expired API key. An HTTP error whose body is a JSON-RPC error reaches
112///   here as an error response and is judged by its code, not its status;
113/// - a request that could not be serialized, or that the transport rejects
114///   locally.
115///
116/// Every other failure is transient: refused connections, resets, timeouts,
117/// truncated bodies, HTTP 5xx, a 4xx that carries `Retry-After`, and every other
118/// JSON-RPC error response. Providers report rate limits and upstream outages
119/// as JSON-RPC error responses over HTTP 200, with codes that differ by
120/// provider — for example `1` "no available upstreams" and `19` "Temporary
121/// internal error".
122///
123/// alloy's own retry checks are allowlists, and each misses a transient class.
124/// `ErrorPayload::is_retry_err` names some providers' rate-limit codes, but not
125/// outage codes such as `1` and `19`. `TransportErrorKind::is_retry_err` accepts
126/// only a few transport kinds, so it rejects an ordinary refused connection or
127/// reset.
128#[must_use]
129#[doc(hidden)]
130pub fn is_permanent_rpc_error(err: &alloy::transports::TransportError) -> bool {
131    use alloy::transports::{RpcError, TransportErrorKind};
132
133    match err {
134        // -32602..=-32600: invalid params, method not found, invalid request.
135        RpcError::ErrorResp(resp) => {
136            resp.code == 3
137                || resp.message.to_ascii_lowercase().contains("revert")
138                || (-32602..=-32600).contains(&resp.code)
139        }
140        RpcError::UnsupportedFeature(_) | RpcError::LocalUsageError(_) | RpcError::SerError(_) => {
141            true
142        }
143        RpcError::Transport(TransportErrorKind::HttpError(h)) => {
144            (400..500).contains(&h.status) && !matches!(h.status, 408 | 429)
145        }
146        // Everything else is transport-shaped and transient. That includes
147        // `HttpErrorWithRetryAfter`: alloy reports an HTTP error that carries a
148        // `Retry-After` header as its own variant, and a server that names when
149        // to retry expects a retry, whatever the status.
150        _ => false,
151    }
152}
153
154#[cfg(test)]
155#[allow(clippy::unwrap_used)]
156mod tests;