Skip to main content

quicknode_sdk/rpc/
mod.rs

1//! Data-plane JSON-RPC client.
2//!
3//! Makes JSON-RPC calls directly against the account's provisioned Tooling
4//! Access endpoint, authenticating with a short-lived session JWT. The JWT is
5//! minted via the Admin control plane ([`crate::admin::AdminApiClient::mint_tooling_token`]),
6//! cached in memory, and refreshed proactively before expiry (or reactively on
7//! a 401). The signing key never leaves the server; this client only ever holds
8//! a minted JWT.
9//!
10//! A host that outlives a single process (e.g. the CLI) can persist the cached
11//! token between runs by seeding [`crate::config::RpcConfig::seed`] on startup
12//! and snapshotting [`RpcApiClient::current_token`] afterwards.
13
14use std::collections::HashMap;
15use std::sync::{Arc, Mutex};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use serde_json::Value;
19
20#[cfg(feature = "payments")]
21pub mod payment;
22
23#[cfg(feature = "payments")]
24pub use crate::config::PaymentConfig;
25#[cfg(feature = "payments")]
26pub use payment::drawdown::{CreditBalance, DripReceipt, GatewaySession};
27#[cfg(feature = "payments-tempo")]
28pub use payment::session::{ChannelState, ChannelStatus};
29#[cfg(feature = "payments")]
30pub use payment::signer::{generate_payment_wallet, ChainKind, GeneratedWallet};
31#[cfg(feature = "payments")]
32pub use payment::{PaymentReceipt, PaymentScheme};
33
34use crate::admin::AdminApiClient;
35use crate::config::{CachedToken, RpcConfig};
36use crate::errors::SdkError;
37use crate::SdkConfig;
38
39// Default seconds before `exp` at which we proactively refresh. Also absorbs
40// clock skew between client and endpoint.
41const DEFAULT_REFRESH_MARGIN_SECS: i64 = 60;
42
43/// JSON-RPC client for the Tooling Access endpoint.
44#[derive(Clone)]
45pub struct RpcApiClient {
46    // Used to mint/refresh session tokens against the control plane.
47    admin: AdminApiClient,
48    config: SdkConfig,
49    refresh_margin_secs: i64,
50    // Current cached token. Guarded by a std Mutex held only for synchronous
51    // read/write — never across an await.
52    cache: Arc<Mutex<Option<CachedToken>>>,
53    // Serializes refreshes so concurrent callers that all see an expired token
54    // trigger a single mint, not a stampede. Held across the mint await, hence
55    // an async mutex.
56    refresh_lock: Arc<tokio::sync::Mutex<()>>,
57    // Per-network URL map for multichain routing: key (e.g. "solana-mainnet")
58    // -> full http_url. The endpoint is multichain by subdomain and the URLs
59    // are not derivable by string munging, so callers seed this map (from
60    // `admin.get_endpoint_urls`). `None` until seeded; a `call` with a network
61    // then errors with a clear message.
62    networks: Arc<Mutex<Option<HashMap<String, String>>>>,
63    // Client-wide default custom endpoint URL. When set, calls bypass the
64    // Tooling Access endpoint and the JWT entirely (see `RpcConfig::endpoint_url`).
65    // A per-call `endpoint_url` overrides this. Immutable after construction.
66    endpoint_url: Option<String>,
67    // Crypto-micropayment lane config. When set, `call`/`call_with_receipt`
68    // pay per request against the x402/MPP gateways instead of minting a JWT.
69    // Resolved to the internal Signer at call time so a malformed config
70    // (bad max_amount, unknown scheme) surfaces as a clear `Config` error.
71    #[cfg(feature = "payments")]
72    payment: Option<Arc<crate::config::PaymentConfig>>,
73}
74
75/// The result of a JSON-RPC call plus an optional settlement receipt. Returned
76/// by [`RpcApiClient::call_with_receipt`]; `payment_receipt` is `Some` only for
77/// the MPP payment lane and `None` for x402 and the non-payment lanes.
78#[cfg(feature = "payments")]
79#[derive(Debug, Clone)]
80pub struct RpcCallResponse {
81    pub result: Value,
82    pub payment_receipt: Option<payment::PaymentReceipt>,
83}
84
85impl std::fmt::Debug for RpcApiClient {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        // Never print the cached JWT.
88        f.debug_struct("RpcApiClient")
89            .field("refresh_margin_secs", &self.refresh_margin_secs)
90            .field(
91                "has_cached_token",
92                &self.cache.lock().is_ok_and(|c| c.is_some()),
93            )
94            .finish()
95    }
96}
97
98impl RpcApiClient {
99    pub fn new(config: SdkConfig, rpc_config: Option<&RpcConfig>) -> Self {
100        let refresh_margin_secs = rpc_config
101            .and_then(|c| c.refresh_margin_secs)
102            .filter(|&m| m >= 0)
103            .unwrap_or(DEFAULT_REFRESH_MARGIN_SECS);
104        // Seed is advisory: a stale/expired seed simply produces a cache miss on
105        // the first call and is replaced by a fresh mint.
106        let seed = rpc_config.and_then(|c| c.seed.clone());
107        let networks = rpc_config.and_then(|c| c.networks.clone());
108        let endpoint_url = rpc_config.and_then(|c| c.endpoint_url.clone());
109        // Hold the plain-data payment config; resolve it to the internal enum
110        // Signer at call time so a malformed config surfaces as a clear
111        // `Config` error (keeps `new` infallible).
112        #[cfg(feature = "payments")]
113        let payment = rpc_config.and_then(|c| c.payment.clone()).map(Arc::new);
114        Self {
115            admin: AdminApiClient::new(config.clone()),
116            config,
117            refresh_margin_secs,
118            cache: Arc::new(Mutex::new(seed)),
119            refresh_lock: Arc::new(tokio::sync::Mutex::new(())),
120            networks: Arc::new(Mutex::new(networks)),
121            endpoint_url,
122            #[cfg(feature = "payments")]
123            payment,
124        }
125    }
126
127    /// Seeds (or replaces) the per-network URL map used for multichain routing.
128    /// The map is `network key -> full http_url`, typically built from
129    /// `admin.get_endpoint_urls(endpoint_id).multichain_urls`. A host that
130    /// didn't seed it via [`RpcConfig`] can install it here before calling with
131    /// a `network`.
132    pub fn set_networks(&self, networks: HashMap<String, String>) {
133        if let Ok(mut guard) = self.networks.lock() {
134            *guard = Some(networks);
135        }
136    }
137
138    /// Returns a snapshot of the current cached token, if any. Hosts use this to
139    /// persist the token between processes. Returns `None` if no token has been
140    /// minted (or seeded) yet.
141    pub fn current_token(&self) -> Option<CachedToken> {
142        self.cache.lock().ok().and_then(|c| c.clone())
143    }
144
145    /// Discards the in-memory cached token, forcing the next call to mint a
146    /// fresh one. Use when the cached token is known stale beyond expiry — e.g.
147    /// the endpoint was disabled and re-enabled out of band.
148    pub fn clear_cached_token(&self) {
149        self.invalidate();
150    }
151
152    /// Makes a JSON-RPC call. `params` defaults to an empty array when `None`;
153    /// it accepts both a positional array and a by-name object.
154    ///
155    /// `endpoint_url` sends this call to a custom HTTP URL, bypassing the
156    /// Tooling Access endpoint and the session JWT entirely — the URL is treated
157    /// as self-authenticating and gets no Authorization header. It overrides the
158    /// client-wide [`RpcConfig::endpoint_url`] default for this call. Because a
159    /// custom URL is not multichain-routed, passing both `endpoint_url` and
160    /// `network` is a [`SdkError::Config`] error.
161    ///
162    /// `network` selects which chain to route to on a multichain endpoint: it
163    /// is a key in the seeded network map (e.g. `"solana-mainnet"`, `"polygon"`).
164    /// When `None`, the call goes to the endpoint's default network. When `Some`,
165    /// the map must be seeded (via [`RpcConfig`] or [`Self::set_networks`]) and
166    /// contain the key, otherwise a [`SdkError::Config`] is returned.
167    ///
168    /// Returns the unwrapped `result`. A JSON-RPC `error` member is surfaced as
169    /// [`SdkError::Rpc`].
170    pub async fn call(
171        &self,
172        method: &str,
173        params: Option<Value>,
174        network: Option<String>,
175        endpoint_url: Option<String>,
176    ) -> Result<Value, SdkError> {
177        // Payment lane wins when configured (see the precedence rules in
178        // `run_payment_lane`); it returns the bare result and discards any
179        // receipt. Every other caller keeps today's behavior unchanged.
180        #[cfg(feature = "payments")]
181        if self.payment.is_some() {
182            return self
183                .run_payment_lane(method, &params, network.as_deref(), endpoint_url.as_deref())
184                .await
185                .map(|(result, _receipt)| result);
186        }
187
188        // Precedence: a per-call custom URL wins; then a per-call network; then
189        // the client-wide custom URL default; then the tooling default endpoint.
190        // A per-call URL and network are mutually exclusive (custom URLs are not
191        // multichain-routed).
192        if endpoint_url.is_some() && network.is_some() {
193            return Err(SdkError::Config(
194                "`endpoint_url` and `network` are mutually exclusive: a custom \
195                 URL is not multichain-routed"
196                    .into(),
197            ));
198        }
199        let custom_url = endpoint_url.or_else(|| self.endpoint_url.clone());
200
201        // Custom mode: no token minted or attached; the URL authenticates itself.
202        // There is no JWT to refresh, so no reactive-401 retry path.
203        if let Some(url) = custom_url {
204            let resp = self.send(None, &url, method, &params).await?;
205            return Self::parse_rpc(resp);
206        }
207
208        // Tooling mode: mint/refresh the JWT and route via the token/network map.
209        let token = self.valid_token().await?;
210        let url = self.resolve_url(&token, network.as_deref())?;
211        let resp = self.send(Some(&token), &url, method, &params).await?;
212
213        // Reactive refresh: a 401 means the token was rejected (expired at the
214        // edge, revoked, clock skew past the margin). Discard, mint once, retry
215        // once. A second 401 surfaces as an Api error.
216        if resp.status == 401 {
217            self.invalidate();
218            let token = self.refresh().await?;
219            let url = self.resolve_url(&token, network.as_deref())?;
220            let retry = self.send(Some(&token), &url, method, &params).await?;
221            return Self::parse_rpc(retry);
222        }
223        Self::parse_rpc(resp)
224    }
225
226    /// Like [`Self::call`], but also returns the settlement receipt for the
227    /// crypto-micropayment lane. `payment_receipt` is `Some` only on the MPP
228    /// happy path; it is `None` for x402 and for every non-payment lane (which
229    /// behave exactly like [`Self::call`]).
230    #[cfg(feature = "payments")]
231    pub async fn call_with_receipt(
232        &self,
233        method: &str,
234        params: Option<Value>,
235        network: Option<String>,
236        endpoint_url: Option<String>,
237    ) -> Result<RpcCallResponse, SdkError> {
238        if self.payment.is_some() {
239            let (result, payment_receipt) = self
240                .run_payment_lane(method, &params, network.as_deref(), endpoint_url.as_deref())
241                .await?;
242            return Ok(RpcCallResponse {
243                result,
244                payment_receipt,
245            });
246        }
247        // No payment lane: delegate to the ordinary call and report no receipt.
248        let result = self.call(method, params, network, endpoint_url).await?;
249        Ok(RpcCallResponse {
250            result,
251            payment_receipt: None,
252        })
253    }
254
255    // Payment-lane precedence + dispatch. Called only when `self.payment` is set.
256    //
257    // Precedence rules (mutually-exclusive with the self-auth URL lanes):
258    // - a per-call `endpoint_url` + payment => Config error;
259    // - a client-wide `endpoint_url` + payment => Config error (a custom
260    //   self-auth URL and a payment lane are mutually exclusive);
261    // - payment present => `network` (the QUERY chain) is required and routed to
262    //   the gateway path slug (NOT looked up in the tooling network map).
263    #[cfg(feature = "payments")]
264    async fn run_payment_lane(
265        &self,
266        method: &str,
267        params: &Option<Value>,
268        network: Option<&str>,
269        endpoint_url: Option<&str>,
270    ) -> Result<(Value, Option<payment::PaymentReceipt>), SdkError> {
271        if endpoint_url.is_some() {
272            return Err(SdkError::Config(
273                "`endpoint_url` and a payment lane are mutually exclusive: a \
274                 self-authenticating URL does not use per-request payment"
275                    .into(),
276            ));
277        }
278        if self.endpoint_url.is_some() {
279            return Err(SdkError::Config(
280                "a client-wide `endpoint_url` and a payment lane are mutually \
281                 exclusive: configure one or the other"
282                    .into(),
283            ));
284        }
285        let query_network = network.ok_or_else(|| {
286            SdkError::Config(
287                "the payment lane requires `network` (the query chain, e.g. \
288                 \"base-sepolia\" or \"solana-mainnet\")"
289                    .into(),
290            )
291        })?;
292
293        // Resolve the plain-data config to the internal Signer here so a
294        // malformed config (bad max_amount, unknown scheme) surfaces as a
295        // clear `Config` error rather than being silently dropped.
296        let config = self
297            .payment
298            .as_ref()
299            .ok_or_else(|| SdkError::Config("no payment lane configured".into()))?;
300        // `resolved` is only mutated on the SVM RPC-source step below, which is
301        // compiled out without `payments-svm`.
302        #[cfg_attr(not(feature = "payments-svm"), allow(unused_mut))]
303        let mut resolved = payment::ResolvedPayment::from_config(config)?;
304
305        // SVM RPC source precedence: an explicit override (already applied in
306        // from_config) wins; otherwise, if the tooling lane is enabled and its
307        // network map resolves the pay-chain's Solana network, read through the
308        // caller's own Quicknode endpoint; else the public default (best-effort
309        // — no API key just means skip to public, never an error).
310        #[cfg(feature = "payments-svm")]
311        if resolved.svm_rpc_url.is_some() && config.svm_rpc_url.is_none() {
312            if let Some(tooling_url) = self.tooling_svm_url(&resolved.pay_network) {
313                resolved.svm_rpc_url = Some(tooling_url);
314            }
315        }
316
317        let body = serde_json::json!({
318            "jsonrpc": "2.0",
319            "id": 1,
320            "method": method,
321            "params": params.clone().unwrap_or_else(|| Value::Array(vec![])),
322        });
323
324        let (text, receipt) = payment::pay_and_call(
325            self.config.rpc_http_client(),
326            &resolved,
327            query_network,
328            &body,
329        )
330        .await?;
331
332        let result = Self::parse_rpc(RawResponse { status: 200, text })?;
333        Ok((result, receipt))
334    }
335
336    // Resolve the plain-data payment config to the internal Signer + selector,
337    // applying the same SVM RPC-source precedence as `run_payment_lane`. Shared
338    // by the x402 drawdown lifecycle methods below. Errors (bad max_amount,
339    // unknown scheme) surface as a clear `Config` error.
340    #[cfg(feature = "payments")]
341    fn resolve_payment(&self) -> Result<payment::ResolvedPayment, SdkError> {
342        let config = self
343            .payment
344            .as_ref()
345            .ok_or_else(|| SdkError::Config("no payment lane configured".into()))?;
346        #[cfg_attr(not(feature = "payments-svm"), allow(unused_mut))]
347        let mut resolved = payment::ResolvedPayment::from_config(config)?;
348        #[cfg(feature = "payments-svm")]
349        if resolved.svm_rpc_url.is_some() && config.svm_rpc_url.is_none() {
350            if let Some(tooling_url) = self.tooling_svm_url(&resolved.pay_network) {
351                resolved.svm_rpc_url = Some(tooling_url);
352            }
353        }
354        Ok(resolved)
355    }
356
357    /// The configured payment wallet's on-chain address (EVM/Tempo `0x…` hex,
358    /// Solana base58), derived offline from the key. A host uses this to key a
359    /// gateway-session cache by wallet without a network round trip.
360    #[cfg(feature = "payments")]
361    pub fn payment_address(&self) -> Result<String, SdkError> {
362        self.resolve_payment()?.signer.address()
363    }
364
365    /// Authenticates against the x402 gateway with a SIWX message and returns a
366    /// [`payment::drawdown::GatewaySession`] (the session JWT). Free — no funds
367    /// move — so a host may (re)auth transparently before a drawdown call. The
368    /// host persists the session and re-seeds it next run, exactly as it does
369    /// the tooling [`crate::config::CachedToken`].
370    #[cfg(feature = "payments")]
371    pub async fn gateway_authenticate(
372        &self,
373    ) -> Result<payment::drawdown::GatewaySession, SdkError> {
374        let resolved = self.resolve_payment()?;
375        payment::drawdown::authenticate(self.config.rpc_http_client(), &resolved).await
376    }
377
378    /// Buys a block of credits against the x402 gateway, settling the offered
379    /// `402` with the same signer construction as the per-request lane. Returns
380    /// the post-purchase [`payment::drawdown::CreditBalance`]. Single-attempt:
381    /// a paid lane never blind-retries.
382    #[cfg(feature = "payments")]
383    pub async fn gateway_buy_credits(
384        &self,
385        session: &payment::drawdown::GatewaySession,
386        network: &str,
387    ) -> Result<payment::drawdown::CreditBalance, SdkError> {
388        let resolved = self.resolve_payment()?;
389        payment::drawdown::buy_credits(self.config.rpc_http_client(), &resolved, session, network)
390            .await
391    }
392
393    /// Reads the account's current x402 credit balance (GET `/credits`).
394    #[cfg(feature = "payments")]
395    pub async fn gateway_credits(
396        &self,
397        session: &payment::drawdown::GatewaySession,
398    ) -> Result<payment::drawdown::CreditBalance, SdkError> {
399        let resolved = self.resolve_payment()?;
400        payment::drawdown::credits(self.config.rpc_http_client(), &resolved, session).await
401    }
402
403    /// Requests testnet tokens from the x402 faucet (POST `/drip`). Allowed once
404    /// per account on Base Sepolia. Returns the funding transaction (not a
405    /// balance — call [`Self::gateway_credits`] afterwards for the balance).
406    #[cfg(feature = "payments")]
407    pub async fn gateway_drip(
408        &self,
409        session: &payment::drawdown::GatewaySession,
410    ) -> Result<payment::drawdown::DripReceipt, SdkError> {
411        let resolved = self.resolve_payment()?;
412        payment::drawdown::drip(self.config.rpc_http_client(), &resolved, session).await
413    }
414
415    /// Makes one x402 drawdown JSON-RPC call against `network` with the session
416    /// JWT as a Bearer token, drawing 1 credit on success. Returns the
417    /// unwrapped JSON-RPC `result`. Single-attempt; the caller decides whether
418    /// to re-auth on a `token_expired` (surfaced as [`SdkError::Api`] 401/403).
419    #[cfg(feature = "payments")]
420    pub async fn gateway_drawdown_call(
421        &self,
422        method: &str,
423        params: Option<Value>,
424        network: &str,
425        session: &payment::drawdown::GatewaySession,
426    ) -> Result<Value, SdkError> {
427        let resolved = self.resolve_payment()?;
428        let body = serde_json::json!({
429            "jsonrpc": "2.0",
430            "id": 1,
431            "method": method,
432            "params": params.unwrap_or_else(|| Value::Array(vec![])),
433        });
434        let text = payment::drawdown::drawdown_call(
435            self.config.rpc_http_client(),
436            &resolved,
437            session,
438            network,
439            &body,
440        )
441        .await?;
442        Self::parse_rpc(RawResponse { status: 200, text })
443    }
444
445    /// Opens an MPP payment channel by depositing `deposit` base units into the
446    /// escrow and returns the new [`payment::session::ChannelState`]. Moves real
447    /// funds; single-attempt.
448    ///
449    /// The channel is scoped by the configured pay network and asset, not by any
450    /// queried chain: one open channel funds paid calls to every supported
451    /// network, so this takes no query network.
452    #[cfg(feature = "payments-tempo")]
453    pub async fn mpp_open(
454        &self,
455        deposit: u128,
456    ) -> Result<payment::session::ChannelState, SdkError> {
457        let resolved = self.resolve_payment()?;
458        payment::session::open(self.config.rpc_http_client(), &resolved, deposit).await
459    }
460
461    /// Adds `additional_deposit` base units to an open MPP channel. Moves real
462    /// funds; single-attempt. Scoped by the configured pay network and asset.
463    #[cfg(feature = "payments-tempo")]
464    pub async fn mpp_top_up(
465        &self,
466        channel: &payment::session::ChannelState,
467        additional_deposit: u128,
468    ) -> Result<payment::session::ChannelState, SdkError> {
469        let resolved = self.resolve_payment()?;
470        payment::session::top_up(
471            self.config.rpc_http_client(),
472            &resolved,
473            channel,
474            additional_deposit,
475        )
476        .await
477    }
478
479    /// Cooperatively closes an MPP channel: settles the final cumulative spend
480    /// on-chain and refunds the unused deposit. Single-attempt. Scoped by the
481    /// configured pay network and asset.
482    #[cfg(feature = "payments-tempo")]
483    pub async fn mpp_close(
484        &self,
485        channel: &payment::session::ChannelState,
486    ) -> Result<(), SdkError> {
487        let resolved = self.resolve_payment()?;
488        payment::session::close(self.config.rpc_http_client(), &resolved, channel).await
489    }
490
491    /// Fetches the gateway's view of the channel (accepted cumulative + spent).
492    ///
493    /// **This costs one request unit.** The gateway prices every session POST as
494    /// a chargeable request and computes the available balance from the *new*
495    /// spend a voucher authorizes, so the probe advances `cumulative_spent` by
496    /// `per_call` exactly like a session RPC call. The caller must persist the
497    /// returned state on success. Returns [`SdkError::PaymentUnsupported`]
498    /// before any network I/O when the channel has no room left for the probe.
499    ///
500    /// Scoped by the configured pay network and asset; takes no query network.
501    #[cfg(feature = "payments-tempo")]
502    pub async fn mpp_status(
503        &self,
504        channel: &payment::session::ChannelState,
505    ) -> Result<payment::session::ChannelStatus, SdkError> {
506        let resolved = self.resolve_payment()?;
507        payment::session::status(self.config.rpc_http_client(), &resolved, channel).await
508    }
509
510    /// Makes one MPP session-lane JSON-RPC call, authorizing it with a
511    /// cumulative voucher for `new_cumulative` (the running total after this
512    /// call). Returns the unwrapped JSON-RPC `result`. Single-attempt; the caller
513    /// advances the persisted `cumulative_spent` after a success.
514    #[cfg(feature = "payments-tempo")]
515    pub async fn mpp_session_call(
516        &self,
517        method: &str,
518        params: Option<Value>,
519        network: &str,
520        channel: &payment::session::ChannelState,
521        new_cumulative: u128,
522    ) -> Result<Value, SdkError> {
523        let resolved = self.resolve_payment()?;
524        let body = serde_json::json!({
525            "jsonrpc": "2.0",
526            "id": 1,
527            "method": method,
528            "params": params.unwrap_or_else(|| Value::Array(vec![])),
529        });
530        let text = payment::session::voucher_call(
531            self.config.rpc_http_client(),
532            &resolved,
533            network,
534            channel,
535            new_cumulative,
536            &body,
537        )
538        .await?;
539        Self::parse_rpc(RawResponse { status: 200, text })
540    }
541
542    // Best-effort tooling-endpoint lookup for the pay-chain's Solana network.
543    // Returns None (skip to the public default) when no map / no matching key —
544    // never an error. The seeded map is itself the effective API-key gate: it's
545    // built from `admin.get_endpoint_urls`, which a keyless SDK cannot call, so
546    // a keyless instance never has a map here and falls through to the public
547    // default exactly as the precedence requires.
548    #[cfg(feature = "payments-svm")]
549    fn tooling_svm_url(&self, pay_network: &str) -> Option<String> {
550        // Map the CAIP-2 solana cluster to its tooling network key. Devnet is
551        // identified by its genesis-hash prefix (the literal "devnet" never
552        // appears in a CAIP-2 id — see payment::solana_pay_network_is_devnet).
553        let key = if payment::solana_pay_network_is_devnet(pay_network) {
554            "solana-devnet"
555        } else {
556            "solana-mainnet"
557        };
558        let guard = self.networks.lock().ok()?;
559        guard.as_ref()?.get(key).cloned()
560    }
561
562    // Resolve the target URL for a call. `None` network -> the token's default
563    // endpoint_url. `Some(key)` -> the mapped per-network URL; errors if no map
564    // is seeded or the key is unknown (listing available keys).
565    fn resolve_url(&self, token: &CachedToken, network: Option<&str>) -> Result<String, SdkError> {
566        let Some(key) = network else {
567            return Ok(token.endpoint_url.clone());
568        };
569        let guard = self
570            .networks
571            .lock()
572            .map_err(|_| SdkError::Config("network map lock poisoned".into()))?;
573        let Some(map) = guard.as_ref() else {
574            return Err(SdkError::Config(format!(
575                "network '{key}' requested but no network map is available; \
576                 seed it via RpcConfig.networks or set_networks()"
577            )));
578        };
579        match map.get(key) {
580            Some(url) => Ok(url.clone()),
581            None => {
582                let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
583                keys.sort_unstable();
584                Err(SdkError::Config(format!(
585                    "unknown network '{key}'. Available: {}",
586                    keys.join(", ")
587                )))
588            }
589        }
590    }
591
592    // ── Token lifecycle ──────────────────────────────────────────────────────
593
594    // Returns a token that is valid past the refresh margin, minting if needed.
595    async fn valid_token(&self) -> Result<CachedToken, SdkError> {
596        if let Some(tok) = self.cached_if_fresh() {
597            return Ok(tok);
598        }
599        self.refresh().await
600    }
601
602    // Returns the cached token only if present and not within the refresh margin.
603    fn cached_if_fresh(&self) -> Option<CachedToken> {
604        let now = now_unix();
605        let guard = self.cache.lock().ok()?;
606        guard
607            .as_ref()
608            .filter(|t| now + self.refresh_margin_secs < t.exp_unix)
609            .cloned()
610    }
611
612    // Single-flight refresh: only one caller mints at a time; others re-check
613    // the cache after acquiring the lock and reuse the just-minted token.
614    async fn refresh(&self) -> Result<CachedToken, SdkError> {
615        let _guard = self.refresh_lock.lock().await;
616        // Another caller may have refreshed while we waited for the lock.
617        if let Some(tok) = self.cached_if_fresh() {
618            return Ok(tok);
619        }
620        let fresh = self.admin.mint_tooling_token().await?;
621        if let Ok(mut guard) = self.cache.lock() {
622            *guard = Some(fresh.clone());
623        }
624        Ok(fresh)
625    }
626
627    fn invalidate(&self) {
628        if let Ok(mut guard) = self.cache.lock() {
629            *guard = None;
630        }
631    }
632
633    // ── Transport ─────────────────────────────────────────────────────────────
634
635    // Sends the JSON-RPC request. `token` is `Some` in tooling mode (attaches a
636    // Bearer JWT) and `None` for a custom endpoint URL, which is treated as
637    // self-authenticating and gets no Authorization header. Either way the
638    // request goes through the keyless `rpc_http_client`, so the account
639    // `x-api-key` never reaches the data plane.
640    async fn send(
641        &self,
642        token: Option<&CachedToken>,
643        target_url: &str,
644        method: &str,
645        params: &Option<Value>,
646    ) -> Result<RawResponse, SdkError> {
647        let url = reqwest::Url::parse(target_url).map_err(|e| SdkError::Config(e.to_string()))?;
648        let body = serde_json::json!({
649            "jsonrpc": "2.0",
650            "id": 1,
651            "method": method,
652            "params": params.clone().unwrap_or_else(|| Value::Array(vec![])),
653        });
654        let mut req = self.config.rpc_http_client().post(url).json(&body);
655        if let Some(token) = token {
656            req = req.bearer_auth(&token.token);
657        }
658        let resp = req.send().await.map_err(SdkError::Http)?;
659        let status = resp.status().as_u16();
660        let text = resp.text().await.map_err(SdkError::Http)?;
661        Ok(RawResponse { status, text })
662    }
663
664    // Parse a JSON-RPC envelope: surface `error` as SdkError::Rpc, else return
665    // `result`. Non-2xx HTTP without a usable JSON-RPC body is an Api error.
666    fn parse_rpc(resp: RawResponse) -> Result<Value, SdkError> {
667        // Try to decode the JSON-RPC envelope regardless of HTTP status — some
668        // endpoints return a JSON-RPC error with a 200, others with 4xx.
669        let parsed: Result<JsonRpcEnvelope, _> = serde_json::from_str(&resp.text);
670        match parsed {
671            Ok(env) => {
672                if let Some(err) = env.error {
673                    return Err(SdkError::Rpc {
674                        code: err.code,
675                        message: err.message,
676                    });
677                }
678                if let Some(result) = env.result {
679                    return Ok(result);
680                }
681                // No result and no error: if the HTTP status was a failure,
682                // surface it; otherwise return null.
683                if !(200..300).contains(&resp.status) {
684                    return Err(SdkError::Api {
685                        status: status_code(resp.status),
686                        body: resp.text,
687                    });
688                }
689                Ok(Value::Null)
690            }
691            Err(source) => {
692                if !(200..300).contains(&resp.status) {
693                    Err(SdkError::Api {
694                        status: status_code(resp.status),
695                        body: resp.text,
696                    })
697                } else {
698                    Err(SdkError::Decode {
699                        source,
700                        body: resp.text,
701                    })
702                }
703            }
704        }
705    }
706}
707
708struct RawResponse {
709    status: u16,
710    text: String,
711}
712
713#[derive(serde::Deserialize)]
714struct JsonRpcEnvelope {
715    #[serde(default)]
716    result: Option<Value>,
717    #[serde(default)]
718    error: Option<JsonRpcError>,
719}
720
721#[derive(serde::Deserialize)]
722struct JsonRpcError {
723    code: i64,
724    message: String,
725}
726
727fn now_unix() -> i64 {
728    SystemTime::now()
729        .duration_since(UNIX_EPOCH)
730        // Pre-epoch system clock is implausible; treat as 0 so a fresh token is
731        // always considered valid rather than panicking.
732        .map_or(0, |d| d.as_secs() as i64)
733}
734
735fn status_code(status: u16) -> reqwest::StatusCode {
736    reqwest::StatusCode::from_u16(status).unwrap_or(reqwest::StatusCode::BAD_GATEWAY)
737}
738
739#[cfg(test)]
740#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
741mod tests {
742    use super::*;
743    use crate::config::{AdminConfig, SdkFullConfig};
744    use crate::QuicknodeSdk;
745    use std::sync::atomic::{AtomicUsize, Ordering};
746    use wiremock::matchers::{body_partial_json, header, method, path};
747    use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
748
749    // A future exp so seeded tokens are considered fresh.
750    fn future_exp() -> i64 {
751        now_unix() + 3600
752    }
753
754    fn token_body(endpoint_url: &str, exp: i64) -> serde_json::Value {
755        // The mint route returns an ISO timestamp; build one far in the future.
756        // We feed exp directly via seed in most tests, but mint tests use this.
757        let _ = exp;
758        serde_json::json!({
759            "data": {
760                "endpoint_url": endpoint_url,
761                "token": "minted.jwt.value",
762                "expires_at": "2099-01-01T00:00:00.000Z"
763            },
764            "error": null
765        })
766    }
767
768    fn sdk_with_seed(admin_base: &str, rpc_endpoint: &str) -> QuicknodeSdk {
769        let mut cfg = SdkFullConfig::from_api_key("test-key".to_string());
770        cfg.admin = Some(AdminConfig {
771            base_url: Some(format!("{admin_base}/")),
772        });
773        cfg.rpc = Some(RpcConfig {
774            endpoint_url: None,
775            seed: Some(CachedToken {
776                endpoint_url: rpc_endpoint.to_string(),
777                token: "seeded.jwt".to_string(),
778                exp_unix: future_exp(),
779            }),
780            refresh_margin_secs: None,
781            networks: None,
782            payment: None,
783        });
784        QuicknodeSdk::new(&cfg).unwrap()
785    }
786
787    #[tokio::test]
788    async fn call_uses_seed_without_minting() {
789        let server = MockServer::start().await;
790        // RPC endpoint returns a result.
791        Mock::given(method("POST"))
792            .and(path("/"))
793            .and(body_partial_json(
794                serde_json::json!({ "method": "eth_blockNumber" }),
795            ))
796            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
797                "jsonrpc": "2.0", "id": 1, "result": "0x1335f9a"
798            })))
799            .mount(&server)
800            .await;
801
802        // Use the same server for both admin and rpc; if mint were called it
803        // would 404 (no mock for /tooling-access/token) and the test would fail.
804        let sdk = sdk_with_seed(&server.uri(), &server.uri());
805        let result = sdk
806            .rpc
807            .call("eth_blockNumber", None, None, None)
808            .await
809            .unwrap();
810        assert_eq!(result, serde_json::json!("0x1335f9a"));
811    }
812
813    #[tokio::test]
814    async fn call_sends_bearer_jwt_but_not_account_api_key() {
815        let server = MockServer::start().await;
816        // Match only requests that carry the Bearer JWT and omit the account
817        // key: the data-plane client must never leak `x-api-key`. If the key
818        // were present this mock would not match and the call would 404.
819        Mock::given(method("POST"))
820            .and(path("/"))
821            .and(header("authorization", "Bearer seeded.jwt"))
822            .and(|req: &Request| !req.headers.contains_key("x-api-key"))
823            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
824                "jsonrpc": "2.0", "id": 1, "result": "0xok"
825            })))
826            .mount(&server)
827            .await;
828
829        let sdk = sdk_with_seed(&server.uri(), &server.uri());
830        let result = sdk
831            .rpc
832            .call("eth_blockNumber", None, None, None)
833            .await
834            .unwrap();
835        assert_eq!(result, serde_json::json!("0xok"));
836    }
837
838    // Builds an SDK whose RPC client has a client-wide custom `endpoint_url` and
839    // NO seed. The admin base points at a dead address, so any attempt to mint a
840    // tooling token would fail — proving custom mode never touches the JWT path.
841    fn sdk_with_custom_url(endpoint_url: &str) -> QuicknodeSdk {
842        let mut cfg = SdkFullConfig::from_api_key("test-key".to_string());
843        cfg.admin = Some(AdminConfig {
844            base_url: Some("http://127.0.0.1:1/".to_string()),
845        });
846        cfg.rpc = Some(RpcConfig {
847            endpoint_url: Some(endpoint_url.to_string()),
848            seed: None,
849            refresh_margin_secs: None,
850            networks: None,
851            payment: None,
852        });
853        QuicknodeSdk::new(&cfg).unwrap()
854    }
855
856    #[tokio::test]
857    async fn config_endpoint_url_bypasses_jwt_and_minting() {
858        let server = MockServer::start().await;
859        // Custom endpoint must receive the call with NO Authorization header and
860        // NO account key. If minting were attempted it would fail against the
861        // dead admin base and the call would error instead.
862        Mock::given(method("POST"))
863            .and(path("/custom"))
864            .and(|req: &Request| {
865                !req.headers.contains_key("authorization") && !req.headers.contains_key("x-api-key")
866            })
867            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
868                "jsonrpc": "2.0", "id": 1, "result": "0xcustom"
869            })))
870            .mount(&server)
871            .await;
872
873        let sdk = sdk_with_custom_url(&format!("{}/custom", server.uri()));
874        let result = sdk
875            .rpc
876            .call("eth_blockNumber", None, None, None)
877            .await
878            .unwrap();
879        assert_eq!(result, serde_json::json!("0xcustom"));
880        // No token was ever minted or cached.
881        assert!(sdk.rpc.current_token().is_none());
882    }
883
884    #[tokio::test]
885    async fn per_call_endpoint_url_overrides_config_default() {
886        let server = MockServer::start().await;
887        // The per-call URL points here; the config default points at /wrong,
888        // which has no mock and would 404.
889        Mock::given(method("POST"))
890            .and(path("/override"))
891            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
892                "jsonrpc": "2.0", "id": 1, "result": "0xoverride"
893            })))
894            .mount(&server)
895            .await;
896
897        let sdk = sdk_with_custom_url(&format!("{}/wrong", server.uri()));
898        let result = sdk
899            .rpc
900            .call(
901                "eth_blockNumber",
902                None,
903                None,
904                Some(format!("{}/override", server.uri())),
905            )
906            .await
907            .unwrap();
908        assert_eq!(result, serde_json::json!("0xoverride"));
909    }
910
911    #[tokio::test]
912    async fn endpoint_url_and_network_together_is_config_error() {
913        let sdk = sdk_with_custom_url("https://example.invalid/rpc");
914        let err = sdk
915            .rpc
916            .call(
917                "eth_blockNumber",
918                None,
919                Some("solana-mainnet".to_string()),
920                Some("https://example.invalid/other".to_string()),
921            )
922            .await
923            .unwrap_err();
924        assert!(matches!(err, SdkError::Config(msg) if msg.contains("mutually exclusive")));
925    }
926
927    #[tokio::test]
928    async fn json_rpc_error_maps_to_rpc_error() {
929        let server = MockServer::start().await;
930        Mock::given(method("POST"))
931            .and(path("/"))
932            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
933                "jsonrpc": "2.0", "id": 1,
934                "error": { "code": -32602, "message": "invalid params" }
935            })))
936            .mount(&server)
937            .await;
938
939        let sdk = sdk_with_seed(&server.uri(), &server.uri());
940        let err = sdk
941            .rpc
942            .call("eth_getBalance", None, None, None)
943            .await
944            .unwrap_err();
945        match err {
946            SdkError::Rpc { code, message } => {
947                assert_eq!(code, -32602);
948                assert!(message.contains("invalid params"));
949            }
950            other => panic!("expected Rpc error, got {other:?}"),
951        }
952    }
953
954    #[tokio::test]
955    async fn reactive_401_refreshes_and_retries_once() {
956        let server = MockServer::start().await;
957
958        // First RPC call returns 401, second (after refresh) returns a result.
959        struct Sequence {
960            calls: AtomicUsize,
961        }
962        impl Respond for Sequence {
963            fn respond(&self, _: &Request) -> ResponseTemplate {
964                let n = self.calls.fetch_add(1, Ordering::SeqCst);
965                if n == 0 {
966                    ResponseTemplate::new(401).set_body_string("unauthorized")
967                } else {
968                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
969                        "jsonrpc": "2.0", "id": 1, "result": "0xokay"
970                    }))
971                }
972            }
973        }
974
975        // RPC endpoint lives at /rpc; mint route at /tooling-access/token.
976        Mock::given(method("POST"))
977            .and(path("/rpc"))
978            .respond_with(Sequence {
979                calls: AtomicUsize::new(0),
980            })
981            .mount(&server)
982            .await;
983        Mock::given(method("POST"))
984            .and(path("/tooling-access/token"))
985            .respond_with(
986                ResponseTemplate::new(200)
987                    .set_body_json(token_body(&format!("{}/rpc", server.uri()), future_exp())),
988            )
989            .mount(&server)
990            .await;
991
992        let sdk = sdk_with_seed(&server.uri(), &format!("{}/rpc", server.uri()));
993        let result = sdk
994            .rpc
995            .call("eth_blockNumber", None, None, None)
996            .await
997            .unwrap();
998        assert_eq!(result, serde_json::json!("0xokay"));
999    }
1000
1001    #[tokio::test]
1002    async fn second_401_surfaces_as_api_error() {
1003        let server = MockServer::start().await;
1004        Mock::given(method("POST"))
1005            .and(path("/rpc"))
1006            .respond_with(ResponseTemplate::new(401).set_body_string("nope"))
1007            .mount(&server)
1008            .await;
1009        Mock::given(method("POST"))
1010            .and(path("/tooling-access/token"))
1011            .respond_with(
1012                ResponseTemplate::new(200)
1013                    .set_body_json(token_body(&format!("{}/rpc", server.uri()), future_exp())),
1014            )
1015            .mount(&server)
1016            .await;
1017
1018        let sdk = sdk_with_seed(&server.uri(), &format!("{}/rpc", server.uri()));
1019        let err = sdk
1020            .rpc
1021            .call("eth_blockNumber", None, None, None)
1022            .await
1023            .unwrap_err();
1024        assert!(matches!(err, SdkError::Api { status, .. } if status.as_u16() == 401));
1025    }
1026
1027    #[tokio::test]
1028    async fn expired_seed_triggers_mint() {
1029        let server = MockServer::start().await;
1030        Mock::given(method("POST"))
1031            .and(path("/tooling-access/token"))
1032            .respond_with(
1033                ResponseTemplate::new(200)
1034                    .set_body_json(token_body(&format!("{}/rpc", server.uri()), future_exp())),
1035            )
1036            .mount(&server)
1037            .await;
1038        Mock::given(method("POST"))
1039            .and(path("/rpc"))
1040            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1041                "jsonrpc": "2.0", "id": 1, "result": "0xfresh"
1042            })))
1043            .mount(&server)
1044            .await;
1045
1046        // Seed an already-expired token.
1047        let mut cfg = SdkFullConfig::from_api_key("test-key".to_string());
1048        cfg.admin = Some(AdminConfig {
1049            base_url: Some(format!("{}/", server.uri())),
1050        });
1051        cfg.rpc = Some(RpcConfig {
1052            endpoint_url: None,
1053            seed: Some(CachedToken {
1054                endpoint_url: format!("{}/rpc", server.uri()),
1055                token: "expired.jwt".to_string(),
1056                exp_unix: now_unix() - 10,
1057            }),
1058            refresh_margin_secs: None,
1059            networks: None,
1060            payment: None,
1061        });
1062        let sdk = QuicknodeSdk::new(&cfg).unwrap();
1063
1064        let result = sdk
1065            .rpc
1066            .call("eth_blockNumber", None, None, None)
1067            .await
1068            .unwrap();
1069        assert_eq!(result, serde_json::json!("0xfresh"));
1070        // current_token now reflects the minted token.
1071        assert_eq!(sdk.rpc.current_token().unwrap().token, "minted.jwt.value");
1072    }
1073
1074    #[tokio::test]
1075    async fn network_routes_to_mapped_url() {
1076        let server = MockServer::start().await;
1077        // The default endpoint is /default; the "solana-mainnet" network maps to
1078        // /solana. A call with that network must POST to /solana, not /default.
1079        Mock::given(method("POST"))
1080            .and(path("/solana"))
1081            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1082                "jsonrpc": "2.0", "id": 1, "result": "12345"
1083            })))
1084            .mount(&server)
1085            .await;
1086
1087        let mut cfg = SdkFullConfig::from_api_key("test-key".to_string());
1088        cfg.admin = Some(AdminConfig {
1089            base_url: Some(format!("{}/", server.uri())),
1090        });
1091        let mut networks = std::collections::HashMap::new();
1092        networks.insert(
1093            "solana-mainnet".to_string(),
1094            format!("{}/solana", server.uri()),
1095        );
1096        cfg.rpc = Some(RpcConfig {
1097            endpoint_url: None,
1098            seed: Some(CachedToken {
1099                endpoint_url: format!("{}/default", server.uri()),
1100                token: "seeded.jwt".to_string(),
1101                exp_unix: future_exp(),
1102            }),
1103            refresh_margin_secs: None,
1104            networks: Some(networks),
1105            payment: None,
1106        });
1107        let sdk = QuicknodeSdk::new(&cfg).unwrap();
1108
1109        let result = sdk
1110            .rpc
1111            .call("getSlot", None, Some("solana-mainnet".to_string()), None)
1112            .await
1113            .unwrap();
1114        assert_eq!(result, serde_json::json!("12345"));
1115    }
1116
1117    #[tokio::test]
1118    async fn unknown_network_is_config_error_listing_keys() {
1119        let server = MockServer::start().await;
1120        let sdk = sdk_with_seed(&server.uri(), &server.uri());
1121        // sdk_with_seed seeds no network map.
1122        sdk.rpc.set_networks(std::collections::HashMap::from([(
1123            "solana-mainnet".to_string(),
1124            "https://x/solana".to_string(),
1125        )]));
1126        let err = sdk
1127            .rpc
1128            .call("getSlot", None, Some("polygon".to_string()), None)
1129            .await
1130            .unwrap_err();
1131        match err {
1132            SdkError::Config(msg) => {
1133                assert!(msg.contains("unknown network 'polygon'"), "msg: {msg}");
1134                assert!(
1135                    msg.contains("solana-mainnet"),
1136                    "msg should list keys: {msg}"
1137                );
1138            }
1139            other => panic!("expected Config error, got {other:?}"),
1140        }
1141    }
1142
1143    #[tokio::test]
1144    async fn network_without_seeded_map_errors() {
1145        let server = MockServer::start().await;
1146        let sdk = sdk_with_seed(&server.uri(), &server.uri());
1147        let err = sdk
1148            .rpc
1149            .call("getSlot", None, Some("solana-mainnet".to_string()), None)
1150            .await
1151            .unwrap_err();
1152        assert!(matches!(err, SdkError::Config(msg) if msg.contains("no network map")));
1153    }
1154}
1155
1156#[cfg(all(test, feature = "payments"))]
1157#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1158mod payment_lane_tests {
1159    use super::*;
1160    use crate::config::{PaymentConfig, SdkFullConfig};
1161    use crate::QuicknodeSdk;
1162    use std::sync::atomic::{AtomicUsize, Ordering};
1163    use wiremock::matchers::method;
1164    use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
1165
1166    const EVM_KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
1167    const USDC: &str = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
1168
1169    // A keyless SDK whose RPC client carries an x402/EVM payment lane pointed at
1170    // the given mock gateway base.
1171    fn keyless_x402_sdk(gateway_base: &str) -> QuicknodeSdk {
1172        let mut cfg = SdkFullConfig::keyless();
1173        cfg.rpc = Some(RpcConfig {
1174            endpoint_url: None,
1175            seed: None,
1176            refresh_margin_secs: None,
1177            networks: None,
1178            payment: Some(PaymentConfig {
1179                scheme: "x402".into(),
1180                key: EVM_KEY.into(),
1181                pay_network: "eip155:84532".into(),
1182                asset: USDC.into(),
1183                max_amount: "10000".into(),
1184                svm_rpc_url: None,
1185                base_url_override: Some(gateway_base.to_string()),
1186            }),
1187        });
1188        QuicknodeSdk::new(&cfg).unwrap()
1189    }
1190
1191    // Mock gateway: unpaid POST -> 402 menu; paid POST (has PAYMENT-SIGNATURE)
1192    // -> 200 result.
1193    async fn mount_x402_gateway(server: &MockServer) {
1194        struct Seq {
1195            calls: AtomicUsize,
1196        }
1197        impl Respond for Seq {
1198            fn respond(&self, req: &Request) -> ResponseTemplate {
1199                let n = self.calls.fetch_add(1, Ordering::SeqCst);
1200                if n == 0 && !req.headers.contains_key("payment-signature") {
1201                    ResponseTemplate::new(402).set_body_json(serde_json::json!({
1202                        "x402Version": 2,
1203                        "accepts": [{
1204                            "scheme": "exact", "network": "eip155:84532",
1205                            "amount": "1000", "payTo": "0x000000000000000000000000000000000000dEaD",
1206                            "maxTimeoutSeconds": 60, "asset": USDC,
1207                            "extra": { "name": "USDC", "version": "2" }
1208                        }]
1209                    }))
1210                } else {
1211                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
1212                        "jsonrpc": "2.0", "id": 1, "result": "0x1335f9a"
1213                    }))
1214                }
1215            }
1216        }
1217        Mock::given(method("POST"))
1218            .respond_with(Seq {
1219                calls: AtomicUsize::new(0),
1220            })
1221            .mount(server)
1222            .await;
1223    }
1224
1225    #[tokio::test]
1226    async fn keyless_payment_call_returns_unwrapped_result() {
1227        let server = MockServer::start().await;
1228        mount_x402_gateway(&server).await;
1229        let sdk = keyless_x402_sdk(&server.uri());
1230        let result = sdk
1231            .rpc
1232            .call(
1233                "eth_blockNumber",
1234                None,
1235                Some("base-sepolia".to_string()),
1236                None,
1237            )
1238            .await
1239            .unwrap();
1240        assert_eq!(result, serde_json::json!("0x1335f9a"));
1241    }
1242
1243    #[tokio::test]
1244    async fn x402_call_with_receipt_has_no_receipt() {
1245        let server = MockServer::start().await;
1246        mount_x402_gateway(&server).await;
1247        let sdk = keyless_x402_sdk(&server.uri());
1248        let resp = sdk
1249            .rpc
1250            .call_with_receipt(
1251                "eth_blockNumber",
1252                None,
1253                Some("base-sepolia".to_string()),
1254                None,
1255            )
1256            .await
1257            .unwrap();
1258        assert_eq!(resp.result, serde_json::json!("0x1335f9a"));
1259        assert!(resp.payment_receipt.is_none());
1260    }
1261
1262    #[tokio::test]
1263    async fn payment_lane_requires_network() {
1264        let server = MockServer::start().await;
1265        let sdk = keyless_x402_sdk(&server.uri());
1266        let err = sdk
1267            .rpc
1268            .call("eth_blockNumber", None, None, None)
1269            .await
1270            .unwrap_err();
1271        assert!(matches!(err, SdkError::Config(m) if m.contains("requires `network`")));
1272    }
1273
1274    #[tokio::test]
1275    async fn per_call_endpoint_url_plus_payment_is_config_error() {
1276        let server = MockServer::start().await;
1277        let sdk = keyless_x402_sdk(&server.uri());
1278        let err = sdk
1279            .rpc
1280            .call(
1281                "eth_blockNumber",
1282                None,
1283                None,
1284                Some("https://example.invalid/rpc".to_string()),
1285            )
1286            .await
1287            .unwrap_err();
1288        assert!(matches!(err, SdkError::Config(m) if m.contains("mutually exclusive")));
1289    }
1290
1291    #[tokio::test]
1292    async fn bad_max_amount_is_config_error_at_call() {
1293        let server = MockServer::start().await;
1294        let mut cfg = SdkFullConfig::keyless();
1295        cfg.rpc = Some(RpcConfig {
1296            endpoint_url: None,
1297            seed: None,
1298            refresh_margin_secs: None,
1299            networks: None,
1300            payment: Some(PaymentConfig {
1301                scheme: "x402".into(),
1302                key: EVM_KEY.into(),
1303                pay_network: "eip155:84532".into(),
1304                asset: USDC.into(),
1305                max_amount: "not-a-number".into(),
1306                svm_rpc_url: None,
1307                base_url_override: Some(server.uri()),
1308            }),
1309        });
1310        let sdk = QuicknodeSdk::new(&cfg).unwrap();
1311        let err = sdk
1312            .rpc
1313            .call(
1314                "eth_blockNumber",
1315                None,
1316                Some("base-sepolia".to_string()),
1317                None,
1318            )
1319            .await
1320            .unwrap_err();
1321        assert!(matches!(err, SdkError::Config(m) if m.contains("max_amount")));
1322    }
1323}