From 87f7059aa5c644239c09b1ed204bd7296eb4e7ae Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 24 Aug 2026 15:16:31 -0400 Subject: [PATCH 1/2] feat(sql): add x402 and MPP payment lanes (DX-6602) SQL Explorer can run without an account: clusters and schema stay public, and query pays with x402 drawdown or an MPP session. qn micropayments (alias pay) shares the existing x402 and mpp runners. --- Cargo.lock | 3 +- Cargo.toml | 2 +- README.md | 49 +++- src/cli.rs | 7 +- src/commands/agent/context.md | 54 ++++- src/commands/micropayments/mod.rs | 46 ++++ src/commands/micropayments/mpp.rs | 4 + src/commands/micropayments/x402.rs | 4 + src/commands/mod.rs | 1 + src/commands/rpc/mod.rs | 6 +- src/commands/rpc/mpp.rs | 8 +- src/commands/rpc/payment.rs | 17 +- src/commands/sql/mod.rs | 348 +++++++++++++++++++++++++++-- src/context.rs | 64 +++++- tests/micropayments.rs | 148 ++++++++++++ tests/sql.rs | 338 +++++++++++++++++++++++++++- 16 files changed, 1047 insertions(+), 52 deletions(-) create mode 100644 src/commands/micropayments/mod.rs create mode 100644 src/commands/micropayments/mpp.rs create mode 100644 src/commands/micropayments/x402.rs create mode 100644 tests/micropayments.rs diff --git a/Cargo.lock b/Cargo.lock index 7eb38f5..abee789 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=2ac2e697fc58a81fb64dd3e5074b2558775aac0e#2ac2e697fc58a81fb64dd3e5074b2558775aac0e" dependencies = [ "alloy-consensus", "alloy-primitives", diff --git a/Cargo.toml b/Cargo.toml index 3896a5a..3a82185 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 = "2ac2e697fc58a81fb64dd3e5074b2558775aac0e", features = [ "payments", # x402/EVM "payments-svm", # + x402/Solana "payments-tempo", # + MPP/Tempo diff --git a/README.md b/README.md index 3fb0195..66e50a7 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; the CLI has no Tempo faucet. + +```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..ec2de37 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -69,7 +69,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 +91,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 +108,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 rejects `--x402` (per-request); use + `--x402-drawdown`. ## 6. Command catalog @@ -133,7 +138,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 +283,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..7644a57 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,172 @@ 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() +} + +fn is_sql_requires_payment(e: &SdkError) -> bool { + matches!( + e, + SdkError::Api { status, body } + if status.as_u16() == 402 + || body.contains("requires_payment") + || body.contains("insufficient_credits") + || body.contains("no_credits") + ) +} + +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() + )) + })?; + + let result = match ctx + .sdk + .sql + .query_with_mpp_session(¶ms, &payment, &channel) + .await + { + Ok(result) => result, + Err(e) => return Err(map_sql_mpp_error(e)), + }; + + channel.cumulative_spent = result.accepted_cumulative; + if let Some(path) = &channels_path { + let _ = config::save_channel(path, &scope, &channel); + } + emit_query(&ctx, result.query) +} + +fn map_sql_mpp_error(e: SdkError) -> CliError { + if let SdkError::Api { status, body } = &e { + if status.as_u16() == 402 + || body.contains("amount-exceeds-deposit") + || body.contains("AmountExceedsDeposit") + || body.contains("insufficient") + { + return CliError::PaymentRefused( + "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'." + .to_string(), + ); + } + } + if let SdkError::PaymentUnsupported { offered } = &e { + if offered.contains("exceeds channel deposit") || offered.contains("top up") { + return CliError::PaymentRefused( + "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'." + .to_string(), + ); + } + } + e.into() } /// Resolves the query text from the inline arg, a file, or stdin (`-`). Exactly @@ -133,6 +432,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..7c89eb7 --- /dev/null +++ b/tests/micropayments.rs @@ -0,0 +1,148 @@ +//! `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); +} diff --git a/tests/sql.rs b/tests/sql.rs index 4d31260..71609fe 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,323 @@ 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 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}" + ); +} From f36cfd4e5372dee47c243727110f5cc482c0c4d1 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 24 Aug 2026 16:01:43 -0400 Subject: [PATCH 2/2] fix(sql): persist a banked MPP voucher when the query fails The MPP query path returned on error before saving the channel, so a query that failed after the gateway banked the voucher left the cached cumulative behind the gateway's accepted value. The next query re-signed that stale cumulative and was refused, stranding the channel. The SDK now advances the channel whenever the voucher reached the gateway, and this saves it on both paths before mapping the error. Also gates the credit and channel error matchers on a 402: the body markers were alternatives to the status check, so a 500 whose message happened to contain "insufficient" reported as an empty balance. Docs: SQL has no per-request --x402 flag, so context.md no longer claims it is rejected; the no-payer query is documented as exit 1, since a key is one of three ways to pay; drops an absence-of-feature line from the README MPP intro. Tests: a failed query persists the receipt cumulative, sql clusters maps a catalog error to exit 2, buy-credits clears the gate with --yes, and a clusters table snapshot. --- Cargo.lock | 2 +- Cargo.toml | 2 +- IMPLEMENTATION_PLAN.md | 2 +- README.md | 2 +- src/commands/agent/context.md | 9 ++- src/commands/sql/mod.rs | 52 ++++-------- tests/micropayments.rs | 24 ++++++ ...ers_table_renders_id_and_display_name.snap | 7 ++ tests/sql.rs | 80 +++++++++++++++++++ tests/table_snapshots.rs | 10 +++ 10 files changed, 148 insertions(+), 42 deletions(-) create mode 100644 tests/snapshots/table_snapshots__sql_clusters_table_renders_id_and_display_name.snap diff --git a/Cargo.lock b/Cargo.lock index abee789..b65df72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3246,7 +3246,7 @@ dependencies = [ [[package]] name = "quicknode-sdk" version = "0.8.2" -source = "git+https://github.com/quicknode/sdk?rev=2ac2e697fc58a81fb64dd3e5074b2558775aac0e#2ac2e697fc58a81fb64dd3e5074b2558775aac0e" +source = "git+https://github.com/quicknode/sdk?rev=0bd4f3cf13b32d75119ffe7b501929dcffa78bfd#0bd4f3cf13b32d75119ffe7b501929dcffa78bfd" dependencies = [ "alloy-consensus", "alloy-primitives", diff --git a/Cargo.toml b/Cargo.toml index 3a82185..5bad0f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ name = "qn" path = "src/lib.rs" [dependencies] -quicknode-sdk = { git = "https://github.com/quicknode/sdk", rev = "2ac2e697fc58a81fb64dd3e5074b2558775aac0e", 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 66e50a7..18490f5 100644 --- a/README.md +++ b/README.md @@ -299,7 +299,7 @@ qn sql query "SELECT * FROM hyperliquid_trades LIMIT 10" \ ``` Get started without an account (MPP session). Fund pathUSD on Tempo testnet -first; the CLI has no Tempo faucet. +first. ```sh qn wallet generate --vm evm --name payer diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index ec2de37..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. @@ -116,8 +119,8 @@ if you need it. 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. SQL rejects `--x402` (per-request); use - `--x402-drawdown`. + 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 diff --git a/src/commands/sql/mod.rs b/src/commands/sql/mod.rs index 7644a57..8144114 100644 --- a/src/commands/sql/mod.rs +++ b/src/commands/sql/mod.rs @@ -296,15 +296,9 @@ fn map_sql_drawdown_error(e: SdkError) -> CliError { 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, body } - if status.as_u16() == 402 - || body.contains("requires_payment") - || body.contains("insufficient_credits") - || body.contains("no_credits") - ) + matches!(e, SdkError::Api { status, .. } if status.as_u16() == 402) } async fn query_mpp_session( @@ -348,46 +342,34 @@ async fn query_mpp_session( )) })?; - let result = match ctx + // Persist before inspecting: a failed query can still have spent the voucher. + let result = ctx .sdk .sql - .query_with_mpp_session(¶ms, &payment, &channel) - .await - { - Ok(result) => result, - Err(e) => return Err(map_sql_mpp_error(e)), - }; - - channel.cumulative_spent = result.accepted_cumulative; + .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 { - if let SdkError::Api { status, body } = &e { - if status.as_u16() == 402 - || body.contains("amount-exceeds-deposit") - || body.contains("AmountExceedsDeposit") - || body.contains("insufficient") - { - return CliError::PaymentRefused( - "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'." - .to_string(), - ); + // 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( - "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'." - .to_string(), - ); + return CliError::PaymentRefused(MPP_CANNOT_COVER.to_string()); } } e.into() diff --git a/tests/micropayments.rs b/tests/micropayments.rs index 7c89eb7..958c0f7 100644 --- a/tests/micropayments.rs +++ b/tests/micropayments.rs @@ -146,3 +146,27 @@ async fn micropayments_buy_credits_without_yes_exits_5_and_sends_nothing() { 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 71609fe..6175a14 100644 --- a/tests/sql.rs +++ b/tests/sql.rs @@ -266,6 +266,22 @@ async fn clusters_and_schema_work_without_an_api_key() { 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; @@ -539,3 +555,67 @@ async fn mpp_session_query_uses_challenge_amount_and_advances_cache() { "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")