Skip to content

Commit 5ff5fbc

Browse files
authored
feat(rpc): crypto-micropayment lane for qn rpc call (--x402/--mpp) (#56)
* build: use local quicknode-sdk with payment features for development Points the SDK dependency at the local checkout's crypto-micropayment branch and enables the payments, payments-svm, and payments-tempo features. Dev-only: the release swaps back to a published crates.io version once the SDK ships this feature. * feat(config): add [rpc.payment] section for the paid RPC lane Parameter defaults (key_file, max_amount, pay_network, asset, svm_rpc_url) for qn rpc call --x402/--mpp. The section only supplies values; activation stays with the per-invocation scheme flag. The raw private key never lives in config.toml: key_file points at a file, and an inline key = ... is captured by a trap field so payment resolution can reject it with an actionable error instead of serde silently ignoring it. max_amount accepts a TOML string or integer. 7 new tests. * feat(errors): map payment errors to exit codes with wallet-check guidance PaymentUnsupported and PaymentRejected map to exit 2 (the gateway refused); PaymentIndeterminate and the new PaymentMaybeCharged wrapper map to exit 3 (request sent, outcome unknown), so scripts can distinguish safe-to-retry from check-your-wallet-first. Render arms state whether anything was charged and warn against blind re-runs; gateway bodies appear only under --verbose. 6 new tests. * feat(context): add keyless Ctx constructor for the paid RPC lane Ctx::from_global_keyless_payment builds a keyless SDK carrying only the payment config, so paid calls work with no API key configured. It skips API-key resolution, the token seed, [rpc] endpoint_url, and --base-url sub-client overrides (the paid lane's test hook is PaymentConfig.base_url_override). The User-Agent install is factored into a helper shared with the keyed path. * feat(rpc): crypto-micropayment lane for qn rpc call (--x402/--mpp) Adds per-request stablecoin payment to qn rpc call via the SDK's 402 -> sign -> resend handshake. Activation is explicit and per invocation: --x402 or --mpp both turns the lane on and selects the scheme; [rpc.payment] config only supplies parameter defaults. The lane lives in src/commands/rpc/payment.rs (rpc.rs became a directory module) and branches off before the default lane's machinery, so the token cache, Tooling Access enable/recovery, networks map, and retrying() are structurally unreachable: paid calls are never auto-retried, and a lost or uninterpretable post-payment response maps to PaymentMaybeCharged (exit 3, check-your-wallet guidance). Key resolution: --payment-key-file <PATH|-> > QN_PAYMENT_KEY > key_file in config; the raw key is never a flag value, never inline in config, and never logged. The spend ceiling (--max-amount, base units) has no built-in default and is integer-validated before any request. --receipt opts stdout into {result, payment_receipt} (settlement tx hash on MPP, null on x402); the default paid output shape is identical to an unpaid call. 20 new unit tests. * test(rpc): integration coverage for the crypto-micropayment lane 18 tests against a wiremock gateway using the SDK's 402-handshake shapes (x402 menu + signed resend; MPP WWW-Authenticate challenge + Payment-Receipt header) and public fixtures (anvil key #0, Base Sepolia test USDC). Safety-critical assertions: - lane isolation: a paid call sends exactly the two gateway POSTs, never touches /v0 control-plane routes, and writes no tokens.toml - config presence never auto-activates payment (gateway .expect(0)) - the paid lane ignores --retries (one request, ever) - an over-cap offer is refused after one request, before signing - every pre-flight failure (missing network/key/cap, non-integer cap, inline config key, double-stdin) exits before any request - --receipt wraps stdout with the MPP settlement reference and null on x402; without it the paid output is the bare result; the raw key never appears on either stream (subprocess assertions) Also applies rustfmt to the new payment modules. * docs: document the paid RPC lane in the agent guide and README Covers the --x402/--mpp surface, the key-resolution ladder (file/stdin flag > QN_PAYMENT_KEY > key_file in config; never a raw key on argv or in config), the per-call spend ceiling, --receipt output shape, the query-chain vs pay-chain distinction, and the payment exit-code semantics: 2 = the gateway refused, 3 = outcome unknown, the payment may have settled — check the wallet before re-running. Notes that paid calls never auto-retry and that config supplies parameters but never activates payment. * feat(rpc): accept network names for --pay-network and refine paid-lane errors Adds human-readable names to the paid RPC lane: --pay-network (and pay_network in [rpc.payment]) now takes a Quicknode network name like base-sepolia, solana-devnet, or tempo-testnet, resolved to CAIP-2 before reaching the SDK. Raw CAIP-2 ids still pass through verbatim, so every chain stays reachable without a table entry. EVM chain ids are verified against the public registry at chainid.network; names that could not be verified are deliberately absent, since a wrong id is worse than an error pointing at the CAIP-2 escape hatch. Also sharpens paid-lane error semantics: a gateway 5xx on the paid resend is wrapped as PaymentMaybeCharged (exit 3, payment submitted, check the wallet), so exit 2 (PaymentRejected 4xx / PaymentUnsupported) always and only means the gateway refused without settling. Messages reworded to match. 9 new tests: 5 unit on the resolver, 3 unit on payment config resolution, 2 integration (name matches a CAIP-2 offer end-to-end; unknown name fails preflight with zero requests sent); MPP flow with --pay-network tempo-testnet verified against the live gateway. * feat(rpc): qn rpc wallet store for the paid lane Adds `qn rpc wallet generate|list|show|rm` to create and manage dedicated payment wallets locally, so the crypto-micropayment lane no longer requires hand-managing a raw key file. - `generate --chain evm|svm --name <NAME>` creates a fresh keypair via the SDK, stores the raw key at 0600 under `<config-dir>/qn/wallets/<name>` (evm also covers MPP/Tempo), and prints the address (plus a QR to fund it on a terminal). - `list` / `show` read a per-wallet `<name>.toml` sidecar (chain, address, created-at) and never open the key file; `show` prints the bare address to stdout and the QR to stderr, so a pipe yields just the address. - `rm` is gated (single --yes; non-TTY without it exits 5) and warns the key is unrecoverable. Keys are stored unencrypted at 0600 (the solana-keygen model) so the keyless, non-interactive paid lane keeps working without a passphrase prompt; treat each managed wallet as a dedicated, minimally-funded hot wallet. Wallet names are restricted to [a-z0-9_-] so they cannot escape the store directory. Reuses config's atomic 0600 writer (now pub(crate)) and adds a keyless Ctx constructor for local-only commands. New dep: qrcode (unicode renderer only, default features off) for the funding QR. 8 integration tests cover the key file, its perms, the sidecar, overwrite refusal, name validation, and rm gating. * feat(rpc): --payment-wallet source; drop env-var payment key Adds `--payment-wallet <NAME>` to `qn rpc call`, resolving a stored wallet (from `qn rpc wallet generate`) to its key file, and a matching `wallet` key under [rpc.payment] in config. New key-source precedence: --payment-key-file > --payment-wallet > config key_file > config wallet. Removes the QN_PAYMENT_KEY environment variable entirely: the payment key now comes only from a file or a stored wallet, never an env var. An exported key is invisible state that outlives its session and leaks into process listings, shell history, and CI logs; a file (0600) or managed wallet is the safer source. The resolver validates the wallet name so it cannot escape the store directory. Updates the payment-key doc/comments and the resolver unit tests, rewrites the subprocess integration helper to pass the key via a file, and adds an end-to-end test that generates a wallet then pays a call with --payment-wallet. (Agent guide + README still mention the old env var; they are updated in the docs-sync commit.) * feat(rpc): qn rpc pay-networks discovery list Adds `qn rpc pay-networks` (alias `pay-nets`): a keyless list of the networks payable via the crypto-micropayment lane, fetched from the gateways' public discovery endpoints (x402 + MPP `/networks`, enriched with the x402 asset from `/discovery/resources`). A slug in the list is a valid `--network` for a paid call; the asset column is a ready `--asset` value. Fetched directly with reqwest, not through the SDK — those hosts are the payment gateways, not the account API. Results are cached in `pay-networks.toml` next to the config with a 24h TTL, mirroring the multichain URL cache; a `--base-url` override targets one host and bypasses the cache (used by tests). Asset enrichment is best-effort: a discovery fetch/parse failure leaves the column blank rather than failing the command. Adds a reverse CAIP-2 -> slug lookup to the pay-network table and promotes reqwest to a direct dependency (json + rustls-tls) for the fetch. 4 integration tests cover the merged/enriched render, the alias, and a fetch failure. * docs(rpc): sync agent guide + README for wallets and pay-networks Documents `qn rpc wallet` (generate/list/show/rm), `qn rpc pay-networks`, and the `--payment-wallet` key source across the embedded agent guide (context.md) and the README, and removes the old QN_PAYMENT_KEY references now that the payment key comes only from a file or a stored wallet. Updates the `rpc call` after-help example to lead with --payment-wallet, and adds a pay-networks table snapshot (merged schemes + the x402 asset mapped onto its network row). * feat(rpc): show key file path + local-custody note on wallet generate/show `qn rpc wallet generate`/`show` now print the key file path and a note that the wallet lives only on this machine — Quicknode does not hold, back up, or recover it, so backing up the key file is the user's responsibility. Both go to stderr (address stays the sole stdout value for pipes); the note respects --quiet. Documents the same in the agent guide and README, and adds a subprocess test asserting the stdout/stderr split. * docs(rpc): working paid-lane examples for x402/MPP EVM and x402 SVM Replaces the single illustrative snippet with three copy-pasteable examples whose --pay-network/--asset/--max-amount match real gateway offers: x402 on Base Sepolia (USDC), MPP on Tempo testnet, and x402 on Solana devnet. Each was run against the live gateway and reaches the signing/settlement stage (only wallet funding is left to the user), rather than failing parameter validation. Notes that asset/amount must match an offer from `qn rpc pay-networks`, since a mismatch and an unfunded wallet both surface as an HTTP 400/402 refusal. * fix(rpc): working paid examples + clearer refusal error The paid-lane examples used --max-amount 1000000, which routes to the SIWX credit-drawdown offer the CLI can't authenticate (HTTP 400 auth_required). Corrects every example to --max-amount 1000 — the per-request USDC offer that only needs a funded wallet — so each command is copy-paste-runnable: generate a wallet, fund the printed address, run the command. Also surfaces the gateway's own reason in the refusal error (leading with "Gateway: <reason>" when the body reduces to one) and reframes the guidance to name the two real causes — an unfunded wallet, or asset/amount/pay-network not matching an offer (pointing at 'qn rpc pay-networks'). A long body (e.g. a 402 menu) still only appears under --verbose. * feat(rpc): label wallet show/generate output as Public Key / Private Key File The stderr lines are now "Public Key: <address>" and "Private Key File: <path>" (was an unlabeled address plus "Key file: <path>"), so it's obvious which is which. The bare address still goes to stdout so a piped `qn rpc wallet show` yields just the address; the labeled pair is stderr. Updates the subprocess test to assert the labels. * feat(rpc): space and style the wallet show/generate output Reworks the generate/show stderr block for readability: a blank line above and below the QR so it isn't jammed against the surrounding text, a blank line before the funding hint and before the custody note, and light ANSI styling (dim labels/fine-print, bold path and command) applied only when color is enabled. Drops the redundant address echo on stderr — the bare address is already on stdout for pipes. Removes the "Public Key" label per the same reasoning. * feat(rpc): rename paid-lane flags to --payment-* and accept asset names Rename the call-side payment flags for a consistent --payment-* stack: --pay-network becomes --payment-network, --asset becomes --payment-asset, with matching [rpc.payment] config keys (payment_network, payment_asset). --payment-asset now accepts friendly names (USDC) resolved per network via pay_asset::resolve, not only raw token addresses. Wallet show/generate output and the paid-lane README/agent-guide examples move to the new flag names. * feat(rpc): qn rpc x402 credit-drawdown lifecycle Adds `qn rpc x402 {buy-credits, balance, drip}` for prepaid gateway credits, an alternative to per-request payment. All three take the same payment parameter stack as the paid lane (--payment-wallet/-key-file, --payment-network, --payment-asset, --max-amount, --svm-rpc-url) with the same [rpc.payment] fallback. - buy-credits: SIWX-authenticates, then settles the gateway's credit offer with the configured wallet. Gated Mild (names the spend ceiling; --yes skips, non-TTY without --yes returns exit 5 with zero requests sent). - balance (alias credits): prints the bare credit count, or the full envelope with --format json. - drip: testnet faucet (Base Sepolia, once per account). The session JWT is authenticated once and cached at <config-dir>/qn/sessions.toml (0600, keyed by wallet address), re-seeded next run like the tooling token; a missing/expired session re-auths transparently. Factors the shared payment-param resolution out of resolve_payment_config into resolve_payment_params, reused by both the call lane and the x402 verbs. Syncs the agent guide. 5 new integration tests (happy/error/both gating paths). * feat(rpc): qn rpc call --x402-drawdown Adds a fourth payment mode to `qn rpc call`: spend prepaid x402 credits with no per-call signing (1 credit per successful response), the counterpart to the `qn rpc x402` credit lifecycle. Joins the mutually-exclusive payment ArgGroup alongside --x402/--mpp and conflicts with --endpoint-url; requires --network (the query chain). The gateway session is authenticated on first use and cached (0600, keyed by wallet address); the SDK's ensure_gateway_session is now shared by both the x402 noun and this call lane. A token_expired 401 triggers exactly one transparent re-auth + retry — that path draws no credit, so it isn't a paid retry; the credit-drawing call itself is single-attempt. Running out of credits surfaces an actionable error pointing at `qn rpc x402 buy-credits`. Syncs the agent guide (exit-code semantics, retry note, catalog) and adds a README drawdown walkthrough plus a call --help example. 3 new integration tests (happy path, expired-JWT auto re-auth sequence, out-of-credits single-attempt). * feat(rpc): qn rpc mpp channel lifecycle + --mpp-session on call Adds `qn rpc mpp {open, top-up, close, status}` for MPP payment channels and `qn rpc call --mpp-session` to pay from an open channel with a cumulative EIP-712 voucher (no on-chain tx per call), the counterpart to per-request --mpp. - open/top-up: deposit into the escrow (moves real funds on-chain, gated Mild). - close: cooperative settle + refund (gated Mild; the prompt warns further --mpp-session calls fail until re-open), then drops the local channel record. - status: the gateway's view, and the recovery path that re-seeds lost local channel state. - --mpp-session: joins the mutually-exclusive payment ArgGroup; requires an open channel and --network; advances the cumulative by one per-call unit, single-attempt; an exhausted deposit points at `qn rpc mpp top-up`. Channel state persists at <config-dir>/qn/channels.toml (0600, keyed by wallet address + network; amounts stored as strings since TOML has no u128). Syncs the agent guide and adds a README MPP-session walkthrough + call --help example. 4 new integration tests (open happy/gating, no-channel session call, plus the channel-state cache assertion). * docs(rpc): one Micropayments section with four zero-to-call paths Restructures the README paid-lane docs into a single Micropayments section: a comparison table of the four ways to pay, a shared "Get a wallet" preamble (local generation or bring-your-own-key), and four self-contained testnet-first walkthroughs (x402 per-request, MPP per-request, x402 drawdown, MPP session), each copy-pasteable end to end. Shared flags, config, and wallet management fold into one closing subsection. * docs(rpc): simplify the top-level rpc command description Drop the Tooling Access mention from 'qn rpc' one-liner; it also serves the paid micropayment lanes, so 'Make RPC calls' is the accurate summary. * fix(rpc): correct paid-lane exit codes + stdin guard on drawdown/session Three fixes from the branch code review: - A gateway refusal that settled nothing (out of credits, monthly limit, exhausted channel) now maps to exit 2 via a new CliError::PaymentRefused, matching the documented payment exit-code contract, instead of the generic exit 1 (was CliError::Arg). The actionable message is preserved. - An expired session token surfaces as HTTP 401 OR 403; is_token_expired now matches both, so a 403 triggers the transparent re-auth + retry instead of failing the call. - The drawdown and session call lanes now share the per-request lane's guard rejecting params-and-key both from stdin (draining stdin into the key would silently drop the params). Adds regression tests: 401 and 403 re-auth, the both-from-stdin rejection, and updates the out-of-credits test to assert exit 2. * fix(rpc): x402 drip reports the funding tx; buy-credits takes --network Match the corrected SDK drawdown shapes: - drip prints the faucet funding transaction (not a credit balance) and points Next at buy-credits. - buy-credits gains --network (the gateway query chain / path slug) since the purchase settles on a network-scoped RPC request; resolved from --network, else the --payment-network name when it isn't a CAIP-2 id. Updates the agent guide, README drawdown walkthrough (drip funds the wallet → buy-credits spends it), and the call/x402 --help examples. Tests updated for the network-scoped purchase + GET /credits balance read and the funding-tx drip. * feat(rpc): drawdown call needs only a wallet; chained next-command hints A drawdown call presents a Bearer JWT and signs nothing per request, so it no longer requires --payment-asset or --max-amount, and the pay network defaults to --network. `qn rpc call --x402-drawdown --payment-wallet <NAME>` now works with just the wallet (new resolve_drawdown_config, separate from the per-request resolver which still requires the full stack). The buy-credits, drip, and drawdown-call success outputs now print a copy-pasteable next command built from the flags the user supplied, so the flow chains without the wall of missing-flag errors. Docs/examples updated to the minimal drawdown call. Adds a test asserting a drawdown call succeeds with only --network + the wallet. * feat(rpc): split x402 balance/drip onto a narrower SessionArgs x402 balance and drip present a Bearer JWT and sign nothing. They now take SessionArgs (key/wallet/network only) instead of the full PaymentArgs, so --payment-asset and --max-amount are no longer on their surface. Spend flags on these verbs are a clap error. Adds resolve_session_params in payment.rs and a SessionArgs struct in x402.rs. Updates the drawdown next-command hint and context.md. Adds a test that rejects spend flags on balance. * refactor(wallet): promote wallet to a top-level noun Moves wallet management from 'qn rpc wallet' to 'qn wallet' (alias 'wallets'). Wallets are a standalone local key store the paid RPC lane uses, not an RPC operation, so they get their own noun. The old 'qn rpc wallet' path is removed. The module moves unchanged to src/commands/wallet.rs and keeps the keyless Ctx (no API key needed). payment.rs now resolves --payment-wallet through a shared commands::wallet::key_path helper instead of its own copy of the name rules, and the not-found message is unified. All help text, error hints, context.md, and the README now say 'qn wallet'; the README gets its own Wallets section. Tests move to tests/wallet.rs with retargeted argv; 417 tests pass. * docs(readme): drop spend flags from x402 drip/balance examples drip and balance no longer accept --payment-asset (they present a Bearer JWT and sign nothing), so the walkthrough examples must not pass it. * Formatting updates * Review updates * Review updates * CLI fixes * refactor(wallet): use the confirm_mild helper + add missing after_help Adds the Examples block the wallet noun was missing, on the top-level command and on the show/rm verbs. Replaces rm's hand-rolled decide_without_prompt + prompt_yes_no pair with confirm::confirm_mild. Same prompt text, same gating behavior; the helper is what every other gated verb calls. * docs: correct the drawdown exit code and a stale key-source comment context.md said an out-of-credits drawdown call exits 1. It returns PaymentRefused, which maps to exit 2. checked_key's doc still named an env var as a key source; that source was removed when --payment-wallet landed. * docs(rpc): drop ellipsis from the rpc call examples Both examples used '...' as a stand-in for params, so neither ran as shown. Uses concrete params instead. * test(rpc): cover the mpp top-up and close confirmation gates Both verbs move funds and are gated, but neither had gating tests. Adds the pair CLAUDE.md requires for each: no --yes on a non-TTY exits 5 and reaches the gateway zero times, --yes exits 0. Each test opens a channel first. Both verbs check for an open channel before the gate, so without one they exit 1 and never reach it. * CLI fixes * docs: condense internal comments * x402 fixes * feat(rpc): support Solana x402 drawdown * ci: check out local SDK dependency * ci: link checked out SDK dependency * ci: allow generated release workflow changes * docs(rpc): show Solana mainnet drawdown example * ci: allow generated release workflow changes * build: use quicknode-sdk 0.8.1 * docs(rpc): clarify paid RPC modes
1 parent a981b5b commit 5ff5fbc

29 files changed

Lines changed: 10471 additions & 317 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ docs/agents/
1818

1919
# Local Claude notes (gitignored per-repo configuration)
2020
CLAUDE.local.md
21+
IMPLEMENTATION_PLAN.md

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ Things to verify for each new endpoint:
7171
- **HTTP-noun → CLI-noun-verb**: `admin.get_endpoints``qn endpoint list`, `admin.show_endpoint``qn endpoint show <ID>`, `admin.update_endpoint_status(id, "paused")``qn endpoint pause <ID>` (split the verb out, since the user thinks "pause", not "update status to paused").
7272
- **Aliases**: every list-style command gets `#[command(visible_alias = "ls")]`. Plural top-level nouns get one too (`#[command(visible_alias = "endpoints")]`).
7373
- **Positional value names**: a field named `id` renders as an uninformative `<ID>` in help. Give every positional an explicit, resource-specific `#[arg(value_name = "ENDPOINT_ID")]` (uppercase, underscored: `STREAM_ID`, `WEBHOOK_ID`, `TEAM_ID`, …). Multi-word fields like `referrer_id` already render fine as `<REFERRER_ID>`.
74+
- **Help examples**: every top-level noun's `Args` struct gets `#[command(after_help = "Examples:\n ...")]`; every verb that takes flags or positionals gets one too. Zero-arg verbs may skip it. Use `after_help` (not `after_long_help`) so examples show in both `-h` and `--help`. Style: `Examples:` header, 2-space indent, explicit `\` line continuations for wrapped commands (clap's `wrap_help` is on), canonical fake values (`payer` wallet, `ep-1`, `base-sepolia`), plain ASCII. **Every example is copy-pasteable on its own: show ALL the flags the command needs, every time.** Never show a shortened invocation that relies on config-supplied values, and never mention config fallbacks in example blocks (that includes README and context.md examples — the dedicated config sections document the fallback). No `...` ellipsis in place of flags.
7475
- **Hyphenation**: clap kebab-cases enum variants by default. `RateLimit``rate-limit`. Test invocations must use the kebab form (`qn endpoint rate-limit method-create`, not `ratelimit`).
7576
- **Negative numbers**: any `i64` flag that accepts `-1` (`--end`, etc.) needs `#[arg(long, allow_hyphen_values = true)]` or clap will read it as another flag.
7677
- **Multi-value flags**: prefer repeatable `--method foo --method bar` (clap `Vec<String>` with `#[arg(long = "method")]`). Optionally also accept `--methods foo,bar` via a second field with `value_delimiter = ','`. The command body extends one into the other.

0 commit comments

Comments
 (0)