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