diff --git a/Cargo.lock b/Cargo.lock index 7eb38f5..b65df72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3246,8 +3246,7 @@ dependencies = [ [[package]] name = "quicknode-sdk" version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f55e09e33fa583b368558949ac98bdbb061c193cd717ee9d9621fedf8325ac8" +source = "git+https://github.com/quicknode/sdk?rev=0bd4f3cf13b32d75119ffe7b501929dcffa78bfd#0bd4f3cf13b32d75119ffe7b501929dcffa78bfd" dependencies = [ "alloy-consensus", "alloy-primitives", diff --git a/Cargo.toml b/Cargo.toml index 3896a5a..5bad0f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ name = "qn" path = "src/lib.rs" [dependencies] -quicknode-sdk = { version = "0.8.2", features = [ +quicknode-sdk = { git = "https://github.com/quicknode/sdk", rev = "0bd4f3cf13b32d75119ffe7b501929dcffa78bfd", features = [ "payments", # x402/EVM "payments-svm", # + x402/Solana "payments-tempo", # + MPP/Tempo diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 31f5a9a..0955607 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -23,4 +23,4 @@ transaction responses from `qn rpc x402 drip`. **Success criteria**: Arc network and USDC resolution work, both response shapes render correctly, and the CLI passes all verification checks against the local SDK. -**Status**: In Progress +**Status**: Complete diff --git a/README.md b/README.md index 3fb0195..18490f5 100644 --- a/README.md +++ b/README.md @@ -262,22 +262,59 @@ qn kv list get allowlist ### SQL +Discovery is always public. `qn sql clusters` and `qn sql schema` need no API +key, wallet, or flag. + +```sh +qn sql clusters +qn sql schema hyperliquid-core-mainnet +``` + +`qn sql query` chooses who pays. An API key with no payment flag uses account +credits. `--x402-drawdown` and `--mpp-session` ignore an API key if one is also +present. Config never turns a payment path on. + ```sh -# Run a query inline, from a file, or from stdin (--file -) +# Account credits (API key required) qn sql query "SELECT action_type, user FROM hyperliquid_system_actions ORDER BY block_time DESC LIMIT 3" --cluster-id hyperliquid-core-mainnet qn sql query --file query.sql --cluster-id hyperliquid-core-mainnet cat query.sql | qn sql query --file - --cluster-id hyperliquid-core-mainnet - -# Pipe rows into jq (stats print to stderr, so stdout stays clean) qn sql query "SELECT 1" --cluster-id hyperliquid-core-mainnet -o json | jq '.data' - -# Inspect a cluster's tables, columns, and types -qn sql schema hyperliquid-core-mainnet ``` Queries are read-only (SELECT) and capped at 1000 rows per request; page through larger result sets with `LIMIT`/`OFFSET` in the SQL. +Get started without an account (x402 drawdown): + +```sh +qn wallet generate --vm evm --name payer +qn micropayments x402 drip --payment-wallet payer --payment-network base-sepolia +qn micropayments x402 buy-credits --network base-sepolia --yes \ + --payment-wallet payer --payment-network base-sepolia \ + --payment-asset USDC --max-amount 10000000 +qn sql query "SELECT * FROM hyperliquid_trades LIMIT 10" \ + --cluster-id hyperliquid-core-mainnet \ + --x402-drawdown --payment-wallet payer --payment-network base-sepolia +``` + +Get started without an account (MPP session). Fund pathUSD on Tempo testnet +first. + +```sh +qn wallet generate --vm evm --name payer +qn micropayments mpp open --deposit 1000000 --yes \ + --payment-wallet payer --payment-network tempo-testnet \ + --payment-asset pathUSD --max-amount 1000000 +qn sql query "SELECT * FROM hyperliquid_trades LIMIT 10" \ + --cluster-id hyperliquid-core-mainnet \ + --mpp-session --payment-wallet payer \ + --payment-network tempo-testnet --payment-asset pathUSD --max-amount 1000000 +``` + +`qn pay` is an alias for `qn micropayments`. `qn rpc x402` and `qn rpc mpp` +call the same funding runners. + ### On-chain RPC Make JSON-RPC calls with no endpoint to provision. `qn rpc call` mints and diff --git a/src/cli.rs b/src/cli.rs index d99f8e4..79fb6ee 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -155,6 +155,10 @@ pub enum Command { /// Run SQL queries and inspect cluster schemas. Sql(commands::sql::Args), + /// Manage crypto micropayments (x402 credits and MPP channels). + #[command(visible_alias = "pay")] + Micropayments(commands::micropayments::Args), + /// Make RPC calls. Rpc(commands::rpc::Args), @@ -255,7 +259,8 @@ impl Cli { Command::Stream(args) => commands::stream::run(args, Ctx::from_global(global)?).await, Command::Webhook(args) => commands::webhook::run(args, Ctx::from_global(global)?).await, Command::Kv(args) => commands::kv::run(args, Ctx::from_global(global)?).await, - Command::Sql(args) => commands::sql::run(args, Ctx::from_global(global)?).await, + Command::Sql(args) => commands::sql::run(args, global).await, + Command::Micropayments(args) => commands::micropayments::run(args, global).await, // RPC resolves its own context for token-cache seeding. Command::Rpc(args) => commands::rpc::run(args, global).await, // Wallet management is local and keyless. diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index 18861af..b43efa7 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -16,7 +16,10 @@ Resolution order for the API key: 1. `--api-key ` flag (highest precedence). 2. Config file: `[api] key = "..."` in `~/.config/qn/config.toml` (or the path passed to `--config-file`). -3. If neither resolves, the command exits **4** (`no API key found`). +3. If neither resolves, the command exits **4** (`no API key found`). The one + exception is `sql query`, where a key is only one of three ways to pay: with + no key and no `--x402-drawdown`/`--mpp-session` it exits **1** and names every + option. There is **no environment-variable fallback** by design — a key left exported in a shell is invisible state that outlives the session. @@ -69,7 +72,8 @@ gateway 5xx after the paid resend, a lost response, or an uninterpretable post-payment response). On exit 3, check the wallet before re-running; never blind-retry a paid call. A **drawdown** call (`--x402-drawdown`) spends prepaid credits, not per-call funds: running out surfaces an actionable exit-2 error -pointing at `qn rpc x402 buy-credits`, and a credit is drawn only on success. +pointing at `qn micropayments x402 buy-credits` (alias `qn rpc x402 buy-credits`), +and a credit is drawn only on success. A paid SQL query uses the same 2/3 split. ## 4. Non-interactive & confirmation behavior @@ -90,6 +94,8 @@ Gated command classes: - `endpoint rate-limit delete-override` - `stream delete`, `webhook delete`, `team delete` - `kv set delete`, `kv list delete` +- `micropayments x402 buy-credits` and `rpc x402 buy-credits` +- `micropayments mpp open`/`top-up`/`close` and `rpc mpp open`/`top-up`/`close` There is **no account-wide wipe command** — that is intentional; use the API directly if you need it. @@ -105,14 +111,16 @@ if you need it. - `qn stream test-filter` evaluates a filter against historical data and changes nothing — it is read-only and safe to retry. - `qn sql query` is read-only but **does not auto-retry**: a query consumes credits, - so a retried query re-bills. `qn sql schema` is a cheap read and retries normally. -- A **paid** `rpc call` (`--x402`/`--mpp`/`--x402-drawdown`/`--mpp-session`) + so a retried query re-bills. `qn sql clusters` and `qn sql schema` are cheap + public-catalog reads and retry normally. +- A **paid** `rpc call` or `sql query` (`--x402`/`--mpp`/`--x402-drawdown`/`--mpp-session`) never auto-retries — `--retries` does not apply. A per-request attempt can move funds, and after a lost response the previous attempt may already have - settled (§3, exit 3). A drawdown call draws 1 credit per success and is + settled (§3, exit 3). A drawdown call draws prepaid credits on success and is single-attempt (the one exception is a transparent re-auth when the session token expired, which draws nothing). A session call signs one cumulative - voucher and is single-attempt. + voucher and is single-attempt. `sql query` has no per-request `--x402`/`--mpp` + flag; its paid lanes are `--x402-drawdown` and `--mpp-session`. ## 6. Command catalog @@ -133,7 +141,16 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase enabled-count - `kv` — `set` (put, get, list, delete, bulk) and `list` (list, get, create, append, contains, remove-item, update, delete) -- `sql` — query (inline SQL, `--file `, or `--file -` for stdin), schema +- `sql` — `clusters` (alias `ls`) and `schema` always hit the public catalog + (no API key, wallet, or flag). `query` (inline SQL, `--file `, or + `--file -` for stdin) chooses who pays: an API key with no payment flag uses + the account host; `--x402-drawdown` uses prepaid x402 credits; `--mpp-session` + uses an open MPP channel. Config never turns a payment path on. Neither a + key nor a payment flag is an error that names both next steps. +- `micropayments` (alias `pay`) — shared funding noun. `x402` (buy-credits, + balance, drip, supported-networks, supported-payments) and `mpp` (open, + top-up, close, status, supported-networks, supported-payments). `qn rpc x402` + and `qn rpc mpp` stay first-class and call the same runners. - `tooling-access` — status, enable, disable (provisions the endpoint `rpc` uses) - `rpc` — make JSON-RPC calls. `qn rpc call [json-params]` calls the account's Tooling Access endpoint (params is a JSON array or object inline, or @@ -269,6 +286,34 @@ qn kv set get my-key qn kv set list ``` +**SQL without an account (x402 drawdown):** + +```sh +qn wallet generate --vm evm --name payer +qn micropayments x402 drip --payment-wallet payer --payment-network base-sepolia +qn micropayments x402 buy-credits --network base-sepolia --yes \ + --payment-wallet payer --payment-network base-sepolia \ + --payment-asset USDC --max-amount 10000000 +qn sql query "SELECT * FROM hyperliquid_trades LIMIT 10" \ + --cluster-id hyperliquid-core-mainnet \ + --x402-drawdown --payment-wallet payer --payment-network base-sepolia +``` + +**SQL without an account (MPP session).** Fund pathUSD on Tempo testnet first. + +```sh +qn wallet generate --vm evm --name payer +qn micropayments mpp open --deposit 1000000 --yes \ + --payment-wallet payer --payment-network tempo-testnet \ + --payment-asset pathUSD --max-amount 1000000 +qn sql query "SELECT * FROM hyperliquid_trades LIMIT 10" \ + --cluster-id hyperliquid-core-mainnet \ + --mpp-session --payment-wallet payer \ + --payment-network tempo-testnet --payment-asset pathUSD --max-amount 1000000 +``` + +`qn sql clusters` and `qn sql schema hyperliquid-core-mainnet` need no key. + **Make on-chain calls (no endpoint to provision):** ```sh diff --git a/src/commands/micropayments/mod.rs b/src/commands/micropayments/mod.rs new file mode 100644 index 0000000..cb1d707 --- /dev/null +++ b/src/commands/micropayments/mod.rs @@ -0,0 +1,46 @@ +//! Shared funding noun for x402 credits and MPP channels. +//! +//! `qn rpc x402` and `qn rpc mpp` stay first-class and call the same runners. + +pub mod mpp; +pub mod x402; + +use clap::{Args as ClapArgs, Subcommand}; + +use crate::context::GlobalArgs; +use crate::errors::CliError; + +#[derive(Debug, ClapArgs)] +#[command(subcommand_required = true, arg_required_else_help = true)] +#[command(after_help = "Examples:\n \ + qn micropayments x402 drip --payment-wallet payer --payment-network base-sepolia\n \ + qn micropayments x402 buy-credits --network base-sepolia --yes \\\n \ + --payment-wallet payer --payment-network base-sepolia \\\n \ + --payment-asset USDC --max-amount 10000000\n \ + qn micropayments mpp open --deposit 1000000 --yes \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset pathUSD --max-amount 1000000\n\n\ + `qn pay` is an alias. `qn rpc x402` and `qn rpc mpp` call the same runners.")] +pub struct Args { + #[command(subcommand)] + pub cmd: MicropaymentsCmd, +} + +#[derive(Debug, Subcommand)] +pub enum MicropaymentsCmd { + /// Manage x402 credit drawdown: buy prepaid credits, check the balance, or + /// drip testnet funds. Pair with `qn sql query --x402-drawdown` or + /// `qn rpc call --x402-drawdown`. + X402(x402::Args), + + /// Manage an MPP payment channel: open, top-up, close, or check status. + /// Pair with `qn sql query --mpp-session` or `qn rpc call --mpp-session`. + Mpp(mpp::Args), +} + +pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { + match args.cmd { + MicropaymentsCmd::X402(a) => x402::run(a, global).await, + MicropaymentsCmd::Mpp(a) => mpp::run(a, global).await, + } +} diff --git a/src/commands/micropayments/mpp.rs b/src/commands/micropayments/mpp.rs new file mode 100644 index 0000000..ca104cf --- /dev/null +++ b/src/commands/micropayments/mpp.rs @@ -0,0 +1,4 @@ +//! Re-export of the MPP lifecycle so `qn micropayments mpp` and +//! `qn rpc mpp` share one runner. + +pub use crate::commands::rpc::mpp::{run, Args}; diff --git a/src/commands/micropayments/x402.rs b/src/commands/micropayments/x402.rs new file mode 100644 index 0000000..1761ae9 --- /dev/null +++ b/src/commands/micropayments/x402.rs @@ -0,0 +1,4 @@ +//! Re-export of the x402 lifecycle so `qn micropayments x402` and +//! `qn rpc x402` share one runner. + +pub use crate::commands::rpc::x402::{run, Args}; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8b84645..0830240 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -10,6 +10,7 @@ pub mod chain; pub mod endpoint; pub mod kv; pub mod metrics; +pub mod micropayments; pub mod rpc; pub mod sql; pub mod stream; diff --git a/src/commands/rpc/mod.rs b/src/commands/rpc/mod.rs index 3a3f946..00c2e76 100644 --- a/src/commands/rpc/mod.rs +++ b/src/commands/rpc/mod.rs @@ -1,12 +1,12 @@ //! RPC call and network-list commands. Default, custom-URL, and paid calls use //! separate lanes so their auth, cache, and retry behavior cannot mix. -mod mpp; +pub(crate) mod mpp; mod pay_asset; mod pay_network; -mod payment; +pub(crate) mod payment; mod supported_networks; -mod x402; +pub(crate) mod x402; use std::io::Read; use std::path::{Path, PathBuf}; diff --git a/src/commands/rpc/mpp.rs b/src/commands/rpc/mpp.rs index c08a579..3a58c7c 100644 --- a/src/commands/rpc/mpp.rs +++ b/src/commands/rpc/mpp.rs @@ -215,20 +215,20 @@ fn setup(args: &PaymentArgs, global: GlobalArgs) -> Result<(Ctx, PayScope), CliE } // Pay scope carried to channel-cache keying; the payer address is added later. -pub(super) struct PayScope { +pub(crate) struct PayScope { pay_network: String, pay_asset: String, } impl PayScope { - pub(super) fn from_config(payment: &quicknode_sdk::PaymentConfig) -> Self { + pub(crate) fn from_config(payment: &quicknode_sdk::PaymentConfig) -> Self { PayScope { pay_network: payment.pay_network.clone(), pay_asset: payment.asset.clone(), } } - pub(super) fn with_address(&self, address: String) -> config::ChannelScope { + pub(crate) fn with_address(&self, address: String) -> config::ChannelScope { config::ChannelScope { address, pay_network: self.pay_network.clone(), @@ -236,7 +236,7 @@ impl PayScope { } } - pub(super) fn describe(&self) -> String { + pub(crate) fn describe(&self) -> String { format!("{} on {}", self.pay_asset, self.pay_network) } } diff --git a/src/commands/rpc/payment.rs b/src/commands/rpc/payment.rs index d9a7a01..5931040 100644 --- a/src/commands/rpc/payment.rs +++ b/src/commands/rpc/payment.rs @@ -145,7 +145,7 @@ pub(super) async fn run_drawdown_call(args: CallArgs, global: GlobalArgs) -> Res } /// Load a fresh cached gateway session or authenticate one. -pub(super) async fn ensure_gateway_session( +pub(crate) async fn ensure_gateway_session( ctx: &Ctx, global: &GlobalArgs, ) -> Result { @@ -163,7 +163,10 @@ pub(super) async fn ensure_gateway_session( } /// Authenticate and replace the cached session. -async fn reauthenticate(ctx: &Ctx, global: &GlobalArgs) -> Result { +pub(crate) async fn reauthenticate( + ctx: &Ctx, + global: &GlobalArgs, +) -> Result { let sessions_path = config::sessions_cache_path(global.resolve_config_path().as_deref()); let address = ctx.sdk.rpc.payment_address()?; let session = ctx.sdk.rpc.gateway_authenticate().await?; @@ -174,7 +177,7 @@ async fn reauthenticate(ctx: &Ctx, global: &GlobalArgs) -> Result bool { +pub(crate) fn is_token_expired(e: &SdkError) -> bool { matches!( e, SdkError::Api { status, body } @@ -328,7 +331,7 @@ fn load_payment_section(global: &GlobalArgs) -> Result } /// Payment parameters shared by paid call and lifecycle commands. -pub(super) struct PaymentParams<'a> { +pub(crate) struct PaymentParams<'a> { pub key_file: Option<&'a Path>, pub wallet: Option<&'a str>, pub max_amount: Option<&'a str>, @@ -338,7 +341,7 @@ pub(super) struct PaymentParams<'a> { } /// Parameters needed by keyless session commands. -pub(super) struct SessionParams<'a> { +pub(crate) struct SessionParams<'a> { pub key_file: Option<&'a Path>, pub wallet: Option<&'a str>, pub payment_network: Option<&'a str>, @@ -439,7 +442,7 @@ fn resolve_drawdown_config( } // Resolve the wallet and pay network for balance/drip. -pub(super) fn resolve_session_params( +pub(crate) fn resolve_session_params( params: &SessionParams<'_>, section: &PaymentSection, wallets_dir: Option<&Path>, @@ -499,7 +502,7 @@ pub(super) fn resolve_session_params( } /// Resolve payment parameters before any network I/O. -pub(super) fn resolve_payment_params( +pub(crate) fn resolve_payment_params( scheme: &str, params: &PaymentParams<'_>, section: &PaymentSection, diff --git a/src/commands/sql/mod.rs b/src/commands/sql/mod.rs index 0e63010..8144114 100644 --- a/src/commands/sql/mod.rs +++ b/src/commands/sql/mod.rs @@ -7,17 +7,31 @@ use std::path::PathBuf; use clap::{ArgGroup, Args as ClapArgs, Subcommand}; use comfy_table::Cell; -use quicknode_sdk::{ChainSchema, QueryParams, QueryResponse}; +use quicknode_sdk::errors::SdkError; +use quicknode_sdk::{ChainSchema, QueryParams, QueryResponse, SqlCluster}; use serde::Serialize; use serde_json::Value; -use crate::context::Ctx; +use crate::commands::rpc::mpp::PayScope; +use crate::commands::rpc::payment::{ + ensure_gateway_session, is_token_expired, reauthenticate, resolve_payment_params, + resolve_session_params, PaymentParams, SessionParams, +}; +use crate::config::{self, PaymentSection}; +use crate::context::{Ctx, GlobalArgs}; use crate::errors::CliError; use crate::output::{new_table, set_header_bold, write_table, Format, Render}; use crate::retry::retrying; use render::json_cell; #[derive(Debug, ClapArgs)] +#[command(after_help = "Examples:\n \ + qn sql clusters\n \ + qn sql schema hyperliquid-core-mainnet\n \ + qn sql query \"SELECT 1\" --cluster-id hyperliquid-core-mainnet\n \ + qn sql query \"SELECT * FROM hyperliquid_trades LIMIT 10\" \\\n \ + --cluster-id hyperliquid-core-mainnet \\\n \ + --x402-drawdown --payment-wallet payer --payment-network base-sepolia")] pub struct Args { #[command(subcommand)] pub cmd: SqlCmd, @@ -25,23 +39,46 @@ pub struct Args { #[derive(Debug, Subcommand)] pub enum SqlCmd { + /// List clusters in the public SQL catalog. No API key required. + #[command(visible_alias = "ls")] + #[command(after_help = "Examples:\n \ + qn sql clusters\n \ + qn sql ls -o json")] + Clusters, + /// Run a read-only SQL query against a cluster. /// /// The query may be passed inline, read from a file with --file, or read /// from stdin with `--file -`. Results are capped at 1000 rows per request; /// page through larger result sets with LIMIT/OFFSET in the SQL. + /// + /// Only this verb chooses who pays. Discovery (`clusters`, `schema`) is + /// always free. Config never turns a payment path on. #[command(after_help = "Examples:\n \ qn sql query \"SELECT 1\" --cluster-id hyperliquid-core-mainnet\n \ qn sql query --file query.sql --cluster-id hyperliquid-core-mainnet\n \ - cat query.sql | qn sql query --file - --cluster-id hyperliquid-core-mainnet")] - Query(QueryArgs), + cat query.sql | qn sql query --file - --cluster-id hyperliquid-core-mainnet\n \ + qn sql query \"SELECT * FROM hyperliquid_trades LIMIT 10\" \\\n \ + --cluster-id hyperliquid-core-mainnet \\\n \ + --x402-drawdown --payment-wallet payer --payment-network base-sepolia\n \ + qn sql query \"SELECT * FROM hyperliquid_trades LIMIT 10\" \\\n \ + --cluster-id hyperliquid-core-mainnet \\\n \ + --mpp-session --payment-wallet payer \\\n \ + --payment-network tempo-testnet --payment-asset pathUSD --max-amount 1000000")] + Query(Box), /// Show a cluster's table schema (tables, engines, columns, types). + /// No API key required; always hits the public catalog. + #[command(after_help = "Examples:\n \ + qn sql schema hyperliquid-core-mainnet")] Schema(SchemaArgs), } #[derive(Debug, ClapArgs)] -#[command(group(ArgGroup::new("source").args(["query", "file"]).required(true)))] +#[command( + group(ArgGroup::new("source").args(["query", "file"]).required(true)), + group(ArgGroup::new("payment").args(["x402_drawdown", "mpp_session"])), +)] pub struct QueryArgs { /// The SQL query to run. Mutually exclusive with --file. #[arg(value_name = "SQL")] @@ -54,6 +91,78 @@ pub struct QueryArgs { /// The cluster to query (e.g. hyperliquid-core-mainnet). #[arg(long, value_name = "CLUSTER_ID")] pub cluster_id: String, + + /// Pay from prepaid x402 credits (SIWX session). Not a per-request + /// payment: buy credits first with `qn micropayments x402 buy-credits`. + /// An API key, if also present, is unused. + #[arg(long, help_heading = "Payment")] + pub x402_drawdown: bool, + + /// Pay from an open MPP channel (session voucher). Open a channel first + /// with `qn micropayments mpp open`. An API key, if also present, is unused. + #[arg(long, help_heading = "Payment")] + pub mpp_session: bool, + + /// File containing the raw payment private key; pass `-` to read it from + /// stdin. Precedence: this flag > --payment-wallet > `key_file` > `wallet` + /// under [rpc.payment] in config. + #[arg( + long, + value_name = "PATH", + requires = "payment", + conflicts_with = "payment_wallet", + help_heading = "Payment" + )] + pub payment_key_file: Option, + + /// Name of a stored wallet (from `qn wallet generate`) to pay with. + #[arg( + long, + value_name = "NAME", + requires = "payment", + help_heading = "Payment" + )] + pub payment_wallet: Option, + + /// Chain you PAY on — a network name or CAIP-2 id. Falls back to + /// `payment_network` under [rpc.payment]. + #[arg( + long, + value_name = "NETWORK", + requires = "payment", + help_heading = "Payment" + )] + pub payment_network: Option, + + /// Token to pay with. Required for `--mpp-session`. Falls back to + /// `payment_asset` under [rpc.payment]. + #[arg( + long, + value_name = "ADDRESS", + requires = "payment", + help_heading = "Payment" + )] + pub payment_asset: Option, + + /// Spend ceiling in integer base units of the asset. Required for + /// `--mpp-session`. Falls back to `max_amount` under [rpc.payment]. + #[arg( + long, + value_name = "BASE_UNITS", + requires = "payment", + help_heading = "Payment" + )] + pub max_amount: Option, + + /// Explicit Solana RPC URL for x402/Solana session auth. Falls back to + /// `svm_rpc_url` in [rpc.payment], then a public Solana RPC. + #[arg( + long, + value_name = "URL", + requires = "payment", + help_heading = "Payment" + )] + pub svm_rpc_url: Option, } #[derive(Debug, ClapArgs)] @@ -63,23 +172,50 @@ pub struct SchemaArgs { pub cluster_id: String, } -pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> { +pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { match args.cmd { - SqlCmd::Query(a) => query(a, ctx).await, - SqlCmd::Schema(a) => schema(a, ctx).await, + SqlCmd::Clusters => clusters(global).await, + SqlCmd::Query(a) => query(*a, global).await, + SqlCmd::Schema(a) => schema(a, global).await, } } -async fn query(a: QueryArgs, ctx: Ctx) -> Result<(), CliError> { - let sql = resolve_query(a.query, a.file)?; +async fn clusters(global: GlobalArgs) -> Result<(), CliError> { + let ctx = Ctx::from_global_keyless_sql_catalog(global)?; + let resp = retrying(ctx.global.retries, || ctx.sdk.sql.list_clusters()).await?; + crate::output::emit(&ctx.out, &ClustersView(resp)) +} + +async fn schema(a: SchemaArgs, global: GlobalArgs) -> Result<(), CliError> { + let ctx = Ctx::from_global_keyless_sql_catalog(global)?; + let resp = retrying(ctx.global.retries, || ctx.sdk.sql.get_schema(&a.cluster_id)).await?; + crate::output::emit(&ctx.out, &SchemaView(resp)) +} + +async fn query(a: QueryArgs, global: GlobalArgs) -> Result<(), CliError> { + let sql = resolve_query(a.query.clone(), a.file.clone())?; let params = QueryParams { query: sql, - cluster_id: a.cluster_id, + cluster_id: a.cluster_id.clone(), + }; + if a.x402_drawdown { + return query_x402_drawdown(a, params, global).await; + } + if a.mpp_session { + return query_mpp_session(a, params, global).await; + } + let ctx = match Ctx::from_global(global) { + Ok(ctx) => ctx, + Err(CliError::NoApiKey) => return Err(CliError::Arg(no_payer_message())), + Err(e) => return Err(e), }; // A query consumes credits and may be expensive; never retry, a retried // query re-runs and re-bills. let resp = ctx.sdk.sql.query(¶ms).await?; + emit_query(&ctx, resp) +} +fn emit_query(ctx: &Ctx, resp: QueryResponse) -> Result<(), CliError> { // Stats are diagnostics: they go to stderr (suppressed by --quiet) so stdout // stays clean for piping. JSON/YAML/TOON already carry the full response, so // only emit the note for the human-facing table/markdown formats. @@ -89,9 +225,154 @@ async fn query(a: QueryArgs, ctx: Ctx) -> Result<(), CliError> { crate::output::emit(&ctx.out, &QueryView(resp)) } -async fn schema(a: SchemaArgs, ctx: Ctx) -> Result<(), CliError> { - let resp = retrying(ctx.global.retries, || ctx.sdk.sql.get_schema(&a.cluster_id)).await?; - crate::output::emit(&ctx.out, &SchemaView(resp)) +fn no_payer_message() -> String { + "sql query needs a payment method or an API key.\n \ + Pay with prepaid x402 credits:\n \ + qn sql query \"SELECT 1\" --cluster-id hyperliquid-core-mainnet \\\n \ + --x402-drawdown --payment-wallet --payment-network base-sepolia\n \ + Pay from an MPP channel:\n \ + qn sql query \"SELECT 1\" --cluster-id hyperliquid-core-mainnet \\\n \ + --mpp-session --payment-wallet \\\n \ + --payment-network tempo-testnet --payment-asset pathUSD --max-amount 1000000\n \ + Or set an API key: qn auth login" + .to_string() +} + +fn load_payment_section(global: &GlobalArgs) -> Result { + let Some(path) = global.resolve_config_path() else { + return Ok(PaymentSection::default()); + }; + Ok(config::load_from(&path)? + .map(|cfg| cfg.rpc.payment) + .unwrap_or_default()) +} + +async fn query_x402_drawdown( + a: QueryArgs, + params: QueryParams, + global: GlobalArgs, +) -> Result<(), CliError> { + let section = load_payment_section(&global)?; + let wallets_dir = config::wallets_dir(global.resolve_config_path().as_deref()); + let (payment, key_file_warning) = resolve_session_params( + &SessionParams { + key_file: a.payment_key_file.as_deref(), + wallet: a.payment_wallet.as_deref(), + payment_network: a.payment_network.as_deref(), + svm_rpc_url: a.svm_rpc_url.as_deref(), + }, + §ion, + wallets_dir.as_deref(), + global.base_url.clone(), + )?; + let ctx = Ctx::from_global_keyless_sql_payment(global.clone(), payment)?; + if let Some(w) = key_file_warning { + ctx.out.warn(&w); + } + + let session = ensure_gateway_session(&ctx, &global).await?; + let resp = match ctx.sdk.sql.query_with_session(¶ms, &session).await { + Ok(resp) => resp, + Err(e) if is_token_expired(&e) => { + let fresh = reauthenticate(&ctx, &global).await?; + match ctx.sdk.sql.query_with_session(¶ms, &fresh).await { + Ok(resp) => resp, + Err(e) => return Err(map_sql_drawdown_error(e)), + } + } + Err(e) => return Err(map_sql_drawdown_error(e)), + }; + emit_query(&ctx, resp) +} + +fn map_sql_drawdown_error(e: SdkError) -> CliError { + if is_sql_requires_payment(&e) { + return CliError::PaymentRefused( + "out of x402 credits. Buy more with \ + 'qn micropayments x402 buy-credits', then retry this query." + .to_string(), + ); + } + e.into() +} + +// Status only: body text on a 500 saying "insufficient" is not an empty balance. +fn is_sql_requires_payment(e: &SdkError) -> bool { + matches!(e, SdkError::Api { status, .. } if status.as_u16() == 402) +} + +async fn query_mpp_session( + a: QueryArgs, + params: QueryParams, + global: GlobalArgs, +) -> Result<(), CliError> { + let section = load_payment_section(&global)?; + let wallets_dir = config::wallets_dir(global.resolve_config_path().as_deref()); + let (payment, key_file_warning) = resolve_payment_params( + "mpp", + &PaymentParams { + key_file: a.payment_key_file.as_deref(), + wallet: a.payment_wallet.as_deref(), + max_amount: a.max_amount.as_deref(), + payment_network: a.payment_network.as_deref(), + payment_asset: a.payment_asset.as_deref(), + svm_rpc_url: a.svm_rpc_url.as_deref(), + }, + §ion, + wallets_dir.as_deref(), + global.base_url.clone(), + )?; + let pay_scope = PayScope::from_config(&payment); + let ctx = Ctx::from_global_keyless_sql_payment(global.clone(), payment.clone())?; + if let Some(w) = key_file_warning { + ctx.out.warn(&w); + } + + let address = ctx.sdk.rpc.payment_address()?; + let scope = pay_scope.with_address(address); + let channels_path = config::channels_cache_path(global.resolve_config_path().as_deref()); + let mut channel = channels_path + .as_deref() + .and_then(|p| config::load_channel(p, &scope)) + .ok_or_else(|| { + CliError::Arg(format!( + "no open MPP channel for this wallet paying {}. Open one with \ + 'qn micropayments mpp open --deposit '.", + pay_scope.describe() + )) + })?; + + // Persist before inspecting: a failed query can still have spent the voucher. + let result = ctx + .sdk + .sql + .query_with_mpp_session(¶ms, &payment, &mut channel) + .await; + if let Some(path) = &channels_path { + let _ = config::save_channel(path, &scope, &channel); + } + let result = result.map_err(map_sql_mpp_error)?; + + emit_query(&ctx, result.query) +} + +const MPP_CANNOT_COVER: &str = "the MPP channel can't cover this query. Top up with \ + 'qn micropayments mpp top-up', or open a new channel with \ + 'qn micropayments mpp open'."; + +fn map_sql_mpp_error(e: SdkError) -> CliError { + // Status only: body text on a 500 saying "insufficient" is not a spent channel. + if let SdkError::Api { status, .. } = &e { + if status.as_u16() == 402 { + return CliError::PaymentRefused(MPP_CANNOT_COVER.to_string()); + } + } + if let SdkError::PaymentUnsupported { offered } = &e { + if offered.contains("exceeds channel deposit") || offered.contains("top up") { + return CliError::PaymentRefused(MPP_CANNOT_COVER.to_string()); + } + } + e.into() } /// Resolves the query text from the inline arg, a file, or stdin (`-`). Exactly @@ -133,6 +414,27 @@ fn stats_line(resp: &QueryResponse) -> String { line } +#[derive(Serialize)] +struct ClustersView(Vec); + +impl Render for ClustersView { + fn render_table( + &self, + w: &mut dyn std::io::Write, + ctx: &crate::output::OutputCtx, + ) -> std::io::Result<()> { + let mut t = new_table(ctx); + set_header_bold(&mut t, ctx, ["ID", "DISPLAY_NAME"]); + for cluster in &self.0 { + t.add_row(vec![ + Cell::new(&cluster.id), + Cell::new(&cluster.display_name), + ]); + } + write_table(w, &t) + } +} + #[derive(Serialize)] struct QueryView(QueryResponse); diff --git a/src/context.rs b/src/context.rs index a5ab953..d3d3841 100644 --- a/src/context.rs +++ b/src/context.rs @@ -6,7 +6,7 @@ use std::io::IsTerminal; use quicknode_sdk::{ AdminConfig, CachedToken, HttpConfig, KvStoreConfig, QuicknodeSdk, RpcConfig, SdkFullConfig, - SqlConfig, StreamsConfig, WebhooksConfig, + SqlConfig, StreamsConfig, WebhooksConfig, X402_SQL_BASE_URL, }; use crate::config; @@ -198,16 +198,49 @@ impl Ctx { pub fn from_global_keyless_payment( global: GlobalArgs, payment: quicknode_sdk::PaymentConfig, + ) -> Result { + Self::from_keyless(global, Some(payment), None) + } + + /// Build a keyless SDK for the public SQL catalog (`clusters` / `schema`). + /// Always points SQL at the x402 catalog host, remapped through `--base-url`. + pub fn from_global_keyless_sql_catalog(global: GlobalArgs) -> Result { + let sql_base = sql_catalog_base_url(&global)?; + Self::from_keyless(global, None, Some(sql_base)) + } + + /// Build a keyless SDK for a paid SQL query. Sets `[rpc].payment` and + /// points SQL at the public catalog host (x402 drawdown). MPP session + /// queries use the payment host override, not this SQL base. + pub fn from_global_keyless_sql_payment( + global: GlobalArgs, + payment: quicknode_sdk::PaymentConfig, + ) -> Result { + let sql_base = sql_catalog_base_url(&global)?; + Self::from_keyless(global, Some(payment), Some(sql_base)) + } + + fn from_keyless( + global: GlobalArgs, + payment: Option, + sql_base: Option, ) -> Result { let stdout_is_tty = std::io::stdout().is_terminal(); let (format, wide) = global.resolve_output(stdout_is_tty); let mut full = SdkFullConfig::keyless(); apply_user_agent(&mut full); - full.rpc = Some(RpcConfig { - payment: Some(payment), - ..Default::default() - }); + if payment.is_some() { + full.rpc = Some(RpcConfig { + payment, + ..Default::default() + }); + } + if let Some(base_url) = sql_base { + full.sql = Some(SqlConfig { + base_url: Some(base_url), + }); + } let sdk = QuicknodeSdk::new(&full)?; let out = OutputCtx::detect_with( @@ -310,6 +343,27 @@ impl Ctx { } } +/// SQL catalog / x402-drawdown base: the public x402 prefix, or +/// `{--base-url}{--base-prefix}/sql/rest/v1/` in tests. +fn sql_catalog_base_url(global: &GlobalArgs) -> Result { + if global.base_prefix.is_some() && global.base_url.is_none() { + return Err(CliError::Arg( + "--base-prefix requires --base-url".to_string(), + )); + } + match &global.base_url { + Some(base) => { + let host = validate_base_url(base)?; + let prefix = match &global.base_prefix { + Some(p) => validate_base_prefix(p)?, + None => String::new(), + }; + Ok(format!("{host}{prefix}/sql/rest/v1/")) + } + None => Ok(X402_SQL_BASE_URL.to_string()), + } +} + /// Validates a user-supplied `--base-url` and returns it with any trailing /// slash stripped. Rejects non-http(s) schemes, embedded userinfo, query/ /// fragment, and non-root paths so we can't accidentally splice attacker- diff --git a/tests/micropayments.rs b/tests/micropayments.rs new file mode 100644 index 0000000..958c0f7 --- /dev/null +++ b/tests/micropayments.rs @@ -0,0 +1,172 @@ +//! `qn micropayments` shares the x402/mpp runners with `qn rpc`. + +mod common; + +use common::run_qn; +use serde_json::json; +use std::io::Write; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const EVM_KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const USDC: &str = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + +fn key_file() -> (tempfile::NamedTempFile, String) { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(EVM_KEY.as_bytes()).unwrap(); + f.flush().unwrap(); + let path = f.path().to_str().unwrap().to_string(); + (f, path) +} + +fn x402_args<'a>(noun: &'a str, cfg: &'a str, key_path: &'a str, verb: &'a str) -> Vec<&'a str> { + vec![ + "--config-file", + cfg, + noun, + "x402", + verb, + "--payment-key-file", + key_path, + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000000", + ] +} + +#[tokio::test] +async fn micropayments_x402_balance_hits_the_same_mocks_as_rpc() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/auth")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "token": "jwt-test", + "expiresAt": "2099-01-01T00:00:00Z", + "accountId": "eip155:84532:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/credits")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "accountId": "eip155:84532:0xabc", "credits": 42u64 + }))) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "micropayments", + "x402", + "balance", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:84532", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn pay_alias_x402_balance_hits_the_same_mocks() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/auth")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "token": "jwt-test", + "expiresAt": "2099-01-01T00:00:00Z", + "accountId": "eip155:84532:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/credits")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "accountId": "eip155:84532:0xabc", "credits": 7u64 + }))) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "pay", + "x402", + "balance", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:84532", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn micropayments_buy_credits_without_yes_exits_5_and_sends_nothing() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/auth")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/credits")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + let args = x402_args("micropayments", &cfg, &key_path, "buy-credits"); + let out = run_qn(&server.uri(), &args).await; + assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn micropayments_buy_credits_with_yes_passes_the_gate_and_settles() { + let server = MockServer::start().await; + // Reaching auth at all is the proof: the ungated run sends nothing. + Mock::given(method("POST")) + .and(path("/auth")) + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + let mut args = x402_args("micropayments", &cfg, &key_path, "buy-credits"); + args.extend_from_slice(&["--network", "base-sepolia", "--yes"]); + let out = run_qn(&server.uri(), &args).await; + assert_ne!( + out.exit_code, 5, + "--yes must clear the gate: {}", + out.stderr + ); +} diff --git a/tests/snapshots/table_snapshots__sql_clusters_table_renders_id_and_display_name.snap b/tests/snapshots/table_snapshots__sql_clusters_table_renders_id_and_display_name.snap new file mode 100644 index 0000000..b766ccf --- /dev/null +++ b/tests/snapshots/table_snapshots__sql_clusters_table_renders_id_and_display_name.snap @@ -0,0 +1,7 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +ID DISPLAY_NAME +hyperliquid-core-mainnet Hyperliquid (HyperCore) +solana-mainnet Solana diff --git a/tests/sql.rs b/tests/sql.rs index 4d31260..6175a14 100644 --- a/tests/sql.rs +++ b/tests/sql.rs @@ -2,11 +2,23 @@ mod common; -use common::run_qn; +use common::{run_qn, run_qn_no_key}; use serde_json::json; use std::io::Write; -use wiremock::matchers::{body_partial_json, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use wiremock::matchers::{body_partial_json, header, method, path}; +use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + +const EVM_KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const PATH_USD: &str = "0x20c0000000000000000000000000000000000000"; + +fn key_file() -> (tempfile::NamedTempFile, String) { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(EVM_KEY.as_bytes()).unwrap(); + f.flush().unwrap(); + let path = f.path().to_str().unwrap().to_string(); + (f, path) +} fn query_body() -> serde_json::Value { json!({ @@ -207,3 +219,403 @@ async fn schema_not_found_maps_to_exit_2() { let out = run_qn(&server.uri(), &["sql", "schema", "bad-cluster"]).await; assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); } + +#[tokio::test] +async fn clusters_and_schema_work_without_an_api_key() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/sql/rest/v1/clusters")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + {"id": "hyperliquid-core-mainnet", "display_name": "Hyperliquid (HyperCore)"}, + {"id": "solana-mainnet", "display_name": "Solana"} + ]))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/sql/rest/v1/schema/hyperliquid-core-mainnet")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "chain": "Hyperliquid (HyperCore)", + "cluster_id": "hyperliquid-core-mainnet", + "tables": [] + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(path("/v0/account/info")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let clusters = run_qn_no_key(&server.uri(), &["--config-file", &cfg, "sql", "clusters"]).await; + assert_eq!(clusters.exit_code, 0, "stderr={}", clusters.stderr); + let schema = run_qn_no_key( + &server.uri(), + &[ + "--config-file", + &cfg, + "sql", + "schema", + "hyperliquid-core-mainnet", + ], + ) + .await; + assert_eq!(schema.exit_code, 0, "stderr={}", schema.stderr); +} + +#[tokio::test] +async fn clusters_maps_a_catalog_error_to_exit_2() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/sql/rest/v1/clusters")) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({"error": "not found"}))) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let out = run_qn_no_key(&server.uri(), &["--config-file", &cfg, "sql", "clusters"]).await; + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn query_without_key_or_payment_flag_names_both_next_steps() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/sql/rest/v1/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(query_body())) + .expect(0) + .mount(&server) + .await; + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let out = run_qn_no_key( + &server.uri(), + &[ + "--config-file", + &cfg, + "sql", + "query", + "SELECT 1", + "--cluster-id", + "hyperliquid-core-mainnet", + ], + ) + .await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains("--x402-drawdown") && out.stderr.contains("--mpp-session"), + "stderr={}", + out.stderr + ); +} + +#[tokio::test] +async fn drawdown_query_uses_bearer_and_skips_control_plane() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/auth")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "token": "jwt-test", + "expiresAt": "2099-01-01T00:00:00Z", + "accountId": "eip155:84532:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/sql/rest/v1/query")) + .and(header("authorization", "Bearer jwt-test")) + .and(|req: &Request| !req.headers.contains_key("x-api-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(query_body())) + .expect(1) + .mount(&server) + .await; + Mock::given(path("/v0/account/info")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + Mock::given(path("/v0/tooling-access")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + let out = run_qn_no_key( + &server.uri(), + &[ + "--config-file", + &cfg, + "sql", + "query", + "SELECT 1", + "--cluster-id", + "hyperliquid-core-mainnet", + "--x402-drawdown", + "--payment-key-file", + &key_path, + "--payment-network", + "base-sepolia", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn drawdown_query_requires_payment_points_at_buy_credits() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/auth")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "token": "jwt-test", + "expiresAt": "2099-01-01T00:00:00Z", + "accountId": "eip155:84532:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/sql/rest/v1/query")) + .respond_with(ResponseTemplate::new(402).set_body_json(json!({ + "error": "requires_payment" + }))) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + let out = run_qn_no_key( + &server.uri(), + &[ + "--config-file", + &cfg, + "sql", + "query", + "SELECT 1", + "--cluster-id", + "hyperliquid-core-mainnet", + "--x402-drawdown", + "--payment-key-file", + &key_path, + "--payment-network", + "base-sepolia", + ], + ) + .await; + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); + assert!( + out.stderr.contains("qn micropayments x402 buy-credits"), + "stderr={}", + out.stderr + ); +} + +fn write_channel(dir: &std::path::Path) { + let text = r#" +[channels] +"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266:eip155:42431:0x20c0000000000000000000000000000000000000" = { channel_id = "0x1111111111111111111111111111111111111111111111111111111111111111", token = "0x20c0000000000000000000000000000000000000", payee = "0xfd24114c3981aba78ae2441991b1bdb89329c556", salt = "0x2222222222222222222222222222222222222222222222222222222222222222", authorized_signer = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", escrow_contract = "0x33b901018174DDabE4841042ab76ba85D4e24f25", deposit = "1000000", cumulative_spent = "10", per_call = "10", chain_id = 42431 } +"#; + std::fs::write(dir.join("channels.toml"), text).unwrap(); +} + +fn sql_session_offer(amount: &str) -> String { + use base64::Engine; + let request = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "amount": amount, + "currency": PATH_USD, + "recipient": "0xfd24114c3981aba78ae2441991b1bdb89329c556", + "methodDetails": { + "chainId": 42431, + "escrowContract": "0x33b901018174DDabE4841042ab76ba85D4e24f25" + } + })) + .unwrap(), + ); + format!( + "Payment id=\"sql1\", realm=\"mpp.quicknode.com\", method=\"tempo\", \ + intent=\"session\", description=\"d\", expires=\"2099-01-01T00:00:00Z\", \ + request=\"{request}\"" + ) +} + +fn receipt_header(accepted: &str) -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "acceptedCumulative": accepted, + "spent": accepted, + "status": "success", + "intent": "session", + "method": "tempo" + })) + .unwrap(), + ) +} + +#[tokio::test] +async fn mpp_session_query_uses_challenge_amount_and_advances_cache() { + struct SqlSeq { + calls: AtomicUsize, + } + impl Respond for SqlSeq { + fn respond(&self, req: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 && !req.headers.contains_key("authorization") { + return ResponseTemplate::new(402) + .insert_header("www-authenticate", sql_session_offer("100")); + } + use base64::Engine; + let auth = req + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let b64 = auth.strip_prefix("Payment ").unwrap_or(auth); + let cred: serde_json::Value = serde_json::from_slice( + &base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(b64.trim_end_matches('=')) + .unwrap(), + ) + .unwrap(); + let cumulative = cred["payload"]["cumulativeAmount"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + if cumulative == 20 { + return ResponseTemplate::new(402).set_body_json(json!({ + "title": "Insufficient Balance", + "detail": "Insufficient balance: requested 100, available 10." + })); + } + assert_eq!(cumulative, 110); + ResponseTemplate::new(200) + .insert_header("payment-receipt", receipt_header("110").as_str()) + .set_body_json(query_body()) + } + } + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/session/sql/rest/v1/query")) + .respond_with(SqlSeq { + calls: AtomicUsize::new(0), + }) + .expect(2) + .mount(&server) + .await; + Mock::given(path("/v0/account/info")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + write_channel(dir.path()); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + let out = run_qn_no_key( + &server.uri(), + &[ + "--config-file", + &cfg, + "sql", + "query", + "SELECT 1", + "--cluster-id", + "hyperliquid-core-mainnet", + "--mpp-session", + "--payment-key-file", + &key_path, + "--payment-network", + "tempo-testnet", + "--payment-asset", + "pathUSD", + "--max-amount", + "1000000", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + + let cache = std::fs::read_to_string(dir.path().join("channels.toml")).unwrap(); + assert!( + cache.contains("cumulative_spent = \"110\""), + "cache must advance to acceptedCumulative, got: {cache}" + ); +} + +#[tokio::test] +async fn mpp_session_query_persists_the_receipt_when_the_query_fails() { + struct ChallengeThenServerError { + calls: AtomicUsize, + } + impl Respond for ChallengeThenServerError { + fn respond(&self, req: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 && !req.headers.contains_key("authorization") { + return ResponseTemplate::new(402) + .insert_header("www-authenticate", sql_session_offer("100").as_str()); + } + // Voucher banked, query body failed. + ResponseTemplate::new(500) + .insert_header("payment-receipt", receipt_header("110").as_str()) + .set_body_string("query engine unavailable") + } + } + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/session/sql/rest/v1/query")) + .respond_with(ChallengeThenServerError { + calls: AtomicUsize::new(0), + }) + .expect(2) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + write_channel(dir.path()); + let (_guard, key_path) = key_file(); + let out = run_qn_no_key( + &server.uri(), + &[ + "--config-file", + &cfg, + "sql", + "query", + "SELECT 1", + "--cluster-id", + "hyperliquid-core-mainnet", + "--mpp-session", + "--payment-key-file", + &key_path, + "--payment-network", + "tempo-testnet", + "--payment-asset", + "pathUSD", + "--max-amount", + "1000000", + ], + ) + .await; + assert_ne!(out.exit_code, 0, "a failed query must not exit 0"); + + let cache = std::fs::read_to_string(dir.path().join("channels.toml")).unwrap(); + assert!( + cache.contains("cumulative_spent = \"110\""), + "a banked voucher must be persisted even when the query fails, got: {cache}" + ); +} diff --git a/tests/table_snapshots.rs b/tests/table_snapshots.rs index d58d235..87871e4 100644 --- a/tests/table_snapshots.rs +++ b/tests/table_snapshots.rs @@ -457,6 +457,16 @@ async fn sql_schema_table_renders_nested_table_blocks() { insta::assert_snapshot!(out); } +#[tokio::test] +async fn sql_clusters_table_renders_id_and_display_name() { + let body = serde_json::json!([ + {"id": "hyperliquid-core-mainnet", "display_name": "Hyperliquid (HyperCore)"}, + {"id": "solana-mainnet", "display_name": "Solana"} + ]); + let out = table_stdout("/sql/rest/v1/clusters", body, &["sql", "clusters"]).await; + insta::assert_snapshot!(out); +} + /// Run an RPC discovery command and return table stdout. async fn discovery_stdout(server: &MockServer, scheme: &str, verb: &str) -> String { let output = assert_cmd::Command::cargo_bin("qn")