use std::io::Read;
use std::path::{Path, PathBuf};
use clap::{ArgGroup, Args as ClapArgs, Subcommand};
use serde_json::Value;
use crate::confirm::{decide_without_prompt, ConfirmCfg, Severity};
use crate::context::{Ctx, GlobalArgs};
use crate::errors::CliError;
use crate::output::Format;
use crate::retry::retrying;
use crate::{config, confirm};
#[derive(Debug, ClapArgs)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub struct Args {
#[command(subcommand)]
pub cmd: RpcCmd,
}
#[derive(Debug, Subcommand)]
pub enum RpcCmd {
#[command(after_help = "Examples:\n \
qn rpc call eth_blockNumber\n \
qn rpc call eth_getBalance '[\"0xabc...\", \"latest\"]'\n \
qn rpc call getSlot --network solana-mainnet\n \
qn rpc call eth_blockNumber --endpoint-url https://my-endpoint.example/rpc\n \
qn rpc call eth_call --params-file params.json\n \
echo '[...]' | qn rpc call eth_call -\n \
cat params.json | qn rpc call eth_call -f -")]
Call(CallArgs),
#[command(visible_alias = "ls")]
ListNetworks,
}
#[derive(Debug, ClapArgs)]
#[command(group(ArgGroup::new("params_source").args(["params", "params_file"])))]
pub struct CallArgs {
#[arg(value_name = "METHOD")]
pub method: String,
#[arg(value_name = "PARAMS")]
pub params: Option<String>,
#[arg(long, short = 'f', value_name = "PATH")]
pub params_file: Option<PathBuf>,
#[arg(long)]
pub network: Option<String>,
#[arg(long, conflicts_with = "network", value_name = "URL")]
pub endpoint_url: Option<String>,
}
pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> {
match args.cmd {
RpcCmd::Call(call) => run_call(call, global).await,
RpcCmd::ListNetworks => run_list_networks(global).await,
}
}
async fn run_list_networks(global: GlobalArgs) -> Result<(), CliError> {
let config_path = global.resolve_config_path();
let networks_path = config::networks_cache_path(config_path.as_deref());
let token_path = config::token_cache_path(config_path.as_deref());
let seed = match (&token_path, resolve_key_quietly(&global)) {
(Some(p), Some(key)) => config::load_token(p, &key),
_ => None,
};
let (ctx, _api_key) = Ctx::from_global_with_rpc_seed(global, seed, None)?;
let map = ensure_networks(&ctx, networks_path.as_deref()).await?;
emit_networks(&ctx, &map)
}
async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> {
let params = parse_params(args.params.as_deref(), args.params_file.as_deref())?;
let flag_endpoint_url = match args.endpoint_url.as_deref() {
Some(u) => Some(crate::context::validate_endpoint_url(u)?),
None => None,
};
let config_endpoint_url = load_config_endpoint_url(&global);
let custom_url = flag_endpoint_url
.clone()
.or_else(|| config_endpoint_url.clone());
let config_path = global.resolve_config_path();
let token_path = config::token_cache_path(config_path.as_deref());
let networks_path = config::networks_cache_path(config_path.as_deref());
let seed = match (&token_path, resolve_key_quietly(&global)) {
(Some(p), Some(key)) => config::load_token(p, &key),
_ => None,
};
let (ctx, api_key) = Ctx::from_global_with_rpc_seed(global, seed, config_endpoint_url)?;
if args.network.is_some() {
let map = ensure_networks(&ctx, networks_path.as_deref()).await?;
ctx.sdk.rpc.set_networks(map);
}
let method = args.method.as_str();
let result = match call_once(
&ctx,
method,
¶ms,
args.network.clone(),
custom_url.clone(),
)
.await
{
Ok(v) => v,
Err(e) if custom_url.is_some() => return Err(map_unknown_network(e)),
Err(e) if is_not_enabled(&e) => {
maybe_enable(&ctx).await?;
call_after_enable(&ctx, method, ¶ms, args.network.clone()).await?
}
Err(e) if is_transport_failure(&e) => {
if disabled_per_status(&ctx).await {
ctx.sdk.rpc.clear_cached_token();
if let Some(p) = &token_path {
let _ = config::delete_config(p);
}
maybe_enable(&ctx).await?;
call_after_enable(&ctx, method, ¶ms, args.network.clone()).await?
} else {
return Err(e);
}
}
Err(e) => return Err(map_unknown_network(e)),
};
if let (Some(p), Some(current)) = (&token_path, ctx.sdk.rpc.current_token()) {
let _ = config::save_token(p, &api_key, ¤t);
}
emit_result(&ctx, &result)
}
fn load_config_endpoint_url(global: &GlobalArgs) -> Option<String> {
let path = global.resolve_config_path()?;
match config::load_from(&path) {
Ok(Some(cfg)) => cfg.rpc.endpoint_url,
_ => None,
}
}
fn map_unknown_network(err: CliError) -> CliError {
if let CliError::Sdk(quicknode_sdk::errors::SdkError::Config(msg)) = &err {
if msg.contains("unknown network") {
let available = msg
.split_once("Available:")
.map(|(_, rest)| rest.trim())
.filter(|s| !s.is_empty());
let mut out = "unknown network key for this endpoint.".to_string();
if let Some(list) = available {
out.push_str(&format!(" Available: {list}"));
}
out.push_str("\nRun 'qn rpc list-networks' to see valid keys.");
return CliError::Arg(out);
}
}
err
}
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
async fn ensure_networks(
ctx: &Ctx,
networks_path: Option<&std::path::Path>,
) -> Result<std::collections::HashMap<String, String>, CliError> {
let status = retrying(ctx.global.retries, || ctx.sdk.admin.tooling_access_status()).await?;
let Some(endpoint_id) = status.endpoint_id else {
return Err(CliError::Arg(
"this account's Tooling Access endpoint did not report an id, so per-network \
routing is unavailable. Omit --network to use the default network."
.to_string(),
));
};
if let Some(p) = networks_path {
if let Some(map) = config::load_networks(p, &endpoint_id, now_unix()) {
return Ok(map);
}
}
let resp = retrying(ctx.global.retries, || {
ctx.sdk.admin.get_endpoint_urls(&endpoint_id)
})
.await?;
let map: std::collections::HashMap<String, String> = resp
.data
.and_then(|d| d.multichain_urls)
.map(|mc| mc.into_iter().map(|(k, v)| (k, v.http_url)).collect())
.unwrap_or_default();
if let Some(p) = networks_path {
let _ = config::save_networks(p, &endpoint_id, now_unix(), &map);
}
Ok(map)
}
fn emit_networks(
ctx: &Ctx,
map: &std::collections::HashMap<String, String>,
) -> Result<(), CliError> {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
if matches!(ctx.global.format, Some(f) if f.is_structured()) {
let v = serde_json::json!({ "networks": keys });
return emit_result(ctx, &v);
}
for k in keys {
println!("{k}");
}
Ok(())
}
fn resolve_key_quietly(global: &GlobalArgs) -> Option<String> {
let path = global.resolve_config_path();
config::resolve_api_key(global.api_key.as_deref(), path.as_deref(), false, || {
Err(CliError::NoApiKey)
})
.ok()
.map(|(k, _)| k)
}
async fn call_once(
ctx: &Ctx,
method: &str,
params: &Option<Value>,
network: Option<String>,
endpoint_url: Option<String>,
) -> Result<Value, CliError> {
retrying(ctx.global.retries, || {
ctx.sdk.rpc.call(
method,
params.clone(),
network.clone(),
endpoint_url.clone(),
)
})
.await
.map_err(Into::into)
}
const POST_ENABLE_BUDGET: std::time::Duration = std::time::Duration::from_secs(10);
const POST_ENABLE_INITIAL_WAIT: std::time::Duration = std::time::Duration::from_secs(1);
async fn call_after_enable(
ctx: &Ctx,
method: &str,
params: &Option<Value>,
network: Option<String>,
) -> Result<Value, CliError> {
tokio::time::sleep(POST_ENABLE_INITIAL_WAIT).await;
let deadline = tokio::time::Instant::now() + POST_ENABLE_BUDGET;
let mut backoff = std::time::Duration::from_millis(500);
loop {
match ctx
.sdk
.rpc
.call(method, params.clone(), network.clone(), None)
.await
{
Ok(v) => return Ok(v),
Err(e) => {
let cli_err = CliError::from(e);
let now = tokio::time::Instant::now();
if !is_transport_failure(&cli_err) || now >= deadline {
return Err(cli_err);
}
let remaining = deadline - now;
tokio::time::sleep(backoff.min(remaining)).await;
backoff = (backoff * 2).min(std::time::Duration::from_secs(4));
}
}
}
}
fn is_not_enabled(err: &CliError) -> bool {
matches!(
err,
CliError::Sdk(quicknode_sdk::errors::SdkError::Api { status, body })
if status.as_u16() == 400 && body.to_lowercase().contains("not enabled")
)
}
fn is_transport_failure(err: &CliError) -> bool {
use quicknode_sdk::errors::HttpKind;
matches!(
err,
CliError::Sdk(sdk @ quicknode_sdk::errors::SdkError::Http(_))
if matches!(sdk.http_kind(), Some(HttpKind::Connect | HttpKind::Timeout))
)
}
async fn disabled_per_status(ctx: &Ctx) -> bool {
matches!(ctx.sdk.admin.tooling_access_status().await, Ok(s) if !s.enabled)
}
async fn maybe_enable(ctx: &Ctx) -> Result<(), CliError> {
let cfg = ConfirmCfg::new(
ctx.global.yes_count,
ctx.global.no_input,
ctx.out.stdout_is_tty,
);
let proceed = match decide_without_prompt(Severity::Mild, cfg) {
Ok(p) => p,
Err(CliError::NeedsConfirmation) => {
return Err(CliError::Arg(
"Tooling Access is not enabled for this account. \
Run 'qn tooling-access enable', or pass --yes to enable it now."
.to_string(),
));
}
Err(e) => return Err(e),
};
let proceed =
proceed || confirm::prompt_yes_no("Tooling Access is not enabled. Enable it now?")?;
if !proceed {
return Err(CliError::Cancelled);
}
ctx.sdk.admin.enable_tooling_access().await?;
ctx.out.note("✓ Enabled Tooling Access");
Ok(())
}
fn parse_params(arg: Option<&str>, file: Option<&Path>) -> Result<Option<Value>, CliError> {
let raw = match (arg, file) {
(None, None) => return Ok(None),
(Some("-"), _) => read_stdin("params")?,
(Some(s), _) => s.to_string(),
(None, Some(path)) if path.as_os_str() == "-" => read_stdin("params")?,
(None, Some(path)) => std::fs::read_to_string(path).map_err(|e| {
CliError::Arg(format!(
"could not read params file '{}': {e}",
path.display()
))
})?,
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
let value: Value = serde_json::from_str(trimmed)
.map_err(|e| CliError::Arg(format!("params is not valid JSON: {e}")))?;
Ok(Some(value))
}
fn read_stdin(what: &str) -> Result<String, CliError> {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| CliError::Arg(format!("could not read {what} from stdin: {e}")))?;
Ok(buf)
}
fn emit_result(ctx: &Ctx, result: &Value) -> Result<(), CliError> {
match ctx.global.format {
None | Some(Format::Json) => {
println!(
"{}",
serde_json::to_string_pretty(result).map_err(CliError::Json)?
);
}
Some(Format::Yaml) => {
print!(
"{}",
serde_yml::to_string(result).map_err(|e| CliError::Format(e.to_string()))?
);
}
Some(Format::Toon) => {
println!(
"{}",
toon_format::encode_default(result).map_err(|e| CliError::Format(e.to_string()))?
);
}
Some(fmt @ (Format::Table | Format::Md)) => {
let name = if fmt == Format::Md { "md" } else { "table" };
ctx.out.warn(&format!(
"ℹ '-o {name}' has no columns for 'qn rpc'; printing JSON. Use -o json/yaml/toon for structured output."
));
println!(
"{}",
serde_json::to_string_pretty(result).map_err(CliError::Json)?
);
}
}
Ok(())
}