diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index dc0f7f5..297e726 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -34,6 +34,10 @@ bon = { version = "3.9", optional = true } config = "0.15" url = "2.5.8" secrecy = "0.8" +# sync-only: provides tokio::sync::Mutex for single-flight JWT refresh in the +# rpc client without pulling in the tokio runtime. The async runtime itself is +# supplied by the caller (bindings or the user's own runtime). +tokio = { version = "1", default-features = false, features = ["sync"] } [[example]] name = "admin" @@ -55,6 +59,10 @@ required-features = ["rust"] name = "webhooks_e2e" required-features = ["rust"] +[[example]] +name = "rpc" +required-features = ["rust"] + [dev-dependencies] tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] } wiremock = "0.6" diff --git a/crates/core/README.md b/crates/core/README.md index 3c93024..e174b8e 100644 --- a/crates/core/README.md +++ b/crates/core/README.md @@ -1760,6 +1760,67 @@ let schema = qn.sql.get_schema("hyperliquid-core-mainnet").await?; println!("{} tables", schema.tables.len()); ``` +--- + +### RPC & Tooling Access + +Tooling Access provisions a single multichain, read-only endpoint per account and +mints short-lived session JWTs. `qn.rpc` makes JSON-RPC calls directly against that +endpoint, minting and refreshing the JWT automatically — no endpoint URL or token to +manage. + +Tooling Access must be enabled once (admin role + eligible plan). The control-plane +methods live on `qn.admin`: + +```rust +// Rust +let status = qn.admin.tooling_access_status().await?; +if !status.enabled { + qn.admin.enable_tooling_access().await?; // idempotent; admin role required +} + +// call(method, params, network, endpoint_url). params is Option; +// None defaults to []. Both trailing args are Option and independently omittable. +let block_number = qn.rpc.call("eth_blockNumber", None, None, None).await?; +let balance = qn + .rpc + .call( + "eth_getBalance", + Some(serde_json::json!(["0xabc...", "latest"])), + None, + None, + ) + .await?; + +// Multichain: seed the per-network URL map (from get_endpoint_urls), then pass +// the network key as the third arg. +let urls = qn.admin.get_endpoint_urls(endpoint_id).await?; +if let Some(data) = urls.data { + if let Some(mc) = data.multichain_urls { + qn.rpc + .set_networks(mc.into_iter().map(|(k, v)| (k, v.http_url)).collect()); + } +} +let slot = qn.rpc.call("getSlot", None, Some("solana-mainnet".into()), None).await?; + +// Custom endpoint URL: send to a fully-formed HTTP URL, bypassing Tooling Access +// and the JWT (no Authorization header). Per-call via the 4th arg, or client-wide +// via RpcConfig { endpoint_url, .. }. endpoint_url and network are mutually +// exclusive (a custom URL is not multichain-routed). +let block = qn + .rpc + .call("eth_blockNumber", None, None, Some("https://my-endpoint.example/rpc".into())) + .await?; + +// A JSON-RPC error member is returned as SdkError::Rpc { code, message }. +``` + +A host that persists across processes can snapshot the cached token with +`qn.rpc.current_token()` and re-seed it via `RpcConfig { seed, .. }`; +`refresh_margin_secs` (default 60) tunes how early the token is refreshed. Set +`RpcConfig { endpoint_url, .. }` to route every call to a custom HTTP URL by +default (no JWT minted); a per-call `endpoint_url` overrides it. + ## Error Handling Every binding exposes a typed exception hierarchy derived from the core `SdkError` @@ -1775,8 +1836,9 @@ subclass to branch on transport vs. API semantics. | `ConnectionError` | connection refused / DNS / TLS (subclass of `HttpError`) | — | | `ApiError` | non-2xx HTTP response | `status`, `body` | | `DecodeError` | 2xx response but JSON parse failed | `body` | +| `RpcError` | JSON-RPC call returned an `error` member | `code`, `message` | -Variants: pattern-match on `SdkError { Http, Api, Decode, UrlParse, Config }`; use `err.http_kind()` to classify `Http` into `Timeout`, `Connect`, or `Other`. +Variants: pattern-match on `SdkError { Http, Api, Decode, UrlParse, Config, Rpc }`; use `err.http_kind()` to classify `Http` into `Timeout`, `Connect`, or `Other`. ```rust // Rust diff --git a/crates/core/examples/admin_e2e.rs b/crates/core/examples/admin_e2e.rs index 0e804cd..f37016b 100644 --- a/crates/core/examples/admin_e2e.rs +++ b/crates/core/examples/admin_e2e.rs @@ -874,6 +874,7 @@ async fn main() { webhooks: None, kvstore: None, sql: None, + rpc: None, }; let headered = QuicknodeSdk::new(&with_headers).expect("build sdk with custom headers"); match headered @@ -900,6 +901,8 @@ async fn main() { streams: None, webhooks: None, kvstore: None, + sql: None, + rpc: None, }; let tiny = QuicknodeSdk::new(&blackhole).expect("build tiny sdk"); match tiny diff --git a/crates/core/examples/rpc.rs b/crates/core/examples/rpc.rs new file mode 100644 index 0000000..276af20 --- /dev/null +++ b/crates/core/examples/rpc.rs @@ -0,0 +1,83 @@ +use quicknode_sdk::{errors::SdkError, QuicknodeSdk, SdkFullConfig}; + +#[tokio::main] +#[allow(clippy::unwrap_used, clippy::expect_used)] +async fn main() { + let config = SdkFullConfig::from_env().expect("Config from env failed"); + let qn = QuicknodeSdk::new(&config).expect("sdk failed to initialize"); + + // Ensure Tooling Access is provisioned (idempotent; requires admin role). + let status = qn + .admin + .tooling_access_status() + .await + .expect("tooling_access_status failed"); + println!("tooling access enabled: {}", status.enabled); + if !status.enabled { + match qn.admin.enable_tooling_access().await { + Ok(s) => println!("enabled tooling access: {}", s.enabled), + Err(e) => { + eprintln!("could not enable tooling access: {e}"); + return; + } + } + } + + // Make a JSON-RPC call. The SDK mints and refreshes the session JWT. + match qn.rpc.call("eth_blockNumber", None, None, None).await { + Ok(result) => println!("eth_blockNumber => {result}"), + Err(e) => eprintln!("rpc call error: {e}"), + } + + // Multichain: seed the per-network URL map from the endpoint id (returned by + // status), then route a call to a specific network by its key. + if let Some(id) = &status.endpoint_id { + if let Ok(urls) = qn.admin.get_endpoint_urls(id).await { + if let Some(mc) = urls.data.and_then(|d| d.multichain_urls) { + qn.rpc + .set_networks(mc.into_iter().map(|(k, v)| (k, v.http_url)).collect()); + match qn + .rpc + .call("getSlot", None, Some("solana-mainnet".to_string()), None) + .await + { + Ok(result) => println!("solana getSlot => {result}"), + Err(e) => eprintln!("solana rpc error: {e}"), + } + } + } + } + + // Demonstrate the typed JSON-RPC error path. + match qn + .rpc + .call( + "eth_getBalance", + Some(serde_json::json!(["not-an-address"])), + None, + None, + ) + .await + { + Ok(result) => println!("unexpected ok: {result}"), + Err(SdkError::Rpc { code, message }) => { + println!("got expected RpcError: code={code} message={message}"); + } + Err(e) => eprintln!("other error: {e}"), + } + + // Custom endpoint URL: send a call to a fully-formed HTTP URL, bypassing the + // Tooling Access endpoint and the session JWT entirely. Useful for a + // provisioned `.quiknode.pro` URL or a self-hosted node. Set it per-call + // here, or client-wide via `RpcConfig::endpoint_url`. + if let Ok(custom_url) = std::env::var("QN_RPC_ENDPOINT_URL") { + match qn + .rpc + .call("eth_blockNumber", None, None, Some(custom_url)) + .await + { + Ok(result) => println!("custom endpoint eth_blockNumber => {result}"), + Err(e) => eprintln!("custom endpoint rpc error: {e}"), + } + } +} diff --git a/crates/core/src/admin/mod.rs b/crates/core/src/admin/mod.rs index f9c7ad2..c8747f2 100644 --- a/crates/core/src/admin/mod.rs +++ b/crates/core/src/admin/mod.rs @@ -11,6 +11,7 @@ pub mod endpoints; pub mod logs; pub mod tags; pub mod teams; +pub mod tooling_access; pub mod usage; pub use account::{AccountInfo, AccountInfoResponse, AccountSubscription}; @@ -66,6 +67,7 @@ pub use teams::{ TeamDetail, TeamEndpoint, TeamMessageData, TeamSummary, TeamUser, UpdateTeamEndpointsData, UpdateTeamEndpointsRequest, UpdateTeamEndpointsResponse, }; +pub use tooling_access::ToolingAccessStatus; pub use usage::{ ChainUsage, EndpointUsage, GetUsageByChainResponse, GetUsageByEndpointResponse, @@ -1957,6 +1959,7 @@ mod tests { webhooks: None, kvstore: None, sql: None, + rpc: None, }) .unwrap() } @@ -3847,6 +3850,7 @@ mod tests { webhooks: None, kvstore: None, sql: None, + rpc: None, }); assert!(matches!(result, Err(crate::errors::SdkError::Config(_)))); } diff --git a/crates/core/src/admin/tooling_access.rs b/crates/core/src/admin/tooling_access.rs new file mode 100644 index 0000000..b029fb5 --- /dev/null +++ b/crates/core/src/admin/tooling_access.rs @@ -0,0 +1,386 @@ +//! Tooling Access control plane. +//! +//! Tooling Access provisions a single multichain, read-only endpoint per +//! account and mints short-lived ES256 session JWTs. Those JWTs are consumed by +//! the [`crate::rpc::RpcApiClient`] to authenticate RPC calls directly against +//! the provisioned endpoint; the private signing key never leaves the server. +//! +//! These routes live on the same Admin API base URL as the rest of this client. +//! Note this is distinct from [`super::AdminApiClient::create_jwt`], which +//! registers a public key on an endpoint's security config — here we mint the +//! session tokens themselves. + +use serde::Deserialize; + +use crate::{config::CachedToken, errors::SdkError}; + +use super::AdminApiClient; + +/// Current Tooling Access status for the account. `enabled` is the source of +/// truth — a previously-provisioned-but-disabled account may still report a +/// non-null `endpoint_url`. +#[cfg_attr(feature = "python", pyo3_stub_gen::derive::gen_stub_pyclass)] +#[cfg_attr(feature = "python", pyo3::pyclass(get_all))] +#[cfg_attr(feature = "node", napi_derive::napi(object))] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ToolingAccessStatus { + pub enabled: bool, + pub endpoint_url: Option, + pub enabled_at: Option, + /// The provisioned endpoint's id. Used to fetch the per-network URL map + /// (`get_endpoint_urls`) for multichain routing. `None` on control planes + /// that don't yet return it. + pub endpoint_id: Option, +} + +// Control-plane responses use the `{ data, error }` envelope. `data` is null on +// error and `error` carries the message; success carries the payload in `data`. +#[derive(Deserialize)] +struct Envelope { + data: Option, + error: Option, +} + +#[derive(Deserialize)] +struct StatusData { + enabled: bool, + #[serde(default)] + endpoint_url: Option, + #[serde(default)] + enabled_at: Option, + // The endpoint id. Serde may receive it as a string or a number depending + // on the control plane; deserialize_optional_id normalizes both to String. + #[serde(default, deserialize_with = "deserialize_optional_id")] + endpoint_id: Option, +} + +#[derive(Deserialize)] +struct TokenData { + endpoint_url: String, + token: String, + expires_at: String, +} + +impl AdminApiClient { + /// Returns the current Tooling Access status. Always succeeds (when + /// authorized); inspect `enabled` to decide whether to enable. + pub async fn tooling_access_status(&self) -> Result { + let url = self.config.admin().base_url.join("tooling-access")?; + let resp = self + .config + .http_client() + .get(url) + .send() + .await + .map_err(SdkError::Http)?; + Self::parse_status(resp).await + } + + /// Enables (provisions) Tooling Access. Idempotent — safe to call when + /// already enabled. Requires an admin role and an eligible plan; ineligible + /// callers receive an [`SdkError::Api`] carrying the reason. + pub async fn enable_tooling_access(&self) -> Result { + self.set_tooling_access_enabled(true).await + } + + /// Disables Tooling Access, pausing the endpoint. Idempotent. + pub async fn disable_tooling_access(&self) -> Result { + self.set_tooling_access_enabled(false).await + } + + async fn set_tooling_access_enabled( + &self, + enabled: bool, + ) -> Result { + let url = self.config.admin().base_url.join("tooling-access")?; + let resp = self + .config + .http_client() + .patch(url) + .json(&serde_json::json!({ "enabled": enabled })) + .send() + .await + .map_err(SdkError::Http)?; + Self::parse_status(resp).await + } + + /// Mints a short-lived session JWT for the provisioned endpoint. Returns the + /// endpoint URL, the JWT, and its expiry as a [`CachedToken`]. Requires + /// Tooling Access to be enabled first; otherwise returns an + /// [`SdkError::Api`] with status 400. + pub async fn mint_tooling_token(&self) -> Result { + let url = self.config.admin().base_url.join("tooling-access/token")?; + let resp = self + .config + .http_client() + .post(url) + .send() + .await + .map_err(SdkError::Http)?; + + let status = resp.status(); + let body = resp.text().await.map_err(SdkError::Http)?; + if !status.is_success() { + return Err(SdkError::Api { status, body }); + } + let env: Envelope = + serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })?; + let data = env.data.ok_or_else(|| SdkError::Api { + status, + body: env + .error + .unwrap_or_else(|| "missing token data".to_string()), + })?; + let exp_unix = parse_rfc3339_to_unix(&data.expires_at)?; + Ok(CachedToken { + endpoint_url: data.endpoint_url, + token: data.token, + exp_unix, + }) + } + + async fn parse_status(resp: reqwest::Response) -> Result { + let status = resp.status(); + let body = resp.text().await.map_err(SdkError::Http)?; + if !status.is_success() { + return Err(SdkError::Api { status, body }); + } + let env: Envelope = + serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })?; + let data = env.data.ok_or_else(|| SdkError::Api { + status, + body: env + .error + .unwrap_or_else(|| "missing status data".to_string()), + })?; + Ok(ToolingAccessStatus { + enabled: data.enabled, + endpoint_url: data.endpoint_url, + enabled_at: data.enabled_at, + endpoint_id: data.endpoint_id, + }) + } +} + +// The endpoint id arrives as either a JSON string or a number. Accept both and +// normalize to an owned String so the field is uniform regardless of source. +fn deserialize_optional_id<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrNum { + Str(String), + Num(i64), + } + let opt = Option::::deserialize(deserializer)?; + Ok(opt.map(|v| match v { + StringOrNum::Str(s) => s, + StringOrNum::Num(n) => n.to_string(), + })) +} + +// Parse an RFC3339 / ISO8601 timestamp (e.g. "2026-06-23T20:40:00.000Z") to +// unix seconds without pulling in a date crate. Handles the common `Z` (UTC) +// and `±HH:MM` offset forms. The control plane emits UTC `Z` timestamps. +pub(crate) fn parse_rfc3339_to_unix(s: &str) -> Result { + let bad = || SdkError::Decode { + // Reuse Decode for malformed control-plane payloads; build a synthetic + // serde error so the variant carries a useful message and the raw body. + source: serde::de::Error::custom("invalid expires_at timestamp"), + body: s.to_string(), + }; + + let (date, rest) = s.split_once('T').ok_or_else(bad)?; + let mut date_parts = date.split('-'); + let y: i64 = date_parts + .next() + .and_then(|v| v.parse().ok()) + .ok_or_else(bad)?; + let mo: i64 = date_parts + .next() + .and_then(|v| v.parse().ok()) + .ok_or_else(bad)?; + let d: i64 = date_parts + .next() + .and_then(|v| v.parse().ok()) + .ok_or_else(bad)?; + + // Strip the timezone designator, capturing the offset in seconds. + let (time_part, offset_secs) = if let Some(t) = rest.strip_suffix('Z') { + (t, 0i64) + } else if let Some(idx) = rest.rfind(['+', '-']) { + let (t, tz) = rest.split_at(idx); + let sign = if tz.starts_with('-') { -1 } else { 1 }; + let tz = &tz[1..]; + let (oh, om) = tz.split_once(':').ok_or_else(bad)?; + let oh: i64 = oh.parse().map_err(|_| bad())?; + let om: i64 = om.parse().map_err(|_| bad())?; + (t, sign * (oh * 3600 + om * 60)) + } else { + (rest, 0i64) + }; + + // Time may carry fractional seconds; drop them. + let time_main = time_part.split('.').next().unwrap_or(time_part); + let mut tparts = time_main.split(':'); + let hh: i64 = tparts.next().and_then(|v| v.parse().ok()).ok_or_else(bad)?; + let mm: i64 = tparts.next().and_then(|v| v.parse().ok()).ok_or_else(bad)?; + let ss: i64 = tparts.next().and_then(|v| v.parse().ok()).unwrap_or(0); + + let days = days_from_civil(y, mo, d); + let secs = days * 86_400 + hh * 3600 + mm * 60 + ss - offset_secs; + Ok(secs) +} + +// Days from 1970-01-01 for a proleptic Gregorian calendar date (Howard +// Hinnant's civil-date algorithm). +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use crate::config::{AdminConfig, SdkFullConfig}; + use crate::QuicknodeSdk; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn sdk_for(base: &str) -> QuicknodeSdk { + let mut cfg = SdkFullConfig::from_api_key("test-key".to_string()); + cfg.admin = Some(AdminConfig { + base_url: Some(format!("{base}/")), + }); + QuicknodeSdk::new(&cfg).unwrap() + } + + #[test] + fn parses_utc_z_timestamp() { + // 2026-06-23T20:40:00Z is 1782247200 unix seconds. + let got = parse_rfc3339_to_unix("2026-06-23T20:40:00.000Z").unwrap(); + assert_eq!(got, 1_782_247_200, "got {got}"); + } + + #[test] + fn epoch_round_trips() { + assert_eq!(parse_rfc3339_to_unix("1970-01-01T00:00:00Z").unwrap(), 0); + assert_eq!(parse_rfc3339_to_unix("1970-01-01T00:00:01Z").unwrap(), 1); + } + + #[test] + fn applies_positive_offset() { + // 00:00 at +01:00 is 23:00 the previous day UTC == -3600. + assert_eq!( + parse_rfc3339_to_unix("1970-01-01T00:00:00+01:00").unwrap(), + -3600 + ); + } + + #[test] + fn rejects_garbage_timestamp() { + assert!(matches!( + parse_rfc3339_to_unix("not-a-date"), + Err(SdkError::Decode { .. }) + )); + } + + #[tokio::test] + async fn status_happy_path() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/tooling-access")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "enabled": true, + // endpoint_id is a JSON number on the wire; it must + // deserialize into the String endpoint_id field. + "endpoint_id": 3, + "endpoint_url": "https://tooling-access-abc123.quiknode.pro", + "enabled_at": "2026-06-23T20:30:00.000Z" + }, + "error": null + }))) + .mount(&server) + .await; + + let sdk = sdk_for(&server.uri()); + let status = sdk.admin.tooling_access_status().await.unwrap(); + assert!(status.enabled); + assert_eq!( + status.endpoint_url.as_deref(), + Some("https://tooling-access-abc123.quiknode.pro") + ); + assert_eq!(status.endpoint_id.as_deref(), Some("3")); + } + + #[tokio::test] + async fn enable_returns_status() { + let server = MockServer::start().await; + Mock::given(method("PATCH")) + .and(path("/tooling-access")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { "enabled": true, "endpoint_url": "https://x.quiknode.pro" }, + "error": null + }))) + .mount(&server) + .await; + + let sdk = sdk_for(&server.uri()); + let status = sdk.admin.enable_tooling_access().await.unwrap(); + assert!(status.enabled); + } + + #[tokio::test] + async fn mint_token_happy_path() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/tooling-access/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "endpoint_url": "https://tooling-access-abc123.quiknode.pro", + "token": "header.payload.sig", + "expires_at": "2026-06-23T20:40:00.000Z" + }, + "error": null + }))) + .mount(&server) + .await; + + let sdk = sdk_for(&server.uri()); + let tok = sdk.admin.mint_tooling_token().await.unwrap(); + assert_eq!(tok.token, "header.payload.sig"); + assert_eq!(tok.exp_unix, 1_782_247_200); + } + + #[tokio::test] + async fn mint_token_not_enabled_is_api_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/tooling-access/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "data": null, + "error": "Tooling access is not enabled. Enable it first." + }))) + .mount(&server) + .await; + + let sdk = sdk_for(&server.uri()); + let err = sdk.admin.mint_tooling_token().await.unwrap_err(); + match err { + SdkError::Api { status, body } => { + assert_eq!(status.as_u16(), 400); + assert!(body.contains("not enabled"), "body: {body}"); + } + other => panic!("expected Api error, got {other:?}"), + } + } +} diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 8cc3eec..c750b92 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -140,6 +140,102 @@ impl KvStoreConfig { } } +/// A minted session JWT plus the endpoint it authenticates against and its +/// wall-clock expiry. This is the unit cached by the RPC client and the unit a +/// host persists between processes (e.g. the CLI's on-disk token cache). +/// +/// `exp_unix` is the JWT `exp` claim (unix seconds), used directly so it +/// survives a process restart (unlike a monotonic `Instant`). +#[cfg_attr(feature = "python", gen_stub_pyclass)] +#[cfg_attr(feature = "python", pyclass(get_all, set_all))] +#[cfg_attr(feature = "node", napi(object))] +#[cfg_attr(feature = "rust", derive(Builder))] +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct CachedToken { + /// The provisioned tooling-access endpoint URL the JWT authenticates against. + pub endpoint_url: String, + /// The minted ES256 session JWT, presented as a Bearer token. + pub token: String, + /// JWT `exp` claim in unix seconds. + pub exp_unix: i64, +} + +// Manual Debug that redacts the JWT: the token is a live bearer credential and +// must never appear in logs or panic messages. +impl std::fmt::Debug for CachedToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CachedToken") + .field("endpoint_url", &self.endpoint_url) + .field("token", &"[redacted]") + .field("exp_unix", &self.exp_unix) + .finish() + } +} + +#[cfg(feature = "python")] +#[gen_stub_pymethods] +#[pymethods] +impl CachedToken { + #[new] + pub fn new(endpoint_url: String, token: String, exp_unix: i64) -> Self { + CachedToken { + endpoint_url, + token, + exp_unix, + } + } +} + +#[cfg_attr(feature = "python", gen_stub_pyclass)] +#[cfg_attr(feature = "python", pyclass(get_all, set_all))] +#[cfg_attr(feature = "node", napi(object))] +#[cfg_attr(feature = "rust", derive(Builder))] +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct RpcConfig { + /// Custom HTTP URL to send JSON-RPC calls to, bypassing the Tooling Access + /// endpoint. When set, every `rpc.call` on this client goes straight to this + /// URL with NO session token minted or attached — the URL is treated as a + /// self-authenticating endpoint (e.g. a provisioned `.quiknode.pro` URL that + /// already embeds its token, or a self-hosted node). A per-call + /// `endpoint_url` overrides this default. Unset means tooling-JWT mode. + pub endpoint_url: Option, + /// Optional pre-existing token to seed the in-memory cache (e.g. loaded + /// from a host's on-disk cache). Advisory: a malformed or expired seed is + /// treated as a cache miss and a fresh token is minted. + pub seed: Option, + /// Seconds before `exp` at which the client proactively refreshes. The + /// margin also absorbs clock skew between client and endpoint. Defaults to + /// 60 when unset. + pub refresh_margin_secs: Option, + /// Per-network URL map for multichain routing: network key (e.g. + /// `"solana-mainnet"`, `"polygon"`) -> full http_url. Built from + /// `admin.get_endpoint_urls(...).multichain_urls`. When set, `rpc.call` with + /// a `network` resolves the target URL here. Optional; the default-network + /// call path needs no map. + pub networks: Option>, +} + +#[cfg(feature = "python")] +#[gen_stub_pymethods] +#[pymethods] +impl RpcConfig { + #[new] + #[pyo3(signature = (endpoint_url=None, seed=None, refresh_margin_secs=None, networks=None))] + pub fn new( + endpoint_url: Option, + seed: Option, + refresh_margin_secs: Option, + networks: Option>, + ) -> Self { + RpcConfig { + endpoint_url, + seed, + refresh_margin_secs, + networks, + } + } +} + #[cfg_attr(feature = "python", gen_stub_pyclass)] #[cfg_attr(feature = "python", pyclass(get_all, set_all))] #[cfg_attr(feature = "node", napi(object))] @@ -173,6 +269,7 @@ pub struct SdkFullConfig { pub webhooks: Option, pub kvstore: Option, pub sql: Option, + pub rpc: Option, } impl SdkFullConfig { @@ -185,6 +282,7 @@ impl SdkFullConfig { webhooks: None, kvstore: None, sql: None, + rpc: None, } } @@ -211,7 +309,8 @@ impl SdkFullConfig { #[pymethods] impl SdkFullConfig { #[new] - #[pyo3(signature = (api_key, http=None, admin=None, streams=None, webhooks=None, kvstore=None, sql=None))] + #[pyo3(signature = (api_key, http=None, admin=None, streams=None, webhooks=None, kvstore=None, sql=None, rpc=None))] + #[allow(clippy::too_many_arguments)] pub fn new( api_key: String, http: Option, @@ -220,6 +319,7 @@ impl SdkFullConfig { webhooks: Option, kvstore: Option, sql: Option, + rpc: Option, ) -> Self { SdkFullConfig { api_key, @@ -229,6 +329,7 @@ impl SdkFullConfig { webhooks, kvstore, sql, + rpc, } } } diff --git a/crates/core/src/errors.rs b/crates/core/src/errors.rs index 70f17bb..9c6c216 100644 --- a/crates/core/src/errors.rs +++ b/crates/core/src/errors.rs @@ -21,6 +21,9 @@ pub enum SdkError { #[error("Configuration error: {0}")] Config(String), + + #[error("JSON-RPC error (code {code}): {message}")] + Rpc { code: i64, message: String }, } // Classifies a transport-level HTTP failure. Bindings use this to pick a diff --git a/crates/core/src/kvstore/mod.rs b/crates/core/src/kvstore/mod.rs index 4d3f564..4343b03 100644 --- a/crates/core/src/kvstore/mod.rs +++ b/crates/core/src/kvstore/mod.rs @@ -697,6 +697,7 @@ mod tests { base_url: Some(base_url), }), sql: None, + rpc: None, }) .unwrap() } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 945bbb0..102265b 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -2,13 +2,15 @@ pub mod admin; pub mod config; pub mod errors; pub mod kvstore; +pub mod rpc; pub mod sql; pub mod streams; pub mod webhooks; +pub use admin::ToolingAccessStatus; pub use config::{ - AdminConfig, ClientInfo, HttpConfig, KvStoreConfig, SdkFullConfig, SqlConfig, StreamsConfig, - WebhooksConfig, + AdminConfig, CachedToken, ClientInfo, HttpConfig, KvStoreConfig, RpcConfig, SdkFullConfig, + SqlConfig, StreamsConfig, WebhooksConfig, }; pub use kvstore::{ AddListItemParams, BulkSetsParams, CreateListParams, CreateSetParams, GetListData, @@ -16,6 +18,7 @@ pub use kvstore::{ GetSetsParams, GetSetsResponse, KvSetEntry, KvStoreApiClient, ListContainsItemResponse, UpdateListParams, }; +pub use rpc::RpcApiClient; pub use sql::{ ChainSchema, ColumnMeta, ColumnSchema, QueryParams, QueryResponse, QueryStatistics, SqlApiClient, TableSchema, @@ -76,6 +79,9 @@ impl std::fmt::Debug for SdkConfig { struct SdkConfigInner { http_client: ReqwestClient, + // Separate client for RPC data-plane calls: no account `x-api-key` header, + // authenticates per-request with a Bearer JWT. See `new_with_client_info`. + rpc_http_client: ReqwestClient, admin: admin::ResolvedAdminConfig, streams: streams::ResolvedStreamsConfig, webhooks: webhooks::ResolvedWebhooksConfig, @@ -99,8 +105,6 @@ impl SdkConfig { config: &SdkFullConfig, client_info: Option, ) -> Result { - let mut builder = ReqwestClient::builder(); - let timeout_secs = match &config.http { Some(h) => match h.timeout_secs { Some(secs) if secs < 0 => { @@ -111,29 +115,37 @@ impl SdkConfig { }, None => DEFAULT_TIMEOUT_SECS, }; - builder = builder.timeout(std::time::Duration::from_secs(timeout_secs)); - - if let Some(http) = &config.http { - if let Some(max_idle) = http.pool_max_idle_per_host { + let pool_max_idle_per_host = config + .http + .as_ref() + .and_then(|http| http.pool_max_idle_per_host); + + // `ClientBuilder` is not cloneable, so build a freshly-configured + // builder for each client from the shared transport settings. + let make_builder = || { + let mut builder = + ReqwestClient::builder().timeout(std::time::Duration::from_secs(timeout_secs)); + if let Some(max_idle) = pool_max_idle_per_host { builder = builder.pool_max_idle_per_host(max_idle as usize); } - } + builder + }; - let mut default_headers = HeaderMap::new(); - default_headers.insert( + // Headers common to every client. The account `x-api-key` is added on + // top of this only for the control-plane client below — RPC data-plane + // calls authenticate with a short-lived Bearer JWT and must never carry + // the long-lived account key. + let mut common_headers = HeaderMap::new(); + common_headers.insert( reqwest::header::ACCEPT, HeaderValue::from_static("application/json"), ); - default_headers.insert( + common_headers.insert( reqwest::header::CONTENT_TYPE, HeaderValue::from_static("application/json"), ); - default_headers.insert( - "x-api-key", - HeaderValue::from_str(&config.api_key).map_err(|e| SdkError::Config(e.to_string()))?, - ); let ua = build_user_agent(&client_info.unwrap_or_else(default_rust_client_info)); - default_headers.insert( + common_headers.insert( reqwest::header::USER_AGENT, HeaderValue::from_str(&ua).map_err(|e| SdkError::Config(e.to_string()))?, ); @@ -149,19 +161,38 @@ impl SdkConfig { let header_value = HeaderValue::from_str(value).map_err(|e| { SdkError::Config(format!("invalid header value for {name:?}: {e}")) })?; - default_headers.insert(header_name, header_value); + common_headers.insert(header_name, header_value); } } } - builder = builder.default_headers(default_headers); + // Control-plane client: carries the account `x-api-key` used to reach + // the QuickNode management APIs (admin, streams, webhooks, etc.). Insert + // the key first, then overlay `common_headers` so a caller-supplied + // `x-api-key` in custom headers still wins (custom headers override all + // SDK-managed defaults). + let mut main_headers = HeaderMap::new(); + main_headers.insert( + "x-api-key", + HeaderValue::from_str(&config.api_key).map_err(|e| SdkError::Config(e.to_string()))?, + ); + main_headers.extend(common_headers.clone()); + let http_client = make_builder() + .default_headers(main_headers) + .build() + .map_err(|e| SdkError::Config(e.to_string()))?; - let http_client = builder + // RPC data-plane client: identical transport config, but WITHOUT the + // account `x-api-key` default header. RPC calls attach a Bearer JWT + // per-request so the account key never leaves the control plane. + let rpc_http_client = make_builder() + .default_headers(common_headers) .build() .map_err(|e| SdkError::Config(e.to_string()))?; Ok(Self(Arc::new(SdkConfigInner { http_client, + rpc_http_client, admin: admin::ResolvedAdminConfig::from_config(config.admin.as_ref())?, streams: streams::ResolvedStreamsConfig::from_config(config.streams.as_ref())?, webhooks: webhooks::ResolvedWebhooksConfig::from_config(config.webhooks.as_ref())?, @@ -174,6 +205,10 @@ impl SdkConfig { &self.0.http_client } + pub(crate) fn rpc_http_client(&self) -> &ReqwestClient { + &self.0.rpc_http_client + } + pub(crate) fn admin(&self) -> &admin::ResolvedAdminConfig { &self.0.admin } @@ -211,6 +246,9 @@ pub struct QuicknodeSdk { /// SQL Explorer client: executes SQL queries against indexed blockchain /// data and fetches the database schema. pub sql: sql::SqlApiClient, + /// JSON-RPC client: makes on-chain calls against the account's Tooling + /// Access endpoint using short-lived session JWTs. + pub rpc: rpc::RpcApiClient, } impl QuicknodeSdk { @@ -232,7 +270,8 @@ impl QuicknodeSdk { streams: streams::StreamsApiClient::new(sdk_config.clone()), webhooks: webhooks::WebhooksApiClient::new(sdk_config.clone()), kvstore: kvstore::KvStoreApiClient::new(sdk_config.clone()), - sql: sql::SqlApiClient::new(sdk_config), + sql: sql::SqlApiClient::new(sdk_config.clone()), + rpc: rpc::RpcApiClient::new(sdk_config, config.rpc.as_ref()), }) } @@ -264,6 +303,7 @@ mod headers_tests { webhooks: None, kvstore: None, sql: None, + rpc: None, } } @@ -353,6 +393,7 @@ mod headers_tests { webhooks: None, kvstore: None, sql: None, + rpc: None, }; let sdk = QuicknodeSdk::new(&cfg).unwrap(); diff --git a/crates/core/src/rpc/mod.rs b/crates/core/src/rpc/mod.rs new file mode 100644 index 0000000..1d0e4b4 --- /dev/null +++ b/crates/core/src/rpc/mod.rs @@ -0,0 +1,766 @@ +//! Data-plane JSON-RPC client. +//! +//! Makes JSON-RPC calls directly against the account's provisioned Tooling +//! Access endpoint, authenticating with a short-lived session JWT. The JWT is +//! minted via the Admin control plane ([`crate::admin::AdminApiClient::mint_tooling_token`]), +//! cached in memory, and refreshed proactively before expiry (or reactively on +//! a 401). The signing key never leaves the server; this client only ever holds +//! a minted JWT. +//! +//! A host that outlives a single process (e.g. the CLI) can persist the cached +//! token between runs by seeding [`crate::config::RpcConfig::seed`] on startup +//! and snapshotting [`RpcApiClient::current_token`] afterwards. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::Value; + +use crate::admin::AdminApiClient; +use crate::config::{CachedToken, RpcConfig}; +use crate::errors::SdkError; +use crate::SdkConfig; + +// Default seconds before `exp` at which we proactively refresh. Also absorbs +// clock skew between client and endpoint. +const DEFAULT_REFRESH_MARGIN_SECS: i64 = 60; + +/// JSON-RPC client for the Tooling Access endpoint. +#[derive(Clone)] +pub struct RpcApiClient { + // Used to mint/refresh session tokens against the control plane. + admin: AdminApiClient, + config: SdkConfig, + refresh_margin_secs: i64, + // Current cached token. Guarded by a std Mutex held only for synchronous + // read/write — never across an await. + cache: Arc>>, + // Serializes refreshes so concurrent callers that all see an expired token + // trigger a single mint, not a stampede. Held across the mint await, hence + // an async mutex. + refresh_lock: Arc>, + // Per-network URL map for multichain routing: key (e.g. "solana-mainnet") + // -> full http_url. The endpoint is multichain by subdomain and the URLs + // are not derivable by string munging, so callers seed this map (from + // `admin.get_endpoint_urls`). `None` until seeded; a `call` with a network + // then errors with a clear message. + networks: Arc>>>, + // Client-wide default custom endpoint URL. When set, calls bypass the + // Tooling Access endpoint and the JWT entirely (see `RpcConfig::endpoint_url`). + // A per-call `endpoint_url` overrides this. Immutable after construction. + endpoint_url: Option, +} + +impl std::fmt::Debug for RpcApiClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never print the cached JWT. + f.debug_struct("RpcApiClient") + .field("refresh_margin_secs", &self.refresh_margin_secs) + .field( + "has_cached_token", + &self.cache.lock().is_ok_and(|c| c.is_some()), + ) + .finish() + } +} + +impl RpcApiClient { + pub fn new(config: SdkConfig, rpc_config: Option<&RpcConfig>) -> Self { + let refresh_margin_secs = rpc_config + .and_then(|c| c.refresh_margin_secs) + .filter(|&m| m >= 0) + .unwrap_or(DEFAULT_REFRESH_MARGIN_SECS); + // Seed is advisory: a stale/expired seed simply produces a cache miss on + // the first call and is replaced by a fresh mint. + let seed = rpc_config.and_then(|c| c.seed.clone()); + let networks = rpc_config.and_then(|c| c.networks.clone()); + let endpoint_url = rpc_config.and_then(|c| c.endpoint_url.clone()); + Self { + admin: AdminApiClient::new(config.clone()), + config, + refresh_margin_secs, + cache: Arc::new(Mutex::new(seed)), + refresh_lock: Arc::new(tokio::sync::Mutex::new(())), + networks: Arc::new(Mutex::new(networks)), + endpoint_url, + } + } + + /// Seeds (or replaces) the per-network URL map used for multichain routing. + /// The map is `network key -> full http_url`, typically built from + /// `admin.get_endpoint_urls(endpoint_id).multichain_urls`. A host that + /// didn't seed it via [`RpcConfig`] can install it here before calling with + /// a `network`. + pub fn set_networks(&self, networks: HashMap) { + if let Ok(mut guard) = self.networks.lock() { + *guard = Some(networks); + } + } + + /// Returns a snapshot of the current cached token, if any. Hosts use this to + /// persist the token between processes. Returns `None` if no token has been + /// minted (or seeded) yet. + pub fn current_token(&self) -> Option { + self.cache.lock().ok().and_then(|c| c.clone()) + } + + /// Discards the in-memory cached token, forcing the next call to mint a + /// fresh one. Use when the cached token is known stale beyond expiry — e.g. + /// the endpoint was disabled and re-enabled out of band. + pub fn clear_cached_token(&self) { + self.invalidate(); + } + + /// Makes a JSON-RPC call. `params` defaults to an empty array when `None`; + /// it accepts both a positional array and a by-name object. + /// + /// `endpoint_url` sends this call to a custom HTTP URL, bypassing the + /// Tooling Access endpoint and the session JWT entirely — the URL is treated + /// as self-authenticating and gets no Authorization header. It overrides the + /// client-wide [`RpcConfig::endpoint_url`] default for this call. Because a + /// custom URL is not multichain-routed, passing both `endpoint_url` and + /// `network` is a [`SdkError::Config`] error. + /// + /// `network` selects which chain to route to on a multichain endpoint: it + /// is a key in the seeded network map (e.g. `"solana-mainnet"`, `"polygon"`). + /// When `None`, the call goes to the endpoint's default network. When `Some`, + /// the map must be seeded (via [`RpcConfig`] or [`Self::set_networks`]) and + /// contain the key, otherwise a [`SdkError::Config`] is returned. + /// + /// Returns the unwrapped `result`. A JSON-RPC `error` member is surfaced as + /// [`SdkError::Rpc`]. + pub async fn call( + &self, + method: &str, + params: Option, + network: Option, + endpoint_url: Option, + ) -> Result { + // Precedence: a per-call custom URL wins; then a per-call network; then + // the client-wide custom URL default; then the tooling default endpoint. + // A per-call URL and network are mutually exclusive (custom URLs are not + // multichain-routed). + if endpoint_url.is_some() && network.is_some() { + return Err(SdkError::Config( + "`endpoint_url` and `network` are mutually exclusive: a custom \ + URL is not multichain-routed" + .into(), + )); + } + let custom_url = endpoint_url.or_else(|| self.endpoint_url.clone()); + + // Custom mode: no token minted or attached; the URL authenticates itself. + // There is no JWT to refresh, so no reactive-401 retry path. + if let Some(url) = custom_url { + let resp = self.send(None, &url, method, ¶ms).await?; + return Self::parse_rpc(resp); + } + + // Tooling mode: mint/refresh the JWT and route via the token/network map. + let token = self.valid_token().await?; + let url = self.resolve_url(&token, network.as_deref())?; + let resp = self.send(Some(&token), &url, method, ¶ms).await?; + + // Reactive refresh: a 401 means the token was rejected (expired at the + // edge, revoked, clock skew past the margin). Discard, mint once, retry + // once. A second 401 surfaces as an Api error. + if resp.status == 401 { + self.invalidate(); + let token = self.refresh().await?; + let url = self.resolve_url(&token, network.as_deref())?; + let retry = self.send(Some(&token), &url, method, ¶ms).await?; + return Self::parse_rpc(retry); + } + Self::parse_rpc(resp) + } + + // Resolve the target URL for a call. `None` network -> the token's default + // endpoint_url. `Some(key)` -> the mapped per-network URL; errors if no map + // is seeded or the key is unknown (listing available keys). + fn resolve_url(&self, token: &CachedToken, network: Option<&str>) -> Result { + let Some(key) = network else { + return Ok(token.endpoint_url.clone()); + }; + let guard = self + .networks + .lock() + .map_err(|_| SdkError::Config("network map lock poisoned".into()))?; + let Some(map) = guard.as_ref() else { + return Err(SdkError::Config(format!( + "network '{key}' requested but no network map is available; \ + seed it via RpcConfig.networks or set_networks()" + ))); + }; + match map.get(key) { + Some(url) => Ok(url.clone()), + None => { + let mut keys: Vec<&str> = map.keys().map(String::as_str).collect(); + keys.sort_unstable(); + Err(SdkError::Config(format!( + "unknown network '{key}'. Available: {}", + keys.join(", ") + ))) + } + } + } + + // ── Token lifecycle ────────────────────────────────────────────────────── + + // Returns a token that is valid past the refresh margin, minting if needed. + async fn valid_token(&self) -> Result { + if let Some(tok) = self.cached_if_fresh() { + return Ok(tok); + } + self.refresh().await + } + + // Returns the cached token only if present and not within the refresh margin. + fn cached_if_fresh(&self) -> Option { + let now = now_unix(); + let guard = self.cache.lock().ok()?; + guard + .as_ref() + .filter(|t| now + self.refresh_margin_secs < t.exp_unix) + .cloned() + } + + // Single-flight refresh: only one caller mints at a time; others re-check + // the cache after acquiring the lock and reuse the just-minted token. + async fn refresh(&self) -> Result { + let _guard = self.refresh_lock.lock().await; + // Another caller may have refreshed while we waited for the lock. + if let Some(tok) = self.cached_if_fresh() { + return Ok(tok); + } + let fresh = self.admin.mint_tooling_token().await?; + if let Ok(mut guard) = self.cache.lock() { + *guard = Some(fresh.clone()); + } + Ok(fresh) + } + + fn invalidate(&self) { + if let Ok(mut guard) = self.cache.lock() { + *guard = None; + } + } + + // ── Transport ───────────────────────────────────────────────────────────── + + // Sends the JSON-RPC request. `token` is `Some` in tooling mode (attaches a + // Bearer JWT) and `None` for a custom endpoint URL, which is treated as + // self-authenticating and gets no Authorization header. Either way the + // request goes through the keyless `rpc_http_client`, so the account + // `x-api-key` never reaches the data plane. + async fn send( + &self, + token: Option<&CachedToken>, + target_url: &str, + method: &str, + params: &Option, + ) -> Result { + let url = reqwest::Url::parse(target_url).map_err(|e| SdkError::Config(e.to_string()))?; + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params.clone().unwrap_or_else(|| Value::Array(vec![])), + }); + let mut req = self.config.rpc_http_client().post(url).json(&body); + if let Some(token) = token { + req = req.bearer_auth(&token.token); + } + let resp = req.send().await.map_err(SdkError::Http)?; + let status = resp.status().as_u16(); + let text = resp.text().await.map_err(SdkError::Http)?; + Ok(RawResponse { status, text }) + } + + // Parse a JSON-RPC envelope: surface `error` as SdkError::Rpc, else return + // `result`. Non-2xx HTTP without a usable JSON-RPC body is an Api error. + fn parse_rpc(resp: RawResponse) -> Result { + // Try to decode the JSON-RPC envelope regardless of HTTP status — some + // endpoints return a JSON-RPC error with a 200, others with 4xx. + let parsed: Result = serde_json::from_str(&resp.text); + match parsed { + Ok(env) => { + if let Some(err) = env.error { + return Err(SdkError::Rpc { + code: err.code, + message: err.message, + }); + } + if let Some(result) = env.result { + return Ok(result); + } + // No result and no error: if the HTTP status was a failure, + // surface it; otherwise return null. + if !(200..300).contains(&resp.status) { + return Err(SdkError::Api { + status: status_code(resp.status), + body: resp.text, + }); + } + Ok(Value::Null) + } + Err(source) => { + if !(200..300).contains(&resp.status) { + Err(SdkError::Api { + status: status_code(resp.status), + body: resp.text, + }) + } else { + Err(SdkError::Decode { + source, + body: resp.text, + }) + } + } + } + } +} + +struct RawResponse { + status: u16, + text: String, +} + +#[derive(serde::Deserialize)] +struct JsonRpcEnvelope { + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[derive(serde::Deserialize)] +struct JsonRpcError { + code: i64, + message: String, +} + +fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + // Pre-epoch system clock is implausible; treat as 0 so a fresh token is + // always considered valid rather than panicking. + .map_or(0, |d| d.as_secs() as i64) +} + +fn status_code(status: u16) -> reqwest::StatusCode { + reqwest::StatusCode::from_u16(status).unwrap_or(reqwest::StatusCode::BAD_GATEWAY) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use crate::config::{AdminConfig, SdkFullConfig}; + use crate::QuicknodeSdk; + use std::sync::atomic::{AtomicUsize, Ordering}; + use wiremock::matchers::{body_partial_json, header, method, path}; + use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + + // A future exp so seeded tokens are considered fresh. + fn future_exp() -> i64 { + now_unix() + 3600 + } + + fn token_body(endpoint_url: &str, exp: i64) -> serde_json::Value { + // The mint route returns an ISO timestamp; build one far in the future. + // We feed exp directly via seed in most tests, but mint tests use this. + let _ = exp; + serde_json::json!({ + "data": { + "endpoint_url": endpoint_url, + "token": "minted.jwt.value", + "expires_at": "2099-01-01T00:00:00.000Z" + }, + "error": null + }) + } + + fn sdk_with_seed(admin_base: &str, rpc_endpoint: &str) -> QuicknodeSdk { + let mut cfg = SdkFullConfig::from_api_key("test-key".to_string()); + cfg.admin = Some(AdminConfig { + base_url: Some(format!("{admin_base}/")), + }); + cfg.rpc = Some(RpcConfig { + endpoint_url: None, + seed: Some(CachedToken { + endpoint_url: rpc_endpoint.to_string(), + token: "seeded.jwt".to_string(), + exp_unix: future_exp(), + }), + refresh_margin_secs: None, + networks: None, + }); + QuicknodeSdk::new(&cfg).unwrap() + } + + #[tokio::test] + async fn call_uses_seed_without_minting() { + let server = MockServer::start().await; + // RPC endpoint returns a result. + Mock::given(method("POST")) + .and(path("/")) + .and(body_partial_json( + serde_json::json!({ "method": "eth_blockNumber" }), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1335f9a" + }))) + .mount(&server) + .await; + + // Use the same server for both admin and rpc; if mint were called it + // would 404 (no mock for /tooling-access/token) and the test would fail. + let sdk = sdk_with_seed(&server.uri(), &server.uri()); + let result = sdk + .rpc + .call("eth_blockNumber", None, None, None) + .await + .unwrap(); + assert_eq!(result, serde_json::json!("0x1335f9a")); + } + + #[tokio::test] + async fn call_sends_bearer_jwt_but_not_account_api_key() { + let server = MockServer::start().await; + // Match only requests that carry the Bearer JWT and omit the account + // key: the data-plane client must never leak `x-api-key`. If the key + // were present this mock would not match and the call would 404. + Mock::given(method("POST")) + .and(path("/")) + .and(header("authorization", "Bearer seeded.jwt")) + .and(|req: &Request| !req.headers.contains_key("x-api-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xok" + }))) + .mount(&server) + .await; + + let sdk = sdk_with_seed(&server.uri(), &server.uri()); + let result = sdk + .rpc + .call("eth_blockNumber", None, None, None) + .await + .unwrap(); + assert_eq!(result, serde_json::json!("0xok")); + } + + // Builds an SDK whose RPC client has a client-wide custom `endpoint_url` and + // NO seed. The admin base points at a dead address, so any attempt to mint a + // tooling token would fail — proving custom mode never touches the JWT path. + fn sdk_with_custom_url(endpoint_url: &str) -> QuicknodeSdk { + let mut cfg = SdkFullConfig::from_api_key("test-key".to_string()); + cfg.admin = Some(AdminConfig { + base_url: Some("http://127.0.0.1:1/".to_string()), + }); + cfg.rpc = Some(RpcConfig { + endpoint_url: Some(endpoint_url.to_string()), + seed: None, + refresh_margin_secs: None, + networks: None, + }); + QuicknodeSdk::new(&cfg).unwrap() + } + + #[tokio::test] + async fn config_endpoint_url_bypasses_jwt_and_minting() { + let server = MockServer::start().await; + // Custom endpoint must receive the call with NO Authorization header and + // NO account key. If minting were attempted it would fail against the + // dead admin base and the call would error instead. + Mock::given(method("POST")) + .and(path("/custom")) + .and(|req: &Request| { + !req.headers.contains_key("authorization") && !req.headers.contains_key("x-api-key") + }) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xcustom" + }))) + .mount(&server) + .await; + + let sdk = sdk_with_custom_url(&format!("{}/custom", server.uri())); + let result = sdk + .rpc + .call("eth_blockNumber", None, None, None) + .await + .unwrap(); + assert_eq!(result, serde_json::json!("0xcustom")); + // No token was ever minted or cached. + assert!(sdk.rpc.current_token().is_none()); + } + + #[tokio::test] + async fn per_call_endpoint_url_overrides_config_default() { + let server = MockServer::start().await; + // The per-call URL points here; the config default points at /wrong, + // which has no mock and would 404. + Mock::given(method("POST")) + .and(path("/override")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xoverride" + }))) + .mount(&server) + .await; + + let sdk = sdk_with_custom_url(&format!("{}/wrong", server.uri())); + let result = sdk + .rpc + .call( + "eth_blockNumber", + None, + None, + Some(format!("{}/override", server.uri())), + ) + .await + .unwrap(); + assert_eq!(result, serde_json::json!("0xoverride")); + } + + #[tokio::test] + async fn endpoint_url_and_network_together_is_config_error() { + let sdk = sdk_with_custom_url("https://example.invalid/rpc"); + let err = sdk + .rpc + .call( + "eth_blockNumber", + None, + Some("solana-mainnet".to_string()), + Some("https://example.invalid/other".to_string()), + ) + .await + .unwrap_err(); + assert!(matches!(err, SdkError::Config(msg) if msg.contains("mutually exclusive"))); + } + + #[tokio::test] + async fn json_rpc_error_maps_to_rpc_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "error": { "code": -32602, "message": "invalid params" } + }))) + .mount(&server) + .await; + + let sdk = sdk_with_seed(&server.uri(), &server.uri()); + let err = sdk + .rpc + .call("eth_getBalance", None, None, None) + .await + .unwrap_err(); + match err { + SdkError::Rpc { code, message } => { + assert_eq!(code, -32602); + assert!(message.contains("invalid params")); + } + other => panic!("expected Rpc error, got {other:?}"), + } + } + + #[tokio::test] + async fn reactive_401_refreshes_and_retries_once() { + let server = MockServer::start().await; + + // First RPC call returns 401, second (after refresh) returns a result. + struct Sequence { + calls: AtomicUsize, + } + impl Respond for Sequence { + fn respond(&self, _: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 { + ResponseTemplate::new(401).set_body_string("unauthorized") + } else { + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xokay" + })) + } + } + } + + // RPC endpoint lives at /rpc; mint route at /tooling-access/token. + Mock::given(method("POST")) + .and(path("/rpc")) + .respond_with(Sequence { + calls: AtomicUsize::new(0), + }) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/tooling-access/token")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(token_body(&format!("{}/rpc", server.uri()), future_exp())), + ) + .mount(&server) + .await; + + let sdk = sdk_with_seed(&server.uri(), &format!("{}/rpc", server.uri())); + let result = sdk + .rpc + .call("eth_blockNumber", None, None, None) + .await + .unwrap(); + assert_eq!(result, serde_json::json!("0xokay")); + } + + #[tokio::test] + async fn second_401_surfaces_as_api_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/rpc")) + .respond_with(ResponseTemplate::new(401).set_body_string("nope")) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/tooling-access/token")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(token_body(&format!("{}/rpc", server.uri()), future_exp())), + ) + .mount(&server) + .await; + + let sdk = sdk_with_seed(&server.uri(), &format!("{}/rpc", server.uri())); + let err = sdk + .rpc + .call("eth_blockNumber", None, None, None) + .await + .unwrap_err(); + assert!(matches!(err, SdkError::Api { status, .. } if status.as_u16() == 401)); + } + + #[tokio::test] + async fn expired_seed_triggers_mint() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/tooling-access/token")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(token_body(&format!("{}/rpc", server.uri()), future_exp())), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/rpc")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xfresh" + }))) + .mount(&server) + .await; + + // Seed an already-expired token. + let mut cfg = SdkFullConfig::from_api_key("test-key".to_string()); + cfg.admin = Some(AdminConfig { + base_url: Some(format!("{}/", server.uri())), + }); + cfg.rpc = Some(RpcConfig { + endpoint_url: None, + seed: Some(CachedToken { + endpoint_url: format!("{}/rpc", server.uri()), + token: "expired.jwt".to_string(), + exp_unix: now_unix() - 10, + }), + refresh_margin_secs: None, + networks: None, + }); + let sdk = QuicknodeSdk::new(&cfg).unwrap(); + + let result = sdk + .rpc + .call("eth_blockNumber", None, None, None) + .await + .unwrap(); + assert_eq!(result, serde_json::json!("0xfresh")); + // current_token now reflects the minted token. + assert_eq!(sdk.rpc.current_token().unwrap().token, "minted.jwt.value"); + } + + #[tokio::test] + async fn network_routes_to_mapped_url() { + let server = MockServer::start().await; + // The default endpoint is /default; the "solana-mainnet" network maps to + // /solana. A call with that network must POST to /solana, not /default. + Mock::given(method("POST")) + .and(path("/solana")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": "12345" + }))) + .mount(&server) + .await; + + let mut cfg = SdkFullConfig::from_api_key("test-key".to_string()); + cfg.admin = Some(AdminConfig { + base_url: Some(format!("{}/", server.uri())), + }); + let mut networks = std::collections::HashMap::new(); + networks.insert( + "solana-mainnet".to_string(), + format!("{}/solana", server.uri()), + ); + cfg.rpc = Some(RpcConfig { + endpoint_url: None, + seed: Some(CachedToken { + endpoint_url: format!("{}/default", server.uri()), + token: "seeded.jwt".to_string(), + exp_unix: future_exp(), + }), + refresh_margin_secs: None, + networks: Some(networks), + }); + let sdk = QuicknodeSdk::new(&cfg).unwrap(); + + let result = sdk + .rpc + .call("getSlot", None, Some("solana-mainnet".to_string()), None) + .await + .unwrap(); + assert_eq!(result, serde_json::json!("12345")); + } + + #[tokio::test] + async fn unknown_network_is_config_error_listing_keys() { + let server = MockServer::start().await; + let sdk = sdk_with_seed(&server.uri(), &server.uri()); + // sdk_with_seed seeds no network map. + sdk.rpc.set_networks(std::collections::HashMap::from([( + "solana-mainnet".to_string(), + "https://x/solana".to_string(), + )])); + let err = sdk + .rpc + .call("getSlot", None, Some("polygon".to_string()), None) + .await + .unwrap_err(); + match err { + SdkError::Config(msg) => { + assert!(msg.contains("unknown network 'polygon'"), "msg: {msg}"); + assert!( + msg.contains("solana-mainnet"), + "msg should list keys: {msg}" + ); + } + other => panic!("expected Config error, got {other:?}"), + } + } + + #[tokio::test] + async fn network_without_seeded_map_errors() { + let server = MockServer::start().await; + let sdk = sdk_with_seed(&server.uri(), &server.uri()); + let err = sdk + .rpc + .call("getSlot", None, Some("solana-mainnet".to_string()), None) + .await + .unwrap_err(); + assert!(matches!(err, SdkError::Config(msg) if msg.contains("no network map"))); + } +} diff --git a/crates/core/src/sql/mod.rs b/crates/core/src/sql/mod.rs index 34a7b87..56f302e 100644 --- a/crates/core/src/sql/mod.rs +++ b/crates/core/src/sql/mod.rs @@ -309,6 +309,7 @@ mod tests { sql: Some(SqlConfig { base_url: Some(base_url), }), + rpc: None, }) .unwrap() } diff --git a/crates/core/src/streams/mod.rs b/crates/core/src/streams/mod.rs index 016775a..b040b39 100644 --- a/crates/core/src/streams/mod.rs +++ b/crates/core/src/streams/mod.rs @@ -328,6 +328,7 @@ mod tests { webhooks: None, kvstore: None, sql: None, + rpc: None, }) .unwrap() } diff --git a/crates/core/src/webhooks/mod.rs b/crates/core/src/webhooks/mod.rs index 74af2da..6096038 100644 --- a/crates/core/src/webhooks/mod.rs +++ b/crates/core/src/webhooks/mod.rs @@ -347,6 +347,7 @@ mod tests { }), kvstore: None, sql: None, + rpc: None, }) .unwrap() } diff --git a/crates/node/src/errors.rs b/crates/node/src/errors.rs index 80605f8..5c55f0c 100644 --- a/crates/node/src/errors.rs +++ b/crates/node/src/errors.rs @@ -7,24 +7,31 @@ use quicknode_sdk::errors::{HttpKind, SdkError}; // and rethrows as the matching typed class (ApiError / TimeoutError / ...). // // Wire format: "[||]" -// - kind: one of Config | Http | Timeout | Connect | Api | Decode -// - status: u16 for Api, "-" otherwise -// - body_len: byte length of body blob appended after message (for Api/Decode), "-" otherwise +// - kind: one of Config | Http | Timeout | Connect | Api | Decode | Rpc +// - status: u16 for Api, the JSON-RPC code (may be negative) for Rpc, "-" otherwise +// - body_len: byte length of body blob appended after message, "-" otherwise. +// For Api/Decode the body is the raw response body; for Rpc it is the +// JSON-RPC error `message` so JS can expose `.message` distinctly. // The body bytes are appended after a "\x1f" (unit separator) so JS can split cleanly. #[allow(clippy::needless_pass_by_value)] pub fn map_sdk_err(e: SdkError) -> Error { let msg = e.to_string(); - let (kind, status, body) = match &e { + // status_str carries a u16 for Api but the JSON-RPC code (i64, possibly + // negative) for Rpc, so it is a free-form string rather than Option. + let (kind, status_str, body) = match &e { SdkError::Config(_) | SdkError::UrlParse(_) => ("Config", None, None), - SdkError::Api { status, body } => ("Api", Some(status.as_u16()), Some(body.clone())), + SdkError::Api { status, body } => { + ("Api", Some(status.as_u16().to_string()), Some(body.clone())) + } SdkError::Decode { body, .. } => ("Decode", None, Some(body.clone())), + SdkError::Rpc { code, message } => ("Rpc", Some(code.to_string()), Some(message.clone())), SdkError::Http(_) => match e.http_kind() { Some(HttpKind::Timeout) => ("Timeout", None, None), Some(HttpKind::Connect) => ("Connect", None, None), _ => ("Http", None, None), }, }; - let status_s = status.map_or("-".to_string(), |s| s.to_string()); + let status_s = status_str.unwrap_or_else(|| "-".to_string()); let body_s = body.as_deref().unwrap_or(""); let body_len = if body.is_some() { body_s.len().to_string() diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index a25ab88..a8b101a 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -17,6 +17,7 @@ pub struct QuicknodeSdk { webhooks: WebhooksApiClient, kvstore: KvStoreApiClient, sql: SqlApiClient, + rpc: RpcApiClient, } /// Build a [`core::ClientInfo`] from the live Node.js runtime so the SDK's @@ -62,7 +63,10 @@ impl QuicknodeSdk { inner: core::kvstore::KvStoreApiClient::new(sdk_config.clone()), }, sql: SqlApiClient { - inner: core::sql::SqlApiClient::new(sdk_config), + inner: core::sql::SqlApiClient::new(sdk_config.clone()), + }, + rpc: RpcApiClient { + inner: core::rpc::RpcApiClient::new(sdk_config, config.rpc.as_ref()), }, }) } @@ -97,6 +101,12 @@ impl QuicknodeSdk { self.sql.clone() } + /// Returns the JSON-RPC sub-client. + #[napi(getter)] + pub fn rpc(&self) -> RpcApiClient { + self.rpc.clone() + } + /// Creates a new SDK instance using configuration from environment variables. #[napi(factory)] pub fn from_env() -> Result { @@ -109,6 +119,7 @@ impl QuicknodeSdk { }, kvstore: KvStoreApiClient { inner: sdk.kvstore }, sql: SqlApiClient { inner: sdk.sql }, + rpc: RpcApiClient { inner: sdk.rpc }, }) .map_err(errors::map_sdk_err) } @@ -737,6 +748,46 @@ impl AdminApiClient { .map_err(errors::map_sdk_err) } + /// Returns the current Tooling Access status for the account. Inspect + /// `enabled` to decide whether to enable provisioning. + #[napi] + pub async fn tooling_access_status(&self) -> Result { + self.inner + .tooling_access_status() + .await + .map_err(errors::map_sdk_err) + } + + /// Enables (provisions) Tooling Access. Idempotent. Requires an admin role + /// and an eligible plan. + #[napi] + pub async fn enable_tooling_access(&self) -> Result { + self.inner + .enable_tooling_access() + .await + .map_err(errors::map_sdk_err) + } + + /// Disables Tooling Access, pausing the endpoint. Idempotent. + #[napi] + pub async fn disable_tooling_access(&self) -> Result { + self.inner + .disable_tooling_access() + .await + .map_err(errors::map_sdk_err) + } + + /// Mints a short-lived session JWT for the provisioned Tooling Access + /// endpoint. Returns the endpoint URL, the JWT, and its expiry. Requires + /// Tooling Access to be enabled first. + #[napi] + pub async fn mint_tooling_token(&self) -> Result { + self.inner + .mint_tooling_token() + .await + .map_err(errors::map_sdk_err) + } + /// Returns the account's invoices, including id, status, billing reason, /// amounts due and paid, line items with descriptions and billing periods, /// and creation timestamps. @@ -1458,3 +1509,64 @@ impl SqlApiClient { .map_err(errors::map_sdk_err) } } + +// ── RpcApiClient ─────────────────────────────────────────────── + +#[derive(Clone)] +#[napi] +pub struct RpcApiClient { + inner: core::rpc::RpcApiClient, +} + +#[napi] +impl RpcApiClient { + /// Makes a JSON-RPC call against the account's Tooling Access endpoint, + /// authenticated with a short-lived session JWT (minted and refreshed + /// automatically). `params` accepts an array (positional) or object + /// (by-name) and defaults to `[]`. `network` selects a chain on the + /// multichain endpoint (a key in the seeded network map, e.g. + /// `"solana-mainnet"`); omit for the endpoint's default network. + /// + /// `endpointUrl` sends this call to a custom HTTP URL instead, bypassing the + /// Tooling Access endpoint and the session JWT (no Authorization header is + /// attached). It overrides the client-wide `RpcConfig.endpointUrl` default. + /// Passing both `endpointUrl` and `network` throws (they are mutually + /// exclusive). Returns the JSON-RPC `result`; a JSON-RPC error is thrown as + /// `RpcError`. + #[napi] + pub async fn call( + &self, + method: String, + params: Option, + network: Option, + endpoint_url: Option, + ) -> Result { + self.inner + .call(&method, params, network, endpoint_url) + .await + .map_err(errors::map_sdk_err) + } + + /// Seeds the per-network URL map for multichain routing (network key -> + /// full http_url), typically built from + /// `admin.getEndpointUrls(...).multichainUrls`. + #[napi] + pub fn set_networks(&self, networks: std::collections::HashMap) { + self.inner.set_networks(networks); + } + + /// Discards the in-memory cached token, forcing the next call to mint a + /// fresh one. Use when the cached token is known stale beyond expiry. + #[napi] + pub fn clear_cached_token(&self) { + self.inner.clear_cached_token(); + } + + /// Returns a snapshot of the currently cached session token, or `null` if + /// no token has been minted or seeded yet. Hosts use this to persist the + /// token between processes. + #[napi] + pub fn current_token(&self) -> Option { + self.inner.current_token() + } +} diff --git a/crates/python/Cargo.toml b/crates/python/Cargo.toml index dca48b8..7de8fa0 100644 --- a/crates/python/Cargo.toml +++ b/crates/python/Cargo.toml @@ -21,5 +21,7 @@ quicknode-sdk = { path = "../core", features = ["python"] } pyo3 = { workspace = true } pyo3-async-runtimes = { workspace = true, features = ["tokio-runtime"] } pyo3-stub-gen = { workspace = true } +# serde <-> Python bridge for arbitrary JSON-RPC params/results. Version-locked +# to our pyo3 (0.27); avoids a hand-rolled recursive Value<->PyAny converter. pythonize = { workspace = true } serde_json = "1.0" diff --git a/crates/python/src/errors.rs b/crates/python/src/errors.rs index 1725a6d..caf4c24 100644 --- a/crates/python/src/errors.rs +++ b/crates/python/src/errors.rs @@ -12,6 +12,13 @@ pub fn map_parse_err(e: serde_json::Error) -> PyErr { PyValueError::new_err(e.to_string()) } +// Conversion failures between Python objects and JSON (rpc params/results) are +// argument errors, surfaced as PyValueError like other parse failures. +#[allow(clippy::needless_pass_by_value)] +pub fn map_pythonize_err(e: pythonize::PythonizeError) -> PyErr { + PyValueError::new_err(e.to_string()) +} + create_exception!(_core, QuicknodeError, PyException); create_exception!(_core, ConfigError, QuicknodeError); create_exception!(_core, HttpError, QuicknodeError); @@ -19,6 +26,7 @@ create_exception!(_core, TimeoutError, HttpError); create_exception!(_core, ConnectionError, HttpError); create_exception!(_core, ApiError, QuicknodeError); create_exception!(_core, DecodeError, QuicknodeError); +create_exception!(_core, RpcError, QuicknodeError); #[allow(clippy::needless_pass_by_value)] pub fn map_sdk_err(e: SdkError) -> PyErr { @@ -47,6 +55,17 @@ pub fn map_sdk_err(e: SdkError) -> PyErr { err }) } + SdkError::Rpc { code, message } => { + let code = *code; + let message = message.clone(); + Python::attach(|py| { + let err = RpcError::new_err(msg); + let val = err.value(py); + let _ = val.setattr("code", code); + let _ = val.setattr("message", message); + err + }) + } SdkError::Http(_) => match e.http_kind() { Some(HttpKind::Timeout) => TimeoutError::new_err(msg), Some(HttpKind::Connect) => ConnectionError::new_err(msg), @@ -64,5 +83,6 @@ pub fn add_to_module(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add("ConnectionError", py.get_type::())?; m.add("ApiError", py.get_type::())?; m.add("DecodeError", py.get_type::())?; + m.add("RpcError", py.get_type::())?; Ok(()) } diff --git a/crates/python/src/lib.rs b/crates/python/src/lib.rs index 3174460..a051d4f 100644 --- a/crates/python/src/lib.rs +++ b/crates/python/src/lib.rs @@ -25,6 +25,8 @@ pub struct QuicknodeSdk { kvstore: KvStoreApiClient, #[pyo3(get)] sql: SqlApiClient, + #[pyo3(get)] + rpc: RpcApiClient, } /// Build a [`core::ClientInfo`] from the live Python runtime so the SDK's @@ -79,7 +81,10 @@ impl QuicknodeSdk { inner: core::kvstore::KvStoreApiClient::new(sdk_config.clone()), }, sql: SqlApiClient { - inner: core::sql::SqlApiClient::new(sdk_config), + inner: core::sql::SqlApiClient::new(sdk_config.clone()), + }, + rpc: RpcApiClient { + inner: core::rpc::RpcApiClient::new(sdk_config, config.rpc.as_ref()), }, }) } @@ -96,6 +101,7 @@ impl QuicknodeSdk { }, kvstore: KvStoreApiClient { inner: sdk.kvstore }, sql: SqlApiClient { inner: sdk.sql }, + rpc: RpcApiClient { inner: sdk.rpc }, }) .map_err(errors::map_sdk_err) } @@ -1103,6 +1109,66 @@ impl AdminApiClient { }) } + /// Returns the current Tooling Access status for the account. Inspect + /// `enabled` to decide whether to enable provisioning. + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, ToolingAccessStatus]" + ))] + fn tooling_access_status<'py>(&self, py: Python<'py>) -> PyResult> { + let client = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + client + .tooling_access_status() + .await + .map_err(errors::map_sdk_err) + }) + } + + /// Enables (provisions) Tooling Access. Idempotent. Requires an admin role + /// and an eligible plan. + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, ToolingAccessStatus]" + ))] + fn enable_tooling_access<'py>(&self, py: Python<'py>) -> PyResult> { + let client = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + client + .enable_tooling_access() + .await + .map_err(errors::map_sdk_err) + }) + } + + /// Disables Tooling Access, pausing the endpoint. Idempotent. + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, ToolingAccessStatus]" + ))] + fn disable_tooling_access<'py>(&self, py: Python<'py>) -> PyResult> { + let client = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + client + .disable_tooling_access() + .await + .map_err(errors::map_sdk_err) + }) + } + + /// Mints a short-lived session JWT for the provisioned Tooling Access + /// endpoint. Returns the endpoint URL, the JWT, and its expiry. Requires + /// Tooling Access to be enabled first. + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, CachedToken]" + ))] + fn mint_tooling_token<'py>(&self, py: Python<'py>) -> PyResult> { + let client = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + client + .mint_tooling_token() + .await + .map_err(errors::map_sdk_err) + }) + } + /// Returns the account's invoices, including id, status, billing reason, /// amounts due and paid, line items with descriptions and billing periods, /// and creation timestamps. @@ -2444,6 +2510,86 @@ impl SqlApiClient { } } +// ── RpcApiClient ─────────────────────────────────────────────── + +#[gen_stub_pyclass] +#[pyclass] +#[derive(Clone)] +pub struct RpcApiClient { + inner: core::rpc::RpcApiClient, +} + +#[gen_stub_pymethods] +#[pymethods] +impl RpcApiClient { + /// Makes a JSON-RPC call against the account's Tooling Access endpoint, + /// authenticated with a short-lived session JWT (minted and refreshed + /// automatically). `params` accepts a list (positional) or dict (by-name) + /// and defaults to `[]`. `network` selects a chain on the multichain + /// endpoint (a key in the seeded network map, e.g. `"solana-mainnet"`); + /// omit for the endpoint's default network. + /// + /// `endpoint_url` sends this call to a custom HTTP URL instead, bypassing + /// the Tooling Access endpoint and the session JWT (no Authorization header + /// is attached). It overrides the client-wide `RpcConfig.endpoint_url` + /// default. Passing both `endpoint_url` and `network` raises (they are + /// mutually exclusive). Returns the JSON-RPC `result`; a JSON-RPC error is + /// raised as `RpcError`. + #[pyo3(signature = (method, params=None, network=None, endpoint_url=None))] + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, typing.Any]" + ))] + fn call<'py>( + &self, + py: Python<'py>, + method: String, + params: Option>, + network: Option, + endpoint_url: Option, + ) -> PyResult> { + let client = self.inner.clone(); + // Convert the Python params to serde_json::Value up front (needs the + // GIL); the network call itself runs without it. + let params_value = match params { + Some(obj) => Some(pythonize::depythonize(&obj).map_err(errors::map_pythonize_err)?), + None => None, + }; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let result = client + .call(&method, params_value, network, endpoint_url) + .await + .map_err(errors::map_sdk_err)?; + // Convert the JSON result back to a Python object under the GIL. + Python::attach(|py| { + pythonize::pythonize(py, &result) + .map(pyo3::Bound::unbind) + .map_err(errors::map_pythonize_err) + }) + }) + } + + /// Seeds the per-network URL map for multichain routing (network key -> + /// full http_url), typically built from + /// `admin.get_endpoint_urls(...).multichain_urls`. + fn set_networks(&self, networks: std::collections::HashMap) { + self.inner.set_networks(networks); + } + + /// Discards the in-memory cached token, forcing the next call to mint a + /// fresh one. Use when the cached token is known stale beyond expiry. + fn clear_cached_token(&self) { + self.inner.clear_cached_token(); + } + + /// Returns a snapshot of the currently cached session token, or `None` if + /// no token has been minted or seeded yet. Hosts use this to persist the + /// token between processes. + #[gen_stub(override_return_type(type_repr = "typing.Optional[CachedToken]"))] + fn current_token(&self) -> Option { + self.inner.current_token() + } +} + // ── Module ───────────────────────────────────────────────────── #[pymodule] @@ -2590,7 +2736,11 @@ fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/crates/ruby/src/errors.rs b/crates/ruby/src/errors.rs index c455bdd..b7af751 100644 --- a/crates/ruby/src/errors.rs +++ b/crates/ruby/src/errors.rs @@ -18,6 +18,7 @@ struct ErrorClasses { connection: Opaque, api: Opaque, decode: Opaque, + rpc: Opaque, } static CLASSES: OnceLock = OnceLock::new(); @@ -31,11 +32,15 @@ pub fn init(ruby: &Ruby, module: &RModule) -> Result<(), magnus::Error> { let connection = module.define_error("ConnectionError", http)?; let api = module.define_error("ApiError", base)?; let decode = module.define_error("DecodeError", base)?; + let rpc = module.define_error("RpcError", base)?; - // attr_reader :status, :body on ApiError; :body on DecodeError + // attr_reader :status, :body on ApiError; :body on DecodeError; + // :code, :message on RpcError api.define_method("status", magnus::method!(read_status, 0))?; api.define_method("body", magnus::method!(read_body, 0))?; decode.define_method("body", magnus::method!(read_body, 0))?; + rpc.define_method("code", magnus::method!(read_code, 0))?; + rpc.define_method("message", magnus::method!(read_message, 0))?; CLASSES .set(ErrorClasses { @@ -46,6 +51,7 @@ pub fn init(ruby: &Ruby, module: &RModule) -> Result<(), magnus::Error> { connection: connection.into(), api: api.into(), decode: decode.into(), + rpc: rpc.into(), }) .map_err(|_| { magnus::Error::new( @@ -64,6 +70,14 @@ fn read_body(rb_self: magnus::Exception) -> Result { as_object(rb_self)?.ivar_get("@body") } +fn read_code(rb_self: magnus::Exception) -> Result { + as_object(rb_self)?.ivar_get("@code") +} + +fn read_message(rb_self: magnus::Exception) -> Result { + as_object(rb_self)?.ivar_get("@message") +} + fn as_object(exc: magnus::Exception) -> Result { RObject::from_value(exc.as_value()).ok_or_else(|| { let ruby = Ruby::get().expect("read_ivar called outside a Ruby thread"); @@ -106,6 +120,9 @@ pub fn map_err(e: SdkError) -> magnus::Error { None, Some(body.clone()), ), + SdkError::Rpc { code, message } => { + build_rpc(&ruby, ruby.get_inner(c.rpc), &msg, *code, message.clone()) + } SdkError::Http(_) => { let cls = match e.http_kind() { Some(HttpKind::Timeout) => ruby.get_inner(c.timeout), @@ -117,6 +134,25 @@ pub fn map_err(e: SdkError) -> magnus::Error { } } +fn build_rpc( + ruby: &Ruby, + class: ExceptionClass, + msg: &str, + code: i64, + message: String, +) -> magnus::Error { + match class.new_instance((msg,)) { + Ok(exc) => { + if let Some(obj) = RObject::from_value(exc.as_value()) { + let _ = obj.ivar_set("@code", code); + let _ = obj.ivar_set("@message", message); + } + magnus::Error::from(exc) + } + Err(_) => magnus::Error::new(ruby.exception_runtime_error(), msg.to_string()), + } +} + fn build_with_ivars( ruby: &Ruby, class: ExceptionClass, diff --git a/crates/ruby/src/lib.rs b/crates/ruby/src/lib.rs index cf432b0..75f806a 100644 --- a/crates/ruby/src/lib.rs +++ b/crates/ruby/src/lib.rs @@ -272,7 +272,7 @@ impl QuicknodeSdk { validate_keys( &opts, &[ - "api_key", "http", "admin", "streams", "webhooks", "kvstore", "sql", + "api_key", "http", "admin", "streams", "webhooks", "kvstore", "sql", "rpc", ], )?; let config: core::SdkFullConfig = @@ -313,6 +313,12 @@ impl QuicknodeSdk { inner: self.inner.sql.clone(), } } + + fn rpc(&self) -> RpcApiClient { + RpcApiClient { + inner: self.inner.rpc.clone(), + } + } } // ── AdminApiClient ────────────────────────────────────────────────────────── @@ -928,6 +934,38 @@ impl AdminApiClient { .and_then(to_ruby) } + fn tooling_access_status(&self) -> Result { + let client = self.inner.clone(); + runtime() + .block_on(client.tooling_access_status()) + .map_err(map_err) + .and_then(to_ruby) + } + + fn enable_tooling_access(&self) -> Result { + let client = self.inner.clone(); + runtime() + .block_on(client.enable_tooling_access()) + .map_err(map_err) + .and_then(to_ruby) + } + + fn disable_tooling_access(&self) -> Result { + let client = self.inner.clone(); + runtime() + .block_on(client.disable_tooling_access()) + .map_err(map_err) + .and_then(to_ruby) + } + + fn mint_tooling_token(&self) -> Result { + let client = self.inner.clone(); + runtime() + .block_on(client.mint_tooling_token()) + .map_err(map_err) + .and_then(to_ruby) + } + fn list_invoices(&self) -> Result { let client = self.inner.clone(); runtime() @@ -1841,6 +1879,63 @@ impl SqlApiClient { } } +// ── RpcApiClient ────────────────────────────────────────────────────────────── + +#[magnus::wrap(class = "QuicknodeSdk::Native::Rpc", free_immediately, size)] +#[derive(Clone)] +pub struct RpcApiClient { + inner: core::rpc::RpcApiClient, +} + +#[allow(clippy::needless_pass_by_value)] +impl RpcApiClient { + // call(method:, params: nil, network: nil, endpoint_url: nil) — params + // accepts an Array (positional) or Hash (by-name) and defaults to []. network + // selects a chain on the multichain endpoint (a key in the seeded network + // map, e.g. "solana-mainnet"); omit for the default network. endpoint_url + // sends the call to a custom HTTP URL instead, bypassing the Tooling Access + // endpoint and the session JWT (no Authorization header); it overrides the + // client-wide RpcConfig endpoint_url default. Passing both endpoint_url and + // network raises (mutually exclusive). Returns the JSON-RPC result; a + // JSON-RPC error is raised as QuicknodeSdk::RpcError. + fn call(&self, opts: RHash) -> Result { + validate_keys(&opts, &["method", "params", "network", "endpoint_url"])?; + let method = hash_require_string(&opts, "method")?; + let network = hash_get_string(&opts, "network")?; + let endpoint_url = hash_get_string(&opts, "endpoint_url")?; + let r = ruby(); + let params: Option = match opts.get(r.to_symbol("params")) { + // serde_magnus::deserialize already returns a magnus::Error. + Some(v) if !v.is_nil() => Some(serde_magnus::deserialize(&r, v)?), + _ => None, + }; + let client = self.inner.clone(); + runtime() + .block_on(client.call(&method, params, network, endpoint_url)) + .map_err(map_err) + .and_then(to_ruby) + } + + // set_networks(networks:) — seed the per-network URL map (key -> http_url) + // for multichain routing, from get_endpoint_urls(...)["multichain_urls"]. + fn set_networks(&self, opts: RHash) -> Result<(), Error> { + validate_keys(&opts, &["networks"])?; + let networks = hash_get_map_string_string(&opts, "networks")?.unwrap_or_default(); + self.inner.set_networks(networks); + Ok(()) + } + + // clear_cached_token — discard the in-memory cached token so the next call + // mints fresh. Use when the token is known stale beyond expiry. + fn clear_cached_token(&self) { + self.inner.clear_cached_token(); + } + + fn current_token(&self) -> Result { + to_ruby(self.inner.current_token()) + } +} + // ── Extension init ────────────────────────────────────────────────────────── #[magnus::init(name = "quicknode_sdk")] @@ -1862,6 +1957,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> { sdk.define_method("webhooks", method!(QuicknodeSdk::webhooks, 0))?; sdk.define_method("kvstore", method!(QuicknodeSdk::kvstore, 0))?; sdk.define_method("sql", method!(QuicknodeSdk::sql, 0))?; + sdk.define_method("rpc", method!(QuicknodeSdk::rpc, 0))?; // ── Admin ───────────────────────────────────────────────── let admin = native.define_class("Admin", ruby.class_object())?; @@ -2010,6 +2106,22 @@ fn init(ruby: &Ruby) -> Result<(), Error> { "get_api_credits", method!(AdminApiClient::get_api_credits, 1), )?; + admin.define_method( + "tooling_access_status", + method!(AdminApiClient::tooling_access_status, 0), + )?; + admin.define_method( + "enable_tooling_access", + method!(AdminApiClient::enable_tooling_access, 0), + )?; + admin.define_method( + "disable_tooling_access", + method!(AdminApiClient::disable_tooling_access, 0), + )?; + admin.define_method( + "mint_tooling_token", + method!(AdminApiClient::mint_tooling_token, 0), + )?; admin.define_method("list_invoices", method!(AdminApiClient::list_invoices, 0))?; admin.define_method("list_payments", method!(AdminApiClient::list_payments, 0))?; admin.define_method("list_teams", method!(AdminApiClient::list_teams, 0))?; @@ -2158,5 +2270,15 @@ fn init(ruby: &Ruby) -> Result<(), Error> { sql.define_method("query", method!(SqlApiClient::query, 1))?; sql.define_method("get_schema", method!(SqlApiClient::get_schema, 1))?; + // ── Rpc ─────────────────────────────────────────────────── + let rpc = native.define_class("Rpc", ruby.class_object())?; + rpc.define_method("call", method!(RpcApiClient::call, 1))?; + rpc.define_method("set_networks", method!(RpcApiClient::set_networks, 1))?; + rpc.define_method( + "clear_cached_token", + method!(RpcApiClient::clear_cached_token, 0), + )?; + rpc.define_method("current_token", method!(RpcApiClient::current_token, 0))?; + Ok(()) } diff --git a/npm/README.md b/npm/README.md index 0f4f847..220f8aa 100644 --- a/npm/README.md +++ b/npm/README.md @@ -1674,6 +1674,59 @@ const schema = await qn.sql.getSchema("hyperliquid-core-mainnet"); console.log(schema.tables.length); ``` +--- + +### RPC & Tooling Access + +Tooling Access provisions a single multichain, read-only endpoint per account and +mints short-lived session JWTs. `qn.rpc` makes JSON-RPC calls directly against that +endpoint, minting and refreshing the JWT automatically — no endpoint URL or token to +manage. + +Tooling Access must be enabled once (admin role + eligible plan). The control-plane +methods live on `qn.admin`: + +```typescript +// Node.js +const status = await qn.admin.toolingAccessStatus(); +if (!status.enabled) { + await qn.admin.enableToolingAccess(); // idempotent; admin role required +} + +// Make on-chain calls. params defaults to []; pass an array (positional) or object. +const blockNumber = await qn.rpc.call("eth_blockNumber"); +const balance = await qn.rpc.call("eth_getBalance", ["0xabc...", "latest"]); + +// Multichain: select a network by its multichain_urls key. Seed the map first +// (from admin.getEndpointUrls), then pass the network as the 3rd arg. +const urls = await qn.admin.getEndpointUrls(endpointId); +const map = Object.fromEntries( + Object.entries(urls.multichainUrls ?? {}).map(([k, v]) => [k, v.httpUrl]), +); +qn.rpc.setNetworks(map); +const slot = await qn.rpc.call("getSlot", [], "solana-mainnet"); + +// Custom endpoint URL: send to a fully-formed HTTP URL, bypassing Tooling Access +// and the JWT (no Authorization header). Per-call via the 4th arg, or client-wide +// via `new RpcConfig({ endpointUrl })`. endpointUrl and network are mutually +// exclusive (a custom URL is not multichain-routed). +const block = await qn.rpc.call("eth_blockNumber", [], undefined, "https://my-endpoint.example/rpc"); + +// A JSON-RPC error member is thrown as RpcError (with .code and .message). +import { RpcError } from "@quicknode/sdk"; +try { + await qn.rpc.call("eth_getBalance", ["bad"]); +} catch (e) { + if (e instanceof RpcError) console.error(e.code, e.message); +} +``` + +A host that persists across processes can snapshot the cached token with +`qn.rpc.currentToken()` and re-seed it via `RpcConfig.seed` on the next construction; +`refreshMarginSecs` (default 60) tunes how early the token is refreshed. Set +`RpcConfig.endpointUrl` to route every call to a custom HTTP URL by default (no +JWT minted); a per-call `endpointUrl` overrides it. + ## Error Handling Every binding exposes a typed exception hierarchy derived from the core `SdkError` @@ -1689,8 +1742,9 @@ subclass to branch on transport vs. API semantics. | `ConnectionError` | connection refused / DNS / TLS (subclass of `HttpError`) | — | | `ApiError` | non-2xx HTTP response | `status`, `body` | | `DecodeError` | 2xx response but JSON parse failed | `body` | +| `RpcError` | JSON-RPC call returned an `error` member | `code`, `message` | -Class names: Importable from `@quicknode/sdk`: `QuicknodeError`, `ConfigError`, `HttpError`, `TimeoutError`, `ConnectionError`, `ApiError`, `DecodeError`. All extend `Error`. +Class names: Importable from `@quicknode/sdk`: `QuicknodeError`, `ConfigError`, `HttpError`, `TimeoutError`, `ConnectionError`, `ApiError`, `DecodeError`, `RpcError`. All extend `Error`. ```typescript // Node.js diff --git a/npm/errors.js b/npm/errors.js index 55cc32a..9570e8a 100644 --- a/npm/errors.js +++ b/npm/errors.js @@ -54,7 +54,15 @@ class DecodeError extends QuicknodeError { } } -const TAG_RE = /^\[(Config|Http|Timeout|Connect|Api|Decode)\|([^|]+)\|([^\]]+)\](.*)$/s; +class RpcError extends QuicknodeError { + constructor(message, code) { + super(message); + this.name = "RpcError"; + this.code = code; + } +} + +const TAG_RE = /^\[(Config|Http|Timeout|Connect|Api|Decode|Rpc)\|([^|]+)\|([^\]]+)\](.*)$/s; function fromNapiError(err) { if (!(err instanceof Error)) return err; @@ -82,6 +90,8 @@ function fromNapiError(err) { case "Http": return new HttpError(msg); case "Api": return new ApiError(msg, Number(statusStr), body); case "Decode": return new DecodeError(msg, body); + // For Rpc, statusStr is the JSON-RPC code and body is its message. + case "Rpc": return new RpcError(body || msg, Number(statusStr)); default: return err; } } @@ -116,6 +126,7 @@ module.exports = { ConnectionError, ApiError, DecodeError, + RpcError, fromNapiError, wrapClient, }; diff --git a/npm/examples/rpc.ts b/npm/examples/rpc.ts new file mode 100644 index 0000000..f55425f --- /dev/null +++ b/npm/examples/rpc.ts @@ -0,0 +1,55 @@ +import { QuicknodeSdk, RpcError } from "../sdk"; + +async function main() { + const qn = QuicknodeSdk.fromEnv(); + + // Ensure Tooling Access is provisioned (idempotent; requires admin role). + const status = await qn.admin.toolingAccessStatus(); + console.log(`tooling access enabled: ${status.enabled}`); + if (!status.enabled) { + try { + const enabled = await qn.admin.enableToolingAccess(); + console.log(`enabled tooling access: ${enabled.enabled}`); + } catch (e) { + console.error(`could not enable tooling access: ${e}`); + return; + } + } + + // Make a JSON-RPC call. The SDK mints and refreshes the session JWT. + const blockNumber = await qn.rpc.call("eth_blockNumber"); + console.log(`eth_blockNumber => ${blockNumber}`); + + // Multichain: seed the per-network URL map (from the endpoint id in status), + // then route a call to a specific network by its key. + if (status.endpointId) { + const urls = await qn.admin.getEndpointUrls(status.endpointId); + if (urls.data?.multichainUrls) { + const map = Object.fromEntries( + Object.entries(urls.data.multichainUrls).map(([k, v]) => [k, v.httpUrl]), + ); + qn.rpc.setNetworks(map); + const slot = await qn.rpc.call("getSlot", [], "solana-mainnet"); + console.log(`solana getSlot => ${slot}`); + } + } + + // Demonstrate the typed JSON-RPC error path. + try { + await qn.rpc.call("eth_getBalance", ["not-an-address"]); + } catch (e) { + if (!(e instanceof RpcError)) throw e; + console.log(`got expected RpcError: code=${e.code} message=${e.message}`); + } + + // Custom endpoint URL: send a call to a fully-formed HTTP URL, bypassing the + // Tooling Access endpoint and the session JWT entirely. Set it per-call here + // (4th arg), or client-wide via `new RpcConfig({ endpointUrl })`. + const customUrl = process.env.QN_RPC_ENDPOINT_URL; + if (customUrl) { + const result = await qn.rpc.call("eth_blockNumber", [], undefined, customUrl); + console.log(`custom endpoint eth_blockNumber => ${result}`); + } +} + +main(); diff --git a/npm/index.d.ts b/npm/index.d.ts index f653f5e..fd48659 100644 --- a/npm/index.d.ts +++ b/npm/index.d.ts @@ -221,6 +221,23 @@ export interface BulkUpdateEndpointStatusResponse { error?: string } +/** + * A minted session JWT plus the endpoint it authenticates against and its + * wall-clock expiry. This is the unit cached by the RPC client and the unit a + * host persists between processes (e.g. the CLI's on-disk token cache). + * + * `exp_unix` is the JWT `exp` claim (unix seconds), used directly so it + * survives a process restart (unlike a monotonic `Instant`). + */ +export interface CachedToken { + /** The provisioned tooling-access endpoint URL the JWT authenticates against. */ + endpointUrl: string + /** The minted ES256 session JWT, presented as a Bearer token. */ + token: string + /** JWT `exp` claim in unix seconds. */ + expUnix: number +} + /** A blockchain supported by Quicknode along with its networks. */ export interface Chain { /** Chain slug (e.g. `ethereum`). */ @@ -1465,6 +1482,38 @@ export interface ResendTeamInviteResponse { error?: string } +export interface RpcConfig { + /** + * Custom HTTP URL to send JSON-RPC calls to, bypassing the Tooling Access + * endpoint. When set, every `rpc.call` on this client goes straight to this + * URL with NO session token minted or attached — the URL is treated as a + * self-authenticating endpoint (e.g. a provisioned `.quiknode.pro` URL that + * already embeds its token, or a self-hosted node). A per-call + * `endpoint_url` overrides this default. Unset means tooling-JWT mode. + */ + endpointUrl?: string + /** + * Optional pre-existing token to seed the in-memory cache (e.g. loaded + * from a host's on-disk cache). Advisory: a malformed or expired seed is + * treated as a cache miss and a fresh token is minted. + */ + seed?: CachedToken + /** + * Seconds before `exp` at which the client proactively refreshes. The + * margin also absorbs clock skew between client and endpoint. Defaults to + * 60 when unset. + */ + refreshMarginSecs?: number + /** + * Per-network URL map for multichain routing: network key (e.g. + * `"solana-mainnet"`, `"polygon"`) -> full http_url. Built from + * `admin.get_endpoint_urls(...).multichain_urls`. When set, `rpc.call` with + * a `network` resolves the target URL here. Optional; the default-network + * call path needs no map. + */ + networks?: Record +} + /** Configuration for delivering stream batches to an S3-compatible object store. */ export interface S3Attributes { /** S3 service endpoint (e.g. `s3.amazonaws.com`). */ @@ -1497,6 +1546,7 @@ export interface SdkFullConfig { webhooks?: WebhooksConfig kvstore?: KvStoreConfig sql?: SqlConfig + rpc?: RpcConfig } /** A single security feature's name, status, and optional value. */ @@ -1778,6 +1828,23 @@ export interface TestFilterResponse { logs: Array } +/** + * Current Tooling Access status for the account. `enabled` is the source of + * truth — a previously-provisioned-but-disabled account may still report a + * non-null `endpoint_url`. + */ +export interface ToolingAccessStatus { + enabled: boolean + endpointUrl?: string + enabledAt?: string + /** + * The provisioned endpoint's id. Used to fetch the per-network URL map + * (`get_endpoint_urls`) for multichain routing. `None` on control planes + * that don't yet return it. + */ + endpointId?: string +} + /** Parameters for `update_endpoint`. */ export interface UpdateEndpointRequest { /** New human-readable label. */ @@ -2265,6 +2332,24 @@ export declare class AdminApiClient { * chain slug rejects with `ApiError` (status 404). */ getApiCredits(chain: string): Promise + /** + * Returns the current Tooling Access status for the account. Inspect + * `enabled` to decide whether to enable provisioning. + */ + toolingAccessStatus(): Promise + /** + * Enables (provisions) Tooling Access. Idempotent. Requires an admin role + * and an eligible plan. + */ + enableToolingAccess(): Promise + /** Disables Tooling Access, pausing the endpoint. Idempotent. */ + disableToolingAccess(): Promise + /** + * Mints a short-lived session JWT for the provisioned Tooling Access + * endpoint. Returns the endpoint URL, the JWT, and its expiry. Requires + * Tooling Access to be enabled first. + */ + mintToolingToken(): Promise /** * Returns the account's invoices, including id, status, billing reason, * amounts due and paid, line items with descriptions and billing periods, @@ -2424,10 +2509,48 @@ export declare class QuicknodeSdk { get kvstore(): KvStoreApiClient /** Returns the sql sub-client. */ get sql(): SqlApiClient + /** Returns the JSON-RPC sub-client. */ + get rpc(): RpcApiClient /** Creates a new SDK instance using configuration from environment variables. */ static fromEnv(): QuicknodeSdk } +export declare class RpcApiClient { + /** + * Makes a JSON-RPC call against the account's Tooling Access endpoint, + * authenticated with a short-lived session JWT (minted and refreshed + * automatically). `params` accepts an array (positional) or object + * (by-name) and defaults to `[]`. `network` selects a chain on the + * multichain endpoint (a key in the seeded network map, e.g. + * `"solana-mainnet"`); omit for the endpoint's default network. + * + * `endpointUrl` sends this call to a custom HTTP URL instead, bypassing the + * Tooling Access endpoint and the session JWT (no Authorization header is + * attached). It overrides the client-wide `RpcConfig.endpointUrl` default. + * Passing both `endpointUrl` and `network` throws (they are mutually + * exclusive). Returns the JSON-RPC `result`; a JSON-RPC error is thrown as + * `RpcError`. + */ + call(method: string, params?: any | undefined | null, network?: string | undefined | null, endpointUrl?: string | undefined | null): Promise + /** + * Seeds the per-network URL map for multichain routing (network key -> + * full http_url), typically built from + * `admin.getEndpointUrls(...).multichainUrls`. + */ + setNetworks(networks: Record): void + /** + * Discards the in-memory cached token, forcing the next call to mint a + * fresh one. Use when the cached token is known stale beyond expiry. + */ + clearCachedToken(): void + /** + * Returns a snapshot of the currently cached session token, or `null` if + * no token has been minted or seeded yet. Hosts use this to persist the + * token between processes. + */ + currentToken(): CachedToken | null +} + export declare class SqlApiClient { /** Executes a SQL query against the given cluster and returns the result set. */ query(query: string, clusterId: string): Promise diff --git a/npm/index.js b/npm/index.js index eca68f8..51ebea1 100644 --- a/npm/index.js +++ b/npm/index.js @@ -588,6 +588,7 @@ module.exports.WebhookTemplateId = nativeBinding.WebhookTemplateId module.exports.AdminApiClient = nativeBinding.AdminApiClient module.exports.KvStoreApiClient = nativeBinding.KvStoreApiClient module.exports.QuicknodeSdk = nativeBinding.QuicknodeSdk +module.exports.RpcApiClient = nativeBinding.RpcApiClient module.exports.SqlApiClient = nativeBinding.SqlApiClient module.exports.StreamsApiClient = nativeBinding.StreamsApiClient module.exports.WebhooksApiClient = nativeBinding.WebhooksApiClient diff --git a/npm/sdk.d.ts b/npm/sdk.d.ts index e2528a8..a2ad1b6 100644 --- a/npm/sdk.d.ts +++ b/npm/sdk.d.ts @@ -326,6 +326,11 @@ export type { ChainSchemaNode, TableSchemaNode, ColumnSchemaNode, + // rpc / tooling access + RpcConfig, + CachedToken, + ToolingAccessStatus, + RpcApiClient, } from "./index"; // const enums must use `export` (not `export type`) so they are usable as values @@ -400,6 +405,7 @@ export class QuicknodeSdk { webhooks: WebhooksApiClientTyped; kvstore: _QuicknodeSdk["kvstore"]; sql: SqlApiClientTyped; + rpc: _QuicknodeSdk["rpc"]; } // Typed static factory methods producing each discriminated variant of @@ -450,3 +456,6 @@ export class ApiError extends QuicknodeError { export class DecodeError extends QuicknodeError { body: string; } +export class RpcError extends QuicknodeError { + code: number; +} diff --git a/npm/sdk.js b/npm/sdk.js index 670b312..5fce388 100644 --- a/npm/sdk.js +++ b/npm/sdk.js @@ -16,6 +16,7 @@ class QuicknodeSdk { this.webhooks = errors.wrapClient(this._inner.webhooks); this.kvstore = errors.wrapClient(this._inner.kvstore); this.sql = errors.wrapClient(this._inner.sql); + this.rpc = errors.wrapClient(this._inner.rpc); } static fromEnv() { @@ -30,6 +31,7 @@ class QuicknodeSdk { instance.webhooks = errors.wrapClient(instance._inner.webhooks); instance.kvstore = errors.wrapClient(instance._inner.kvstore); instance.sql = errors.wrapClient(instance._inner.sql); + instance.rpc = errors.wrapClient(instance._inner.rpc); return instance; } } @@ -87,4 +89,5 @@ module.exports = { ConnectionError: errors.ConnectionError, ApiError: errors.ApiError, DecodeError: errors.DecodeError, + RpcError: errors.RpcError, }; diff --git a/npm/sdk.mjs b/npm/sdk.mjs index c3921aa..1d0e51c 100644 --- a/npm/sdk.mjs +++ b/npm/sdk.mjs @@ -25,6 +25,7 @@ export const { WebhooksApiClient, KvStoreApiClient, SqlApiClient, + RpcApiClient, QuicknodeError, ConfigError, HttpError, @@ -32,4 +33,5 @@ export const { ConnectionError, ApiError, DecodeError, + RpcError, } = cjs; diff --git a/python/README.md b/python/README.md index d4ea2fa..c4aeae5 100644 --- a/python/README.md +++ b/python/README.md @@ -1674,6 +1674,55 @@ schema = await qn.sql.get_schema("hyperliquid-core-mainnet") print(len(schema.tables)) ``` +--- + +### RPC & Tooling Access + +Tooling Access provisions a single multichain, read-only endpoint per account and +mints short-lived session JWTs. `qn.rpc` makes JSON-RPC calls directly against that +endpoint, minting and refreshing the JWT automatically — no endpoint URL or token to +manage. + +Tooling Access must be enabled once (admin role + eligible plan). The control-plane +methods live on `qn.admin`: + +```python +# Python +status = await qn.admin.tooling_access_status() +if not status.enabled: + await qn.admin.enable_tooling_access() # idempotent; admin role required + +# Make on-chain calls. params defaults to []; pass a list (positional) or dict. +block_number = await qn.rpc.call("eth_blockNumber") +balance = await qn.rpc.call("eth_getBalance", ["0xabc...", "latest"]) + +# Multichain: select a network by its multichain_urls key. Seed the map first +# (from admin.get_endpoint_urls), then pass network=. +urls = await qn.admin.get_endpoint_urls(endpoint_id) +mc = urls.data.multichain_urls if urls.data else {} +qn.rpc.set_networks({k: v.http_url for k, v in (mc or {}).items()}) +slot = await qn.rpc.call("getSlot", network="solana-mainnet") + +# Custom endpoint URL: send to a fully-formed HTTP URL, bypassing Tooling Access +# and the JWT (no Authorization header). Per-call via endpoint_url=, or +# client-wide via RpcConfig(endpoint_url=...). endpoint_url and network are +# mutually exclusive (a custom URL is not multichain-routed). +block = await qn.rpc.call("eth_blockNumber", endpoint_url="https://my-endpoint.example/rpc") + +# A JSON-RPC error member is raised as RpcError (with .code and .message). +from quicknode_sdk import RpcError +try: + await qn.rpc.call("eth_getBalance", ["bad"]) +except RpcError as e: + print(e.code, e.message) +``` + +A host that persists across processes can snapshot the cached token with +`qn.rpc.current_token()` and re-seed it via `RpcConfig(seed=...)` on the next +construction; `refresh_margin_secs` (default 60) tunes how early the token is +refreshed. Set `RpcConfig(endpoint_url=...)` to route every call to a custom +HTTP URL by default (no JWT minted); a per-call `endpoint_url` overrides it. + ## Error Handling Every binding exposes a typed exception hierarchy derived from the core `SdkError` @@ -1689,8 +1738,9 @@ subclass to branch on transport vs. API semantics. | `ConnectionError` | connection refused / DNS / TLS (subclass of `HttpError`) | — | | `ApiError` | non-2xx HTTP response | `status`, `body` | | `DecodeError` | 2xx response but JSON parse failed | `body` | +| `RpcError` | JSON-RPC call returned an `error` member | `code`, `message` | -Class names: Importable from `quicknode_sdk`: `QuicknodeError`, `ConfigError`, `HttpError`, `TimeoutError`, `ConnectionError`, `ApiError`, `DecodeError`. +Class names: Importable from `quicknode_sdk`: `QuicknodeError`, `ConfigError`, `HttpError`, `TimeoutError`, `ConnectionError`, `ApiError`, `DecodeError`, `RpcError`. ```python # Python diff --git a/python/examples/rpc.py b/python/examples/rpc.py new file mode 100644 index 0000000..ffc02da --- /dev/null +++ b/python/examples/rpc.py @@ -0,0 +1,50 @@ +import asyncio +import os +from quicknode_sdk import QuicknodeSdk, RpcError + + +async def main(): + qn = QuicknodeSdk.from_env() + + # Ensure Tooling Access is provisioned (idempotent; requires admin role). + status = await qn.admin.tooling_access_status() + print(f"tooling access enabled: {status.enabled}") + if not status.enabled: + try: + enabled = await qn.admin.enable_tooling_access() + print(f"enabled tooling access: {enabled.enabled}") + except Exception as e: + print(f"could not enable tooling access: {e}") + return + + # Make a JSON-RPC call. The SDK mints and refreshes the session JWT. + block_number = await qn.rpc.call("eth_blockNumber") + print(f"eth_blockNumber => {block_number}") + + # Multichain: seed the per-network URL map (from the endpoint id in status), + # then route a call to a specific network by its key. + if status.endpoint_id: + urls = await qn.admin.get_endpoint_urls(status.endpoint_id) + if urls.data and urls.data.multichain_urls: + qn.rpc.set_networks( + {k: v.http_url for k, v in urls.data.multichain_urls.items()} + ) + slot = await qn.rpc.call("getSlot", network="solana-mainnet") + print(f"solana getSlot => {slot}") + + # Demonstrate the typed JSON-RPC error path. + try: + await qn.rpc.call("eth_getBalance", ["not-an-address"]) + except RpcError as e: + print(f"got expected RpcError: code={e.code} message={e.message}") + + # Custom endpoint URL: send a call to a fully-formed HTTP URL, bypassing the + # Tooling Access endpoint and the session JWT entirely. Set it per-call here, + # or client-wide via RpcConfig(endpoint_url=...). + custom_url = os.environ.get("QN_RPC_ENDPOINT_URL") + if custom_url: + result = await qn.rpc.call("eth_blockNumber", endpoint_url=custom_url) + print(f"custom endpoint eth_blockNumber => {result}") + + +asyncio.run(main()) diff --git a/python/quicknode_sdk/__init__.py b/python/quicknode_sdk/__init__.py index b8af2c7..62dcf55 100644 --- a/python/quicknode_sdk/__init__.py +++ b/python/quicknode_sdk/__init__.py @@ -115,7 +115,11 @@ StreamsConfig, KvStoreConfig, SqlConfig, + RpcConfig, + CachedToken, SdkFullConfig, + RpcApiClient, + ToolingAccessStatus, KvStoreApiClient, KvSetEntry, GetSetsResponse, @@ -208,6 +212,7 @@ ConnectionError, ApiError, DecodeError, + RpcError, ) __all__ = [ @@ -326,7 +331,11 @@ "AdminConfig", "KvStoreConfig", "SqlConfig", + "RpcConfig", + "CachedToken", "SdkFullConfig", + "RpcApiClient", + "ToolingAccessStatus", "KvStoreApiClient", "KvSetEntry", "GetSetsResponse", @@ -419,4 +428,5 @@ "ConnectionError", "ApiError", "DecodeError", + "RpcError", ] diff --git a/python/quicknode_sdk/__init__.pyi b/python/quicknode_sdk/__init__.pyi index fbc1834..2d0b5b4 100644 --- a/python/quicknode_sdk/__init__.pyi +++ b/python/quicknode_sdk/__init__.pyi @@ -117,7 +117,11 @@ from quicknode_sdk._core import ( StreamsConfig, KvStoreConfig, SqlConfig, + RpcConfig, + CachedToken, SdkFullConfig, + RpcApiClient, + ToolingAccessStatus, KvStoreApiClient, KvSetEntry, GetSetsResponse, @@ -226,6 +230,7 @@ from quicknode_sdk._core import ( ConnectionError, ApiError, DecodeError, + RpcError, ) __all__ = [ @@ -344,7 +349,11 @@ __all__ = [ "AdminConfig", "KvStoreConfig", "SqlConfig", + "RpcConfig", + "CachedToken", "SdkFullConfig", + "RpcApiClient", + "ToolingAccessStatus", "KvStoreApiClient", "KvSetEntry", "GetSetsResponse", @@ -453,4 +462,5 @@ __all__ = [ "ConnectionError", "ApiError", "DecodeError", + "RpcError", ] diff --git a/python/quicknode_sdk/_core/__init__.pyi b/python/quicknode_sdk/_core/__init__.pyi index 2b67431..ca6c4cf 100644 --- a/python/quicknode_sdk/_core/__init__.pyi +++ b/python/quicknode_sdk/_core/__init__.pyi @@ -28,6 +28,7 @@ __all__ = [ "BulkUpdateEndpointStatusData", "BulkUpdateEndpointStatusRequest", "BulkUpdateEndpointStatusResponse", + "CachedToken", "Chain", "ChainNetwork", "ChainSchema", @@ -160,6 +161,8 @@ __all__ = [ "RenameTagRequest", "RenameTagResponse", "ResendTeamInviteResponse", + "RpcApiClient", + "RpcConfig", "S3Attributes", "SdkFullConfig", "SecurityOption", @@ -192,6 +195,7 @@ __all__ = [ "TeamSummary", "TeamUser", "TestFilterResponse", + "ToolingAccessStatus", "UpdateEndpointRequest", "UpdateEndpointStatusRequest", "UpdateEndpointStatusResponse", @@ -664,6 +668,26 @@ class AdminApiClient: item carries the RPC `method` name and its `credits` cost. An unknown chain slug raises `ApiError` with status 404. """ + def tooling_access_status(self) -> typing.Coroutine[typing.Any, typing.Any, ToolingAccessStatus]: + r""" + Returns the current Tooling Access status for the account. Inspect + `enabled` to decide whether to enable provisioning. + """ + def enable_tooling_access(self) -> typing.Coroutine[typing.Any, typing.Any, ToolingAccessStatus]: + r""" + Enables (provisions) Tooling Access. Idempotent. Requires an admin role + and an eligible plan. + """ + def disable_tooling_access(self) -> typing.Coroutine[typing.Any, typing.Any, ToolingAccessStatus]: + r""" + Disables Tooling Access, pausing the endpoint. Idempotent. + """ + def mint_tooling_token(self) -> typing.Coroutine[typing.Any, typing.Any, CachedToken]: + r""" + Mints a short-lived session JWT for the provisioned Tooling Access + endpoint. Returns the endpoint URL, the JWT, and its expiry. Requires + Tooling Access to be enabled first. + """ def list_invoices(self) -> typing.Coroutine[typing.Any, typing.Any, ListInvoicesResponse]: r""" Returns the account's invoices, including id, status, billing reason, @@ -1290,6 +1314,48 @@ class BulkUpdateEndpointStatusResponse: Error message when the request did not succeed. """ +@typing.final +class CachedToken: + r""" + A minted session JWT plus the endpoint it authenticates against and its + wall-clock expiry. This is the unit cached by the RPC client and the unit a + host persists between processes (e.g. the CLI's on-disk token cache). + + `exp_unix` is the JWT `exp` claim (unix seconds), used directly so it + survives a process restart (unlike a monotonic `Instant`). + """ + @property + def endpoint_url(self) -> builtins.str: + r""" + The provisioned tooling-access endpoint URL the JWT authenticates against. + """ + @endpoint_url.setter + def endpoint_url(self, value: builtins.str) -> None: + r""" + The provisioned tooling-access endpoint URL the JWT authenticates against. + """ + @property + def token(self) -> builtins.str: + r""" + The minted ES256 session JWT, presented as a Bearer token. + """ + @token.setter + def token(self, value: builtins.str) -> None: + r""" + The minted ES256 session JWT, presented as a Bearer token. + """ + @property + def exp_unix(self) -> builtins.int: + r""" + JWT `exp` claim in unix seconds. + """ + @exp_unix.setter + def exp_unix(self, value: builtins.int) -> None: + r""" + JWT `exp` claim in unix seconds. + """ + def __new__(cls, endpoint_url: builtins.str, token: builtins.str, exp_unix: builtins.int) -> CachedToken: ... + @typing.final class Chain: r""" @@ -5154,6 +5220,8 @@ class QuicknodeSdk: def kvstore(self) -> KvStoreApiClient: ... @property def sql(self) -> SqlApiClient: ... + @property + def rpc(self) -> RpcApiClient: ... def __new__(cls, config: SdkFullConfig) -> QuicknodeSdk: r""" Creates a new SDK instance from an explicit configuration. @@ -5362,6 +5430,112 @@ class ResendTeamInviteResponse: Error message when the request did not succeed. """ +@typing.final +class RpcApiClient: + def call(self, method: builtins.str, params: typing.Optional[typing.Any] = None, network: typing.Optional[builtins.str] = None, endpoint_url: typing.Optional[builtins.str] = None) -> typing.Coroutine[typing.Any, typing.Any, typing.Any]: + r""" + Makes a JSON-RPC call against the account's Tooling Access endpoint, + authenticated with a short-lived session JWT (minted and refreshed + automatically). `params` accepts a list (positional) or dict (by-name) + and defaults to `[]`. `network` selects a chain on the multichain + endpoint (a key in the seeded network map, e.g. `"solana-mainnet"`); + omit for the endpoint's default network. + + `endpoint_url` sends this call to a custom HTTP URL instead, bypassing + the Tooling Access endpoint and the session JWT (no Authorization header + is attached). It overrides the client-wide `RpcConfig.endpoint_url` + default. Passing both `endpoint_url` and `network` raises (they are + mutually exclusive). Returns the JSON-RPC `result`; a JSON-RPC error is + raised as `RpcError`. + """ + def set_networks(self, networks: typing.Mapping[builtins.str, builtins.str]) -> None: + r""" + Seeds the per-network URL map for multichain routing (network key -> + full http_url), typically built from + `admin.get_endpoint_urls(...).multichain_urls`. + """ + def clear_cached_token(self) -> None: + r""" + Discards the in-memory cached token, forcing the next call to mint a + fresh one. Use when the cached token is known stale beyond expiry. + """ + def current_token(self) -> typing.Optional[CachedToken]: + r""" + Returns a snapshot of the currently cached session token, or `None` if + no token has been minted or seeded yet. Hosts use this to persist the + token between processes. + """ + +@typing.final +class RpcConfig: + @property + def endpoint_url(self) -> typing.Optional[builtins.str]: + r""" + Custom HTTP URL to send JSON-RPC calls to, bypassing the Tooling Access + endpoint. When set, every `rpc.call` on this client goes straight to this + URL with NO session token minted or attached — the URL is treated as a + self-authenticating endpoint (e.g. a provisioned `.quiknode.pro` URL that + already embeds its token, or a self-hosted node). A per-call + `endpoint_url` overrides this default. Unset means tooling-JWT mode. + """ + @endpoint_url.setter + def endpoint_url(self, value: typing.Optional[builtins.str]) -> None: + r""" + Custom HTTP URL to send JSON-RPC calls to, bypassing the Tooling Access + endpoint. When set, every `rpc.call` on this client goes straight to this + URL with NO session token minted or attached — the URL is treated as a + self-authenticating endpoint (e.g. a provisioned `.quiknode.pro` URL that + already embeds its token, or a self-hosted node). A per-call + `endpoint_url` overrides this default. Unset means tooling-JWT mode. + """ + @property + def seed(self) -> typing.Optional[CachedToken]: + r""" + Optional pre-existing token to seed the in-memory cache (e.g. loaded + from a host's on-disk cache). Advisory: a malformed or expired seed is + treated as a cache miss and a fresh token is minted. + """ + @seed.setter + def seed(self, value: typing.Optional[CachedToken]) -> None: + r""" + Optional pre-existing token to seed the in-memory cache (e.g. loaded + from a host's on-disk cache). Advisory: a malformed or expired seed is + treated as a cache miss and a fresh token is minted. + """ + @property + def refresh_margin_secs(self) -> typing.Optional[builtins.int]: + r""" + Seconds before `exp` at which the client proactively refreshes. The + margin also absorbs clock skew between client and endpoint. Defaults to + 60 when unset. + """ + @refresh_margin_secs.setter + def refresh_margin_secs(self, value: typing.Optional[builtins.int]) -> None: + r""" + Seconds before `exp` at which the client proactively refreshes. The + margin also absorbs clock skew between client and endpoint. Defaults to + 60 when unset. + """ + @property + def networks(self) -> typing.Optional[builtins.dict[builtins.str, builtins.str]]: + r""" + Per-network URL map for multichain routing: network key (e.g. + `"solana-mainnet"`, `"polygon"`) -> full http_url. Built from + `admin.get_endpoint_urls(...).multichain_urls`. When set, `rpc.call` with + a `network` resolves the target URL here. Optional; the default-network + call path needs no map. + """ + @networks.setter + def networks(self, value: typing.Optional[builtins.dict[builtins.str, builtins.str]]) -> None: + r""" + Per-network URL map for multichain routing: network key (e.g. + `"solana-mainnet"`, `"polygon"`) -> full http_url. Built from + `admin.get_endpoint_urls(...).multichain_urls`. When set, `rpc.call` with + a `network` resolves the target URL here. Optional; the default-network + call path needs no map. + """ + def __new__(cls, endpoint_url: typing.Optional[builtins.str] = None, seed: typing.Optional[CachedToken] = None, refresh_margin_secs: typing.Optional[builtins.int] = None, networks: typing.Optional[typing.Mapping[builtins.str, builtins.str]] = None) -> RpcConfig: ... + @typing.final class S3Attributes: r""" @@ -5499,7 +5673,11 @@ class SdkFullConfig: def sql(self) -> typing.Optional[SqlConfig]: ... @sql.setter def sql(self, value: typing.Optional[SqlConfig]) -> None: ... - def __new__(cls, api_key: builtins.str, http: typing.Optional[HttpConfig] = None, admin: typing.Optional[AdminConfig] = None, streams: typing.Optional[StreamsConfig] = None, webhooks: typing.Optional[WebhooksConfig] = None, kvstore: typing.Optional[KvStoreConfig] = None, sql: typing.Optional[SqlConfig] = None) -> SdkFullConfig: ... + @property + def rpc(self) -> typing.Optional[RpcConfig]: ... + @rpc.setter + def rpc(self, value: typing.Optional[RpcConfig]) -> None: ... + def __new__(cls, api_key: builtins.str, http: typing.Optional[HttpConfig] = None, admin: typing.Optional[AdminConfig] = None, streams: typing.Optional[StreamsConfig] = None, webhooks: typing.Optional[WebhooksConfig] = None, kvstore: typing.Optional[KvStoreConfig] = None, sql: typing.Optional[SqlConfig] = None, rpc: typing.Optional[RpcConfig] = None) -> SdkFullConfig: ... @typing.final class SecurityOption: @@ -6457,6 +6635,27 @@ class TestFilterResponse: Log lines emitted by the filter function during evaluation. """ +@typing.final +class ToolingAccessStatus: + r""" + Current Tooling Access status for the account. `enabled` is the source of + truth — a previously-provisioned-but-disabled account may still report a + non-null `endpoint_url`. + """ + @property + def enabled(self) -> builtins.bool: ... + @property + def endpoint_url(self) -> typing.Optional[builtins.str]: ... + @property + def enabled_at(self) -> typing.Optional[builtins.str]: ... + @property + def endpoint_id(self) -> typing.Optional[builtins.str]: + r""" + The provisioned endpoint's id. Used to fetch the per-network URL map + (`get_endpoint_urls`) for multichain routing. `None` on control planes + that don't yet return it. + """ + @typing.final class UpdateEndpointRequest: r""" diff --git a/python/quicknode_sdk/init_manual_override.pyi b/python/quicknode_sdk/init_manual_override.pyi index fbc1834..2d0b5b4 100644 --- a/python/quicknode_sdk/init_manual_override.pyi +++ b/python/quicknode_sdk/init_manual_override.pyi @@ -117,7 +117,11 @@ from quicknode_sdk._core import ( StreamsConfig, KvStoreConfig, SqlConfig, + RpcConfig, + CachedToken, SdkFullConfig, + RpcApiClient, + ToolingAccessStatus, KvStoreApiClient, KvSetEntry, GetSetsResponse, @@ -226,6 +230,7 @@ from quicknode_sdk._core import ( ConnectionError, ApiError, DecodeError, + RpcError, ) __all__ = [ @@ -344,7 +349,11 @@ __all__ = [ "AdminConfig", "KvStoreConfig", "SqlConfig", + "RpcConfig", + "CachedToken", "SdkFullConfig", + "RpcApiClient", + "ToolingAccessStatus", "KvStoreApiClient", "KvSetEntry", "GetSetsResponse", @@ -453,4 +462,5 @@ __all__ = [ "ConnectionError", "ApiError", "DecodeError", + "RpcError", ] diff --git a/ruby/README.md b/ruby/README.md index 7e2d499..7da6498 100644 --- a/ruby/README.md +++ b/ruby/README.md @@ -1682,6 +1682,54 @@ schema = qn.sql.get_schema(cluster_id: "hyperliquid-core-mainnet") puts schema[:tables].length ``` +--- + +### RPC & Tooling Access + +Tooling Access provisions a single multichain, read-only endpoint per account and +mints short-lived session JWTs. `qn.rpc` makes JSON-RPC calls directly against that +endpoint, minting and refreshing the JWT automatically — no endpoint URL or token to +manage. + +Tooling Access must be enabled once (admin role + eligible plan). The control-plane +methods live on `qn.admin`: + +```ruby +# Ruby +status = qn.admin.tooling_access_status +qn.admin.enable_tooling_access unless status["enabled"] # idempotent; admin role required + +# Make on-chain calls. params defaults to []; pass an Array (positional) or Hash. +block_number = qn.rpc.call(method: "eth_blockNumber") +balance = qn.rpc.call(method: "eth_getBalance", params: ["0xabc...", "latest"]) + +# Multichain: select a network by its multichain_urls key. Seed the map first +# (from admin.get_endpoint_urls), then pass network:. +urls = qn.admin.get_endpoint_urls(id: endpoint_id) +map = (urls.dig("data", "multichain_urls") || {}).transform_values { |v| v["http_url"] } +qn.rpc.set_networks(networks: map) +slot = qn.rpc.call(method: "getSlot", network: "solana-mainnet") + +# Custom endpoint URL: send to a fully-formed HTTP URL, bypassing Tooling Access +# and the JWT (no Authorization header). Per-call via endpoint_url:, or client-wide +# via the rpc: { endpoint_url: ... } config key. endpoint_url and network are +# mutually exclusive (a custom URL is not multichain-routed). +block = qn.rpc.call(method: "eth_blockNumber", endpoint_url: "https://my-endpoint.example/rpc") + +# A JSON-RPC error member is raised as QuicknodeSdk::RpcError (with #code, #message). +begin + qn.rpc.call(method: "eth_getBalance", params: ["bad"]) +rescue QuicknodeSdk::RpcError => e + warn "#{e.code}: #{e.message}" +end +``` + +Responses are wrapped in `QuicknodeSdk::IndifferentHash` — access with `[]`. A host that +persists across processes can snapshot the cached token with `qn.rpc.current_token` and +re-seed it via the `rpc: { seed: ... }` config key; `refresh_margin_secs` (default 60) +tunes how early the token is refreshed. Set `rpc: { endpoint_url: ... }` to route every +call to a custom HTTP URL by default (no JWT minted); a per-call `endpoint_url` overrides it. + ## Error Handling Every binding exposes a typed exception hierarchy derived from the core `SdkError` @@ -1697,8 +1745,9 @@ subclass to branch on transport vs. API semantics. | `ConnectionError` | connection refused / DNS / TLS (subclass of `HttpError`) | — | | `ApiError` | non-2xx HTTP response | `status`, `body` | | `DecodeError` | 2xx response but JSON parse failed | `body` | +| `RpcError` | JSON-RPC call returned an `error` member | `code`, `message` | -Class names: `QuicknodeSdk::Error`, `QuicknodeSdk::ConfigError`, `QuicknodeSdk::HttpError`, `QuicknodeSdk::TimeoutError`, `QuicknodeSdk::ConnectionError`, `QuicknodeSdk::ApiError`, `QuicknodeSdk::DecodeError`. All extend `StandardError`. Hash-key validation still raises `ArgumentError`. +Class names: `QuicknodeSdk::Error`, `QuicknodeSdk::ConfigError`, `QuicknodeSdk::HttpError`, `QuicknodeSdk::TimeoutError`, `QuicknodeSdk::ConnectionError`, `QuicknodeSdk::ApiError`, `QuicknodeSdk::DecodeError`, `QuicknodeSdk::RpcError`. All extend `StandardError`. Hash-key validation still raises `ArgumentError`. ```ruby # Ruby diff --git a/ruby/examples/rpc.rb b/ruby/examples/rpc.rb new file mode 100644 index 0000000..9dd69da --- /dev/null +++ b/ruby/examples/rpc.rb @@ -0,0 +1,49 @@ +require_relative "../lib/quicknode_sdk" + +qn = QuicknodeSdk::SDK.from_env + +# Ensure Tooling Access is provisioned (idempotent; requires admin role). +status = qn.admin.tooling_access_status +puts "tooling access enabled: #{status["enabled"]}" +unless status["enabled"] + begin + enabled = qn.admin.enable_tooling_access + puts "enabled tooling access: #{enabled["enabled"]}" + rescue QuicknodeSdk::Error => e + warn "could not enable tooling access: #{e.message}" + exit + end +end + +# Make a JSON-RPC call. The SDK mints and refreshes the session JWT. +block_number = qn.rpc.call(method: "eth_blockNumber") +puts "eth_blockNumber => #{block_number}" + +# Multichain: seed the per-network URL map (from the endpoint id in status), +# then route a call to a specific network by its key. +if status["endpoint_id"] + urls = qn.admin.get_endpoint_urls(id: status["endpoint_id"]) + mc = urls.dig("data", "multichain_urls") + if mc + map = mc.transform_values { |v| v["http_url"] } + qn.rpc.set_networks(networks: map) + slot = qn.rpc.call(method: "getSlot", network: "solana-mainnet") + puts "solana getSlot => #{slot}" + end +end + +# Demonstrate the typed JSON-RPC error path. +begin + qn.rpc.call(method: "eth_getBalance", params: ["not-an-address"]) +rescue QuicknodeSdk::RpcError => e + puts "got expected RpcError: code=#{e.code} message=#{e.message}" +end + +# Custom endpoint URL: send a call to a fully-formed HTTP URL, bypassing the +# Tooling Access endpoint and the session JWT entirely. Set it per-call here, +# or client-wide via RpcConfig(endpoint_url:). +custom_url = ENV.fetch("QN_RPC_ENDPOINT_URL", nil) +if custom_url + result = qn.rpc.call(method: "eth_blockNumber", endpoint_url: custom_url) + puts "custom endpoint eth_blockNumber => #{result}" +end diff --git a/ruby/lib/quicknode_sdk.rb b/ruby/lib/quicknode_sdk.rb index 4ec085e..232684a 100644 --- a/ruby/lib/quicknode_sdk.rb +++ b/ruby/lib/quicknode_sdk.rb @@ -14,4 +14,5 @@ require_relative "quicknode_sdk/clients/webhooks" require_relative "quicknode_sdk/clients/kvstore" require_relative "quicknode_sdk/clients/sql" +require_relative "quicknode_sdk/clients/rpc" require_relative "quicknode_sdk/sdk" diff --git a/ruby/lib/quicknode_sdk/clients/rpc.rb b/ruby/lib/quicknode_sdk/clients/rpc.rb new file mode 100644 index 0000000..79f44e8 --- /dev/null +++ b/ruby/lib/quicknode_sdk/clients/rpc.rb @@ -0,0 +1,4 @@ +module QuicknodeSdk + class Rpc < NativeDelegator + end +end diff --git a/ruby/lib/quicknode_sdk/sdk.rb b/ruby/lib/quicknode_sdk/sdk.rb index 957d620..e02cfcb 100644 --- a/ruby/lib/quicknode_sdk/sdk.rb +++ b/ruby/lib/quicknode_sdk/sdk.rb @@ -38,5 +38,9 @@ def kvstore def sql Sql.new(@native.sql) end + + def rpc + Rpc.new(@native.rpc) + end end end diff --git a/ruby/sig/quicknode_sdk.rbs b/ruby/sig/quicknode_sdk.rbs index 20489d5..0bfed8e 100644 --- a/ruby/sig/quicknode_sdk.rbs +++ b/ruby/sig/quicknode_sdk.rbs @@ -25,6 +25,11 @@ module QuicknodeSdk attr_reader body: String end + class RpcError < Error + attr_reader code: Integer + attr_reader message: String + end + class SDK def self.from_env: () -> SDK def self.from_config: (Hash[Symbol | String, untyped] opts) -> SDK @@ -34,6 +39,7 @@ module QuicknodeSdk def webhooks: () -> Webhooks def kvstore: () -> KvStore def sql: () -> Sql + def rpc: () -> Rpc end class DestinationAttributes @@ -93,6 +99,10 @@ module QuicknodeSdk def list_chains: () -> untyped def account_info: () -> untyped def get_api_credits: (chain: String) -> untyped + def tooling_access_status: () -> untyped + def enable_tooling_access: () -> untyped + def disable_tooling_access: () -> untyped + def mint_tooling_token: () -> untyped def list_invoices: () -> untyped def list_payments: () -> untyped def list_teams: () -> untyped @@ -168,4 +178,13 @@ module QuicknodeSdk def query: (query: String, cluster_id: String) -> untyped def get_schema: (cluster_id: String) -> untyped end + + class Rpc + def initialize: (untyped native) -> void + + def call: (method: String, ?params: untyped, ?network: String, ?endpoint_url: String) -> untyped + def set_networks: (networks: Hash[String, String]) -> void + def clear_cached_token: () -> void + def current_token: () -> untyped + end end