1use 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
39const DEFAULT_REFRESH_MARGIN_SECS: i64 = 60;
42
43#[derive(Clone)]
45pub struct RpcApiClient {
46 admin: AdminApiClient,
48 config: SdkConfig,
49 refresh_margin_secs: i64,
50 cache: Arc<Mutex<Option<CachedToken>>>,
53 refresh_lock: Arc<tokio::sync::Mutex<()>>,
57 networks: Arc<Mutex<Option<HashMap<String, String>>>>,
63 endpoint_url: Option<String>,
67 #[cfg(feature = "payments")]
72 payment: Option<Arc<crate::config::PaymentConfig>>,
73}
74
75#[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 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 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 #[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 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 pub fn current_token(&self) -> Option<CachedToken> {
142 self.cache.lock().ok().and_then(|c| c.clone())
143 }
144
145 pub fn clear_cached_token(&self) {
149 self.invalidate();
150 }
151
152 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 #[cfg(feature = "payments")]
181 if self.payment.is_some() {
182 return self
183 .run_payment_lane(method, ¶ms, network.as_deref(), endpoint_url.as_deref())
184 .await
185 .map(|(result, _receipt)| result);
186 }
187
188 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 if let Some(url) = custom_url {
204 let resp = self.send(None, &url, method, ¶ms).await?;
205 return Self::parse_rpc(resp);
206 }
207
208 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, ¶ms).await?;
212
213 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, ¶ms).await?;
221 return Self::parse_rpc(retry);
222 }
223 Self::parse_rpc(resp)
224 }
225
226 #[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, ¶ms, network.as_deref(), endpoint_url.as_deref())
241 .await?;
242 return Ok(RpcCallResponse {
243 result,
244 payment_receipt,
245 });
246 }
247 let result = self.call(method, params, network, endpoint_url).await?;
249 Ok(RpcCallResponse {
250 result,
251 payment_receipt: None,
252 })
253 }
254
255 #[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 let config = self
297 .payment
298 .as_ref()
299 .ok_or_else(|| SdkError::Config("no payment lane configured".into()))?;
300 #[cfg_attr(not(feature = "payments-svm"), allow(unused_mut))]
303 let mut resolved = payment::ResolvedPayment::from_config(config)?;
304
305 #[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 #[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 #[cfg(feature = "payments")]
361 pub fn payment_address(&self) -> Result<String, SdkError> {
362 self.resolve_payment()?.signer.address()
363 }
364
365 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[cfg(feature = "payments-svm")]
549 fn tooling_svm_url(&self, pay_network: &str) -> Option<String> {
550 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 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 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 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 async fn refresh(&self) -> Result<CachedToken, SdkError> {
615 let _guard = self.refresh_lock.lock().await;
616 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 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 fn parse_rpc(resp: RawResponse) -> Result<Value, SdkError> {
667 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 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 .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 fn future_exp() -> i64 {
751 now_unix() + 3600
752 }
753
754 fn token_body(endpoint_url: &str, exp: i64) -> serde_json::Value {
755 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 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 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 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 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 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 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 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 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 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 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 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 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.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 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 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}