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;