Skip to content
Open
2,528 changes: 2,445 additions & 83 deletions Cargo.lock

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@ name = "qn"
path = "src/lib.rs"

[dependencies]
quicknode-sdk = "0.7"
# DEV ONLY: path dep on the SDK's unreleased crypto-micropayment branch.
# Swap back to a published crates.io version (with the same features) before
# any release tag — the release is gated on the SDK publishing this feature.
quicknode-sdk = { path = "../sdk/crates/core", features = [
"payments", # x402/EVM
"payments-svm", # + x402/Solana
"payments-tempo", # + MPP/Tempo
] }
clap = { version = "4", features = ["derive", "env", "wrap_help"] }
clap_complete = "4"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
Expand Down
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,64 @@ API key, so subsequent calls skip the mint round trip while it's valid. Results
schemaless JSON; `-o json|yaml|toon` controls the format (`table`/`md` fall back to
JSON).

#### Pay per call with a crypto micropayment

`--x402` (EVM or Solana stablecoin) or `--mpp` (Tempo) pays for the call per
request instead of using an account API key — no login and no Tooling Access
needed. **This moves real funds** (testnet tokens are still real transfers):
use a dedicated, minimally funded wallet.

```sh
qn rpc call eth_blockNumber --network base-sepolia --x402 \
--payment-key-file ~/.keys/payer \
--pay-network base-sepolia \
--asset 0x036CbD53842c5426634e7929541eC2318f3dCF7e \
--max-amount 10000

# MPP settles on Tempo and returns a settlement receipt with --receipt:
qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt ...
```

- `--network` is required and names the chain you *query*, as the payment
gateway's path slug (independent of `--pay-network`, the chain the payment
*settles* on).
- `--pay-network` takes a Quicknode network name (`base-sepolia`,
`solana-devnet`, `tempo-testnet`, ...) or a raw CAIP-2 id (`eip155:84532`,
`solana:EtWTRA...`). Anything containing a `:` is passed through verbatim,
so any `eip155:<chain-id>` works even without a named entry.
- The private key resolves from `--payment-key-file <PATH>` (`-` for stdin),
then the `QN_PAYMENT_KEY` env var, then `key_file` in config. It is never
accepted as a flag value, never stored in config, and never printed.
- `--max-amount` is the per-call spend ceiling in integer base units of the
asset (e.g. `10000` = 0.01 USDC). There is no built-in default; offers above
the ceiling are refused before anything is signed.
- `--receipt` wraps stdout as `{"result": ..., "payment_receipt": ...}`. On
MPP the receipt is an object (`method`, `status`, `timestamp`, and
`reference` — the settlement transaction hash); on x402 it is `null`.
Without it, paid output is shaped exactly like unpaid output.
- Paid calls **never auto-retry** (`--retries` does not apply). Exit code 2
means the gateway refused and nothing settled; exit 3 means the outcome is
unknown — the payment was submitted and may have settled; check the wallet
before re-running.
- x402/Solana at volume: pass `--svm-rpc-url <URL>`; the default public Solana
RPC rate-limits aggressively.

Store the parameters once in `~/.config/qn/config.toml` and the invocation
shrinks to the scheme flag — config supplies values but never activates
payment by itself:

```toml
[rpc.payment]
key_file = "/home/me/.config/qn/payment.key" # a path; never the raw key
max_amount = "10000"
pay_network = "base-sepolia" # network name or CAIP-2 id
asset = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
```

```sh
qn rpc call eth_blockNumber --network base-sepolia --x402
```

### Other

```sh
Expand Down
52 changes: 52 additions & 0 deletions src/commands/agent/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ Branch on these — especially **4** and **5**.
| 5 | Cancelled, or confirmation required and not granted (see §4). |
| 130 | Interrupted (SIGINT). |

On a **paid** `rpc call` (`--x402`/`--mpp`, §6) the 2/3 split carries payment
semantics: **2** means the gateway refused and nothing settled (an unmatched
or unreadable offer, or a 4xx-refused payment), while **3** means the outcome
is unknown — the payment was submitted and **may have been charged** (a
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.

## 4. Non-interactive & confirmation behavior

Destructive commands are gated. On a TTY they prompt `y/N`. To proceed without a
Expand Down Expand Up @@ -96,6 +104,9 @@ if you need it.
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`) never auto-retries — `--retries` does
not apply. Each attempt can move funds, and after a lost response the previous
attempt may already have settled (§3, exit 3).

## 6. Command catalog

Expand Down Expand Up @@ -128,6 +139,23 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase
available keys. Custom endpoint: `--endpoint-url <URL>` (or `[rpc] endpoint_url`
in config) sends the call to a fully-formed HTTP URL that authenticates itself
(no token minted); it's mutually exclusive with `--network`.
**Paid lane**: `--x402` (EVM/Solana stablecoin) or `--mpp` (Tempo) pays for
the call per request with a crypto micropayment instead of an API key — no
login, no Tooling Access. Requires `--network` as the payment gateway's path
slug (e.g. `base-sepolia`; NOT validated by `list-networks`). Parameters:
`--pay-network <NETWORK>` (the chain the payment settles on — independent of
`--network`; a network name like `base-sepolia`/`solana-devnet`/`tempo-testnet`,
or a raw CAIP-2 id like `eip155:84532`, which always passes through
verbatim), `--asset <ADDRESS>`, `--max-amount <BASE_UNITS>` (spend ceiling
per call, integer base units, no default), and the private key via
`--payment-key-file <PATH|->` > `QN_PAYMENT_KEY` env > `key_file` under
`[rpc.payment]` in config (a path — never the raw key). All of these fall
back to `[rpc.payment]`, but config never activates payment by itself: the
scheme flag is always required. `--receipt` wraps stdout as
`{"result": ..., "payment_receipt": ...}` (on MPP an object whose
`reference` is the settlement tx hash; `null` on x402); without it the paid
output shape is identical to an unpaid call.
Mutually exclusive with `--endpoint-url`.

Drill into any level with `--help`: `qn endpoint --help`, `qn endpoint security --help`,
`qn endpoint rate-limit --help`. Shell completions: `qn completions <bash|zsh|fish|...>`.
Expand Down Expand Up @@ -197,9 +225,33 @@ qn rpc call eth_blockNumber --endpoint-url https://my-endpoint.example/rpc
is enabling Tooling Access (or pass `--yes` to enable on first use). A custom
`--endpoint-url` (or `[rpc] endpoint_url` in config) bypasses that entirely.

**Pay per call with a crypto micropayment (no API key, no login):**

```sh
# One-time: store the wallet parameters (never the raw key) in config
cat >> ~/.config/qn/config.toml <<'EOF'
[rpc.payment]
key_file = "/home/me/.config/qn/payment.key" # file holding the raw key; chmod 600
max_amount = "10000" # spend ceiling per call, base units
pay_network = "base-sepolia" # settlement chain: network name or CAIP-2 id
asset = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
EOF

qn rpc call eth_blockNumber --network base-sepolia --x402 # pays, prints the result
qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt # + settlement receipt
```

This moves real funds (even testnet tokens are real transfers) — use a
dedicated, minimally funded wallet. The spend ceiling bounds each call; there
is no built-in default.

## 8. Gotchas & safety rails

- Mutations are never retried; re-running a failed create can double-provision (§5).
- Paid `rpc call` moves real funds and never auto-retries; exit 3 means the
payment may have settled — check the wallet before re-running (§3, §5). The
CLI never prints the payment key; keep it in a file or `QN_PAYMENT_KEY`, not
in config or argv.
- No account-wide wipe command exists by design (§4).
- Piped output defaults to `json`; pass `-o toon` for the compact LLM form (§2).
- `--base-url` overrides the API host; it exists for testing.
Expand Down
114 changes: 107 additions & 7 deletions src/commands/rpc.rs → src/commands/rpc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@
//! config) is a separate lane: the SDK sends the call straight to that URL with
//! no JWT minted or attached. That lane never touches the token cache or the
//! Tooling Access enable/probe recovery.
//!
//! The crypto-micropayment lane (`--x402`/`--mpp`) is a third lane, in
//! `payment.rs`: keyless, paid per request, and structurally separate — it
//! branches off before any of this module's token-cache or Tooling Access
//! machinery runs.

mod pay_network;
mod payment;

use std::io::Read;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -49,16 +57,25 @@ pub enum RpcCmd {
qn rpc call eth_blockNumber --endpoint-url https://my-endpoint.example/rpc\n \
qn rpc call eth_call --params-file params.json\n \
echo '[...]' | qn rpc call eth_call -\n \
cat params.json | qn rpc call eth_call -f -")]
Call(CallArgs),
cat params.json | qn rpc call eth_call -f -\n\n\
Paid (crypto micropayment, no API key; params from [rpc.payment] in config):\n \
qn rpc call eth_blockNumber --network base-sepolia --x402\n \
qn rpc call eth_blockNumber --network base-sepolia --x402 \\\n \
--payment-key-file ~/.keys/payer --pay-network base-sepolia \\\n \
--asset 0x036C... --max-amount 10000\n \
qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt")]
Call(Box<CallArgs>),

/// List the endpoint's available network keys (no RPC call).
#[command(visible_alias = "ls")]
ListNetworks,
}

#[derive(Debug, ClapArgs)]
#[command(group(ArgGroup::new("params_source").args(["params", "params_file"])))]
#[command(
group(ArgGroup::new("params_source").args(["params", "params_file"])),
group(ArgGroup::new("payment").args(["x402", "mpp"])),
)]
pub struct CallArgs {
/// The JSON-RPC method, e.g. `eth_blockNumber`.
#[arg(value_name = "METHOD")]
Expand Down Expand Up @@ -90,11 +107,84 @@ pub struct CallArgs {
/// exclusive with `--network` (a custom URL is not multichain-routed).
#[arg(long, conflicts_with = "network", value_name = "URL")]
pub endpoint_url: Option<String>,

/// Pay for this call per request with the x402 protocol (EVM or Solana
/// stablecoin) instead of the account's API key. Moves real funds; use a
/// dedicated, minimally funded wallet. Requires --network (the query
/// chain, as the payment gateway's path slug — e.g. `base-sepolia`).
#[arg(long, conflicts_with = "endpoint_url", help_heading = "Payment")]
pub x402: bool,

/// Pay for this call per request with MPP (Tempo). Mutually exclusive
/// with --x402; same rules otherwise.
#[arg(long, conflicts_with = "endpoint_url", help_heading = "Payment")]
pub mpp: bool,

/// File containing the raw payment private key (EVM/Tempo hex, Solana
/// base58); pass `-` to read it from stdin. Never accepts the key itself.
/// Precedence: this flag > QN_PAYMENT_KEY env var (raw key) > `key_file`
/// under [rpc.payment] in config.
#[arg(
long,
value_name = "PATH",
requires = "payment",
help_heading = "Payment"
)]
pub payment_key_file: Option<PathBuf>,

/// Spend ceiling per call, in integer base units of the asset (e.g.
/// 10000 = 0.01 USDC). No built-in default: flag > `max_amount` under
/// [rpc.payment]. Offered payments above the ceiling are never signed.
#[arg(
long,
value_name = "BASE_UNITS",
requires = "payment",
help_heading = "Payment"
)]
pub max_amount: Option<String>,

/// Chain you PAY on — a network name (e.g. `base-sepolia`) or CAIP-2 id
/// (e.g. `eip155:84532`) — independent of --network (the chain you
/// query). Falls back to `pay_network` under [rpc.payment].
#[arg(
long,
value_name = "NETWORK",
requires = "payment",
help_heading = "Payment"
)]
pub pay_network: Option<String>,

/// Token to pay with: EVM contract address or Solana mint. Falls back to
/// `asset` under [rpc.payment].
#[arg(
long,
value_name = "ADDRESS",
requires = "payment",
help_heading = "Payment"
)]
pub asset: Option<String>,

/// Explicit Solana RPC URL for building x402/Solana payments. Falls back
/// to `svm_rpc_url` under [rpc.payment], then a public Solana RPC (which
/// rate-limits aggressively — set this at any real volume).
#[arg(
long,
value_name = "URL",
requires = "payment",
help_heading = "Payment"
)]
pub svm_rpc_url: Option<String>,

/// Wrap stdout as {"result": ..., "payment_receipt": ...}. The receipt is
/// non-null only on MPP (the settlement transaction hash); null on x402.
/// Payment happens either way — this only changes the output.
#[arg(long, requires = "payment", help_heading = "Payment")]
pub receipt: bool,
}

pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> {
match args.cmd {
RpcCmd::Call(call) => run_call(call, global).await,
RpcCmd::Call(call) => run_call(*call, global).await,
RpcCmd::ListNetworks => run_list_networks(global).await,
}
}
Expand All @@ -114,6 +204,13 @@ async fn run_list_networks(global: GlobalArgs) -> Result<(), CliError> {
}

async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> {
// The crypto-micropayment lane branches off before any token-cache or
// Tooling Access work: it is keyless, never retried, and never touches
// this function's caches or recovery paths.
if args.x402 || args.mpp {
return payment::run_paid_call(args, global).await;
}

let params = parse_params(args.params.as_deref(), args.params_file.as_deref())?;

// A custom URL (per-call flag, else the `[rpc] endpoint_url` config default)
Expand Down Expand Up @@ -516,7 +613,10 @@ async fn maybe_enable(ctx: &Ctx) -> Result<(), CliError> {
/// as JSON. `None`/`None` → no params (sends `[]`). Either source accepts `-` to
/// read from stdin. The clap `ArgGroup` guarantees at most one is set. An empty
/// value (after trimming) is treated as no params.
fn parse_params(arg: Option<&str>, file: Option<&Path>) -> Result<Option<Value>, CliError> {
pub(super) fn parse_params(
arg: Option<&str>,
file: Option<&Path>,
) -> Result<Option<Value>, CliError> {
let raw = match (arg, file) {
(None, None) => return Ok(None),
(Some("-"), _) => read_stdin("params")?,
Expand All @@ -539,7 +639,7 @@ fn parse_params(arg: Option<&str>, file: Option<&Path>) -> Result<Option<Value>,
}

/// Read all of stdin as a UTF-8 string, labeling errors with `what`.
fn read_stdin(what: &str) -> Result<String, CliError> {
pub(super) fn read_stdin(what: &str) -> Result<String, CliError> {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
Expand All @@ -552,7 +652,7 @@ fn read_stdin(what: &str) -> Result<String, CliError> {
/// raw `--format` flag, not the TTY-aware resolved default). `json`/`yaml`/`toon`
/// render as requested. `table`/`md` have no columns here, so they fall back to
/// JSON — and only that explicit case prints a one-line note on stderr.
fn emit_result(ctx: &Ctx, result: &Value) -> Result<(), CliError> {
pub(super) fn emit_result(ctx: &Ctx, result: &Value) -> Result<(), CliError> {
match ctx.global.format {
None | Some(Format::Json) => {
println!(
Expand Down
Loading
Loading