diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4430f58..a877077 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,45 @@ jobs: - name: cargo test run: cargo test -p quicknode-sdk --lib + # Payment lanes are feature-gated and add crypto deps + #[cfg]'d types, so + # each combo must build and test independently, plus a features-off build to + # prove the base crate is unaffected. + payment-features: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + features: + - "" # features-off baseline + - "payments" + - "payments-svm" + - "payments-tempo" + - "payments,payments-svm,payments-tempo" + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + + - name: cargo clippy (features="${{ matrix.features }}") + run: | + if [ -z "${{ matrix.features }}" ]; then + cargo clippy -p quicknode-sdk --lib --tests -- -D warnings + else + cargo clippy -p quicknode-sdk --lib --tests --features "${{ matrix.features }}" -- -D warnings + fi + + - name: cargo test (features="${{ matrix.features }}") + run: | + if [ -z "${{ matrix.features }}" ]; then + cargo test -p quicknode-sdk --lib + else + cargo test -p quicknode-sdk --lib --features "${{ matrix.features }}" + fi + python: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index dbd651c..ad6573d 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,9 @@ notes.md # Ruby native extension (built locally by `just ruby-build`) ruby/lib/quicknode_sdk/*.bundle ruby/lib/quicknode_sdk/*.so + +# Local scratch +scratch/ + +# Local working notes +IMPLEMENTATION_PLAN.md diff --git a/CLAUDE.md b/CLAUDE.md index a9422f7..1e7c8b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,12 +107,14 @@ pub struct SomeRequest { ... } - `rust` feature — `bon` builder pattern for ergonomic Rust usage ### Error Handling -`SdkError` (`crates/core/src/errors.rs`) uses `thiserror` with five variants: +`SdkError` (`crates/core/src/errors.rs`) uses `thiserror`: - `Http` — wraps `reqwest::Error` (further classified via `SdkError::http_kind()` → `HttpKind::{Timeout, Connect, Other}`) - `Api` — non-2xx response with status code and raw body - `Decode` — JSON parse failure with raw body for debugging - `UrlParse` — invalid URL (wraps `url::ParseError`) - `Config` — invalid configuration (string message) +- `Rpc` — JSON-RPC `error` member (code + message) +- `PaymentUnsupported` / `PaymentRejected` / `PaymentIndeterminate` — crypto-micropayment lane (the `payments*` features; see the payment-lane docs) Each binding exposes a typed exception hierarchy rooted at a shared base class so callers can `rescue` / `catch` / `except` by category. The mapping is: @@ -124,6 +126,10 @@ Each binding exposes a typed exception hierarchy rooted at a shared base class s | `Http` + `HttpKind::Other` | `HttpError` | `HttpError` | `QuicknodeError` | | `Api { status, body }` | `ApiError` (with `.status`, `.body`) | `ApiError` (with `.status`, `.body`) | `QuicknodeError` | | `Decode { body, .. }` | `DecodeError` (with `.body`) | `DecodeError` (with `.body`) | `QuicknodeError` | +| `Rpc { code, message }` | `RpcError` (with `.code`, `.message`) | `RpcError` (with `.code`) | `QuicknodeError` | +| `PaymentUnsupported` | `PaymentUnsupportedError` | `PaymentUnsupportedError` | `PaymentError` | +| `PaymentRejected { status, body }` | `PaymentRejectedError` (with `.status`, `.body`) | `PaymentRejectedError` (with `.status`, `.body`) | `PaymentError` | +| `PaymentIndeterminate` | `PaymentIndeterminateError` | `PaymentIndeterminateError` | `PaymentError` | Each binding owns its mapping in a dedicated `errors.rs` file: - **Python** — `crates/python/src/errors.rs` uses `create_exception!` macros; `map_sdk_err` sets `.status` / `.body` attributes via `setattr` on the exception instance. Exceptions are registered on the module in `add_to_module`. @@ -198,6 +204,7 @@ Core clients are tested using mocked API calls with wiremock. All functions maki - Any user-facing change to a method, parameter, return type, error class, or environment variable must be reflected in **all four** per-language READMEs in the same PR. This matches the polyglot consistency rule for `__init__.py`, `sdk.d.ts`, and `quicknode_sdk.rbs` documented in §SDK-Specific Guidelines → Polyglot consistency. - The Configuration env-var table and the Error Handling class table are duplicated verbatim across all four per-language READMEs. When one changes, update all four — keep them byte-identical. - Per-language READMEs are wired into package metadata (`crates/core/Cargo.toml` `readme`, `pyproject.toml` `readme`, `npm/package.json` `files`, `ruby/quicknode_sdk.gemspec` `s.files`). When adding a new language or moving a README, update the corresponding manifest. +- Every README has a **manually maintained Table of Contents** — it is NOT auto-generated. When you add, remove, rename, or reorder any `##`/`###`/`####` heading, update that file's TOC in the same PR. The TOC covers every heading below `## Table of Contents`, nested by level, and each anchor must match the GitHub slug of its heading (lowercase, spaces to hyphens, punctuation and backticks dropped, em dashes dropped — so `### Option A — Pass config directly` becomes `#option-a--pass-config-directly` with a double hyphen). Because the per-language READMEs share almost all their headings, a heading change in one usually needs the same TOC change in the other three. ### Platform support diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 71de92d..f7968f1 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -19,6 +19,22 @@ ruby = ["magnus"] rust = ["bon"] extension-module = ["pyo3/extension-module"] +# Crypto-micropayment lanes for rpc.call. Layered so a crates.io consumer can +# opt into only the pay-chains it needs; wheels/npm/gems ship features-on, so +# "zero cost when off" is true only for the crate itself. +# payments = x402/EVM (EIP-712 TransferWithAuthorization). +# payments-svm = + x402/Solana (hand-rolled v0 tx: compute budget, SPL +# TransferChecked, memo; ed25519). +# payments-tempo = + MPP/Tempo (native type-0x76 tx via tempo-primitives). +payments = ["dep:k256", "dep:sha3", "dep:hex", "dep:base64", "dep:rand"] +payments-svm = ["payments", "dep:ed25519-dalek", "dep:bs58", "dep:sha2"] +# tempo-primitives MUST stay default-features=false: its defaults pull revm + +# aws-lc-rs (C/cmake) which break cross+zig at glibc-2.17/musl. The base64 dep +# is force-included with the `alloc` feature to work around an upstream no_std +# feature-unification bug (tempo-primitives no-default-features won't compile +# without base64/alloc present in the tree). +payments-tempo = ["payments", "dep:tempo-primitives", "dep:alloy-primitives", "dep:alloy-consensus", "dep:alloy-rlp"] + [dependencies] thiserror = "1.0" serde = { version = "1.0", features = ["derive"] } @@ -39,6 +55,27 @@ secrecy = "0.8" # supplied by the caller (bindings or the user's own runtime). tokio = { version = "1", default-features = false, features = ["sync"] } +# ── Payment lane crypto (feature-gated) ────────────────────────────────────── +# secp256k1 signing for EIP-712 (x402/EVM) and Tempo (MPP). k256 0.14's +# sign_prehash_recoverable returns the tuple directly (no Result). +k256 = { version = "0.14", optional = true } +sha3 = { version = "0.10", optional = true } # keccak256 for EIP-712 + Tempo memo +hex = { version = "0.4", optional = true } +rand = { version = "0.8", optional = true } # random EIP-3009 nonce +# base64 is also force-enabled with `alloc` to unify features for +# tempo-primitives' no_std build (see the payments-tempo feature comment). +base64 = { version = "0.22", optional = true, features = ["alloc"] } +# x402/Solana: ed25519 signing + base58 addresses + sha256 for PDA/ATA derivation. +ed25519-dalek = { version = "2", optional = true } +bs58 = { version = "0.5", optional = true } +sha2 = { version = "0.10", optional = true } +# MPP/Tempo native type-0x76 tx. default-features=false is REQUIRED (see feature +# comment). alloy-* pinned to the versions tempo-primitives 1.8.1 builds against. +tempo-primitives = { version = "1.8.1", optional = true, default-features = false } +alloy-primitives = { version = "1.6", optional = true } +alloy-consensus = { version = "2.1", optional = true } +alloy-rlp = { version = "0.3", optional = true } + [[example]] name = "admin" required-features = ["rust"] @@ -63,6 +100,10 @@ required-features = ["rust"] name = "rpc" required-features = ["rust"] +[[example]] +name = "rpc_payment" +required-features = ["rust", "payments", "payments-svm", "payments-tempo"] + [dev-dependencies] tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] } wiremock = "0.6" diff --git a/crates/core/README.md b/crates/core/README.md index e174b8e..613b3ab 100644 --- a/crates/core/README.md +++ b/crates/core/README.md @@ -9,10 +9,15 @@ This is one of four language bindings published from the same Rust core. See the ## Table of Contents - [Installation](#installation) + - [Optional features — the crypto-micropayment lane](#optional-features--the-crypto-micropayment-lane) - [Quick Start](#quick-start) - [Configuration](#configuration) + - [Option A — Pass config directly](#option-a--pass-config-directly) + - [Option B — Load from environment (`from_env()`)](#option-b--load-from-environment-from_env) + - [Custom headers and `User-Agent`](#custom-headers-and-user-agent) - [Platform Support](#platform-support) - [API Reference](#api-reference) + - [Language conventions](#language-conventions) - [Admin Client](#admin-client) - [Endpoints](#endpoints) - [Endpoint Tags](#endpoint-tags) @@ -48,6 +53,12 @@ This is one of four language bindings published from the same Rust core. See the - [Sets](#sets) - [Lists](#lists) - [SQL Client](#sql-client) + - [RPC & Tooling Access](#rpc--tooling-access) +- [Crypto-micropayment lane (`rpc.call`)](#crypto-micropayment-lane-rpccall) + - [Wallet generation](#wallet-generation) + - [x402 credit drawdown (authenticate once, then draw one credit per call)](#x402-credit-drawdown-authenticate-once-then-draw-one-credit-per-call) + - [Testnet faucet](#testnet-faucet) + - [MPP payment channel (deposit once, then vouchers)](#mpp-payment-channel-deposit-once-then-vouchers) - [Error Handling](#error-handling) - [License](#license) @@ -55,6 +66,23 @@ This is one of four language bindings published from the same Rust core. See the `cargo add quicknode-sdk` +### Optional features — the crypto-micropayment lane + +The pay-per-request `rpc.call` lane is feature-gated so you only pay its +dependency/build cost when you use it: + +- `payments` — x402/EVM (EIP-712). +- `payments-svm` — adds x402/Solana (ed25519 + hand-rolled SPL). +- `payments-tempo` — adds MPP/Tempo (native Tempo tx). **Requires Rust ≥ 1.93** + (pulls `tempo-primitives`). + +```toml +quicknode-sdk = { version = "0.7", features = ["payments", "payments-svm", "payments-tempo"] } +``` + +The Python, Node, and Ruby packages ship precompiled with all payment features +on, so those consumers get the lane out of the box (and pay its cost regardless). + ## Quick Start Construct the SDK once, then reach into the five sub-clients (`admin`, `streams`, `webhooks`, `kvstore`, `sql`). Subsequent API Reference snippets assume you have a `qn` handle from one of these blocks. @@ -83,6 +111,10 @@ There are two ways to configure the SDK. let qn = QuicknodeSdk::new(&SdkFullConfig::builder().api_key("your-key").build())?; ``` +`api_key` is optional here: the [crypto-micropayment lane](#crypto-micropayment-lane-rpccall) +pays per request instead, so `SdkFullConfig::keyless()` builds a usable SDK with no key. +Every other client still needs one. `from_env()` always requires `QN_SDK__API_KEY`. + ### Option B — Load from environment (`from_env()`) ```rust @@ -1821,6 +1853,171 @@ A host that persists across processes can snapshot the cached token with `RpcConfig { endpoint_url, .. }` to route every call to a custom HTTP URL by default (no JWT minted); a per-call `endpoint_url` overrides it. +## Crypto-micropayment lane (`rpc.call`) + +Pay per RPC request with a stablecoin instead of a provisioned account + API key, +against Quicknode's `x402.quicknode.com` and `mpp.quicknode.com` gateways. Configure +it by setting `payment` on the RPC config; the SDK runs the `402` → sign → resend +handshake for you. An API key is **not** required for this lane — build a keyless SDK. + +There are four payment paths. Two pay per request; two amortize one signature over many +calls. + +| Path | Entry point | Gateway | Signs | +|---|---|---|---| +| Per-request x402 | `call` / `call_with_receipt` with `scheme: "x402"` | x402 | once per call | +| Per-request MPP charge | `call` / `call_with_receipt` with `scheme: "mpp"` | mpp | once per call | +| [x402 credit drawdown](#x402-credit-drawdown-authenticate-once-then-draw-one-credit-per-call) | `gateway_authenticate` → `gateway_drawdown_call` | x402 | once per session | +| [MPP payment channel](#mpp-payment-channel-deposit-once-then-vouchers) | `mpp_open` → `mpp_session_call` | mpp | once per channel | + +The signer construction is derived from the scheme and pay network, never stated directly: +**x402/EVM** signs an EIP-712 `TransferWithAuthorization`, **x402/Solana** an SPL +`TransferChecked` in a v0 tx (the gateway sponsors gas), and **MPP/Tempo** a native Tempo +transaction. + +`scheme` selects the gateway for `call` only. The `gateway_*` drawdown methods always use +the x402 gateway and the `mpp_*` channel methods always use the MPP gateway, whatever +`scheme` is set to. + +`PaymentConfig` fields: + +| Field | Meaning | +|---|---| +| `scheme` | `"x402"` (pay-per-request) or `"mpp"` (MPP charge; `"mpp-charge"` is accepted too) | +| `key` | raw private key — EVM/Tempo: hex; Solana: base58 64-byte secret | +| `pay_network` | CAIP-2 pay network, e.g. `eip155:84532`, `solana:5eykt4…` | +| `asset` | token address/mint to pay in (matches the offered menu entry) | +| `max_amount` | **required** spend ceiling in integer base units of `asset` | +| `svm_rpc_url` | optional Solana RPC for x402/Solana payment-build reads (mint + blockhash) | +| `base_url_override` | optional gateway base (testing) | + +`network` on the call is the **query** chain (gateway path slug), independent of the +pay network. Use `call_with_receipt` to also get the settlement receipt (`reference` = +settlement tx hash) — populated on the MPP lane, `null`/`None`/`nil` for x402. + +**Things to know:** + +- **Do not log your own `PaymentConfig`** — the `key` field is readable. The SDK + never prints it in its own errors/`Debug`, but a plain `{:?}`/`dbg!(config)` will show it. +- **`max_amount` is integer base units of the selected asset.** The SDK skips any offered + entry above it and refuses to sign one — a guard against an overcharging gateway. +- **`PaymentIndeterminateError` means the paid request was sent but the response was lost.** + You MAY have been charged — do **not** blindly retry. +- **x402/Solana: one payment per call.** Building a payment reads the mint and a recent + blockhash from a Solana RPC. The default is a public RPC that **rate-limits + aggressively** — set `svm_rpc_url` to your own endpoint at any volume. + +```rust +use quicknode_sdk::{PaymentConfig, QuicknodeSdk, RpcConfig, SdkFullConfig}; + +let mut config = SdkFullConfig::keyless(); +config.rpc = Some(RpcConfig { + payment: Some(PaymentConfig { + scheme: "x402".into(), + key: std::env::var("QN_PAYMENT_KEY").unwrap(), + pay_network: "eip155:84532".into(), + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e".into(), + max_amount: "10000".into(), + svm_rpc_url: None, + base_url_override: None, + }), + ..Default::default() +}); +let qn = QuicknodeSdk::new(&config)?; +let resp = qn.rpc.call_with_receipt("eth_blockNumber", None, Some("base-sepolia".into()), None).await?; +println!("{}", resp.result); +``` + +### Wallet generation + +`generate_payment_wallet(chain)` creates a fresh keypair offline — no network call, no +funds — for `ChainKind::Evm`, `Svm`, or `Tempo`. The private key is returned **exactly +once**, at generation; nothing in the SDK stores or re-derives it, so persist it before +dropping the value. + +```rust +use quicknode_sdk::{generate_payment_wallet, ChainKind}; + +let wallet = generate_payment_wallet(ChainKind::Evm)?; +println!("fund this address: {}", wallet.address); +std::fs::write("payment.key", wallet.into_key())?; // consuming: a deliberate, one-shot read +``` + +### x402 credit drawdown (authenticate once, then draw one credit per call) + +Cheaper per call than paying per request: one SIWE signature mints a session JWT, then +each call draws a single credit from the account balance instead of signing a fresh +settlement. Minting the JWT is free and moves no funds, so a host can re-authenticate +transparently. Persist the session between processes. + +Fund the payment wallet out of band — the testnet faucet below, or by sending funds to +`payment_address()` directly. Credits are provisioned against the account gateway-side. + +EVM signers only: SIWE is an EIP-4361 construction, so an x402/Solana key errors here. + +| Method | Cost | Returns | +|---|---|---| +| `payment_address()` | free, offline | the wallet address derived from the key | +| `gateway_authenticate()` | free | `GatewaySession { token, exp_unix, account_id }` | +| `gateway_credits(session)` | free | `CreditBalance { account_id, credits }` | +| `gateway_drip(session)` | free (testnet) | `DripReceipt { account_id, transaction_hash }` | +| `gateway_drawdown_call(method, params, network, session)` | 1 credit | the JSON-RPC `result` | + +```rust +let session = qn.rpc.gateway_authenticate().await?; +let balance = qn.rpc.gateway_credits(&session).await?; +println!("credits: {}", balance.credits); +let result = qn.rpc.gateway_drawdown_call("eth_blockNumber", None, "base-sepolia", &session).await?; +``` + +A `token_expired` surfaces as `SdkError::Api` with status 401/403; re-authenticate and +retry that call. + +#### Testnet faucet + +`gateway_drip` requests testnet tokens for the payment **wallet** on Base Sepolia. The +gateway allows one drip per account, and it returns the on-chain funding transaction hash +— not a credit balance. + +### MPP payment channel (deposit once, then vouchers) + +Open a payment channel by depositing into the escrow, then authorize each call with a +cumulative voucher — one `ecrecover` server-side, no on-chain transaction per call. +Requires the `payments-tempo` feature. + +| Method | Cost | Returns | +|---|---|---| +| `mpp_open(deposit)` | **moves funds** | `ChannelState` — persist it | +| `mpp_top_up(channel, additional_deposit)` | **moves funds** | the updated `ChannelState` | +| `mpp_status(channel)` | **1 request unit** | `ChannelStatus { channel_id, accepted_cumulative, spent }` | +| `mpp_session_call(method, params, network, channel, new_cumulative)` | 1 request unit | the JSON-RPC `result` | +| `mpp_close(channel)` | settles on-chain | `()` — refunds the unused deposit | + +```rust +let channel = qn.rpc.mpp_open(1_000_000).await?; // persist this +let result = qn.rpc + .mpp_session_call("eth_blockNumber", None, "base-sepolia", &channel, channel.cumulative_spent + channel.per_call) + .await?; +// On success, advance and re-persist cumulative_spent by per_call. +``` + +**Things to know:** + +- **Persist `ChannelState`.** The gateway exposes no read-only channel endpoint, so a + lost local record means opening (and funding) a new channel. +- **`mpp_status` is not free.** The gateway prices every session POST as a chargeable + request and computes the balance from the *new* spend a voucher authorizes, so the + probe advances `cumulative_spent` by `per_call` exactly like a call. Re-persist the + advanced total. It returns `PaymentUnsupported` before any network I/O when the + channel has no room left for the probe. +- **The lifecycle takes no query network.** A channel is scoped by the configured pay + network and asset, so one channel funds calls to every supported network. Only + `mpp_session_call` takes a `network`, because it routes an RPC method. +- **Advance `cumulative_spent` only after a success.** A voucher authorizes the running + total *after* the call; re-presenting the current high-water mark authorizes zero and + is always refused with `insufficient-balance`. + + ## Error Handling Every binding exposes a typed exception hierarchy derived from the core `SdkError` @@ -1837,8 +2034,12 @@ subclass to branch on transport vs. API semantics. | `ApiError` | non-2xx HTTP response | `status`, `body` | | `DecodeError` | 2xx response but JSON parse failed | `body` | | `RpcError` | JSON-RPC call returned an `error` member | `code`, `message` | +| `PaymentError` | base class for the crypto-micropayment lane | — | +| `PaymentUnsupportedError` | no offered payment option matched your selector (or all were over `max_amount`/unsupported) | — | +| `PaymentRejectedError` | the gateway rejected a signed payment (terminal, one resend only) | `status`, `body` | +| `PaymentIndeterminateError` | paid request sent but response lost — MAY have been charged; do NOT blindly retry | — | -Variants: pattern-match on `SdkError { Http, Api, Decode, UrlParse, Config, Rpc }`; use `err.http_kind()` to classify `Http` into `Timeout`, `Connect`, or `Other`. +Variants: pattern-match on `SdkError { Http, Api, Decode, UrlParse, Config, Rpc, PaymentUnsupported, PaymentRejected, PaymentIndeterminate }`; use `err.http_kind()` to classify `Http` into `Timeout`, `Connect`, or `Other`. The `Payment*` variants require a `payments*` feature. ```rust // Rust diff --git a/crates/core/examples/rpc_payment.rs b/crates/core/examples/rpc_payment.rs new file mode 100644 index 0000000..4f271c5 --- /dev/null +++ b/crates/core/examples/rpc_payment.rs @@ -0,0 +1,76 @@ +//! Crypto-micropayment lane for `rpc.call`: pay per RPC request with a +//! stablecoin instead of an account API key, against Quicknode's x402/MPP +//! gateways. +//! +//! ⚠️ MOVES REAL FUNDS when it settles. Use a throwaway, minimally-funded +//! wallet. Reads the private key from `QN_PAYMENT_KEY` — never hard-code it. +//! +//! Run (x402/EVM on Base Sepolia testnet): +//! QN_PAYMENT_KEY=0x \ +//! cargo run --example rpc_payment -p quicknode-sdk \ +//! --features rust,payments,payments-svm,payments-tempo + +use quicknode_sdk::{PaymentConfig, QuicknodeSdk, RpcConfig, SdkFullConfig}; + +#[tokio::main] +#[allow(clippy::unwrap_used, clippy::expect_used)] +async fn main() { + let key = std::env::var("QN_PAYMENT_KEY").expect("set QN_PAYMENT_KEY to a throwaway key"); + + // Keyless SDK: this example only uses the payment lane. + let mut config = SdkFullConfig::keyless(); + config.rpc = Some(RpcConfig { + // Do not log this config; it contains the private key. + payment: Some(PaymentConfig { + scheme: "x402".into(), + key, + // Base Sepolia testnet USDC (x402/EVM). + pay_network: "eip155:84532".into(), + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e".into(), + // Spend ceiling in asset base units. + max_amount: "10000".into(), + svm_rpc_url: None, + base_url_override: None, + }), + ..Default::default() + }); + + let qn = QuicknodeSdk::new(&config).expect("sdk failed to initialize"); + + // Query network is independent of the payment network. + match qn + .rpc + .call( + "eth_blockNumber", + None, + Some("base-sepolia".to_string()), + None, + ) + .await + { + Ok(result) => println!("paid eth_blockNumber => {result}"), + Err(e) => eprintln!("payment call error: {e}"), + } + + // This call also returns an MPP settlement receipt. Do not retry an + // indeterminate payment. + match qn + .rpc + .call_with_receipt( + "eth_blockNumber", + None, + Some("base-sepolia".to_string()), + None, + ) + .await + { + Ok(resp) => { + println!("result => {}", resp.result); + match resp.payment_receipt { + Some(r) => println!("settlement reference: {}", r.reference), + None => println!("(no receipt — x402 lane)"), + } + } + Err(e) => eprintln!("payment call error: {e}"), + } +} diff --git a/crates/core/src/admin/mod.rs b/crates/core/src/admin/mod.rs index c8747f2..5f90765 100644 --- a/crates/core/src/admin/mod.rs +++ b/crates/core/src/admin/mod.rs @@ -1950,7 +1950,7 @@ mod tests { fn make_sdk(base_url: String) -> QuicknodeSdk { QuicknodeSdk::new(&SdkFullConfig { - api_key: "test-key".to_string(), + api_key: Some("test-key".to_string()), http: None, admin: Some(AdminConfig { base_url: Some(base_url), @@ -3839,7 +3839,7 @@ mod tests { fn negative_timeout_secs_returns_error() { use crate::{HttpConfig, SdkConfig, SdkFullConfig}; let result = SdkConfig::new(&SdkFullConfig { - api_key: "test-key".to_string(), + api_key: Some("test-key".to_string()), http: Some(HttpConfig { timeout_secs: Some(-1), pool_max_idle_per_host: None, diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index c750b92..6e5fff5 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -213,6 +213,105 @@ pub struct RpcConfig { /// a `network` resolves the target URL here. Optional; the default-network /// call path needs no map. pub networks: Option>, + /// Crypto-micropayment lane. When set, `rpc.call` pays per request with a + /// stablecoin against Quicknode's x402/MPP gateways instead of using the + /// account API key + session JWT. `#[serde(skip)]` so `from_env` can never + /// populate it — an env-derived private key is exactly what we don't want; + /// callers must pass this programmatically. The field is always present + /// (plain data), but the payment lane is only wired into `rpc.call` when a + /// crypto feature (`payments`/`payments-svm`/`payments-tempo`) is enabled; + /// built without any of them, a set `payment` is ignored and `rpc.call` + /// keeps its normal tooling-JWT behavior. The precompiled Python/Node/Ruby + /// packages always ship with the payment features on. + #[serde(skip)] + pub payment: Option, +} + +/// Binding-facing crypto-micropayment configuration. **Plain data** — all +/// fields are strings so this can be a `napi(object)` / `pyclass` / Ruby hash; +/// it is converted to the internal `enum Signer` + resolved config at the Rust +/// boundary. The private `key` field stays readable to the caller, but the +/// SDK's own `Debug` redacts it (below) so an SDK log line or panic can't leak +/// it. +/// +/// **Do not log your own `PaymentConfig`** — `println!("{config:?}")` on the +/// derived-Debug *binding* object (napi/pyclass/hash) still shows the raw key. +/// Only the SDK's internal rendering is redacted. +#[cfg_attr(feature = "python", gen_stub_pyclass)] +#[cfg_attr(feature = "python", pyclass(get_all, set_all))] +#[cfg_attr(feature = "node", napi(object))] +#[cfg_attr(feature = "rust", derive(Builder))] +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct PaymentConfig { + /// Payment protocol: `"x402"` (pay-per-request) or `"mpp"` (MPP charge). + pub scheme: String, + /// Raw private key. EVM/Tempo: hex (with or without `0x`). Solana: base58 + /// 64-byte secret key. + pub key: String, + /// CAIP-2 pay network selector, e.g. `"eip155:84532"` (x402/EVM), + /// `"solana:5eykt4…"` (x402/Solana), or `"eip155:42431"` (MPP/Tempo). + pub pay_network: String, + /// Asset (token) address/mint to pay in. Matches the offered menu entry's + /// `asset`. EVM: token contract hex. Solana: mint base58. + pub asset: String, + /// Spend ceiling in base units of `asset` (integer string). **Required.** + /// The selector skips any offered entry above this, and the driver refuses + /// to sign one — guarding against a buggy/hostile gateway overcharging a + /// custodied key. + pub max_amount: String, + /// Explicit Solana RPC URL for x402/Solana payment-build reads: the mint + /// (for its decimals and owning token program) and a recent blockhash, so + /// two reads per payment. Optional; when unset the SDK falls back to a + /// public Solana RPC matching the pay cluster. **Set this at any real + /// volume** — the public default rate-limits aggressively. + pub svm_rpc_url: Option, + /// Test-only gateway base override (points the lane at a mock gateway). + pub base_url_override: Option, +} + +// Manual redacting Debug: the SDK must never print the raw key in its own log +// lines, error context, or panics. Mirrors the CachedToken pattern above. The +// caller's own object is still readable (see the struct doc) — this only +// governs the SDK's `{:?}` output. +impl std::fmt::Debug for PaymentConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PaymentConfig") + .field("scheme", &self.scheme) + .field("key", &"[redacted]") + .field("pay_network", &self.pay_network) + .field("asset", &self.asset) + .field("max_amount", &self.max_amount) + .field("svm_rpc_url", &self.svm_rpc_url) + .field("base_url_override", &self.base_url_override) + .finish() + } +} + +#[cfg(feature = "python")] +#[gen_stub_pymethods] +#[pymethods] +impl PaymentConfig { + #[new] + #[pyo3(signature = (scheme, key, pay_network, asset, max_amount, svm_rpc_url=None, base_url_override=None))] + pub fn new( + scheme: String, + key: String, + pay_network: String, + asset: String, + max_amount: String, + svm_rpc_url: Option, + base_url_override: Option, + ) -> Self { + PaymentConfig { + scheme, + key, + pay_network, + asset, + max_amount, + svm_rpc_url, + base_url_override, + } + } } #[cfg(feature = "python")] @@ -220,18 +319,20 @@ pub struct RpcConfig { #[pymethods] impl RpcConfig { #[new] - #[pyo3(signature = (endpoint_url=None, seed=None, refresh_margin_secs=None, networks=None))] + #[pyo3(signature = (endpoint_url=None, seed=None, refresh_margin_secs=None, networks=None, payment=None))] pub fn new( endpoint_url: Option, seed: Option, refresh_margin_secs: Option, networks: Option>, + payment: Option, ) -> Self { RpcConfig { endpoint_url, seed, refresh_margin_secs, networks, + payment, } } } @@ -262,7 +363,16 @@ impl SqlConfig { #[cfg_attr(feature = "rust", derive(Builder))] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct SdkFullConfig { - pub api_key: String, + /// Account API key. **Optional** so a keyless SDK can be built for the + /// crypto-micropayment lane (`rpc.call` with `RpcConfig.payment`). When + /// absent, no `x-api-key` header is installed: the payment lane works, while + /// the keyed surfaces (admin/streams/webhooks/kvstore/sql and tooling-JWT + /// `rpc.call`) send un-authenticated requests and the gateway rejects them + /// (surfacing as an `ApiError`, typically 401). `from_env` still requires + /// the key (validated in `from_config`) — only programmatic construction + /// may omit it. + #[serde(default)] + pub api_key: Option, pub http: Option, pub admin: Option, pub streams: Option, @@ -275,7 +385,23 @@ pub struct SdkFullConfig { impl SdkFullConfig { pub fn from_api_key(api_key: String) -> Self { SdkFullConfig { - api_key, + api_key: Some(api_key), + http: None, + admin: None, + streams: None, + webhooks: None, + kvstore: None, + sql: None, + rpc: None, + } + } + + /// Build a keyless config for the crypto-micropayment lane. No API key is + /// installed; the payment-lane `rpc.call` works, while every keyed surface + /// sends un-authenticated requests that the gateway rejects (`ApiError`). + pub fn keyless() -> Self { + SdkFullConfig { + api_key: None, http: None, admin: None, streams: None, @@ -299,8 +425,19 @@ impl SdkFullConfig { } fn from_config(cfg: config::Config) -> Result { - cfg.try_deserialize::() - .map_err(|e| SdkError::Config(e.to_string())) + let parsed: SdkFullConfig = cfg + .try_deserialize::() + .map_err(|e| SdkError::Config(e.to_string()))?; + // from_env stays strict: it can't configure payments (payment is + // serde-skipped), so a from_env caller by definition wants the keyed + // lanes. Fail fast here rather than surfacing a confusing per-call + // Config error later from a typo'd env var. + if parsed.api_key.as_deref().unwrap_or("").is_empty() { + return Err(SdkError::Config( + "api_key is required (set QN_SDK__API_KEY)".into(), + )); + } + Ok(parsed) } } @@ -309,10 +446,10 @@ impl SdkFullConfig { #[pymethods] impl SdkFullConfig { #[new] - #[pyo3(signature = (api_key, http=None, admin=None, streams=None, webhooks=None, kvstore=None, sql=None, rpc=None))] + #[pyo3(signature = (api_key=None, http=None, admin=None, streams=None, webhooks=None, kvstore=None, sql=None, rpc=None))] #[allow(clippy::too_many_arguments)] pub fn new( - api_key: String, + api_key: Option, http: Option, admin: Option, streams: Option, @@ -360,7 +497,7 @@ mod tests { fn from_env_only_api_key() { let cfg = build_config(&[("api_key", "test-key")]); let config = SdkFullConfig::from_config(cfg).unwrap(); - assert_eq!(config.api_key, "test-key"); + assert_eq!(config.api_key.as_deref(), Some("test-key")); assert!(config.http.is_none()); assert!(config.admin.is_none()); } @@ -374,7 +511,7 @@ mod tests { ("admin.base_url", "https://example.com/"), ]); let config = SdkFullConfig::from_config(cfg).unwrap(); - assert_eq!(config.api_key, "my-api-key"); + assert_eq!(config.api_key.as_deref(), Some("my-api-key")); let http = config.http.unwrap(); assert_eq!(http.timeout_secs, Some(30)); assert_eq!(http.pool_max_idle_per_host, Some(5)); diff --git a/crates/core/src/errors.rs b/crates/core/src/errors.rs index 9c6c216..3927f3a 100644 --- a/crates/core/src/errors.rs +++ b/crates/core/src/errors.rs @@ -24,6 +24,27 @@ pub enum SdkError { #[error("JSON-RPC error (code {code}): {message}")] Rpc { code: i64, message: String }, + + /// No offered payment option matched the caller's selector (pay_network + + /// asset), or every match was skipped (over `max_amount`, unsupported + /// `extra` shape, non-integer amount). `offered` lists what the gateway + /// presented, for diagnosis. Not retryable without changing the selector. + #[error("no supported payment option matched the selector; offered: {offered}")] + PaymentUnsupported { offered: String }, + + /// A signed payment was submitted and the gateway rejected it (a second + /// 402, or a non-2xx settlement response). Terminal — the SDK will not + /// resend. `body` carries the gateway's explanation. + #[error("payment rejected by the gateway (status {status}): {body}")] + PaymentRejected { status: u16, body: String }, + + /// A paid request was sent but its response was lost (timeout or a + /// transport error after the bytes may have reached the gateway). The + /// payment MAY have settled — callers must NOT blindly retry, or they risk + /// a double charge. Distinct from a plain `Http` error precisely so this + /// case can be caught separately. + #[error("payment result indeterminate: request sent but response lost — do not blindly retry (may have been charged)")] + PaymentIndeterminate, } // Classifies a transport-level HTTP failure. Bindings use this to pick a diff --git a/crates/core/src/kvstore/mod.rs b/crates/core/src/kvstore/mod.rs index 4343b03..04736a7 100644 --- a/crates/core/src/kvstore/mod.rs +++ b/crates/core/src/kvstore/mod.rs @@ -688,7 +688,7 @@ mod tests { fn make_sdk(base_url: String) -> QuicknodeSdk { QuicknodeSdk::new(&SdkFullConfig { - api_key: "test-key".to_string(), + api_key: Some("test-key".to_string()), http: None, admin: None, streams: None, diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 102265b..e9f6182 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -19,6 +19,13 @@ pub use kvstore::{ UpdateListParams, }; pub use rpc::RpcApiClient; +#[cfg(feature = "payments")] +pub use rpc::{ + generate_payment_wallet, ChainKind, CreditBalance, DripReceipt, GatewaySession, + GeneratedWallet, PaymentConfig, PaymentReceipt, PaymentScheme, RpcCallResponse, +}; +#[cfg(feature = "payments-tempo")] +pub use rpc::{ChannelState, ChannelStatus}; pub use sql::{ ChainSchema, ColumnMeta, ColumnSchema, QueryParams, QueryResponse, QueryStatistics, SqlApiClient, TableSchema, @@ -171,11 +178,18 @@ impl SdkConfig { // the key first, then overlay `common_headers` so a caller-supplied // `x-api-key` in custom headers still wins (custom headers override all // SDK-managed defaults). + // + // A keyless config (no `api_key`) installs NO key header: the payment + // lane needs no account key. Keyed surfaces then send un-authenticated + // requests that the gateway rejects (surfacing as `ApiError`), rather + // than sending an empty key header. let mut main_headers = HeaderMap::new(); - main_headers.insert( - "x-api-key", - HeaderValue::from_str(&config.api_key).map_err(|e| SdkError::Config(e.to_string()))?, - ); + if let Some(api_key) = config.api_key.as_deref() { + main_headers.insert( + "x-api-key", + HeaderValue::from_str(api_key).map_err(|e| SdkError::Config(e.to_string()))?, + ); + } main_headers.extend(common_headers.clone()); let http_client = make_builder() .default_headers(main_headers) @@ -296,7 +310,7 @@ mod headers_tests { fn base_config(api_key: &str) -> SdkFullConfig { SdkFullConfig { - api_key: api_key.to_string(), + api_key: Some(api_key.to_string()), http: None, admin: None, streams: None, @@ -380,7 +394,7 @@ mod headers_tests { headers.insert("x-api-key".to_string(), "override-key".to_string()); let cfg = SdkFullConfig { - api_key: "real-key".to_string(), + api_key: Some("real-key".to_string()), http: Some(HttpConfig { timeout_secs: None, pool_max_idle_per_host: None, diff --git a/crates/core/src/rpc/mod.rs b/crates/core/src/rpc/mod.rs index 1d0e4b4..8e8fc1f 100644 --- a/crates/core/src/rpc/mod.rs +++ b/crates/core/src/rpc/mod.rs @@ -17,6 +17,20 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::Value; +#[cfg(feature = "payments")] +pub mod payment; + +#[cfg(feature = "payments")] +pub use crate::config::PaymentConfig; +#[cfg(feature = "payments")] +pub use payment::drawdown::{CreditBalance, DripReceipt, GatewaySession}; +#[cfg(feature = "payments-tempo")] +pub use payment::session::{ChannelState, ChannelStatus}; +#[cfg(feature = "payments")] +pub use payment::signer::{generate_payment_wallet, ChainKind, GeneratedWallet}; +#[cfg(feature = "payments")] +pub use payment::{PaymentReceipt, PaymentScheme}; + use crate::admin::AdminApiClient; use crate::config::{CachedToken, RpcConfig}; use crate::errors::SdkError; @@ -50,6 +64,22 @@ pub struct RpcApiClient { // Tooling Access endpoint and the JWT entirely (see `RpcConfig::endpoint_url`). // A per-call `endpoint_url` overrides this. Immutable after construction. endpoint_url: Option, + // Crypto-micropayment lane config. When set, `call`/`call_with_receipt` + // pay per request against the x402/MPP gateways instead of minting a JWT. + // Resolved to the internal Signer at call time so a malformed config + // (bad max_amount, unknown scheme) surfaces as a clear `Config` error. + #[cfg(feature = "payments")] + payment: Option>, +} + +/// The result of a JSON-RPC call plus an optional settlement receipt. Returned +/// by [`RpcApiClient::call_with_receipt`]; `payment_receipt` is `Some` only for +/// the MPP payment lane and `None` for x402 and the non-payment lanes. +#[cfg(feature = "payments")] +#[derive(Debug, Clone)] +pub struct RpcCallResponse { + pub result: Value, + pub payment_receipt: Option, } impl std::fmt::Debug for RpcApiClient { @@ -76,6 +106,11 @@ impl RpcApiClient { let seed = rpc_config.and_then(|c| c.seed.clone()); let networks = rpc_config.and_then(|c| c.networks.clone()); let endpoint_url = rpc_config.and_then(|c| c.endpoint_url.clone()); + // Hold the plain-data payment config; resolve it to the internal enum + // Signer at call time so a malformed config surfaces as a clear + // `Config` error (keeps `new` infallible). + #[cfg(feature = "payments")] + let payment = rpc_config.and_then(|c| c.payment.clone()).map(Arc::new); Self { admin: AdminApiClient::new(config.clone()), config, @@ -84,6 +119,8 @@ impl RpcApiClient { refresh_lock: Arc::new(tokio::sync::Mutex::new(())), networks: Arc::new(Mutex::new(networks)), endpoint_url, + #[cfg(feature = "payments")] + payment, } } @@ -137,6 +174,17 @@ impl RpcApiClient { network: Option, endpoint_url: Option, ) -> Result { + // Payment lane wins when configured (see the precedence rules in + // `run_payment_lane`); it returns the bare result and discards any + // receipt. Every other caller keeps today's behavior unchanged. + #[cfg(feature = "payments")] + if self.payment.is_some() { + return self + .run_payment_lane(method, ¶ms, network.as_deref(), endpoint_url.as_deref()) + .await + .map(|(result, _receipt)| result); + } + // Precedence: a per-call custom URL wins; then a per-call network; then // the client-wide custom URL default; then the tooling default endpoint. // A per-call URL and network are mutually exclusive (custom URLs are not @@ -175,6 +223,342 @@ impl RpcApiClient { Self::parse_rpc(resp) } + /// Like [`Self::call`], but also returns the settlement receipt for the + /// crypto-micropayment lane. `payment_receipt` is `Some` only on the MPP + /// happy path; it is `None` for x402 and for every non-payment lane (which + /// behave exactly like [`Self::call`]). + #[cfg(feature = "payments")] + pub async fn call_with_receipt( + &self, + method: &str, + params: Option, + network: Option, + endpoint_url: Option, + ) -> Result { + if self.payment.is_some() { + let (result, payment_receipt) = self + .run_payment_lane(method, ¶ms, network.as_deref(), endpoint_url.as_deref()) + .await?; + return Ok(RpcCallResponse { + result, + payment_receipt, + }); + } + // No payment lane: delegate to the ordinary call and report no receipt. + let result = self.call(method, params, network, endpoint_url).await?; + Ok(RpcCallResponse { + result, + payment_receipt: None, + }) + } + + // Payment-lane precedence + dispatch. Called only when `self.payment` is set. + // + // Precedence rules (mutually-exclusive with the self-auth URL lanes): + // - a per-call `endpoint_url` + payment => Config error; + // - a client-wide `endpoint_url` + payment => Config error (a custom + // self-auth URL and a payment lane are mutually exclusive); + // - payment present => `network` (the QUERY chain) is required and routed to + // the gateway path slug (NOT looked up in the tooling network map). + #[cfg(feature = "payments")] + async fn run_payment_lane( + &self, + method: &str, + params: &Option, + network: Option<&str>, + endpoint_url: Option<&str>, + ) -> Result<(Value, Option), SdkError> { + if endpoint_url.is_some() { + return Err(SdkError::Config( + "`endpoint_url` and a payment lane are mutually exclusive: a \ + self-authenticating URL does not use per-request payment" + .into(), + )); + } + if self.endpoint_url.is_some() { + return Err(SdkError::Config( + "a client-wide `endpoint_url` and a payment lane are mutually \ + exclusive: configure one or the other" + .into(), + )); + } + let query_network = network.ok_or_else(|| { + SdkError::Config( + "the payment lane requires `network` (the query chain, e.g. \ + \"base-sepolia\" or \"solana-mainnet\")" + .into(), + ) + })?; + + // Resolve the plain-data config to the internal Signer here so a + // malformed config (bad max_amount, unknown scheme) surfaces as a + // clear `Config` error rather than being silently dropped. + let config = self + .payment + .as_ref() + .ok_or_else(|| SdkError::Config("no payment lane configured".into()))?; + // `resolved` is only mutated on the SVM RPC-source step below, which is + // compiled out without `payments-svm`. + #[cfg_attr(not(feature = "payments-svm"), allow(unused_mut))] + let mut resolved = payment::ResolvedPayment::from_config(config)?; + + // SVM RPC source precedence: an explicit override (already applied in + // from_config) wins; otherwise, if the tooling lane is enabled and its + // network map resolves the pay-chain's Solana network, read through the + // caller's own Quicknode endpoint; else the public default (best-effort + // — no API key just means skip to public, never an error). + #[cfg(feature = "payments-svm")] + if resolved.svm_rpc_url.is_some() && config.svm_rpc_url.is_none() { + if let Some(tooling_url) = self.tooling_svm_url(&resolved.pay_network) { + resolved.svm_rpc_url = Some(tooling_url); + } + } + + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params.clone().unwrap_or_else(|| Value::Array(vec![])), + }); + + let (text, receipt) = payment::pay_and_call( + self.config.rpc_http_client(), + &resolved, + query_network, + &body, + ) + .await?; + + let result = Self::parse_rpc(RawResponse { status: 200, text })?; + Ok((result, receipt)) + } + + // Resolve the plain-data payment config to the internal Signer + selector, + // applying the same SVM RPC-source precedence as `run_payment_lane`. Shared + // by the x402 drawdown lifecycle methods below. Errors (bad max_amount, + // unknown scheme) surface as a clear `Config` error. + #[cfg(feature = "payments")] + fn resolve_payment(&self) -> Result { + let config = self + .payment + .as_ref() + .ok_or_else(|| SdkError::Config("no payment lane configured".into()))?; + #[cfg_attr(not(feature = "payments-svm"), allow(unused_mut))] + let mut resolved = payment::ResolvedPayment::from_config(config)?; + #[cfg(feature = "payments-svm")] + if resolved.svm_rpc_url.is_some() && config.svm_rpc_url.is_none() { + if let Some(tooling_url) = self.tooling_svm_url(&resolved.pay_network) { + resolved.svm_rpc_url = Some(tooling_url); + } + } + Ok(resolved) + } + + /// The configured payment wallet's on-chain address (EVM/Tempo `0x…` hex, + /// Solana base58), derived offline from the key. A host uses this to key a + /// gateway-session cache by wallet without a network round trip. + #[cfg(feature = "payments")] + pub fn payment_address(&self) -> Result { + self.resolve_payment()?.signer.address() + } + + /// Authenticates against the x402 gateway with a SIWX message and returns a + /// [`payment::drawdown::GatewaySession`] (the session JWT). Free — no funds + /// move — so a host may (re)auth transparently before a drawdown call. The + /// host persists the session and re-seeds it next run, exactly as it does + /// the tooling [`crate::config::CachedToken`]. + #[cfg(feature = "payments")] + pub async fn gateway_authenticate( + &self, + ) -> Result { + let resolved = self.resolve_payment()?; + payment::drawdown::authenticate(self.config.rpc_http_client(), &resolved).await + } + + /// Buys a block of credits against the x402 gateway, settling the offered + /// `402` with the same signer construction as the per-request lane. Returns + /// the post-purchase [`payment::drawdown::CreditBalance`]. Single-attempt: + /// a paid lane never blind-retries. + #[cfg(feature = "payments")] + pub async fn gateway_buy_credits( + &self, + session: &payment::drawdown::GatewaySession, + network: &str, + ) -> Result { + let resolved = self.resolve_payment()?; + payment::drawdown::buy_credits(self.config.rpc_http_client(), &resolved, session, network) + .await + } + + /// Reads the account's current x402 credit balance (GET `/credits`). + #[cfg(feature = "payments")] + pub async fn gateway_credits( + &self, + session: &payment::drawdown::GatewaySession, + ) -> Result { + let resolved = self.resolve_payment()?; + payment::drawdown::credits(self.config.rpc_http_client(), &resolved, session).await + } + + /// Requests testnet tokens from the x402 faucet (POST `/drip`). Allowed once + /// per account on Base Sepolia. Returns the funding transaction (not a + /// balance — call [`Self::gateway_credits`] afterwards for the balance). + #[cfg(feature = "payments")] + pub async fn gateway_drip( + &self, + session: &payment::drawdown::GatewaySession, + ) -> Result { + let resolved = self.resolve_payment()?; + payment::drawdown::drip(self.config.rpc_http_client(), &resolved, session).await + } + + /// Makes one x402 drawdown JSON-RPC call against `network` with the session + /// JWT as a Bearer token, drawing 1 credit on success. Returns the + /// unwrapped JSON-RPC `result`. Single-attempt; the caller decides whether + /// to re-auth on a `token_expired` (surfaced as [`SdkError::Api`] 401/403). + #[cfg(feature = "payments")] + pub async fn gateway_drawdown_call( + &self, + method: &str, + params: Option, + network: &str, + session: &payment::drawdown::GatewaySession, + ) -> Result { + let resolved = self.resolve_payment()?; + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params.unwrap_or_else(|| Value::Array(vec![])), + }); + let text = payment::drawdown::drawdown_call( + self.config.rpc_http_client(), + &resolved, + session, + network, + &body, + ) + .await?; + Self::parse_rpc(RawResponse { status: 200, text }) + } + + /// Opens an MPP payment channel by depositing `deposit` base units into the + /// escrow and returns the new [`payment::session::ChannelState`]. Moves real + /// funds; single-attempt. + /// + /// The channel is scoped by the configured pay network and asset, not by any + /// queried chain: one open channel funds paid calls to every supported + /// network, so this takes no query network. + #[cfg(feature = "payments-tempo")] + pub async fn mpp_open( + &self, + deposit: u128, + ) -> Result { + let resolved = self.resolve_payment()?; + payment::session::open(self.config.rpc_http_client(), &resolved, deposit).await + } + + /// Adds `additional_deposit` base units to an open MPP channel. Moves real + /// funds; single-attempt. Scoped by the configured pay network and asset. + #[cfg(feature = "payments-tempo")] + pub async fn mpp_top_up( + &self, + channel: &payment::session::ChannelState, + additional_deposit: u128, + ) -> Result { + let resolved = self.resolve_payment()?; + payment::session::top_up( + self.config.rpc_http_client(), + &resolved, + channel, + additional_deposit, + ) + .await + } + + /// Cooperatively closes an MPP channel: settles the final cumulative spend + /// on-chain and refunds the unused deposit. Single-attempt. Scoped by the + /// configured pay network and asset. + #[cfg(feature = "payments-tempo")] + pub async fn mpp_close( + &self, + channel: &payment::session::ChannelState, + ) -> Result<(), SdkError> { + let resolved = self.resolve_payment()?; + payment::session::close(self.config.rpc_http_client(), &resolved, channel).await + } + + /// Fetches the gateway's view of the channel (accepted cumulative + spent). + /// + /// **This costs one request unit.** The gateway prices every session POST as + /// a chargeable request and computes the available balance from the *new* + /// spend a voucher authorizes, so the probe advances `cumulative_spent` by + /// `per_call` exactly like a session RPC call. The caller must persist the + /// returned state on success. Returns [`SdkError::PaymentUnsupported`] + /// before any network I/O when the channel has no room left for the probe. + /// + /// Scoped by the configured pay network and asset; takes no query network. + #[cfg(feature = "payments-tempo")] + pub async fn mpp_status( + &self, + channel: &payment::session::ChannelState, + ) -> Result { + let resolved = self.resolve_payment()?; + payment::session::status(self.config.rpc_http_client(), &resolved, channel).await + } + + /// Makes one MPP session-lane JSON-RPC call, authorizing it with a + /// cumulative voucher for `new_cumulative` (the running total after this + /// call). Returns the unwrapped JSON-RPC `result`. Single-attempt; the caller + /// advances the persisted `cumulative_spent` after a success. + #[cfg(feature = "payments-tempo")] + pub async fn mpp_session_call( + &self, + method: &str, + params: Option, + network: &str, + channel: &payment::session::ChannelState, + new_cumulative: u128, + ) -> Result { + let resolved = self.resolve_payment()?; + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params.unwrap_or_else(|| Value::Array(vec![])), + }); + let text = payment::session::voucher_call( + self.config.rpc_http_client(), + &resolved, + network, + channel, + new_cumulative, + &body, + ) + .await?; + Self::parse_rpc(RawResponse { status: 200, text }) + } + + // Best-effort tooling-endpoint lookup for the pay-chain's Solana network. + // Returns None (skip to the public default) when no map / no matching key — + // never an error. The seeded map is itself the effective API-key gate: it's + // built from `admin.get_endpoint_urls`, which a keyless SDK cannot call, so + // a keyless instance never has a map here and falls through to the public + // default exactly as the precedence requires. + #[cfg(feature = "payments-svm")] + fn tooling_svm_url(&self, pay_network: &str) -> Option { + // Map the CAIP-2 solana cluster to its tooling network key. Devnet is + // identified by its genesis-hash prefix (the literal "devnet" never + // appears in a CAIP-2 id — see payment::solana_pay_network_is_devnet). + let key = if payment::solana_pay_network_is_devnet(pay_network) { + "solana-devnet" + } else { + "solana-mainnet" + }; + let guard = self.networks.lock().ok()?; + guard.as_ref()?.get(key).cloned() + } + // Resolve the target URL for a call. `None` network -> the token's default // endpoint_url. `Some(key)` -> the mapped per-network URL; errors if no map // is seeded or the key is unknown (listing available keys). @@ -395,6 +779,7 @@ mod tests { }), refresh_margin_secs: None, networks: None, + payment: None, }); QuicknodeSdk::new(&cfg).unwrap() } @@ -463,6 +848,7 @@ mod tests { seed: None, refresh_margin_secs: None, networks: None, + payment: None, }); QuicknodeSdk::new(&cfg).unwrap() } @@ -671,6 +1057,7 @@ mod tests { }), refresh_margin_secs: None, networks: None, + payment: None, }); let sdk = QuicknodeSdk::new(&cfg).unwrap(); @@ -715,6 +1102,7 @@ mod tests { }), refresh_margin_secs: None, networks: Some(networks), + payment: None, }); let sdk = QuicknodeSdk::new(&cfg).unwrap(); @@ -764,3 +1152,172 @@ mod tests { assert!(matches!(err, SdkError::Config(msg) if msg.contains("no network map"))); } } + +#[cfg(all(test, feature = "payments"))] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod payment_lane_tests { + use super::*; + use crate::config::{PaymentConfig, SdkFullConfig}; + use crate::QuicknodeSdk; + use std::sync::atomic::{AtomicUsize, Ordering}; + use wiremock::matchers::method; + use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + + const EVM_KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + const USDC: &str = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + + // A keyless SDK whose RPC client carries an x402/EVM payment lane pointed at + // the given mock gateway base. + fn keyless_x402_sdk(gateway_base: &str) -> QuicknodeSdk { + let mut cfg = SdkFullConfig::keyless(); + cfg.rpc = Some(RpcConfig { + endpoint_url: None, + seed: None, + refresh_margin_secs: None, + networks: None, + payment: Some(PaymentConfig { + scheme: "x402".into(), + key: EVM_KEY.into(), + pay_network: "eip155:84532".into(), + asset: USDC.into(), + max_amount: "10000".into(), + svm_rpc_url: None, + base_url_override: Some(gateway_base.to_string()), + }), + }); + QuicknodeSdk::new(&cfg).unwrap() + } + + // Mock gateway: unpaid POST -> 402 menu; paid POST (has PAYMENT-SIGNATURE) + // -> 200 result. + async fn mount_x402_gateway(server: &MockServer) { + struct Seq { + calls: AtomicUsize, + } + impl Respond for Seq { + fn respond(&self, req: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 && !req.headers.contains_key("payment-signature") { + ResponseTemplate::new(402).set_body_json(serde_json::json!({ + "x402Version": 2, + "accepts": [{ + "scheme": "exact", "network": "eip155:84532", + "amount": "1000", "payTo": "0x000000000000000000000000000000000000dEaD", + "maxTimeoutSeconds": 60, "asset": USDC, + "extra": { "name": "USDC", "version": "2" } + }] + })) + } else { + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1335f9a" + })) + } + } + } + Mock::given(method("POST")) + .respond_with(Seq { + calls: AtomicUsize::new(0), + }) + .mount(server) + .await; + } + + #[tokio::test] + async fn keyless_payment_call_returns_unwrapped_result() { + let server = MockServer::start().await; + mount_x402_gateway(&server).await; + let sdk = keyless_x402_sdk(&server.uri()); + let result = sdk + .rpc + .call( + "eth_blockNumber", + None, + Some("base-sepolia".to_string()), + None, + ) + .await + .unwrap(); + assert_eq!(result, serde_json::json!("0x1335f9a")); + } + + #[tokio::test] + async fn x402_call_with_receipt_has_no_receipt() { + let server = MockServer::start().await; + mount_x402_gateway(&server).await; + let sdk = keyless_x402_sdk(&server.uri()); + let resp = sdk + .rpc + .call_with_receipt( + "eth_blockNumber", + None, + Some("base-sepolia".to_string()), + None, + ) + .await + .unwrap(); + assert_eq!(resp.result, serde_json::json!("0x1335f9a")); + assert!(resp.payment_receipt.is_none()); + } + + #[tokio::test] + async fn payment_lane_requires_network() { + let server = MockServer::start().await; + let sdk = keyless_x402_sdk(&server.uri()); + let err = sdk + .rpc + .call("eth_blockNumber", None, None, None) + .await + .unwrap_err(); + assert!(matches!(err, SdkError::Config(m) if m.contains("requires `network`"))); + } + + #[tokio::test] + async fn per_call_endpoint_url_plus_payment_is_config_error() { + let server = MockServer::start().await; + let sdk = keyless_x402_sdk(&server.uri()); + let err = sdk + .rpc + .call( + "eth_blockNumber", + None, + None, + Some("https://example.invalid/rpc".to_string()), + ) + .await + .unwrap_err(); + assert!(matches!(err, SdkError::Config(m) if m.contains("mutually exclusive"))); + } + + #[tokio::test] + async fn bad_max_amount_is_config_error_at_call() { + let server = MockServer::start().await; + let mut cfg = SdkFullConfig::keyless(); + cfg.rpc = Some(RpcConfig { + endpoint_url: None, + seed: None, + refresh_margin_secs: None, + networks: None, + payment: Some(PaymentConfig { + scheme: "x402".into(), + key: EVM_KEY.into(), + pay_network: "eip155:84532".into(), + asset: USDC.into(), + max_amount: "not-a-number".into(), + svm_rpc_url: None, + base_url_override: Some(server.uri()), + }), + }); + let sdk = QuicknodeSdk::new(&cfg).unwrap(); + let err = sdk + .rpc + .call( + "eth_blockNumber", + None, + Some("base-sepolia".to_string()), + None, + ) + .await + .unwrap_err(); + assert!(matches!(err, SdkError::Config(m) if m.contains("max_amount"))); + } +} diff --git a/crates/core/src/rpc/payment/drawdown.rs b/crates/core/src/rpc/payment/drawdown.rs new file mode 100644 index 0000000..4ec3006 --- /dev/null +++ b/crates/core/src/rpc/payment/drawdown.rs @@ -0,0 +1,905 @@ +//! x402 credit drawdown lane for `rpc.call`. +//! +//! Distinct from the per-request 402 loop in the parent module: instead of +//! signing a fresh settlement per call, the caller authenticates once with a +//! SIWX (Sign-In-With-X) message and receives a session JWT. Each drawdown call +//! then presents `Authorization: Bearer ` and draws 1 credit per successful +//! response — no per-call signing. +//! +//! The flow: +//! 1. [`authenticate`] — build a SIWE (EIP-4361) message, sign it with the +//! payment key, POST `/auth`, and cache the returned [`GatewaySession`]. +//! 2. [`drawdown_call`] — POST `/:network` with the Bearer JWT; returns the raw +//! JSON-RPC envelope text. +//! 3. [`credits`] — GET `/credits` with the Bearer JWT → the current balance. +//! 4. [`drip`] — POST `/drip` (testnet faucet, once per account) — funds the +//! wallet, not the credit ledger. +//! +//! [`buy_credits`] settles a credit block by signing the gateway's credit-tier +//! offer. It is reachable only where that offer's construction is signable; see +//! [`super::authorize_x402_credit`]. +//! +//! State (the JWT) is held by the caller: the SDK is stateless here, so a host +//! persists [`GatewaySession`] between runs exactly as it does the tooling +//! [`crate::config::CachedToken`]. + +use serde::Deserialize; +use serde_json::Value; + +use crate::admin::tooling_access::parse_rfc3339_to_unix; +use crate::errors::SdkError; + +use super::{now_unix, random_nonce, ResolvedPayment}; + +/// A gateway session JWT plus its expiry and the account it authenticates. +/// This is the unit the drawdown lane caches; a host (the CLI) persists it +/// between processes and re-seeds it next run, the same pattern as +/// [`crate::config::CachedToken`]. +/// +/// `token` is a live bearer credential and is redacted in `Debug`. +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct GatewaySession { + /// The session JWT, presented as `Authorization: Bearer `. + pub token: String, + /// JWT expiry in unix seconds (from the gateway's `expiresAt`). + pub exp_unix: i64, + /// The CAIP-10 account the JWT authenticates (the payer's address on the + /// pay chain). Used as the cache key so distinct wallets don't collide. + pub account_id: String, +} + +// Never print the live JWT. +impl std::fmt::Debug for GatewaySession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GatewaySession") + .field("token", &"[redacted]") + .field("exp_unix", &self.exp_unix) + .field("account_id", &self.account_id) + .finish() + } +} + +impl GatewaySession { + /// Whether the session is still valid `margin_secs` before its expiry. + /// A caller re-authenticates when this is false. + pub fn is_fresh(&self, margin_secs: i64) -> bool { + now_unix() as i64 + margin_secs < self.exp_unix + } +} + +/// The gateway `/auth` response. +#[derive(Deserialize)] +struct AuthResponse { + token: String, + #[serde(rename = "expiresAt")] + expires_at: String, + #[serde(rename = "accountId")] + account_id: String, +} + +/// The gateway `/credits` response. +#[derive(Deserialize)] +struct CreditsResponse { + #[serde(rename = "accountId")] + account_id: String, + credits: u64, +} + +/// The current credit balance for an account. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreditBalance { + pub account_id: String, + pub credits: u64, +} + +// The /auth endpoint requires this statement verbatim. +const SIWX_STATEMENT: &str = + "I accept the Quicknode Terms of Service: https://www.quicknode.com/terms"; + +/// Authenticates against the x402 gateway with a SIWE (EIP-4361) message and +/// returns a cached [`GatewaySession`]. Free — no funds move — so a caller may +/// (re)auth transparently on a missing/expired session without user consent. +/// +/// EVM signers only (SIWE). An SVM signer errors — SIWS is a separate +/// construction. +pub async fn authenticate( + client: &reqwest::Client, + payment: &ResolvedPayment, +) -> Result { + let base = super::PaymentScheme::X402.host_base(payment.base_url_override.as_deref()); + // SIWE compares the recovered address case-sensitively. + let address = to_checksum_address(&payment.signer.address()?); + // SIWE requires the decimal EIP-155 id, not the CAIP-2 string. + let chain_id = eip155_chain_id(&payment.pay_network)?; + + // Build the gateway's fixed SIWE message with a fresh nonce and timestamp. + let host = host_only(base); + let nonce = hex::encode(&random_nonce()[..8]); + let issued_at = rfc3339_now(); + let message = siwe_message( + &host, + &address, + chain_id, + &nonce, + &issued_at, + SIWX_STATEMENT, + ); + let signature = payment.signer.sign_siwe(&message)?; + + let url = format!("{}/auth", base.trim_end_matches('/')); + let resp = client + .post(&url) + .json(&serde_json::json!({ + "type": "siwx", + "message": message, + "signature": signature, + })) + .send() + .await + .map_err(SdkError::Http)?; + + let status = resp.status(); + let body = resp.text().await.map_err(SdkError::Http)?; + if !status.is_success() { + return Err(SdkError::Api { status, body }); + } + let parsed: AuthResponse = + serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })?; + let exp_unix = parse_rfc3339_to_unix(&parsed.expires_at)?; + Ok(GatewaySession { + token: parsed.token, + exp_unix, + account_id: parsed.account_id, + }) +} + +/// Makes one drawdown JSON-RPC call against `/:query_network` with the session +/// JWT as a Bearer token, drawing 1 credit on success. Returns the raw +/// JSON-RPC envelope text for the caller to parse. +/// +/// Never retries on its own: the caller decides, and a paid lane never +/// blind-retries. A 401/403 surfaces as [`SdkError::Api`] so the caller can +/// map `token_expired` → re-auth and `monthly_limit_reached` → an actionable +/// error. +pub async fn drawdown_call( + client: &reqwest::Client, + payment: &ResolvedPayment, + session: &GatewaySession, + query_network: &str, + body: &Value, +) -> Result { + let base = super::PaymentScheme::X402.host_base(payment.base_url_override.as_deref()); + let url = format!("{}/{}", base.trim_end_matches('/'), query_network); + let resp = client + .post(&url) + .bearer_auth(&session.token) + .json(body) + .send() + .await + .map_err(SdkError::Http)?; + let status = resp.status(); + let text = resp.text().await.map_err(SdkError::Http)?; + if !status.is_success() { + return Err(SdkError::Api { status, body: text }); + } + Ok(text) +} + +/// Fetches the account's current credit balance (GET `/credits`, Bearer JWT). +pub async fn credits( + client: &reqwest::Client, + payment: &ResolvedPayment, + session: &GatewaySession, +) -> Result { + let base = super::PaymentScheme::X402.host_base(payment.base_url_override.as_deref()); + let url = format!("{}/credits", base.trim_end_matches('/')); + let resp = client + .get(&url) + .bearer_auth(&session.token) + .send() + .await + .map_err(SdkError::Http)?; + let status = resp.status(); + let body = resp.text().await.map_err(SdkError::Http)?; + if !status.is_success() { + return Err(SdkError::Api { status, body }); + } + let parsed: CreditsResponse = + serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })?; + Ok(CreditBalance { + account_id: parsed.account_id, + credits: parsed.credits, + }) +} + +/// The faucet drip result: the on-chain funding transaction. The gateway's +/// `/drip` returns the settlement tx, not a credit balance — call [`credits`] +/// afterwards to read the updated balance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DripReceipt { + pub account_id: String, + /// The faucet funding transaction hash. + pub transaction_hash: String, +} + +/// Requests testnet tokens from the faucet (POST `/drip`, Bearer JWT). The +/// gateway allows this once per account on Base Sepolia and returns the funding +/// transaction (NOT a balance). +pub async fn drip( + client: &reqwest::Client, + payment: &ResolvedPayment, + session: &GatewaySession, +) -> Result { + let base = super::PaymentScheme::X402.host_base(payment.base_url_override.as_deref()); + let url = format!("{}/drip", base.trim_end_matches('/')); + let resp = client + .post(&url) + .bearer_auth(&session.token) + .send() + .await + .map_err(SdkError::Http)?; + let status = resp.status(); + let body = resp.text().await.map_err(SdkError::Http)?; + if !status.is_success() { + return Err(SdkError::Api { status, body }); + } + #[derive(Deserialize)] + struct DripBody { + #[serde(rename = "accountId")] + account_id: String, + #[serde(rename = "transactionHash")] + transaction_hash: String, + } + let parsed: DripBody = + serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })?; + Ok(DripReceipt { + account_id: parsed.account_id, + transaction_hash: parsed.transaction_hash, + }) +} + +/// Buys a block of credits: POST `/credits` with the Bearer JWT, settle the +/// `402` credit offer with the SAME x402 signer construction as the +/// per-request lane, and resend exactly once. Returns the balance after the +/// purchase settles. +/// +/// The amount is chosen by the gateway's offer (bounded by `payment.max_amount` +/// like every signed payment); the caller does not name it. A second 402 is a +/// terminal [`SdkError::PaymentRejected`]; a lost response after the paid +/// resend is [`SdkError::PaymentIndeterminate`] — never blind-retry. +pub async fn buy_credits( + client: &reqwest::Client, + payment: &ResolvedPayment, + session: &GatewaySession, + query_network: &str, +) -> Result { + use crate::errors::HttpKind; + + // Credit purchases use a network-scoped RPC request; the balance is read + // separately from GET /credits. + let base = super::PaymentScheme::X402.host_base(payment.base_url_override.as_deref()); + let url = format!("{}/{}", base.trim_end_matches('/'), query_network); + let rpc_body = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "eth_chainId", "params": [] + }); + + // Probe the offer. A non-402 means no purchase is needed. + let first = client + .post(&url) + .bearer_auth(&session.token) + .json(&rpc_body) + .send() + .await + .map_err(SdkError::Http)?; + let status = first.status(); + if status.as_u16() != 402 { + if !status.is_success() { + let body = first.text().await.unwrap_or_default(); + return Err(SdkError::Api { status, body }); + } + return credits(client, payment, session).await; + } + + // Select and settle only the credit tier; never fall back to per-request. + let challenge_body = first.text().await.map_err(SdkError::Http)?; + let authorized = super::authorize_x402_credit(client, payment, &challenge_body).await?; + let header = authorized + .x402_header() + .ok_or_else(|| SdkError::Config("credit purchase produced no x402 credential".into()))?; + + // Resend once. A lost response makes the purchase indeterminate. + let paid = match client + .post(&url) + .bearer_auth(&session.token) + .header("PAYMENT-SIGNATURE", header) + .json(&rpc_body) + .send() + .await + { + Ok(resp) => resp, + Err(e) => { + let err = SdkError::Http(e); + return Err(match err.http_kind() { + Some(HttpKind::Connect) => err, + _ => SdkError::PaymentIndeterminate, + }); + } + }; + let paid_status = paid.status().as_u16(); + if !(200..300).contains(&paid_status) { + let body = paid.text().await.unwrap_or_default(); + return Err(SdkError::PaymentRejected { + status: paid_status, + body, + }); + } + // Drain the response, then read the funded balance. + let _ = paid.text().await; + credits(client, payment, session).await +} + +// ── SIWE message construction ──────────────────────────────────────────────── + +/// Build a Sign-In-With-Ethereum (EIP-4361) message. The gateway pins the +/// domain/uri to its own host and requires the ToS `statement`; `chain_id` is +/// the caller's CAIP-2 pay network. Deterministic given its inputs so the +/// message tests are byte-exact. +pub(super) fn siwe_message( + host: &str, + address: &str, + chain_id: u64, + nonce: &str, + issued_at: &str, + statement: &str, +) -> String { + // EIP-4361 field order and the decimal chain id are fixed. + format!( + "{host} wants you to sign in with your Ethereum account:\n\ + {address}\n\ + \n\ + {statement}\n\ + \n\ + URI: https://{host}\n\ + Version: 1\n\ + Chain ID: {chain_id}\n\ + Nonce: {nonce}\n\ + Issued At: {issued_at}" + ) +} + +// Apply the EIP-55 checksum required by SIWE. +fn to_checksum_address(addr: &str) -> String { + use sha3::{Digest, Keccak256}; + let lower = addr.strip_prefix("0x").unwrap_or(addr).to_lowercase(); + let hash = Keccak256::digest(lower.as_bytes()); + let mut out = String::with_capacity(42); + out.push_str("0x"); + for (i, c) in lower.chars().enumerate() { + if c.is_ascii_digit() { + out.push(c); + } else { + // nibble i of the hash: high nibble for even i, low for odd. + let nibble = if i % 2 == 0 { + hash[i / 2] >> 4 + } else { + hash[i / 2] & 0x0f + }; + if nibble >= 8 { + out.push(c.to_ascii_uppercase()); + } else { + out.push(c); + } + } + } + out +} + +// Parse the EIP-155 id required by SIWE. +fn eip155_chain_id(pay_network: &str) -> Result { + pay_network + .strip_prefix("eip155:") + .and_then(|s| s.parse().ok()) + .ok_or_else(|| { + SdkError::Config(format!( + "x402 drawdown requires an eip155 pay network (e.g. eip155:84532), got {pay_network:?}" + )) + }) +} + +// Return the gateway host used by SIWE domain and URI fields. +fn host_only(base: &str) -> String { + base.trim_end_matches('/') + .trim_start_matches("https://") + .trim_start_matches("http://") + .to_string() +} + +// Build an RFC-3339 UTC timestamp without adding a date dependency. +fn rfc3339_now() -> String { + let secs = now_unix() as i64; + let days = secs.div_euclid(86_400); + let rem = secs.rem_euclid(86_400); + let (hour, min, sec) = (rem / 3600, (rem % 3600) / 60, rem % 60); + let (year, month, day) = civil_from_days(days); + // SIWE expects millisecond precision. + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}.000Z") +} + +// Days-since-epoch → (year, month, day), Howard Hinnant's civil_from_days. +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use secrecy::SecretString; + use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; + use wiremock::matchers::{body_partial_json, header, method, path}; + use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + + // anvil key #0 (public throwaway, never funded). + const EVM_KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + const EVM_ADDR: &str = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"; + const USDC: &str = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + + fn evm_payment(base: &str) -> ResolvedPayment { + ResolvedPayment { + scheme: super::super::PaymentScheme::X402, + signer: super::super::signer::Signer::Evm(SecretString::new(EVM_KEY.to_string())), + pay_network: "eip155:84532".into(), + asset: USDC.into(), + max_amount: 10_000_000, + base_url_override: Some(base.to_string()), + svm_rpc_url: None, + } + } + + fn x402_credit_offer(amount: &str) -> Value { + json!({ + "x402Version": 2, + "accepts": [{ + "scheme": "exact", + "network": "eip155:84532", + "amount": amount, + "payTo": "0x000000000000000000000000000000000000dEaD", + "maxTimeoutSeconds": 60, + "asset": USDC, + "extra": { "name": "USDC", "version": "2" } + }] + }) + } + + #[test] + fn siwe_message_is_byte_exact() { + let msg = siwe_message( + "x402.quicknode.com", + EVM_ADDR, + 84532, + "abc12345", + "2026-07-17T12:00:00Z", + SIWX_STATEMENT, + ); + let expected = "x402.quicknode.com wants you to sign in with your Ethereum account:\n\ + 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266\n\ + \n\ + I accept the Quicknode Terms of Service: https://www.quicknode.com/terms\n\ + \n\ + URI: https://x402.quicknode.com\n\ + Version: 1\n\ + Chain ID: 84532\n\ + Nonce: abc12345\n\ + Issued At: 2026-07-17T12:00:00Z"; + assert_eq!(msg, expected); + } + + // Verify the generated timestamp uses the gateway's millisecond format. + #[test] + fn issued_at_carries_millisecond_precision() { + let iso = rfc3339_now(); + assert!(iso.ends_with(".000Z"), "issued_at was {iso}"); + assert_eq!( + iso.len(), + 24, + "expected YYYY-MM-DDTHH:MM:SS.000Z, got {iso}" + ); + + let msg = siwe_message( + "x402.quicknode.com", + EVM_ADDR, + 84532, + "abc12345", + &iso, + SIWX_STATEMENT, + ); + assert!(msg.ends_with(&format!("Issued At: {iso}"))); + } + + #[test] + fn rfc3339_now_round_trips_through_the_parser() { + // Generated timestamps must round-trip through the parser. + let iso = rfc3339_now(); + let back = parse_rfc3339_to_unix(&iso).unwrap(); + let now = now_unix() as i64; + assert!((now - back).abs() <= 1, "iso={iso} back={back} now={now}"); + } + + #[test] + fn checksum_address_matches_eip55() { + // Known-good EIP-55 checksum. + assert_eq!( + to_checksum_address("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"), + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + ); + // Idempotent on already-checksummed input. + assert_eq!( + to_checksum_address("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"), + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + ); + } + + #[test] + fn host_only_strips_scheme_and_slash() { + assert_eq!( + host_only("https://x402.quicknode.com/"), + "x402.quicknode.com" + ); + assert_eq!(host_only("http://127.0.0.1:8080"), "127.0.0.1:8080"); + } + + #[test] + fn session_freshness() { + let s = GatewaySession { + token: "t".into(), + exp_unix: now_unix() as i64 + 3600, + account_id: "a".into(), + }; + assert!(s.is_fresh(60)); + let stale = GatewaySession { + token: "t".into(), + exp_unix: now_unix() as i64 + 10, + account_id: "a".into(), + }; + assert!(!stale.is_fresh(60)); + } + + #[test] + fn session_debug_redacts_the_jwt() { + let s = GatewaySession { + token: "super-secret-jwt".into(), + exp_unix: 0, + account_id: "a".into(), + }; + let rendered = format!("{s:?}"); + assert!(rendered.contains("[redacted]")); + assert!(!rendered.contains("super-secret-jwt")); + } + + #[tokio::test] + async fn authenticate_posts_siwx_and_caches_session() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/auth")) + .and(body_partial_json(json!({ "type": "siwx" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "token": "jwt-abc", + "expiresAt": "2099-01-01T00:00:00Z", + "accountId": "eip155:84532:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" + }))) + .expect(1) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri()); + let client = reqwest::Client::new(); + let session = authenticate(&client, &payment).await.unwrap(); + assert_eq!(session.token, "jwt-abc"); + assert!(session.account_id.contains("0xf39fd6e5")); + assert!(session.is_fresh(60)); + } + + #[tokio::test] + async fn authenticate_error_surfaces_as_api() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/auth")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": "invalid_signature", "message": "bad SIWX signature" + }))) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri()); + let client = reqwest::Client::new(); + let err = authenticate(&client, &payment).await.unwrap_err(); + assert!(matches!(err, SdkError::Api { status, .. } if status == 401)); + } + + #[tokio::test] + async fn drawdown_call_attaches_bearer_and_returns_envelope() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .and(header("authorization", "Bearer jwt-abc")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1335f9a" + }))) + .expect(1) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri()); + let session = GatewaySession { + token: "jwt-abc".into(), + exp_unix: now_unix() as i64 + 3600, + account_id: "a".into(), + }; + let body = json!({ "jsonrpc": "2.0", "id": 1, "method": "eth_blockNumber", "params": [] }); + let client = reqwest::Client::new(); + let text = drawdown_call(&client, &payment, &session, "base-sepolia", &body) + .await + .unwrap(); + assert!(text.contains("0x1335f9a")); + } + + #[tokio::test] + async fn drawdown_call_403_monthly_limit_is_api_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ResponseTemplate::new(403).set_body_json(json!({ + "error": "monthly_limit_reached", "message": "monthly credit limit reached" + }))) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri()); + let session = GatewaySession { + token: "jwt-abc".into(), + exp_unix: now_unix() as i64 + 3600, + account_id: "a".into(), + }; + let body = json!({ "jsonrpc": "2.0", "id": 1, "method": "eth_blockNumber", "params": [] }); + let client = reqwest::Client::new(); + let err = drawdown_call(&client, &payment, &session, "base-sepolia", &body) + .await + .unwrap_err(); + assert!( + matches!(&err, SdkError::Api { status, body } if *status == 403 && body.contains("monthly_limit_reached")) + ); + } + + #[tokio::test] + async fn credits_reads_the_balance() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/credits")) + .and(header("authorization", "Bearer jwt-abc")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "accountId": "eip155:84532:0xabc", "credits": 1_000_095u64 + }))) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri()); + let session = GatewaySession { + token: "jwt-abc".into(), + exp_unix: now_unix() as i64 + 3600, + account_id: "a".into(), + }; + let client = reqwest::Client::new(); + let bal = credits(&client, &payment, &session).await.unwrap(); + assert_eq!(bal.credits, 1_000_095); + } + + #[tokio::test] + async fn drip_returns_the_funding_transaction() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/drip")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "accountId": "eip155:84532:0xabc", + "walletAddress": "0xabc", + "transactionHash": "0xfeed" + }))) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri()); + let session = GatewaySession { + token: "jwt-abc".into(), + exp_unix: now_unix() as i64 + 3600, + account_id: "a".into(), + }; + let client = reqwest::Client::new(); + let receipt = drip(&client, &payment, &session).await.unwrap(); + assert_eq!(receipt.transaction_hash, "0xfeed"); + assert_eq!(receipt.account_id, "eip155:84532:0xabc"); + } + + // Menu with credit, per-request, and nanopayment tiers. + fn gateway_menu() -> Value { + let mut nanopayment = x402_credit_offer("100") + .pointer("/accepts/0") + .cloned() + .unwrap(); + nanopayment["maxTimeoutSeconds"] = json!(604_900); + nanopayment["extra"] = json!({ + "name": "GatewayWalletBatched", + "version": "1", + "verifyingContract": "0x0077777d7EBA4688BDeF3E311b846F25870A19B9" + }); + json!({ + "x402Version": 2, + "accepts": [ + x402_credit_offer("1000000").pointer("/accepts/0").cloned().unwrap(), + x402_credit_offer("1000").pointer("/accepts/0").cloned().unwrap(), + nanopayment, + ] + }) + } + + // Select and settle the regular credit tier; do not fall back to the + // cheaper per-request or nanopayment tiers. + #[tokio::test] + async fn buy_credits_settles_the_regular_credit_tier() { + let server = MockServer::start().await; + struct BuySeq { + calls: AtomicUsize, + } + impl Respond for BuySeq { + fn respond(&self, req: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 && !req.headers.contains_key("payment-signature") { + ResponseTemplate::new(402).set_body_json(gateway_menu()) + } else { + use base64::Engine; + let header = req + .headers + .get("payment-signature") + .expect("paid resend must include a payment signature") + .to_str() + .unwrap(); + let envelope: Value = serde_json::from_slice( + &base64::engine::general_purpose::STANDARD + .decode(header) + .unwrap(), + ) + .unwrap(); + assert_eq!(envelope["accepted"]["amount"], "1000000"); + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1" + })) + } + } + } + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(BuySeq { + calls: AtomicUsize::new(0), + }) + .expect(2) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/credits")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "accountId": "eip155:84532:0xabc", "credits": 100_000u64 + }))) + .expect(1) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri()); + let session = GatewaySession { + token: "jwt-abc".into(), + exp_unix: now_unix() as i64 + 3600, + account_id: "a".into(), + }; + let client = reqwest::Client::new(); + let balance = buy_credits(&client, &payment, &session, "base-sepolia") + .await + .unwrap(); + assert_eq!(balance.credits, 100_000); + } + + #[tokio::test] + async fn buy_credits_does_not_downgrade_when_credit_tier_exceeds_ceiling() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ResponseTemplate::new(402).set_body_json(gateway_menu())) + .expect(1) + .mount(&server) + .await; + + let mut payment = evm_payment(&server.uri()); + payment.max_amount = 1_000; + let session = GatewaySession { + token: "jwt-abc".into(), + exp_unix: now_unix() as i64 + 3600, + account_id: "a".into(), + }; + let client = reqwest::Client::new(); + let err = buy_credits(&client, &payment, &session, "base-sepolia") + .await + .unwrap_err(); + assert!( + matches!(&err, SdkError::PaymentUnsupported { offered } + if offered.contains("above max_amount 1000")), + "unexpected error: {err:?}" + ); + } + + // No credit tier means refusal, not per-request fallback. + #[tokio::test] + async fn buy_credits_without_a_credit_offer_settles_nothing() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ResponseTemplate::new(402).set_body_json(x402_credit_offer("1000"))) + .expect(1) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri()); + let session = GatewaySession { + token: "jwt-abc".into(), + exp_unix: now_unix() as i64 + 3600, + account_id: "a".into(), + }; + let client = reqwest::Client::new(); + let err = buy_credits(&client, &payment, &session, "base-sepolia") + .await + .unwrap_err(); + assert!( + matches!(&err, SdkError::PaymentUnsupported { offered } + if offered.contains("no credit-drawdown offer")), + "unexpected error: {err:?}" + ); + } + + // A non-402 probe means no purchase is needed. + #[tokio::test] + async fn buy_credits_with_existing_credits_reads_the_balance() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1" + }))) + .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 + }))) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri()); + let session = GatewaySession { + token: "jwt-abc".into(), + exp_unix: now_unix() as i64 + 3600, + account_id: "a".into(), + }; + let client = reqwest::Client::new(); + let bal = buy_credits(&client, &payment, &session, "base-sepolia") + .await + .unwrap(); + assert_eq!(bal.credits, 42); + } +} diff --git a/crates/core/src/rpc/payment/mod.rs b/crates/core/src/rpc/payment/mod.rs new file mode 100644 index 0000000..2cc5392 --- /dev/null +++ b/crates/core/src/rpc/payment/mod.rs @@ -0,0 +1,1735 @@ +//! Crypto-micropayment lanes for `rpc.call`. +//! +//! A caller can pay per RPC request with a stablecoin instead of a provisioned +//! account + API key, against Quicknode's `x402.quicknode.com` and +//! `mpp.quicknode.com` gateways. [`pay_and_call`] runs the shared 402 loop; the +//! per-protocol differences (challenge parse, entry select, credential build, +//! receipt parse) live inline on [`PaymentScheme`]. +//! +//! The flow: POST the JSON-RPC body keyless → the gateway answers `402` with a +//! menu of payment options → select the entry matching the caller's selector +//! (skipping unsupported shapes and anything over `max_amount`) → sign a +//! credential → resend **exactly once** with the credential attached → `200`. +//! A second 402 is terminal ([`SdkError::PaymentRejected`]); a lost response +//! after the paid resend is [`SdkError::PaymentIndeterminate`]. + +pub mod drawdown; +#[cfg(feature = "payments-tempo")] +pub mod session; +pub mod signer; + +use serde::Deserialize; +use serde_json::Value; + +use crate::errors::{HttpKind, SdkError}; +use signer::Signer; + +#[cfg(feature = "payments-tempo")] +use signer::TempoChargeRequest; + +/// The payment protocol. `X402` (pay-per-request via `x402.quicknode.com`) +/// covers EVM + Solana; `MppCharge` (via `mpp.quicknode.com`) covers Tempo. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaymentScheme { + X402, + MppCharge, +} + +impl PaymentScheme { + /// The gateway host base for this scheme. `base_url_override` (tests / the + /// wiremock harness) wins when set. + pub fn host_base<'a>(&self, override_base: Option<&'a str>) -> &'a str + where + 'static: 'a, + { + override_base.unwrap_or(match self { + PaymentScheme::X402 => "https://x402.quicknode.com", + PaymentScheme::MppCharge => "https://mpp.quicknode.com", + }) + } +} + +/// A typed MPP settlement receipt (the caller's proof of payment). `reference` +/// is the settlement transaction hash. `None` for x402 and non-payment lanes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaymentReceipt { + pub method: String, + pub status: String, + pub timestamp: String, + /// Settlement transaction hash. + pub reference: String, +} + +/// The caller's payment selector + custody parameters, resolved from the +/// binding-facing `PaymentConfig` at the config boundary. Holds the live +/// `Signer` (which custodies the key in a `SecretString`). +pub struct ResolvedPayment { + pub scheme: PaymentScheme, + pub signer: Signer, + /// CAIP-2 pay network, e.g. `eip155:84532`, `solana:5eykt4…`, or a Tempo + /// chain selector. Used to match the offered menu entry. + pub pay_network: String, + /// Asset (token) address/mint to pay in — matches the menu entry's `asset`. + pub asset: String, + /// Spend ceiling in base units of `asset`. The selector skips any entry + /// above this and the driver refuses to sign one. + pub max_amount: u128, + /// Test-only gateway base override. + pub base_url_override: Option, + /// Resolved Solana RPC URL for x402/Solana payment-build reads (blockhash, + /// token program). `None` for EVM/Tempo. + pub svm_rpc_url: Option, +} + +impl std::fmt::Debug for ResolvedPayment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResolvedPayment") + .field("scheme", &self.scheme) + .field("signer", &self.signer) // renders [redacted] + .field("pay_network", &self.pay_network) + .field("asset", &self.asset) + .field("max_amount", &self.max_amount) + .finish() + } +} + +impl ResolvedPayment { + /// Convert the binding-facing plain-data `PaymentConfig` into the internal + /// resolved form. The signer variant is DERIVED — never stated by the + /// caller — from the scheme + pay_network CAIP-2 prefix: + /// MPP ⇒ Tempo; x402 + `eip155:` ⇒ Evm; x402 + `solana:` ⇒ Svm. A + /// `max_amount` that isn't an integer is a `Config` error at construction, + /// not at call time. + pub fn from_config(config: &crate::config::PaymentConfig) -> Result { + use secrecy::SecretString; + + let scheme = match config.scheme.as_str() { + "x402" => PaymentScheme::X402, + "mpp" | "mpp-charge" => PaymentScheme::MppCharge, + other => { + return Err(SdkError::Config(format!( + "unknown payment scheme {other:?} (expected \"x402\" or \"mpp\")" + ))) + } + }; + + let key = SecretString::new(config.key.clone()); + let signer = match scheme { + PaymentScheme::MppCharge => Signer::Tempo(key), + PaymentScheme::X402 => { + if config.pay_network.starts_with("eip155:") { + Signer::Evm(key) + } else if config.pay_network.starts_with("solana:") { + Signer::Svm(key) + } else { + return Err(SdkError::Config(format!( + "x402 pay_network must start with eip155: or solana:, got {:?}", + config.pay_network + ))); + } + } + }; + + let max_amount = config.max_amount.parse::().map_err(|_| { + SdkError::Config(format!( + "max_amount must be an integer in base units, got {:?}", + config.max_amount + )) + })?; + + // Prefer the explicit Solana RPC; the caller may replace this default + // with the tooling endpoint in RpcApiClient. + let svm_rpc_url = if matches!(signer.kind(), signer::ChainKind::Svm) { + Some( + config + .svm_rpc_url + .clone() + .unwrap_or_else(|| default_solana_rpc(&config.pay_network).to_string()), + ) + } else { + None + }; + + Ok(ResolvedPayment { + scheme, + signer, + pay_network: config.pay_network.clone(), + asset: config.asset.clone(), + max_amount, + base_url_override: config.base_url_override.clone(), + svm_rpc_url, + }) + } +} + +// CAIP-2 Solana ids use the genesis-hash prefix, not a "devnet" label. +pub(crate) fn solana_pay_network_is_devnet(pay_network: &str) -> bool { + pay_network.contains("EtWTRABZaYq6iMfeYKouRu166VU2xqa1") +} + +// Public fallback RPC for the selected Solana cluster. +fn default_solana_rpc(pay_network: &str) -> &'static str { + if solana_pay_network_is_devnet(pay_network) { + "https://api.devnet.solana.com" + } else { + "https://api.mainnet-beta.solana.com" + } +} + +// ── x402 challenge shapes (v2) ─────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct X402Body { + #[serde(rename = "x402Version")] + x402_version: u32, + accepts: Vec, +} + +// ── The 402 driver ─────────────────────────────────────────────────────────── + +/// Runs the payment handshake for one JSON-RPC call and returns the raw +/// JSON-RPC envelope text plus an optional settlement receipt. The caller +/// (`RpcApiClient`) parses the JSON-RPC envelope; this layer owns only the +/// 402 dance. +pub async fn pay_and_call( + client: &reqwest::Client, + payment: &ResolvedPayment, + query_network: &str, + body: &Value, +) -> Result<(String, Option), SdkError> { + let base = payment + .scheme + .host_base(payment.base_url_override.as_deref()); + let url = format!("{}/{}", base.trim_end_matches('/'), query_network); + + // Unpaid probe: transport errors are ordinary HTTP errors. + let first = client + .post(&url) + .json(body) + .send() + .await + .map_err(SdkError::Http)?; + let status = first.status().as_u16(); + + // Pass non-402 responses to the JSON-RPC parser unchanged. + if status != 402 { + let text = first.text().await.map_err(SdkError::Http)?; + return Ok((text, None)); + } + + // Parse the challenge and build a matching credential. + let www_authenticate = first + .headers() + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .map(String::from); + let challenge_body = first.text().await.map_err(SdkError::Http)?; + + let authorized = match payment.scheme { + PaymentScheme::X402 => authorize_x402(client, payment, &challenge_body).await?, + PaymentScheme::MppCharge => { + let header = www_authenticate.ok_or_else(|| SdkError::PaymentUnsupported { + offered: "MPP 402 without a WWW-Authenticate header".into(), + })?; + authorize_mpp(payment, &header)? + } + }; + + // Resend once. A lost response is indeterminate; a refused connection is + // safe to retry. + let mut req = client.post(&url).json(body); + req = match &authorized { + Authorized::X402 { header } => req.header("PAYMENT-SIGNATURE", header), + #[cfg(feature = "payments-tempo")] + Authorized::Mpp { credential } => { + req.header("Authorization", format!("Payment {credential}")) + } + }; + let paid = match req.send().await { + Ok(resp) => resp, + Err(e) => { + let err = SdkError::Http(e); + return Err(match err.http_kind() { + Some(HttpKind::Connect) => err, // TCP never established: safe to retry + _ => SdkError::PaymentIndeterminate, // Timeout/Other: bytes may have landed + }); + } + }; + let paid_status = paid.status().as_u16(); + + // Any non-2xx after payment is terminal and keeps the payment outcome + // visible to the caller. + if !(200..300).contains(&paid_status) { + let body = paid.text().await.unwrap_or_default(); + return Err(SdkError::PaymentRejected { + status: paid_status, + body: enrich_rejection(payment, body), + }); + } + + // Read the receipt before consuming the body. + let receipt = paid + .headers() + .get("payment-receipt") + .and_then(|v| v.to_str().ok()) + .and_then(parse_receipt); + + // Body-read failures after payment are indeterminate unless unconnected. + let text = match paid.text().await { + Ok(t) => t, + Err(e) => { + let err = SdkError::Http(e); + return Err(match err.http_kind() { + Some(HttpKind::Connect) => err, + _ => SdkError::PaymentIndeterminate, + }); + } + }; + Ok((text, receipt)) +} + +pub(super) enum Authorized { + X402 { + header: String, + }, + #[cfg(feature = "payments-tempo")] + Mpp { + credential: String, + }, +} + +impl Authorized { + /// The x402 `PAYMENT-SIGNATURE` header value, for reuse by the drawdown + /// credit-purchase path. `None` for non-x402 credentials. + pub(super) fn x402_header(&self) -> Option<&str> { + match self { + Authorized::X402 { header } => Some(header), + #[cfg(feature = "payments-tempo")] + Authorized::Mpp { .. } => None, + } + } +} + +// ── x402 authorize (EVM + Solana) ──────────────────────────────────────────── + +pub(super) async fn authorize_x402( + client: &reqwest::Client, + payment: &ResolvedPayment, + challenge_body: &str, +) -> Result { + // Before payment, an unreadable menu is an unsupported offer. + let parsed: X402Body = + serde_json::from_str(challenge_body).map_err(|source| SdkError::PaymentUnsupported { + offered: format!("an unparseable x402 challenge (invalid JSON: {source})"), + })?; + + authorize_x402_entry(client, payment, &parsed).await +} + +/// Like [`authorize_x402`], but selects the credit-drawdown offer rather than +/// the per-request one. The credit tier is the largest regular x402 offer in +/// the menu. `GatewayWalletBatched` is the separate Circle nanopayment tier, +/// not a credit offer, and requires a different funding model. +pub(super) async fn authorize_x402_credit( + client: &reqwest::Client, + payment: &ResolvedPayment, + challenge_body: &str, +) -> Result { + let parsed: X402Body = + serde_json::from_str(challenge_body).map_err(|source| SdkError::PaymentUnsupported { + offered: format!("an unparseable x402 challenge (invalid JSON: {source})"), + })?; + + let mut skipped = Vec::new(); + let (entry, largest_offer) = select_x402_credit_entry(payment, &parsed.accepts, &mut skipped); + let Some(entry) = entry else { + let offered = match largest_offer { + Some(amount) => format!( + "the credit-drawdown offer for {}/{} is {amount} base units, above \ + max_amount {}; raise max_amount to at least {amount}. Full menu: {}", + payment.pay_network, + payment.asset, + payment.max_amount, + describe_offered(&parsed.accepts, &skipped) + ), + None => format!( + "no credit-drawdown offer for {}/{}. {}", + payment.pay_network, + payment.asset, + describe_offered(&parsed.accepts, &skipped) + ), + }; + return Err(SdkError::PaymentUnsupported { offered }); + }; + + authorize_x402_selected(client, payment, &parsed.x402_version, &entry).await +} + +// Select the cheapest matching entry and authorize it. +async fn authorize_x402_entry( + client: &reqwest::Client, + payment: &ResolvedPayment, + parsed: &X402Body, +) -> Result { + let mut skipped: Vec = Vec::new(); + let chosen = select_x402_entry(payment, &parsed.accepts, &mut skipped); + let Some(entry) = chosen else { + // Lead with the setting the caller can change. + let offered = match cheapest_over_ceiling(payment, &parsed.accepts) { + Some(cheapest) => format!( + "every offer for {}/{} is above max_amount {}; the cheapest is \ + {cheapest} base units — raise max_amount to at least that. \ + Full menu: {}", + payment.pay_network, + payment.asset, + payment.max_amount, + describe_offered(&parsed.accepts, &skipped) + ), + None => describe_offered(&parsed.accepts, &skipped), + }; + return Err(SdkError::PaymentUnsupported { offered }); + }; + + authorize_x402_selected(client, payment, &parsed.x402_version, &entry).await +} + +async fn authorize_x402_selected( + client: &reqwest::Client, + payment: &ResolvedPayment, + x402_version: &u32, + entry: &Value, +) -> Result { + match payment.signer.kind() { + signer::ChainKind::Evm => authorize_x402_evm(payment, x402_version, entry), + signer::ChainKind::Svm => authorize_x402_svm(client, payment, x402_version, entry).await, + signer::ChainKind::Tempo => Err(SdkError::PaymentUnsupported { + offered: "a Tempo signer cannot pay an x402 challenge (use the MPP scheme)".into(), + }), + } +} + +// Circle nanopayments use a different EIP-712 domain than regular x402 offers. +const GATEWAY_BATCHED: &str = "GatewayWalletBatched"; + +// Select the cheapest supported integer amount for the requested network and +// asset. Record skipped entries for PaymentUnsupported. +fn select_x402_entry( + payment: &ResolvedPayment, + accepts: &[Value], + skipped: &mut Vec, +) -> Option { + let mut best: Option<(u128, &Value)> = None; + for entry in accepts { + let network = entry.get("network").and_then(Value::as_str).unwrap_or(""); + let asset = entry.get("asset").and_then(Value::as_str).unwrap_or(""); + if network != payment.pay_network || !asset.eq_ignore_ascii_case(&payment.asset) { + continue; + } + // Nanopayments use Circle Gateway funding, not the regular x402 signer. + if entry.pointer("/extra/name").and_then(Value::as_str) == Some(GATEWAY_BATCHED) { + skipped.push(format!( + "{network}/{asset}: {GATEWAY_BATCHED} (nanopayment)" + )); + continue; + } + // Amounts must be integer base-unit strings within the ceiling. + let amount_str = entry.get("amount").and_then(Value::as_str).unwrap_or(""); + match amount_str.parse::() { + Ok(amount) if amount <= payment.max_amount => { + if best.is_none_or(|(best_amount, _)| amount < best_amount) { + best = Some((amount, entry)); + } + } + Ok(amount) => skipped.push(format!( + "{network}/{asset}: amount {amount} exceeds max_amount {}", + payment.max_amount + )), + Err(_) => skipped.push(format!( + "{network}/{asset}: amount {amount_str:?} is not an integer" + )), + } + } + best.map(|(_, entry)| entry.clone()) +} + +// Select the largest regular tier for a credit purchase. A credit purchase +// must not silently downgrade to a per-request payment when its tier is too +// expensive, so the largest advertised tier is checked against the ceiling +// before any smaller tier can be selected. +fn select_x402_credit_entry( + payment: &ResolvedPayment, + accepts: &[Value], + skipped: &mut Vec, +) -> (Option, Option) { + let mut candidates: Vec<(u128, &Value)> = Vec::new(); + for entry in accepts { + let network = entry.get("network").and_then(Value::as_str).unwrap_or(""); + let asset = entry.get("asset").and_then(Value::as_str).unwrap_or(""); + if network != payment.pay_network || !asset.eq_ignore_ascii_case(&payment.asset) { + continue; + } + if entry.pointer("/extra/name").and_then(Value::as_str) == Some(GATEWAY_BATCHED) { + skipped.push(format!( + "{network}/{asset}: {GATEWAY_BATCHED} (nanopayment)" + )); + continue; + } + let amount_str = entry.get("amount").and_then(Value::as_str).unwrap_or(""); + match amount_str.parse::() { + Ok(amount) => candidates.push((amount, entry)), + Err(_) => skipped.push(format!( + "{network}/{asset}: amount {amount_str:?} is not an integer" + )), + } + } + + // A single regular offer is not enough to identify the credit tier. The + // gateway's tiered menu includes both credit and per-request offers. + if candidates.len() < 2 { + return (None, None); + } + + let Some((amount, entry)) = candidates.into_iter().max_by_key(|(amount, _)| *amount) else { + return (None, None); + }; + if amount > payment.max_amount { + skipped.push(format!( + "{}/{}: credit amount {amount} exceeds max_amount {}", + payment.pay_network, payment.asset, payment.max_amount + )); + return (None, Some(amount)); + } + (Some(entry.clone()), Some(amount)) +} + +fn authorize_x402_evm( + payment: &ResolvedPayment, + x402_version: &u32, + entry: &Value, +) -> Result { + let chain_id = caip2_evm_chain_id(&payment.pay_network)?; + let name = entry + .pointer("/extra/name") + .and_then(Value::as_str) + .ok_or_else(|| SdkError::Config("x402 EVM entry missing extra.name".into()))?; + let version = entry + .pointer("/extra/version") + .and_then(Value::as_str) + .ok_or_else(|| SdkError::Config("x402 EVM entry missing extra.version".into()))?; + let pay_to = entry + .get("payTo") + .and_then(Value::as_str) + .ok_or_else(|| SdkError::Config("x402 entry missing payTo".into()))?; + let amount = entry + .get("amount") + .and_then(Value::as_str) + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| SdkError::Config("x402 entry missing/invalid amount".into()))?; + let max_timeout = entry + .get("maxTimeoutSeconds") + .and_then(Value::as_u64) + .unwrap_or(60); + + let from = payment.signer.address()?; + let now = now_unix(); + let valid_before = now + max_timeout; + let nonce = random_nonce(); + + let domain = signer::Eip712Domain { + name: name.to_string(), + version: version.to_string(), + chain_id, + verifying_contract: payment.asset.clone(), + }; + let message = signer::TransferWithAuthorization { + from: from.clone(), + to: pay_to.to_string(), + value: amount, + valid_after: 0, + valid_before, + nonce, + }; + let sig = payment.signer.sign_eip712(&domain, &message)?; + + // x402 envelope: accepted entry plus signature and authorization. + let envelope = serde_json::json!({ + "x402Version": x402_version, + "accepted": entry, + "payload": { + "signature": format!("0x{}", hex::encode(sig)), + "authorization": { + "from": from, + "to": pay_to, + "value": amount.to_string(), + "validAfter": "0", + "validBefore": valid_before.to_string(), + "nonce": format!("0x{}", hex::encode(nonce)), + } + } + }); + // Do not turn serialization failure into an empty credential. + let header = base64_std(serde_json::to_vec(&envelope).map_err(|e| { + SdkError::Config(format!( + "could not serialize the x402 payment credential: {e}" + )) + })?); + Ok(Authorized::X402 { header }) +} + +#[cfg(feature = "payments-svm")] +async fn authorize_x402_svm( + client: &reqwest::Client, + payment: &ResolvedPayment, + x402_version: &u32, + entry: &Value, +) -> Result { + use signer::SvmTransferRequest; + + let pay_to = entry + .get("payTo") + .and_then(Value::as_str) + .ok_or_else(|| SdkError::Config("x402 Solana entry missing payTo".into()))?; + let fee_payer = entry + .pointer("/extra/feePayer") + .and_then(Value::as_str) + .ok_or_else(|| SdkError::Config("x402 Solana entry missing extra.feePayer".into()))?; + // The selector uses u128, but SPL TransferChecked uses u64. + let amount_str = entry + .get("amount") + .and_then(Value::as_str) + .ok_or_else(|| SdkError::Config("x402 Solana entry missing amount".into()))?; + let amount = amount_str + .parse::() + .ok() + .filter(|a| *a <= u128::from(u64::MAX)) + .and_then(|a| u64::try_from(a).ok()) + .ok_or_else(|| { + SdkError::Config(format!( + "x402 Solana amount {amount_str:?} is not a valid u64 base-unit integer" + )) + })?; + // Read the mint and blockhash from a plain Solana RPC; the gateway rejects + // these keyless sub-reads. + let rpc_url = payment + .svm_rpc_url + .as_deref() + .ok_or_else(|| SdkError::Config("x402/Solana requires a resolved Solana RPC URL".into()))?; + + // Read decimals and the token program from the mint. TransferChecked + // validates both on-chain. + let mint = fetch_mint_metadata(client, rpc_url, &payment.asset).await?; + let recent_blockhash = fetch_latest_blockhash(client, rpc_url).await?; + + // Preserve a seller memo when present; otherwise generate a nonce. + let memo = match entry.pointer("/extra/memo").and_then(Value::as_str) { + Some(seller_memo) => seller_memo.to_string(), + None => random_memo_nonce(), + }; + + let req = SvmTransferRequest { + mint: payment.asset.clone(), + pay_to: pay_to.to_string(), + fee_payer: fee_payer.to_string(), + amount, + decimals: mint.decimals, + recent_blockhash, + token_program: mint.token_program, + memo, + }; + let tx = payment.signer.sign_svm_transfer(&req)?; + + // x402 v2 requires the transaction inside a payload object. + let envelope = serde_json::json!({ + "x402Version": x402_version, + "accepted": entry, + "payload": { "transaction": base64_std(tx) }, + }); + // Do not turn serialization failure into an empty credential. + let header = base64_std(serde_json::to_vec(&envelope).map_err(|e| { + SdkError::Config(format!( + "could not serialize the x402 payment credential: {e}" + )) + })?); + Ok(Authorized::X402 { header }) +} + +#[cfg(feature = "payments-svm")] +async fn fetch_latest_blockhash( + client: &reqwest::Client, + rpc_url: &str, +) -> Result { + let body = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "getLatestBlockhash", + "params": [{ "commitment": "finalized" }] + }); + let resp = client + .post(rpc_url) + .json(&body) + .send() + .await + .map_err(SdkError::Http)?; + let text = resp.text().await.map_err(SdkError::Http)?; + // This read happens before signing, so malformed data is a config error. + let parsed: Value = serde_json::from_str(&text).map_err(|source| { + SdkError::Config(format!( + "could not parse the Solana RPC response as JSON: {source}" + )) + })?; + parsed + .pointer("/result/value/blockhash") + .and_then(Value::as_str) + .map(String::from) + .ok_or_else(|| { + SdkError::Config(format!("could not read blockhash from Solana RPC: {text}")) + }) +} + +/// A mint's decimals and the token program that owns it. +#[cfg(feature = "payments-svm")] +struct MintMetadata { + decimals: u8, + token_program: String, +} + +/// Reads a mint account to learn its decimals and owning token program. Both +/// matter for `TransferChecked`: the instruction re-checks decimals against the +/// mint on-chain, and SPL Token vs Token-2022 changes the program the +/// instruction must target. +#[cfg(feature = "payments-svm")] +async fn fetch_mint_metadata( + client: &reqwest::Client, + rpc_url: &str, + mint: &str, +) -> Result { + let body = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "getAccountInfo", + "params": [mint, { "encoding": "jsonParsed", "commitment": "finalized" }] + }); + let resp = client + .post(rpc_url) + .json(&body) + .send() + .await + .map_err(SdkError::Http)?; + let text = resp.text().await.map_err(SdkError::Http)?; + // This read happens before signing, so malformed data is a config error. + let parsed: Value = serde_json::from_str(&text).map_err(|source| { + SdkError::Config(format!( + "could not parse the Solana RPC response as JSON: {source}" + )) + })?; + let account = parsed.pointer("/result/value").ok_or_else(|| { + SdkError::Config(format!( + "Solana mint {mint} was not found (asset may be wrong for this network)" + )) + })?; + let token_program = account + .get("owner") + .and_then(Value::as_str) + .ok_or_else(|| { + SdkError::Config(format!("could not read the owning program of mint {mint}")) + })? + .to_string(); + let decimals = account + .pointer("/data/parsed/info/decimals") + .and_then(Value::as_u64) + .and_then(|d| u8::try_from(d).ok()) + .ok_or_else(|| SdkError::Config(format!("could not read decimals of mint {mint}")))?; + Ok(MintMetadata { + decimals, + token_program, + }) +} + +/// A 16-byte random nonce, hex-encoded, for the payment's memo. Randomness +/// comes from `rand::thread_rng` (the OS CSPRNG), matching the other nonce +/// generators in this module. +#[cfg(feature = "payments-svm")] +fn random_memo_nonce() -> String { + use rand::RngCore; + let mut nonce = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut nonce); + nonce.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(not(feature = "payments-svm"))] +async fn authorize_x402_svm( + _client: &reqwest::Client, + _payment: &ResolvedPayment, + _x402_version: &u32, + _entry: &Value, +) -> Result { + Err(SdkError::PaymentUnsupported { + offered: "x402/Solana requires the `payments-svm` feature".into(), + }) +} + +// ── MPP authorize (Tempo) ──────────────────────────────────────────────────── + +#[cfg(feature = "payments-tempo")] +fn authorize_mpp( + payment: &ResolvedPayment, + www_authenticate: &str, +) -> Result { + let challenges = parse_mpp_challenges(www_authenticate); + let target_chain = caip2_or_bare_chain_id(&payment.pay_network)?; + + // Select the Tempo challenge for this chain. + let mut skipped = Vec::new(); + for challenge in &challenges { + if challenge.method != "tempo" { + skipped.push(format!("method={}", challenge.method)); + continue; + } + let request = match decode_b64url_json(&challenge.request) { + Ok(v) => v, + Err(_) => continue, + }; + let chain_id = request + .pointer("/methodDetails/chainId") + .and_then(Value::as_u64); + if chain_id != Some(target_chain) { + skipped.push(format!("tempo chainId={chain_id:?}")); + continue; + } + return build_mpp_credential(payment, challenge, &request, target_chain); + } + Err(SdkError::PaymentUnsupported { + offered: format!("MPP challenges: [{}]", skipped.join(", ")), + }) +} + +#[cfg(not(feature = "payments-tempo"))] +fn authorize_mpp( + _payment: &ResolvedPayment, + _www_authenticate: &str, +) -> Result { + Err(SdkError::PaymentUnsupported { + offered: "MPP/Tempo requires the `payments-tempo` feature".into(), + }) +} + +#[cfg(feature = "payments-tempo")] +fn build_mpp_credential( + payment: &ResolvedPayment, + challenge: &MppChallenge, + request: &Value, + chain_id: u64, +) -> Result { + let recipient = request + .get("recipient") + .and_then(Value::as_str) + .ok_or_else(|| SdkError::Config("MPP request missing recipient".into()))?; + let currency = request + .get("currency") + .and_then(Value::as_str) + .ok_or_else(|| SdkError::Config("MPP request missing currency".into()))?; + let amount = request + .get("amount") + .and_then(Value::as_str) + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| SdkError::Config("MPP request missing/invalid amount".into()))?; + + if amount > payment.max_amount { + return Err(SdkError::PaymentUnsupported { + offered: format!( + "MPP amount {amount} exceeds max_amount {}", + payment.max_amount + ), + }); + } + + // Bound validBefore by both the local window and challenge expiry. + let expiry = parse_iso_unix(&challenge.expires).ok_or_else(|| { + SdkError::Config(format!( + "MPP challenge has an unparseable `expires` value: {}", + challenge.expires + )) + })?; + let valid_before = (now_unix() + 25).min(expiry); + + let req = TempoChargeRequest { + chain_id, + currency: currency.to_string(), + recipient: recipient.to_string(), + amount, + challenge_id: challenge.id.clone(), + realm: challenge.realm.clone(), + valid_before, + gas_limit: None, + max_fee_per_gas: None, + max_priority_fee_per_gas: None, + }; + let handoff = payment.signer.sign_tempo_tx(&req)?; + let sender = payment.signer.address()?; + + let credential_json = serde_json::json!({ + "challenge": { + "description": challenge.description, + "expires": challenge.expires, + "id": challenge.id, + "intent": challenge.intent, + "method": challenge.method, + "realm": challenge.realm, + "request": challenge.request, + }, + "payload": { "signature": format!("0x{}", hex::encode(handoff)), "type": "transaction" }, + "source": format!("did:pkh:eip155:{chain_id}:{sender}"), + }); + let credential = base64_url_nopad(serde_json::to_vec(&credential_json).map_err(|e| { + SdkError::Config(format!( + "could not serialize the MPP charge credential: {e}" + )) + })?); + Ok(Authorized::Mpp { credential }) +} + +// One parsed MPP `Payment` challenge from the WWW-Authenticate header. +#[cfg(feature = "payments-tempo")] +#[derive(Debug, Clone)] +struct MppChallenge { + id: String, + realm: String, + method: String, + intent: String, + description: String, + expires: String, + /// Original base64url request string (re-embedded verbatim in the credential). + request: String, +} + +// Split "Payment k1="v1", k2="v2", Payment ..." into challenge objects. +#[cfg(feature = "payments-tempo")] +fn parse_mpp_challenges(header: &str) -> Vec { + let mut out = Vec::new(); + for part in split_payment_challenges(header) { + let get = |key: &str| extract_quoted(&part, key).unwrap_or_default(); + let method = get("method"); + if method.is_empty() { + continue; + } + out.push(MppChallenge { + id: get("id"), + realm: get("realm"), + method, + intent: get("intent"), + description: get("description"), + expires: get("expires"), + request: get("request"), + }); + } + out +} + +// Split on `Payment ` boundaries (at start or after a comma-space). +#[cfg(feature = "payments-tempo")] +pub(super) fn split_payment_challenges(header: &str) -> Vec { + let mut parts = Vec::new(); + let mut rest = header.trim(); + // Strip a leading "Payment ". + while let Some(idx) = rest.find("Payment ") { + let after = &rest[idx + "Payment ".len()..]; + // Find the next ", Payment " boundary. + if let Some(next) = after.find(", Payment ") { + parts.push(after[..next].to_string()); + rest = &after[next + 2..]; // keep "Payment ..." + } else { + parts.push(after.to_string()); + break; + } + } + parts +} + +// Extract key="value" (values contain no escaped quotes in the challenge). +#[cfg(feature = "payments-tempo")] +pub(super) fn extract_quoted(part: &str, key: &str) -> Option { + let needle = format!("{key}=\""); + let start = part.find(&needle)? + needle.len(); + let end = part[start..].find('"')? + start; + Some(part[start..end].to_string()) +} + +// ── Shared helpers ─────────────────────────────────────────────────────────── + +fn parse_receipt(header: &str) -> Option { + let value = decode_b64url_json(header).ok()?; + Some(PaymentReceipt { + method: value.get("method").and_then(Value::as_str)?.to_string(), + status: value.get("status").and_then(Value::as_str)?.to_string(), + timestamp: value + .get("timestamp") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + reference: value + .get("reference") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }) +} + +pub(super) fn decode_b64url_json(s: &str) -> Result { + use base64::Engine; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(s.trim_end_matches('=')) + .or_else(|_| base64::engine::general_purpose::STANDARD.decode(s)) + .map_err(|_| SdkError::Config("invalid base64url payload".into()))?; + serde_json::from_slice(&bytes).map_err(|source| SdkError::Decode { + source, + body: String::from_utf8_lossy(&bytes).into_owned(), + }) +} + +fn base64_std(bytes: Vec) -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +// Used by the MPP credential builder and receipt tests. +#[cfg_attr(not(feature = "payments-tempo"), allow(dead_code))] +pub(super) fn base64_url_nopad(bytes: Vec) -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +// eip155:84532 → 84532. +fn caip2_evm_chain_id(pay_network: &str) -> Result { + pay_network + .strip_prefix("eip155:") + .and_then(|s| s.parse().ok()) + .ok_or_else(|| { + SdkError::Config(format!( + "pay_network must be an eip155 CAIP-2 id for x402/EVM, got {pay_network:?}" + )) + }) +} + +// Accept CAIP-2 or bare numeric chain ids. +#[cfg_attr(not(feature = "payments-tempo"), allow(dead_code))] +fn caip2_or_bare_chain_id(pay_network: &str) -> Result { + if let Some(rest) = pay_network.strip_prefix("eip155:") { + return rest + .parse() + .map_err(|_| SdkError::Config(format!("invalid eip155 chain id: {pay_network:?}"))); + } + pay_network.parse().map_err(|_| { + SdkError::Config(format!( + "pay_network must be an eip155 CAIP-2 id or a bare chain id, got {pay_network:?}" + )) + }) +} + +// Name the cheapest offer when only max_amount blocked selection. +fn cheapest_over_ceiling(payment: &ResolvedPayment, accepts: &[Value]) -> Option { + let mut cheapest: Option = None; + for entry in accepts { + let network = entry.get("network").and_then(Value::as_str).unwrap_or(""); + let asset = entry.get("asset").and_then(Value::as_str).unwrap_or(""); + if network != payment.pay_network || !asset.eq_ignore_ascii_case(&payment.asset) { + continue; + } + if entry.pointer("/extra/name").and_then(Value::as_str) == Some(GATEWAY_BATCHED) { + continue; + } + if let Ok(amount) = entry + .get("amount") + .and_then(Value::as_str) + .unwrap_or("") + .parse::() + { + if amount > payment.max_amount && cheapest.is_none_or(|c| amount < c) { + cheapest = Some(amount); + } + } + } + cheapest +} + +fn describe_offered(accepts: &[Value], skipped: &[String]) -> String { + let offered: Vec = accepts + .iter() + .filter_map(|e| { + let network = e.get("network").and_then(Value::as_str)?; + let asset = e.get("asset").and_then(Value::as_str)?; + Some(format!("{network}/{asset}")) + }) + .collect(); + if skipped.is_empty() { + format!("[{}]", offered.join(", ")) + } else { + format!( + "[{}]; skipped: [{}]", + offered.join(", "), + skipped.join(", ") + ) + } +} + +// Add a hint when clock skew likely expired a Tempo credential. +fn enrich_rejection(payment: &ResolvedPayment, body: String) -> String { + let out = reduce_rejection_body(body); + if payment.signer.kind() == signer::ChainKind::Tempo { + format!("{out} (if this persists, check the system clock — Tempo payment windows are ~25s)") + } else { + out + } +} + +/// Reduces the gateway's rejection body to its own reason when the body is the +/// JSON error shape (`{"error": ..., "message": ...}`), so the terse reason can +/// lead the caller's message. A body that isn't that shape (e.g. a full 402 +/// payment menu, or plain text) is returned unchanged. +fn reduce_rejection_body(body: String) -> String { + if let Ok(v) = serde_json::from_str::(&body) { + let err = v.get("error").and_then(|e| e.as_str()); + let msg = v.get("message").and_then(|m| m.as_str()); + let reason = match (err, msg) { + (Some(e), Some(m)) if e != m => format!("{e}: {m}"), + (_, Some(m)) => m.to_string(), + (Some(e), None) => e.to_string(), + _ => String::new(), + }; + if !reason.is_empty() { + return reason; + } + } + body +} + +pub(super) fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} + +// Parse an ISO-8601 timestamp to unix seconds without adding chrono. +#[cfg(feature = "payments-tempo")] +pub(super) fn parse_iso_unix(iso: &str) -> Option { + // Expect YYYY-MM-DDTHH:MM:SS... + let bytes = iso.as_bytes(); + if bytes.len() < 19 { + return None; + } + let num = |a: usize, b: usize| iso.get(a..b)?.parse::().ok(); + let year = num(0, 4)?; + let month = num(5, 7)?; + let day = num(8, 10)?; + let hour = num(11, 13)?; + let min = num(14, 16)?; + let sec = num(17, 19)?; + // Days from civil (Howard Hinnant's algorithm). + let y = if month <= 2 { year - 1 } else { year }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let doy = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146097 + doe - 719468; + let secs = days * 86400 + hour * 3600 + min * 60 + sec; + u64::try_from(secs).ok() +} + +pub(super) fn random_nonce() -> [u8; 32] { + use rand::RngCore; + let mut nonce = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut nonce); + nonce +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn reduce_rejection_body_extracts_json_reason() { + // error + message → "error: message". + assert_eq!( + reduce_rejection_body( + r#"{"error":"auth_required","message":"SIWX authentication required"}"#.to_string() + ), + "auth_required: SIWX authentication required" + ); + // message only. + assert_eq!( + reduce_rejection_body(r#"{"message":"insufficient funds"}"#.to_string()), + "insufficient funds" + ); + // A body that isn't the error shape (e.g. a payment menu) is unchanged. + let menu = r#"{"accepts":[{"amount":"1000"}]}"#.to_string(); + assert_eq!(reduce_rejection_body(menu.clone()), menu); + // Plain text is unchanged. + assert_eq!( + reduce_rejection_body("bad signature".to_string()), + "bad signature" + ); + } + + #[test] + fn caip2_evm_parse() { + assert_eq!(caip2_evm_chain_id("eip155:84532").unwrap(), 84532); + assert!(caip2_evm_chain_id("solana:foo").is_err()); + } + + #[test] + fn solana_devnet_detection_by_genesis_hash() { + // CAIP-2 ids carry a genesis-hash prefix, never the literal "devnet". + assert!(solana_pay_network_is_devnet( + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + )); + assert!(!solana_pay_network_is_devnet( + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + )); + assert_eq!( + default_solana_rpc("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"), + "https://api.devnet.solana.com" + ); + assert_eq!( + default_solana_rpc("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"), + "https://api.mainnet-beta.solana.com" + ); + } + + #[test] + fn caip2_or_bare_parse() { + assert_eq!(caip2_or_bare_chain_id("eip155:42431").unwrap(), 42431); + assert_eq!(caip2_or_bare_chain_id("42431").unwrap(), 42431); + assert!(caip2_or_bare_chain_id("solana:foo").is_err()); + } + + #[cfg(feature = "payments-tempo")] + #[test] + fn iso_to_unix() { + // 2026-07-13T02:05:10Z — sanity check against a known value range. + let t = parse_iso_unix("2026-07-13T02:05:10.119Z").unwrap(); + // 2026-07-13 is ~1.78e9 seconds after epoch. + assert!((1_783_000_000..1_785_000_000).contains(&t), "got {t}"); + } + + #[cfg(feature = "payments-tempo")] + #[test] + fn mpp_multi_challenge_split() { + let header = r#"Payment id="c1", realm="mpp.quicknode.com", method="tempo", intent="charge", description="d", expires="2026-07-13T02:05:10Z", request="eyJ4IjoxfQ", Payment id="c2", realm="mpp.quicknode.com", method="solana", intent="charge", description="d2", expires="2026-07-13T02:05:10Z", request="eyJ5IjoyfQ""#; + let challenges = parse_mpp_challenges(header); + assert_eq!(challenges.len(), 2); + assert_eq!(challenges[0].method, "tempo"); + assert_eq!(challenges[0].id, "c1"); + assert_eq!(challenges[1].method, "solana"); + } + + #[test] + fn receipt_parse_from_b64url() { + let json = r#"{"method":"tempo","status":"success","timestamp":"2026-07-13T02:05:10.119Z","reference":"0xabc"}"#; + let header = base64_url_nopad(json.as_bytes().to_vec()); + let receipt = parse_receipt(&header).unwrap(); + assert_eq!(receipt.method, "tempo"); + assert_eq!(receipt.reference, "0xabc"); + } + + // Wiremock tests cover the 402 parse, selection, signing, and resend flow. + use secrecy::SecretString; + use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; + use wiremock::matchers::{header_exists, method, path}; + use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + + // anvil key #0 (public throwaway, never funded). + const EVM_KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + const USDC: &str = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + + fn evm_payment(base: &str, max_amount: u128) -> ResolvedPayment { + ResolvedPayment { + scheme: PaymentScheme::X402, + signer: Signer::Evm(SecretString::new(EVM_KEY.to_string())), + pay_network: "eip155:84532".into(), + asset: USDC.into(), + max_amount, + base_url_override: Some(base.to_string()), + svm_rpc_url: None, + } + } + + fn x402_accepts_entry(amount: &str, name: &str) -> Value { + json!({ + "scheme": "exact", + "network": "eip155:84532", + "amount": amount, + "payTo": "0x000000000000000000000000000000000000dEaD", + "maxTimeoutSeconds": 60, + "asset": USDC, + "extra": { "name": name, "version": "2" } + }) + } + + fn rpc_body() -> Value { + json!({ "jsonrpc": "2.0", "id": 1, "method": "eth_blockNumber", "params": [] }) + } + + #[tokio::test] + async fn x402_evm_happy_path() { + let server = MockServer::start().await; + // Unpaid POST gets 402; the signed resend gets 200. + struct Seq { + calls: AtomicUsize, + } + impl Respond for Seq { + fn respond(&self, req: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + let has_sig = req.headers.contains_key("payment-signature"); + if n == 0 && !has_sig { + ResponseTemplate::new(402).set_body_json(json!({ + "x402Version": 2, + "accepts": [ x402_accepts_entry("1000", "USDC") ] + })) + } else { + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1335f9a" + })) + } + } + } + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(Seq { + calls: AtomicUsize::new(0), + }) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri(), 10_000); + let client = reqwest::Client::new(); + let (text, receipt) = pay_and_call(&client, &payment, "base-sepolia", &rpc_body()) + .await + .unwrap(); + assert!(text.contains("0x1335f9a")); + assert!(receipt.is_none()); // x402 has no receipt + } + + #[tokio::test] + async fn over_max_amount_is_unsupported() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(402).set_body_json(json!({ + "x402Version": 2, + "accepts": [ x402_accepts_entry("999999", "USDC") ] + }))) + .mount(&server) + .await; + + // Ceiling is below the only offer. + let payment = evm_payment(&server.uri(), 1000); + let client = reqwest::Client::new(); + let err = pay_and_call(&client, &payment, "base-sepolia", &rpc_body()) + .await + .unwrap_err(); + assert!( + matches!(err, SdkError::PaymentUnsupported { offered } if offered.contains("exceeds max_amount")) + ); + } + + #[tokio::test] + async fn gateway_wallet_batched_is_skipped() { + let server = MockServer::start().await; + // Batched offer cannot be signed here. + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(402).set_body_json(json!({ + "x402Version": 2, + "accepts": [ x402_accepts_entry("1000", "GatewayWalletBatched") ] + }))) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri(), 10_000); + let client = reqwest::Client::new(); + let err = pay_and_call(&client, &payment, "base-sepolia", &rpc_body()) + .await + .unwrap_err(); + assert!( + matches!(err, SdkError::PaymentUnsupported { offered } if offered.contains("GatewayWalletBatched")) + ); + } + + #[tokio::test] + async fn non_integer_amount_is_skipped() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(402).set_body_json(json!({ + "x402Version": 2, + "accepts": [ x402_accepts_entry("0.001", "USDC") ] + }))) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri(), 10_000); + let client = reqwest::Client::new(); + let err = pay_and_call(&client, &payment, "base-sepolia", &rpc_body()) + .await + .unwrap_err(); + assert!( + matches!(err, SdkError::PaymentUnsupported { offered } if offered.contains("not an integer")) + ); + } + + #[tokio::test] + async fn huge_amount_over_u64_compares_correctly() { + let server = MockServer::start().await; + // This amount exceeds u64 but fits u128 and the configured ceiling. + let huge = "20000000000000000000"; // 2e19 > u64::MAX (~1.8e19) + struct Seq { + calls: AtomicUsize, + } + impl Respond for Seq { + fn respond(&self, req: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 && !req.headers.contains_key("payment-signature") { + ResponseTemplate::new(402).set_body_json(json!({ + "x402Version": 2, + "accepts": [ x402_accepts_entry("20000000000000000000", "USDC") ] + })) + } else { + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xok" + })) + } + } + } + Mock::given(method("POST")) + .respond_with(Seq { + calls: AtomicUsize::new(0), + }) + .mount(&server) + .await; + let _ = huge; + + let payment = evm_payment(&server.uri(), 30_000_000_000_000_000_000u128); + let client = reqwest::Client::new(); + let (text, _) = pay_and_call(&client, &payment, "base-sepolia", &rpc_body()) + .await + .unwrap(); + assert!(text.contains("0xok")); + } + + #[tokio::test] + async fn second_402_is_terminal_rejection() { + let server = MockServer::start().await; + // A second 402 is PaymentRejected. + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(402).set_body_json(json!({ + "x402Version": 2, + "accepts": [ x402_accepts_entry("1000", "USDC") ] + }))) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri(), 10_000); + let client = reqwest::Client::new(); + let err = pay_and_call(&client, &payment, "base-sepolia", &rpc_body()) + .await + .unwrap_err(); + assert!(matches!(err, SdkError::PaymentRejected { status, .. } if status == 402)); + } + + #[tokio::test] + async fn malformed_challenge_menu_is_unsupported_not_decode() { + let server = MockServer::start().await; + // An invalid pre-payment menu is PaymentUnsupported. + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(402).set_body_string("menu?")) + .expect(1) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri(), 10_000); + let client = reqwest::Client::new(); + let err = pay_and_call(&client, &payment, "base-sepolia", &rpc_body()) + .await + .unwrap_err(); + assert!( + matches!(&err, SdkError::PaymentUnsupported { offered } if offered.contains("unparseable")), + "expected PaymentUnsupported, got {err:?}" + ); + } + + #[tokio::test] + async fn gateway_5xx_on_paid_resend_is_rejection_not_decode() { + // A paid 500 remains PaymentRejected, not Decode. + let server = MockServer::start().await; + struct Seq { + calls: AtomicUsize, + } + impl Respond for Seq { + fn respond(&self, req: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 && !req.headers.contains_key("payment-signature") { + ResponseTemplate::new(402).set_body_json(json!({ + "x402Version": 2, + "accepts": [ x402_accepts_entry("1000", "USDC") ] + })) + } else { + ResponseTemplate::new(500).set_body_string("upstream settlement error") + } + } + } + Mock::given(method("POST")) + .respond_with(Seq { + calls: AtomicUsize::new(0), + }) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri(), 10_000); + let client = reqwest::Client::new(); + let err = pay_and_call(&client, &payment, "base-sepolia", &rpc_body()) + .await + .unwrap_err(); + assert!( + matches!(&err, SdkError::PaymentRejected { status, body } if *status == 500 && body.contains("settlement error")), + "expected PaymentRejected(500), got {err:?}" + ); + } + + #[tokio::test] + async fn paid_resend_sends_exactly_one_credential() { + // The signed request is sent exactly once. + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(header_exists("payment-signature")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xpaid" + }))) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(402).set_body_json(json!({ + "x402Version": 2, + "accepts": [ x402_accepts_entry("1000", "USDC") ] + }))) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri(), 10_000); + let client = reqwest::Client::new(); + let (text, _) = pay_and_call(&client, &payment, "base-sepolia", &rpc_body()) + .await + .unwrap(); + assert!(text.contains("0xpaid")); + } + + #[tokio::test] + async fn lost_response_after_payment_is_indeterminate() { + // A timeout after sending is PaymentIndeterminate. + let server = MockServer::start().await; + struct Seq { + calls: AtomicUsize, + } + impl Respond for Seq { + fn respond(&self, req: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 && !req.headers.contains_key("payment-signature") { + ResponseTemplate::new(402).set_body_json(json!({ + "x402Version": 2, + "accepts": [ x402_accepts_entry("1000", "USDC") ] + })) + } else { + // Simulate a lost response after payment. + ResponseTemplate::new(200) + .set_delay(std::time::Duration::from_secs(30)) + .set_body_json(json!({ "jsonrpc": "2.0", "id": 1, "result": "0xlate" })) + } + } + } + Mock::given(method("POST")) + .respond_with(Seq { + calls: AtomicUsize::new(0), + }) + .mount(&server) + .await; + + let payment = evm_payment(&server.uri(), 10_000); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_millis(300)) + .build() + .unwrap(); + let err = pay_and_call(&client, &payment, "base-sepolia", &rpc_body()) + .await + .unwrap_err(); + assert!( + matches!(err, SdkError::PaymentIndeterminate), + "expected PaymentIndeterminate, got {err:?}" + ); + } + + #[cfg(feature = "payments-tempo")] + #[tokio::test] + async fn mpp_happy_path_captures_receipt() { + let server = MockServer::start().await; + // Tempo challenge for chain 42431. + let request = base64_url_nopad( + serde_json::to_vec(&json!({ + "amount": "1000", + "currency": "0x20c0000000000000000000000000000000000000", + "recipient": "0xfd24114c3981aba78ae2441991b1bdb89329c556", + "methodDetails": { "chainId": 42431, "feePayer": true } + })) + .unwrap(), + ); + let www = format!( + "Payment id=\"c1\", realm=\"mpp.quicknode.com\", method=\"tempo\", intent=\"charge\", description=\"d\", expires=\"2099-01-01T00:00:00Z\", request=\"{request}\"" + ); + let receipt = base64_url_nopad( + serde_json::to_vec(&json!({ + "method": "tempo", "status": "success", + "timestamp": "2026-07-13T02:05:10.119Z", + "reference": "0xdeadbeef" + })) + .unwrap(), + ); + + struct Seq { + www: String, + receipt: String, + calls: AtomicUsize, + } + impl Respond for Seq { + fn respond(&self, req: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 && !req.headers.contains_key("authorization") { + ResponseTemplate::new(402) + .insert_header("WWW-Authenticate", self.www.as_str()) + .set_body_json(json!({ "type": "about:blank" })) + } else { + ResponseTemplate::new(200) + .insert_header("Payment-Receipt", self.receipt.as_str()) + .set_body_json(json!({ "jsonrpc": "2.0", "id": 1, "result": "0xok" })) + } + } + } + Mock::given(method("POST")) + .respond_with(Seq { + www, + receipt, + calls: AtomicUsize::new(0), + }) + .mount(&server) + .await; + + let payment = ResolvedPayment { + scheme: PaymentScheme::MppCharge, + signer: Signer::Tempo(SecretString::new(EVM_KEY.to_string())), + pay_network: "eip155:42431".into(), + asset: "0x20c0000000000000000000000000000000000000".into(), + max_amount: 10_000, + base_url_override: Some(server.uri()), + svm_rpc_url: None, + }; + let client = reqwest::Client::new(); + let (text, receipt) = pay_and_call(&client, &payment, "base-sepolia", &rpc_body()) + .await + .unwrap(); + assert!(text.contains("0xok")); + let receipt = receipt.expect("MPP happy path must capture a receipt"); + assert_eq!(receipt.method, "tempo"); + assert_eq!(receipt.reference, "0xdeadbeef"); + } + + // The selector accepts u128, but SPL TransferChecked encodes u64. + #[cfg(feature = "payments-svm")] + #[tokio::test] + async fn x402_svm_amount_over_u64_is_clear_error() { + let over_u64 = (u128::from(u64::MAX) + 1).to_string(); + let entry = json!({ + "scheme": "exact", + "network": "solana:mainnet", + "amount": over_u64, + "payTo": "11111111111111111111111111111112", + "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "extra": { "feePayer": "11111111111111111111111111111112", "decimals": 6 } + }); + let payment = ResolvedPayment { + scheme: PaymentScheme::X402, + signer: Signer::Svm(SecretString::new(EVM_KEY.to_string())), + pay_network: "solana:mainnet".into(), + asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(), + max_amount: u128::MAX, + base_url_override: None, + svm_rpc_url: Some("http://127.0.0.1:1".into()), + }; + let client = reqwest::Client::new(); + // Reject before reading the Solana RPC. + let Err(err) = authorize_x402_svm(&client, &payment, &2, &entry).await else { + unreachable!("over-u64 amount must be rejected"); + }; + let msg = err.to_string(); + assert!( + msg.contains("not a valid u64"), + "expected u64 overflow error, got: {msg}" + ); + } + + // Solana tiers have no name and may be out of price order. + fn solana_menu() -> Vec { + let offer = |amount: &str| { + json!({ + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "amount": amount, + "payTo": "2LWbc9Mi6dRUrdEHBttoNS4udDtH1A4xwBdm1EKqcT57", + "maxTimeoutSeconds": 60, + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "extra": { "feePayer": "CPZSjRmyfTS95UjQD8ZdeTEWbQvW9QvEXnn6aGP7yyMN" } + }) + }; + vec![offer("1000000"), offer("1000")] + } + + fn solana_payment(max_amount: u128) -> ResolvedPayment { + ResolvedPayment { + scheme: PaymentScheme::X402, + signer: Signer::Svm(SecretString::new(EVM_KEY.to_string())), + pay_network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1".into(), + asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU".into(), + max_amount, + base_url_override: None, + svm_rpc_url: Some("http://127.0.0.1:1".into()), + } + } + + #[test] + fn select_prefers_the_cheapest_offer_over_menu_order() { + let payment = solana_payment(1_000_000); + let mut skipped = Vec::new(); + let chosen = select_x402_entry(&payment, &solana_menu(), &mut skipped) + .expect("a matching offer exists"); + assert_eq!( + chosen.get("amount").and_then(Value::as_str), + Some("1000"), + "must pick the cheapest, not the first listed" + ); + } + + #[test] + fn select_skips_offers_over_the_ceiling() { + // Only the cheaper tier fits. + let payment = solana_payment(2_000); + let mut skipped = Vec::new(); + let chosen = select_x402_entry(&payment, &solana_menu(), &mut skipped) + .expect("the cheap offer fits"); + assert_eq!(chosen.get("amount").and_then(Value::as_str), Some("1000")); + assert_eq!(skipped.len(), 1, "the dearer offer is reported as skipped"); + assert!( + skipped[0].contains("exceeds max_amount"), + "got: {skipped:?}" + ); + } + + // The error should name the cheapest blocked offer. + #[test] + fn ceiling_under_every_offer_names_the_cheapest() { + let payment = solana_payment(100); + let mut skipped = Vec::new(); + assert!(select_x402_entry(&payment, &solana_menu(), &mut skipped).is_none()); + assert_eq!(cheapest_over_ceiling(&payment, &solana_menu()), Some(1_000)); + } +} diff --git a/crates/core/src/rpc/payment/session.rs b/crates/core/src/rpc/payment/session.rs new file mode 100644 index 0000000..b97ccb5 --- /dev/null +++ b/crates/core/src/rpc/payment/session.rs @@ -0,0 +1,1067 @@ +//! MPP session (payment-channel) lane for `rpc.call`. +//! +//! The counterpart to the per-request MPP charge in the parent module: instead +//! of signing a fresh Tempo transaction per request, the caller opens a payment +//! channel by depositing into the escrow contract the gateway advertises in its +//! session challenge (`methodDetails.escrowContract`), then authorizes spend +//! with cumulative EIP-712 vouchers (`Authorization: Payment`) — one +//! `ecrecover` server-side, no on-chain tx per call. The gateway settles the +//! channel on-chain in batches on its own schedule; the client cooperatively +//! closes to settle + refund the unused deposit. +//! +//! Wire protocol (the gateway's contract-backed session, as advertised in the +//! 402 challenge): +//! - Endpoints under `{mpp}/session/:network`. The gateway requires the slug to +//! name a network it serves, but selects the challenge by the caller's pay +//! chain, so the value only matters for `voucher_call` (which routes an RPC +//! method). The lifecycle verbs pin `SESSION_ROUTE_NETWORK`. +//! - Channel lifecycle credentials are a discriminated union on `action` +//! (`open`/`topUp`/`voucher`/`close`), each a `Payment ` +//! credential of `{challenge, payload, source}`. +//! - `open`/`topUp` carry a fee-sponsored Tempo tx with two calls — a token +//! `approve(escrow, amount)` plus the escrow `open`/`topUp` call; +//! `voucher`/`close` are pure EIP-712 voucher signatures. +//! - The channelId is derived locally (keccak over the channel parameters, as +//! the escrow contract derives it) and the gateway re-derives it from the +//! open calldata. +//! - The gateway exposes no read-only channel endpoint, and it prices every +//! `/session/:network` POST as a chargeable request: the available balance is +//! the NEW spend a voucher authorizes, so re-presenting the current +//! high-water voucher is always refused with `insufficient-balance`. `status` +//! therefore advances the voucher by one request unit like any session call, +//! and reads the `Payment-Receipt` header. + +use serde_json::Value; + +use crate::errors::{HttpKind, SdkError}; + +use super::signer::tempo::{EscrowAction, TempoEscrowRequest}; +use super::{now_unix, parse_iso_unix, random_nonce, PaymentScheme, ResolvedPayment}; + +// Lifecycle calls use a served route slug, but the challenge is selected by +// pay chain. Only voucher_call uses the queried network. +const SESSION_ROUTE_NETWORK: &str = "tempo-testnet"; + +// Bound validBefore by the local 25-second window and challenge expiry. +fn session_valid_before(challenge: &SessionChallenge) -> Result { + let expiry = parse_iso_unix(&challenge.expires).ok_or_else(|| { + SdkError::Config(format!( + "MPP session challenge has an unparseable `expires` value: {}", + challenge.expires + )) + })?; + Ok((now_unix() + 25).min(expiry)) +} + +/// Local state for an open MPP payment channel. The CLI persists this between +/// runs (like the drawdown session JWT); the gateway has no read-only channel +/// endpoint, so a lost local record means opening a new channel. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct ChannelState { + /// Channel id (`0x`-hex bytes32), derived from the channel parameters. + pub channel_id: String, + /// The escrow token (TIP-20 currency) the channel is denominated in. + pub token: String, + /// The channel payee (settlement recipient), from the open challenge. + pub payee: String, + /// Payer entropy used to derive the channel (`0x`-hex bytes32). + pub salt: String, + /// Voucher signer (the payer; the SDK delegates to no separate signer). + pub authorized_signer: String, + /// The escrow contract the channel lives in, from the open challenge. + pub escrow_contract: String, + /// Total deposited into the channel so far, in token base units. + pub deposit: u128, + /// Highest cumulative amount authorized by a voucher so far. + pub cumulative_spent: u128, + /// The gateway's per-call price (from the open challenge), so the caller can + /// advance `cumulative_spent` by one unit per session call. + pub per_call: u128, + /// EIP-155 chain id the channel lives on. + pub chain_id: u64, +} + +// ── Session challenge parse ────────────────────────────────────────────────── + +// One MPP `Payment` challenge parsed from a WWW-Authenticate header, plus its +// decoded request body. Mirrors the charge-side parser in the parent module but +// keeps the raw pieces the credential must echo back verbatim. +struct SessionChallenge { + id: String, + realm: String, + intent: String, + description: String, + expires: String, + /// The original base64url request string, re-embedded in the credential. + request_b64: String, + /// The decoded request JSON (currency, recipient, amount, chainId, …). + request: Value, +} + +// ── Channel lifecycle ──────────────────────────────────────────────────────── + +/// Opens a payment channel: deposit `deposit` base units into the escrow, sign +/// the opening voucher for `initial_cumulative`, and POST the `open` credential +/// to `{mpp}/session/:network`. Returns the new [`ChannelState`]. +/// +/// `deposit` moves real funds on-chain; the caller gates this. Single-attempt. +pub async fn open( + client: &reqwest::Client, + payment: &ResolvedPayment, + deposit: u128, +) -> Result { + if deposit > payment.max_amount { + return Err(SdkError::PaymentUnsupported { + offered: format!( + "requested channel deposit {deposit} exceeds max_amount {}", + payment.max_amount + ), + }); + } + let challenge = probe_session_challenge(client, payment).await?; + let chain_id = challenge_chain_id(&challenge)?; + let token = require_str(&challenge.request, "currency")?; + let payee = require_str(&challenge.request, "recipient")?; + let escrow = challenge_escrow_contract(&challenge)?; + let payer = payment.signer.address()?; + + // Sign approve + open. The gateway re-derives channelId from these fields. + let salt = format!("0x{}", hex::encode(random_nonce())); + let signed = payment.signer.sign_escrow_tx(&TempoEscrowRequest { + chain_id, + valid_before: session_valid_before(&challenge)?, + escrow_contract: escrow.clone(), + action: EscrowAction::Open { + payee: payee.clone(), + token: token.clone(), + deposit, + salt: salt.clone(), + authorized_signer: payer.clone(), + }, + })?; + let channel_id = signed + .channel_id + .map(|c| format!("0x{}", hex::encode(c))) + .ok_or_else(|| SdkError::Config("open did not derive a channelId".into()))?; + + // The opening voucher authorizes the first per-call amount. + let per_unit = require_amount(&challenge.request)?; + let voucher_sig = + payment + .signer + .sign_session_voucher(&channel_id, per_unit, chain_id, &escrow)?; + + let payload = serde_json::json!({ + "action": "open", + "type": "transaction", + "channelId": channel_id, + "transaction": format!("0x{}", hex::encode(&signed.transaction)), + "signature": voucher_sig, + "authorizedSigner": payer, + "cumulativeAmount": per_unit.to_string(), + }); + post_session_credential(client, payment, &challenge, &payer, payload).await?; + + Ok(ChannelState { + channel_id, + token, + payee, + salt, + authorized_signer: payer, + escrow_contract: escrow, + deposit, + cumulative_spent: per_unit, + per_call: per_unit, + chain_id, + }) +} + +/// Adds `additional_deposit` base units to an open channel: sign the escrow +/// `topUp` tx and POST the `topUp` credential. Moves real funds; single-attempt. +pub async fn top_up( + client: &reqwest::Client, + payment: &ResolvedPayment, + channel: &ChannelState, + additional_deposit: u128, +) -> Result { + let payer = payment.signer.address()?; + let challenge = probe_session_challenge(client, payment).await?; + + let signed = payment.signer.sign_escrow_tx(&TempoEscrowRequest { + chain_id: channel.chain_id, + valid_before: session_valid_before(&challenge)?, + escrow_contract: channel.escrow_contract.clone(), + action: EscrowAction::TopUp { + channel_id: channel.channel_id.clone(), + token: channel.token.clone(), + additional_deposit, + }, + })?; + let payload = serde_json::json!({ + "action": "topUp", + "type": "transaction", + "channelId": channel.channel_id, + "transaction": format!("0x{}", hex::encode(&signed.transaction)), + "additionalDeposit": additional_deposit.to_string(), + }); + post_session_credential(client, payment, &challenge, &payer, payload).await?; + + let mut updated = channel.clone(); + updated.deposit = channel.deposit.saturating_add(additional_deposit); + Ok(updated) +} + +/// Cooperatively closes a channel at its final cumulative spend: sign the close +/// voucher and POST the `close` credential. The gateway settles the final +/// amount on-chain and refunds the unused deposit. Single-attempt. +pub async fn close( + client: &reqwest::Client, + payment: &ResolvedPayment, + channel: &ChannelState, +) -> Result<(), SdkError> { + let payer = payment.signer.address()?; + let challenge = probe_session_challenge(client, payment).await?; + let signature = payment.signer.sign_session_voucher( + &channel.channel_id, + channel.cumulative_spent, + channel.chain_id, + &channel.escrow_contract, + )?; + let payload = serde_json::json!({ + "action": "close", + "channelId": channel.channel_id, + "cumulativeAmount": channel.cumulative_spent.to_string(), + "signature": signature, + }); + post_session_credential(client, payment, &challenge, &payer, payload).await?; + Ok(()) +} + +/// The gateway's view of a channel: the accepted cumulative high-water mark +/// and the amount it counts as spent. (The deposit is tracked locally; the +/// gateway exposes no read-only channel endpoint.) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelStatus { + pub channel_id: String, + pub accepted_cumulative: u128, + pub spent: u128, +} + +/// Fetches the gateway's view of the channel and reads the `Payment-Receipt` +/// header. +/// +/// **This costs one request unit.** The voucher advances by `per_call`, so +/// persist the new `cumulative_spent` on success. +/// +/// Returns [`SdkError::PaymentUnsupported`] before any network I/O when the +/// channel has no room left for the probe. +pub async fn status( + client: &reqwest::Client, + payment: &ResolvedPayment, + channel: &ChannelState, +) -> Result { + let probe_cumulative = channel.cumulative_spent.saturating_add(channel.per_call); + if probe_cumulative > channel.deposit { + return Err(SdkError::PaymentUnsupported { + offered: format!( + "the channel has no room for a status probe (it costs {} of the {} remaining); \ + top up first", + channel.per_call, + channel.deposit.saturating_sub(channel.cumulative_spent), + ), + }); + } + let payer = payment.signer.address()?; + let signature = payment.signer.sign_session_voucher( + &channel.channel_id, + probe_cumulative, + channel.chain_id, + &channel.escrow_contract, + )?; + let challenge = probe_session_challenge(client, payment).await?; + let payload = serde_json::json!({ + "action": "voucher", + "channelId": channel.channel_id, + "cumulativeAmount": probe_cumulative.to_string(), + "signature": signature, + }); + let resp = post_session_credential(client, payment, &challenge, &payer, payload).await?; + + let receipt_b64 = resp + .headers() + .get("payment-receipt") + .and_then(|v| v.to_str().ok()) + .map(String::from) + .ok_or_else(|| { + SdkError::Config("the gateway's response carried no Payment-Receipt header".into()) + })?; + let receipt = super::decode_b64url_json(&receipt_b64) + .map_err(|_| SdkError::Config("the gateway's Payment-Receipt did not decode".into()))?; + let accepted = require_str(&receipt, "acceptedCumulative")?; + let spent = require_str(&receipt, "spent")?; + Ok(ChannelStatus { + channel_id: channel.channel_id.clone(), + accepted_cumulative: parse_u128(&accepted)?, + spent: parse_u128(&spent)?, + }) +} + +/// Makes one session-lane JSON-RPC call, authorizing it with a cumulative +/// voucher for `new_cumulative` (the running total after this call). Returns the +/// raw JSON-RPC envelope text. Single-attempt: a paid lane never blind-retries. +/// +/// The caller advances `cumulative_spent` in the persisted channel state after +/// a success; a `NeedVoucher` / insufficient-deposit refusal surfaces as an +/// `Api` error the caller maps to a top-up hint. +pub async fn voucher_call( + client: &reqwest::Client, + payment: &ResolvedPayment, + query_network: &str, + channel: &ChannelState, + new_cumulative: u128, + body: &Value, +) -> Result { + if new_cumulative > channel.deposit { + return Err(SdkError::PaymentUnsupported { + offered: format!( + "voucher cumulative {new_cumulative} exceeds channel deposit {}; top up first", + channel.deposit + ), + }); + } + let payer = payment.signer.address()?; + let signature = payment.signer.sign_session_voucher( + &channel.channel_id, + new_cumulative, + channel.chain_id, + &channel.escrow_contract, + )?; + // Obtain the challenge on the pinned lifecycle route, then send the paid + // request to the queried network. + let challenge = probe_session_challenge(client, payment).await?; + let payload = serde_json::json!({ + "action": "voucher", + "channelId": channel.channel_id, + "cumulativeAmount": new_cumulative.to_string(), + "signature": signature, + }); + let credential = build_credential(&challenge, &payer, channel.chain_id, &payload)?; + + let base = session_base(payment, query_network); + let paid = match client + .post(&base) + .header("Authorization", format!("Payment {credential}")) + .json(body) + .send() + .await + { + Ok(resp) => resp, + Err(e) => { + let err = SdkError::Http(e); + return Err(match err.http_kind() { + Some(HttpKind::Connect) => err, + _ => SdkError::PaymentIndeterminate, + }); + } + }; + let paid_status = paid.status(); + let text = paid.text().await.map_err(SdkError::Http)?; + if !paid_status.is_success() { + return Err(SdkError::Api { + status: paid_status, + body: text, + }); + } + Ok(text) +} + +// ── HTTP + credential helpers ──────────────────────────────────────────────── + +fn session_base(payment: &ResolvedPayment, query_network: &str) -> String { + let base = PaymentScheme::MppCharge.host_base(payment.base_url_override.as_deref()); + format!("{}/session/{}", base.trim_end_matches('/'), query_network) +} + +// Probe keyless for the 402 session challenge. +async fn probe_session_challenge( + client: &reqwest::Client, + payment: &ResolvedPayment, +) -> Result { + let base = session_base(payment, SESSION_ROUTE_NETWORK); + let resp = client + .post(&base) + .json(&serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "eth_chainId", "params": [] })) + .send() + .await + .map_err(SdkError::Http)?; + // A missing pinned route is a configuration/protocol mismatch. + if resp.status().as_u16() == 404 { + return Err(SdkError::PaymentUnsupported { + offered: format!( + "the gateway does not serve the channel-lifecycle route \ + (/session/{SESSION_ROUTE_NETWORK} returned 404); the SDK's pinned \ + route network needs updating to one the gateway lists" + ), + }); + } + if resp.status().as_u16() != 402 { + return Err(SdkError::PaymentUnsupported { + offered: format!( + "the session endpoint did not return a 402 challenge (status {})", + resp.status().as_u16() + ), + }); + } + let header = resp + .headers() + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .map(String::from) + .ok_or_else(|| SdkError::PaymentUnsupported { + offered: "session 402 without a WWW-Authenticate header".into(), + })?; + parse_session_challenge( + &header, + super::caip2_or_bare_chain_id(&payment.pay_network)?, + ) +} + +// Select the session challenge matching the configured pay chain. The gateway +// may offer multiple chains and escrow contracts in one header. +fn parse_session_challenge(header: &str, want_chain_id: u64) -> Result { + let mut offered: Vec = Vec::new(); + for part in split_payment_challenges(header) { + let get = |k: &str| extract_quoted(&part, k).unwrap_or_default(); + if get("method") != "tempo" || get("intent") != "session" { + continue; + } + let request_b64 = get("request"); + let request = + super::decode_b64url_json(&request_b64).map_err(|_| SdkError::PaymentUnsupported { + offered: "session challenge has an undecodable request".into(), + })?; + let challenge = SessionChallenge { + id: get("id"), + realm: get("realm"), + intent: "session".into(), + description: get("description"), + expires: get("expires"), + request_b64, + request, + }; + match challenge_chain_id(&challenge) { + Ok(chain_id) if chain_id == want_chain_id => return Ok(challenge), + Ok(chain_id) => offered.push(format!("eip155:{chain_id}")), + Err(_) => offered.push("a challenge with no chainId".into()), + } + } + Err(SdkError::PaymentUnsupported { + offered: if offered.is_empty() { + "no tempo/session challenge offered".into() + } else { + format!( + "no tempo/session challenge for eip155:{want_chain_id} (offered: {})", + offered.join(", ") + ) + }, + }) +} + +// Build the credential with the original request and the payer's CAIP-10 source. +fn build_credential( + challenge: &SessionChallenge, + payer: &str, + chain_id: u64, + payload: &Value, +) -> Result { + let credential = serde_json::json!({ + "challenge": { + "id": challenge.id, + "realm": challenge.realm, + "method": "tempo", + "intent": challenge.intent, + "description": challenge.description, + "expires": challenge.expires, + "request": challenge.request_b64, + }, + "payload": payload, + "source": format!("did:pkh:eip155:{chain_id}:{payer}"), + }); + // Do not turn serialization failure into an empty credential. + Ok(super::base64_url_nopad( + serde_json::to_vec(&credential).map_err(|e| { + SdkError::Config(format!( + "could not serialize the MPP session credential: {e}" + )) + })?, + )) +} + +// POST a channel-management credential and require a 2xx response. +async fn post_session_credential( + client: &reqwest::Client, + payment: &ResolvedPayment, + challenge: &SessionChallenge, + payer: &str, + payload: Value, +) -> Result { + let chain_id = challenge_chain_id(challenge)?; + let credential = build_credential(challenge, payer, chain_id, &payload)?; + let base = session_base(payment, SESSION_ROUTE_NETWORK); + let resp = client + .post(&base) + .header("Authorization", format!("Payment {credential}")) + .json(&serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "eth_chainId", "params": [] })) + .send() + .await + .map_err(SdkError::Http)?; + let http_status = resp.status(); + if !http_status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(SdkError::Api { + status: http_status, + body, + }); + } + Ok(resp) +} + +// ── small parse helpers ────────────────────────────────────────────────────── + +fn require_str(request: &Value, key: &str) -> Result { + request + .get(key) + .and_then(Value::as_str) + .map(String::from) + .ok_or_else(|| SdkError::Config(format!("session challenge missing {key}"))) +} + +fn require_amount(request: &Value) -> Result { + let s = require_str(request, "amount")?; + parse_u128(&s) +} + +fn challenge_chain_id(challenge: &SessionChallenge) -> Result { + challenge + .request + .pointer("/methodDetails/chainId") + .and_then(Value::as_u64) + .or_else(|| challenge.request.get("chainId").and_then(Value::as_u64)) + .ok_or_else(|| SdkError::Config("session challenge missing chainId".into())) +} + +// Read the escrow contract required by the contract-backed session. +fn challenge_escrow_contract(challenge: &SessionChallenge) -> Result { + challenge + .request + .pointer("/methodDetails/escrowContract") + .and_then(Value::as_str) + .map(String::from) + .ok_or_else(|| SdkError::PaymentUnsupported { + offered: "the session challenge named no escrowContract".into(), + }) +} + +fn parse_u128(s: &str) -> Result { + s.parse::() + .map_err(|_| SdkError::Config(format!("expected an integer base-unit amount, got {s:?}"))) +} + +// Reuse the parent module's WWW-Authenticate splitters via re-export. +use super::{extract_quoted, split_payment_challenges}; + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use secrecy::SecretString; + use wiremock::matchers::{header_exists, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + const EVM_KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + const EVM_ADDR_LOWER: &str = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"; + + fn tempo_payment(base: &str) -> ResolvedPayment { + ResolvedPayment { + scheme: PaymentScheme::MppCharge, + signer: super::super::signer::Signer::Tempo(SecretString::new(EVM_KEY.to_string())), + pay_network: "eip155:42431".into(), + asset: "0x20c0000000000000000000000000000000000000".into(), + max_amount: 1_000_000, + base_url_override: Some(base.to_string()), + svm_rpc_url: None, + } + } + + fn sample_channel() -> ChannelState { + ChannelState { + channel_id: format!("0x{}", "11".repeat(32)), + token: "0x20c0000000000000000000000000000000000000".into(), + payee: "0xfd24114c3981aba78ae2441991b1bdb89329c556".into(), + salt: format!("0x{}", "22".repeat(32)), + authorized_signer: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".into(), + escrow_contract: "0x33b901018174DDabE4841042ab76ba85D4e24f25".into(), + deposit: 100_000, + cumulative_spent: 500, + per_call: 500, + chain_id: 42431, + } + } + + // One MPP challenge entry. + fn session_offer(id: &str, chain_id: u64, escrow: &str) -> String { + let request = super::super::base64_url_nopad( + serde_json::to_vec(&serde_json::json!({ + "amount": "10", + "currency": "0x20c0000000000000000000000000000000000000", + "recipient": "0xfd24114c3981aba78ae2441991b1bdb89329c556", + "methodDetails": { "chainId": chain_id, "escrowContract": escrow } + })) + .unwrap(), + ); + format!( + "Payment id=\"{id}\", realm=\"mpp.quicknode.com\", method=\"tempo\", \ + intent=\"session\", description=\"d\", expires=\"2099-01-01T00:00:00Z\", \ + request=\"{request}\"" + ) + } + + // Match by chainId, not header order. + #[test] + fn parse_session_challenge_selects_the_offer_for_the_pay_chain() { + let header = format!( + "Payment id=\"c0\", realm=\"mpp.quicknode.com\", method=\"tempo\", \ + intent=\"charge\", description=\"d\", expires=\"2099-01-01T00:00:00Z\", \ + request=\"ey000\", {}, {}", + session_offer( + "mainnet", + 4217, + "0x33b901018174DDabE4841042ab76ba85D4e24f25" + ), + session_offer( + "testnet", + 42431, + "0xe1c4d3dce17bc111181ddf716f75bae49e61a336" + ), + ); + + // Testnet is second, so first-match parsing would be wrong. + let parsed = parse_session_challenge(&header, 42431).unwrap(); + assert_eq!(parsed.id, "testnet"); + assert_eq!(challenge_chain_id(&parsed).unwrap(), 42431); + assert_eq!( + challenge_escrow_contract(&parsed).unwrap(), + "0xe1c4d3dce17bc111181ddf716f75bae49e61a336" + ); + + // Mainnet still resolves when requested. + let parsed = parse_session_challenge(&header, 4217).unwrap(); + assert_eq!(parsed.id, "mainnet"); + assert_eq!( + challenge_escrow_contract(&parsed).unwrap(), + "0x33b901018174DDabE4841042ab76ba85D4e24f25" + ); + } + + #[test] + fn session_challenge_for_an_unoffered_chain_names_what_was_offered() { + let header = session_offer( + "mainnet", + 4217, + "0x33b901018174DDabE4841042ab76ba85D4e24f25", + ); + let Err(err) = parse_session_challenge(&header, 42431) else { + panic!("a challenge for an unoffered chain must not resolve"); + }; + assert!( + matches!(&err, SdkError::PaymentUnsupported { offered } + if offered.contains("eip155:42431") && offered.contains("eip155:4217")), + "unexpected error: {err:?}" + ); + } + + #[test] + fn challenge_without_escrow_contract_is_unsupported() { + let request = super::super::base64_url_nopad( + serde_json::to_vec(&serde_json::json!({ + "amount": "500", + "currency": "0x20c0000000000000000000000000000000000000", + "recipient": "0xfd24114c3981aba78ae2441991b1bdb89329c556", + "methodDetails": { "chainId": 42431 } + })) + .unwrap(), + ); + let header = format!( + "Payment id=\"c1\", realm=\"mpp.quicknode.com\", method=\"tempo\", intent=\"session\", description=\"d\", expires=\"2099-01-01T00:00:00Z\", request=\"{request}\"" + ); + let parsed = parse_session_challenge(&header, 42431).unwrap(); + let err = challenge_escrow_contract(&parsed).unwrap_err(); + assert!( + matches!(err, SdkError::PaymentUnsupported { offered } if offered.contains("escrowContract")) + ); + } + + #[tokio::test] + async fn voucher_over_deposit_is_rejected_before_signing() { + let payment = tempo_payment("http://127.0.0.1:1"); + let ch = sample_channel(); + let body = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "eth_blockNumber" }); + // Must fail before network I/O. + let err = voucher_call( + &reqwest::Client::new(), + &payment, + "tempo-testnet", + &ch, + ch.deposit + 1, + &body, + ) + .await + .unwrap_err(); + assert!( + matches!(err, SdkError::PaymentUnsupported { offered } if offered.contains("exceeds channel deposit")) + ); + } + + // validBefore must not outlive the challenge. + #[test] + fn valid_before_is_clamped_to_a_near_term_challenge_expiry() { + // A past expiry must be used unchanged. + const EXPIRES: &str = "2026-07-17T12:00:00Z"; + let mut parsed = parse_session_challenge( + &session_offer("c1", 42431, "0x33b901018174DDabE4841042ab76ba85D4e24f25"), + 42431, + ) + .unwrap(); + parsed.expires = EXPIRES.into(); + assert_eq!( + session_valid_before(&parsed).unwrap(), + parse_iso_unix(EXPIRES).unwrap() + ); + } + + // A far-future expiry uses the local 25-second window. + #[test] + fn valid_before_uses_the_25s_envelope_when_the_challenge_outlives_it() { + let parsed = parse_session_challenge( + &session_offer("c1", 42431, "0x33b901018174DDabE4841042ab76ba85D4e24f25"), + 42431, + ) + .unwrap(); + assert_eq!(session_valid_before(&parsed).unwrap(), now_unix() + 25); + } + + // Lifecycle tests distinguish probe and credential POSTs by Authorization. + + const ESCROW: &str = "0x33b901018174DDabE4841042ab76ba85D4e24f25"; + + // The 402 challenge menu the probe receives. + fn probe_mock(chain_id: u64) -> Mock { + Mock::given(method("POST")) + .and(path("/session/tempo-testnet")) + // wiremock 0.6 requires an inline negative matcher. + .and(|req: &wiremock::Request| !req.headers.contains_key("authorization")) + .respond_with( + ResponseTemplate::new(402) + .insert_header("www-authenticate", session_offer("c1", chain_id, ESCROW)), + ) + } + + // A base64url `Payment-Receipt` header, as the gateway emits it. + fn receipt_header(accepted: &str, spent: &str) -> String { + super::super::base64_url_nopad( + serde_json::to_vec(&serde_json::json!({ + "acceptedCumulative": accepted, + "spent": spent, + })) + .unwrap(), + ) + } + + // Credential POST, matched by Authorization. + fn credential_mock(resp: ResponseTemplate) -> Mock { + Mock::given(method("POST")) + .and(path("/session/tempo-testnet")) + .and(header_exists("authorization")) + .respond_with(resp) + } + + fn rpc_ok() -> ResponseTemplate { + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xa5bf" + })) + } + + #[tokio::test] + async fn open_deposits_and_returns_the_new_channel() { + let server = MockServer::start().await; + probe_mock(42431).mount(&server).await; + credential_mock(rpc_ok()).expect(1).mount(&server).await; + + let payment = tempo_payment(&server.uri()); + let ch = open(&reqwest::Client::new(), &payment, 100_000) + .await + .unwrap(); + + assert_eq!(ch.chain_id, 42431); + assert_eq!(ch.escrow_contract, ESCROW); + assert_eq!(ch.deposit, 100_000); + // Opening also authorizes the first per-call unit. + assert_eq!(ch.per_call, 10); + assert_eq!(ch.cumulative_spent, 10); + assert!(ch.channel_id.starts_with("0x")); + assert_eq!(ch.authorized_signer.to_lowercase(), EVM_ADDR_LOWER); + } + + #[tokio::test] + async fn open_above_max_amount_is_refused_before_any_request() { + // A closed port proves the guard runs before I/O. + let payment = tempo_payment("http://127.0.0.1:1"); + let err = open(&reqwest::Client::new(), &payment, payment.max_amount + 1) + .await + .unwrap_err(); + assert!( + matches!(err, SdkError::PaymentUnsupported { offered } if offered.contains("exceeds max_amount")) + ); + } + + #[tokio::test] + async fn open_surfaces_a_gateway_refusal_as_api() { + let server = MockServer::start().await; + probe_mock(42431).mount(&server).await; + credential_mock( + ResponseTemplate::new(400) + .set_body_string("transaction does not contain a valid escrow open call"), + ) + .mount(&server) + .await; + + let payment = tempo_payment(&server.uri()); + let err = open(&reqwest::Client::new(), &payment, 100_000) + .await + .unwrap_err(); + assert!(matches!(err, SdkError::Api { status, .. } if status == 400)); + } + + // Do not open a channel for another chain. + #[tokio::test] + async fn open_for_an_unoffered_chain_is_unsupported() { + let server = MockServer::start().await; + probe_mock(1).mount(&server).await; + + let payment = tempo_payment(&server.uri()); + let err = open(&reqwest::Client::new(), &payment, 100_000) + .await + .unwrap_err(); + assert!(matches!(err, SdkError::PaymentUnsupported { .. })); + } + + // A non-402 response is not a session offer. + #[tokio::test] + async fn open_without_a_402_challenge_is_unsupported() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/session/tempo-testnet")) + .respond_with(rpc_ok()) + .mount(&server) + .await; + + let payment = tempo_payment(&server.uri()); + let err = open(&reqwest::Client::new(), &payment, 100_000) + .await + .unwrap_err(); + assert!( + matches!(err, SdkError::PaymentUnsupported { offered } if offered.contains("did not return a 402")) + ); + } + + // A missing pinned route is an SDK configuration error. + #[tokio::test] + async fn a_404_on_the_lifecycle_route_names_the_pinned_network() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/session/{SESSION_ROUTE_NETWORK}"))) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let payment = tempo_payment(&server.uri()); + let err = open(&reqwest::Client::new(), &payment, 100_000) + .await + .unwrap_err(); + assert!( + matches!(err, SdkError::PaymentUnsupported { offered } + if offered.contains(SESSION_ROUTE_NETWORK) && offered.contains("404")), + "a 404 should name the pinned route network" + ); + } + + #[tokio::test] + async fn top_up_adds_to_the_local_deposit() { + let server = MockServer::start().await; + probe_mock(42431).mount(&server).await; + credential_mock(rpc_ok()).expect(1).mount(&server).await; + + let payment = tempo_payment(&server.uri()); + let ch = sample_channel(); + let after = top_up(&reqwest::Client::new(), &payment, &ch, 50_000) + .await + .unwrap(); + + assert_eq!(after.deposit, ch.deposit + 50_000); + // Top-up changes deposit, not spend. + assert_eq!(after.cumulative_spent, ch.cumulative_spent); + assert_eq!(after.channel_id, ch.channel_id); + } + + #[tokio::test] + async fn top_up_surfaces_a_gateway_refusal_as_api() { + let server = MockServer::start().await; + probe_mock(42431).mount(&server).await; + credential_mock(ResponseTemplate::new(402).set_body_string("insufficient-balance")) + .mount(&server) + .await; + + let payment = tempo_payment(&server.uri()); + let err = top_up(&reqwest::Client::new(), &payment, &sample_channel(), 50_000) + .await + .unwrap_err(); + assert!(matches!(err, SdkError::Api { status, .. } if status == 402)); + } + + #[tokio::test] + async fn close_settles_the_final_cumulative() { + let server = MockServer::start().await; + probe_mock(42431).mount(&server).await; + credential_mock(rpc_ok()).expect(1).mount(&server).await; + + let payment = tempo_payment(&server.uri()); + close(&reqwest::Client::new(), &payment, &sample_channel()) + .await + .unwrap(); + } + + #[tokio::test] + async fn close_surfaces_a_gateway_refusal_as_api() { + let server = MockServer::start().await; + probe_mock(42431).mount(&server).await; + credential_mock(ResponseTemplate::new(409).set_body_string("channel already closed")) + .mount(&server) + .await; + + let payment = tempo_payment(&server.uri()); + let err = close(&reqwest::Client::new(), &payment, &sample_channel()) + .await + .unwrap_err(); + assert!(matches!(err, SdkError::Api { status, .. } if status == 409)); + } + + #[tokio::test] + async fn status_reads_the_gateways_channel_view_from_the_receipt() { + let server = MockServer::start().await; + probe_mock(42431).mount(&server).await; + credential_mock( + rpc_ok().insert_header("payment-receipt", receipt_header("1000", "1000").as_str()), + ) + .expect(1) + .mount(&server) + .await; + + let payment = tempo_payment(&server.uri()); + let ch = sample_channel(); + let st = status(&reqwest::Client::new(), &payment, &ch) + .await + .unwrap(); + + assert_eq!(st.channel_id, ch.channel_id); + assert_eq!(st.accepted_cumulative, 1000); + assert_eq!(st.spent, 1000); + } + + // A full channel must reject status before I/O. + #[tokio::test] + async fn status_without_room_for_the_probe_is_refused_before_any_request() { + let payment = tempo_payment("http://127.0.0.1:1"); + let mut ch = sample_channel(); + ch.cumulative_spent = ch.deposit; + let err = status(&reqwest::Client::new(), &payment, &ch) + .await + .unwrap_err(); + assert!( + matches!(err, SdkError::PaymentUnsupported { offered } if offered.contains("no room for a status probe")) + ); + } + + #[tokio::test] + async fn status_without_a_receipt_header_is_a_config_error() { + let server = MockServer::start().await; + probe_mock(42431).mount(&server).await; + credential_mock(rpc_ok()).mount(&server).await; + + let payment = tempo_payment(&server.uri()); + let err = status(&reqwest::Client::new(), &payment, &sample_channel()) + .await + .unwrap_err(); + assert!(matches!(err, SdkError::Config(m) if m.contains("no Payment-Receipt"))); + } + + #[tokio::test] + async fn voucher_call_returns_the_rpc_envelope() { + let server = MockServer::start().await; + probe_mock(42431).mount(&server).await; + credential_mock(rpc_ok()).expect(1).mount(&server).await; + + let payment = tempo_payment(&server.uri()); + let ch = sample_channel(); + let body = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "eth_chainId" }); + let out = voucher_call( + &reqwest::Client::new(), + &payment, + "tempo-testnet", + &ch, + ch.cumulative_spent + ch.per_call, + &body, + ) + .await + .unwrap(); + assert!(out.contains("0xa5bf")); + } + + #[tokio::test] + async fn voucher_call_refusal_surfaces_the_gateway_status() { + let server = MockServer::start().await; + probe_mock(42431).mount(&server).await; + credential_mock(ResponseTemplate::new(402).set_body_string("insufficient-balance")) + .mount(&server) + .await; + + let payment = tempo_payment(&server.uri()); + let ch = sample_channel(); + let body = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "eth_chainId" }); + let err = voucher_call( + &reqwest::Client::new(), + &payment, + "tempo-testnet", + &ch, + ch.cumulative_spent + ch.per_call, + &body, + ) + .await + .unwrap_err(); + assert!(matches!( + err, + SdkError::Api { .. } | SdkError::PaymentRejected { .. } + )); + } + + #[test] + fn unparseable_challenge_expiry_is_an_error_not_an_unbounded_window() { + let mut parsed = parse_session_challenge( + &session_offer("c1", 42431, "0x33b901018174DDabE4841042ab76ba85D4e24f25"), + 42431, + ) + .unwrap(); + parsed.expires = "not-a-timestamp".into(); + let err = session_valid_before(&parsed).unwrap_err(); + assert!(matches!(err, SdkError::Config(m) if m.contains("unparseable"))); + } +} diff --git a/crates/core/src/rpc/payment/signer/mod.rs b/crates/core/src/rpc/payment/signer/mod.rs new file mode 100644 index 0000000..0dcf706 --- /dev/null +++ b/crates/core/src/rpc/payment/signer/mod.rs @@ -0,0 +1,581 @@ +//! Payment signers for the crypto-micropayment lanes. +//! +//! One `enum Signer` (not a trait) holds the caller's private key as a +//! `SecretString` and dispatches to one of three signing constructions at +//! runtime. An enum is used deliberately: a trait would force `Box` +//! into the FFI-facing config and break its derived `Clone`/`Serialize`/ +//! `napi(object)`/`pyclass`, and would expose the key through `get_all`. +//! +//! The three constructions: +//! - `Evm` — EIP-712 `TransferWithAuthorization` (x402/EVM). Sync, no chain I/O. +//! - `Svm` — partially-signed SPL `TransferChecked` tx (x402/Solana). Async; +//! reads a recent blockhash + the payer's ATA from a Solana RPC. +//! - `Tempo` — native Tempo type-0x76 tx, 0x78 fee-payer handoff envelope +//! (MPP). Sync, no chain I/O (gas/fee caps are preset). + +use secrecy::{ExposeSecret, SecretString}; + +use crate::errors::SdkError; + +/// Which pay-chain family a [`Signer`] targets. Derived from the selector's +/// CAIP-2 `pay_network` (and the payment scheme) at the config boundary, so a +/// caller never states it redundantly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChainKind { + Evm, + Svm, + Tempo, +} + +/// A payment signer over a raw private key. The key is held in a +/// `SecretString` and never printed by the SDK (manual `Debug` below); it is +/// `#[serde(skip)]` so it can never be populated from the environment or +/// serialized into a log. +pub enum Signer { + /// secp256k1 key (hex, with or without `0x`) for x402/EVM EIP-712 signing. + Evm(SecretString), + /// ed25519 key (base58 64-byte secret) for x402/Solana SPL signing. + Svm(SecretString), + /// secp256k1 key (hex) for MPP/Tempo native-tx signing. + Tempo(SecretString), +} + +// Never expose the private key in SDK output. +impl std::fmt::Debug for Signer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let variant = match self { + Signer::Evm(_) => "Evm", + Signer::Svm(_) => "Svm", + Signer::Tempo(_) => "Tempo", + }; + f.debug_tuple(variant).field(&"[redacted]").finish() + } +} + +impl Signer { + pub fn kind(&self) -> ChainKind { + match self { + Signer::Evm(_) => ChainKind::Evm, + Signer::Svm(_) => ChainKind::Svm, + Signer::Tempo(_) => ChainKind::Tempo, + } + } + + fn secret(&self) -> &SecretString { + match self { + Signer::Evm(s) | Signer::Svm(s) | Signer::Tempo(s) => s, + } + } +} + +// ── secp256k1 helpers (EVM + Tempo) ────────────────────────────────────────── + +#[cfg(feature = "payments")] +mod secp { + use k256::ecdsa::SigningKey; + use sha3::{Digest, Keccak256}; + + use crate::errors::SdkError; + + pub(super) fn signing_key(hex_key: &str) -> Result { + let cleaned = hex_key.strip_prefix("0x").unwrap_or(hex_key); + let bytes = hex::decode(cleaned) + .map_err(|_| SdkError::Config("payment key is not valid hex".into()))?; + SigningKey::from_slice(&bytes) + .map_err(|_| SdkError::Config("payment key is not a valid secp256k1 key".into())) + } + + // EVM address: last 20 bytes of keccak(uncompressed public key). + pub(super) fn evm_address(key: &SigningKey) -> String { + let verifying = key.verifying_key(); + let point = verifying.to_sec1_point(false); + // Skip the uncompressed-point prefix. + let hash = Keccak256::digest(&point.as_bytes()[1..]); + format!("0x{}", hex::encode(&hash[12..])) + } + + pub(super) fn keccak256(bytes: &[u8]) -> [u8; 32] { + Keccak256::digest(bytes).into() + } + + // Generate an OS-random secp256k1 key in the format signing_key accepts. + pub(super) fn generate_key() -> String { + use rand::RngCore; + loop { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + if SigningKey::from_slice(&bytes).is_ok() { + return format!("0x{}", hex::encode(bytes)); + } + } + } + + // EIP-191 digest used for SIWE signatures. + pub(super) fn personal_sign_digest(message: &[u8]) -> [u8; 32] { + let mut prefixed = format!("\x19Ethereum Signed Message:\n{}", message.len()).into_bytes(); + prefixed.extend_from_slice(message); + keccak256(&prefixed) + } + + // Sign a prehash as r||s||v with v = 27 or 28. + pub(super) fn sign_prehash_65(key: &SigningKey, prehash: &[u8; 32]) -> [u8; 65] { + let (sig, recid) = key.sign_prehash_recoverable(prehash); + let r = sig.r().to_bytes(); + let s = sig.s().to_bytes(); + let mut out = [0u8; 65]; + out[..32].copy_from_slice(&r); + out[32..64].copy_from_slice(&s); + out[64] = 27 + recid.to_byte(); + out + } +} + +// ── EIP-712 (x402/EVM) ─────────────────────────────────────────────────────── + +/// EIP-712 domain for the USDC `TransferWithAuthorization` message. +#[cfg(feature = "payments")] +#[derive(Debug, Clone)] +pub struct Eip712Domain { + pub name: String, + pub version: String, + pub chain_id: u64, + /// Verifying contract = the asset (token) address, `0x`-prefixed hex. + pub verifying_contract: String, +} + +/// EIP-3009 `TransferWithAuthorization` message. +#[cfg(feature = "payments")] +#[derive(Debug, Clone)] +pub struct TransferWithAuthorization { + pub from: String, + pub to: String, + pub value: u128, + pub valid_after: u64, + pub valid_before: u64, + /// 32-byte nonce, `0x`-prefixed hex. + pub nonce: [u8; 32], +} + +#[cfg(feature = "payments")] +impl Signer { + /// The signer's on-chain address in the pay-chain's native encoding + /// (EVM/Tempo → `0x…` hex; Solana → base58 pubkey). + pub fn address(&self) -> Result { + match self { + Signer::Evm(_) | Signer::Tempo(_) => { + let key = secp::signing_key(self.secret().expose_secret())?; + Ok(secp::evm_address(&key)) + } + #[cfg(feature = "payments-svm")] + Signer::Svm(_) => self.svm_address(), + #[cfg(not(feature = "payments-svm"))] + Signer::Svm(_) => Err(SdkError::Config( + "x402/Solana requires the `payments-svm` feature".into(), + )), + } + } + + /// Sign an EIP-712 `TransferWithAuthorization` (x402/EVM). Returns the + /// 65-byte `r||s||v` signature. Sync — no chain I/O. + pub fn sign_eip712( + &self, + domain: &Eip712Domain, + message: &TransferWithAuthorization, + ) -> Result<[u8; 65], SdkError> { + let key = secp::signing_key(self.secret().expose_secret())?; + let digest = eip712_digest(domain, message)?; + Ok(secp::sign_prehash_65(&key, &digest)) + } + + /// Sign a Sign-In-With-Ethereum (EIP-4361) message via EIP-191 + /// `personal_sign`, returning the `0x`-prefixed 65-byte `r||s||v` hex the + /// gateway's SIWX `/auth` handshake expects. EVM only — an SVM signer must + /// use the SIWS (ed25519) construction instead. + pub fn sign_siwe(&self, message: &str) -> Result { + match self { + Signer::Evm(_) | Signer::Tempo(_) => { + let key = secp::signing_key(self.secret().expose_secret())?; + let digest = secp::personal_sign_digest(message.as_bytes()); + let sig = secp::sign_prehash_65(&key, &digest); + Ok(format!("0x{}", hex::encode(sig))) + } + Signer::Svm(_) => Err(SdkError::Config( + "SIWE signing is EVM-only; an SVM signer uses SIWS (ed25519)".into(), + )), + } + } + + /// Sign an MPP session voucher (`Voucher(bytes32 channelId,uint128 + /// cumulativeAmount)`) against the legacy escrow contract's EIP-712 domain + /// ("Tempo Stream Channel"), returning the `0x`-prefixed 65-byte `r||s||v` + /// hex. For a secp256k1 payer the on-wire SignatureEnvelope is the raw 65 + /// bytes (no type prefix), so this hex IS the envelope. `escrow` is the + /// verifying contract, from the session challenge's `methodDetails`. + pub fn sign_session_voucher( + &self, + channel_id: &str, + cumulative_amount: u128, + chain_id: u64, + escrow: &str, + ) -> Result { + let key = secp::signing_key(self.secret().expose_secret())?; + let digest = session_voucher_digest(channel_id, cumulative_amount, chain_id, escrow)?; + let sig = secp::sign_prehash_65(&key, &digest); + Ok(format!("0x{}", hex::encode(sig))) + } +} + +// Build the legacy escrow voucher EIP-712 digest. +#[cfg(feature = "payments")] +fn session_voucher_digest( + channel_id: &str, + cumulative_amount: u128, + chain_id: u64, + escrow: &str, +) -> Result<[u8; 32], SdkError> { + // Build the escrow domain separator. + let domain_type = + b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"; + let mut sep = Vec::with_capacity(160); + sep.extend_from_slice(&secp::keccak256(domain_type)); + sep.extend_from_slice(&secp::keccak256(b"Tempo Stream Channel")); + sep.extend_from_slice(&secp::keccak256(b"1")); + sep.extend_from_slice(&u256_be(chain_id as u128)); + sep.extend_from_slice(&address_word(escrow)?); + let domain_separator = secp::keccak256(&sep); + + // Build the voucher hash. + let voucher_type = b"Voucher(bytes32 channelId,uint128 cumulativeAmount)"; + let channel = bytes32_word(channel_id)?; + let mut vh = Vec::with_capacity(96); + vh.extend_from_slice(&secp::keccak256(voucher_type)); + vh.extend_from_slice(&channel); + vh.extend_from_slice(&u256_be(cumulative_amount)); + let voucher_hash = secp::keccak256(&vh); + + let mut final_input = Vec::with_capacity(66); + final_input.extend_from_slice(&[0x19, 0x01]); + final_input.extend_from_slice(&domain_separator); + final_input.extend_from_slice(&voucher_hash); + Ok(secp::keccak256(&final_input)) +} + +// A 32-byte value (`0x`-prefixed hex) as a raw EVM word. Errors if not 32 bytes. +#[cfg(feature = "payments")] +fn bytes32_word(hex_str: &str) -> Result<[u8; 32], SdkError> { + let cleaned = hex_str.strip_prefix("0x").unwrap_or(hex_str); + let bytes = hex::decode(cleaned) + .map_err(|_| SdkError::Config(format!("invalid bytes32: {hex_str}")))?; + if bytes.len() != 32 { + return Err(SdkError::Config(format!( + "channelId must be 32 bytes, got {}", + bytes.len() + ))); + } + let mut word = [0u8; 32]; + word.copy_from_slice(&bytes); + Ok(word) +} + +/// A freshly generated payment wallet: the raw private key in the on-wire +/// format the key-file reader expects, plus its derived on-chain address. +/// +/// The key is held in a [`SecretString`] so it is never printed or logged by +/// accident; the caller decides where to persist it. `chain` records which +/// pay-chain family the key targets. +#[cfg(feature = "payments")] +pub struct GeneratedWallet { + /// Raw private key: EVM/Tempo → `0x`-prefixed secp256k1 hex; SVM → base58 + /// 64-byte `[secret || public]`. + pub key: SecretString, + /// On-chain address: EVM/Tempo → `0x…` hex; SVM → base58 pubkey. + pub address: String, + /// The pay-chain family this key is for. + pub chain: ChainKind, +} + +#[cfg(feature = "payments")] +impl GeneratedWallet { + /// Consumes the wallet and returns the raw private key string, for callers + /// that must persist it (e.g. writing a key file). Consuming (rather than + /// borrowing) keeps the exposure a deliberate, one-shot step. + pub fn into_key(self) -> String { + self.key.expose_secret().to_string() + } +} + +/// Generates a fresh payment keypair for `chain`, returning the raw key (in the +/// format `PaymentConfig::key` accepts) and its derived address. Randomness +/// comes from the OS CSPRNG. +/// +/// `Tempo` uses the same secp256k1 key format as `Evm`. +#[cfg(feature = "payments")] +pub fn generate_payment_wallet(chain: ChainKind) -> Result { + let raw = match chain { + ChainKind::Evm | ChainKind::Tempo => secp::generate_key(), + #[cfg(feature = "payments-svm")] + ChainKind::Svm => svm::generate_svm_key(), + #[cfg(not(feature = "payments-svm"))] + ChainKind::Svm => { + return Err(SdkError::Config( + "x402/Solana wallet generation requires the `payments-svm` feature".into(), + )) + } + }; + let signer = match chain { + ChainKind::Evm => Signer::Evm(SecretString::new(raw.clone())), + ChainKind::Tempo => Signer::Tempo(SecretString::new(raw.clone())), + ChainKind::Svm => Signer::Svm(SecretString::new(raw.clone())), + }; + let address = signer.address()?; + Ok(GeneratedWallet { + key: SecretString::new(raw), + address, + chain, + }) +} + +// EIP-712 final digest: keccak256(0x1901 || domainSeparator || hashStruct). +#[cfg(feature = "payments")] +fn eip712_digest( + domain: &Eip712Domain, + message: &TransferWithAuthorization, +) -> Result<[u8; 32], SdkError> { + // domainSeparator = keccak256(typeHash || keccak(name) || keccak(version) + // || chainId || verifyingContract) + let domain_type = + b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"; + let mut sep = Vec::with_capacity(160); + sep.extend_from_slice(&secp::keccak256(domain_type)); + sep.extend_from_slice(&secp::keccak256(domain.name.as_bytes())); + sep.extend_from_slice(&secp::keccak256(domain.version.as_bytes())); + sep.extend_from_slice(&u256_be(domain.chain_id as u128)); + sep.extend_from_slice(&address_word(&domain.verifying_contract)?); + let domain_separator = secp::keccak256(&sep); + + // hashStruct(message) = keccak256(typeHash || from || to || value + // || validAfter || validBefore || nonce) + let msg_type = b"TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"; + let mut hs = Vec::with_capacity(224); + hs.extend_from_slice(&secp::keccak256(msg_type)); + hs.extend_from_slice(&address_word(&message.from)?); + hs.extend_from_slice(&address_word(&message.to)?); + hs.extend_from_slice(&u256_be(message.value)); + hs.extend_from_slice(&u256_be(message.valid_after as u128)); + hs.extend_from_slice(&u256_be(message.valid_before as u128)); + hs.extend_from_slice(&message.nonce); + let hash_struct = secp::keccak256(&hs); + + let mut final_input = Vec::with_capacity(66); + final_input.extend_from_slice(&[0x19, 0x01]); + final_input.extend_from_slice(&domain_separator); + final_input.extend_from_slice(&hash_struct); + Ok(secp::keccak256(&final_input)) +} + +// A u128 as a 32-byte big-endian EVM word (left-padded with zeros). +#[cfg(feature = "payments")] +fn u256_be(value: u128) -> [u8; 32] { + let mut word = [0u8; 32]; + word[16..].copy_from_slice(&value.to_be_bytes()); + word +} + +// A 20-byte address left-padded into a 32-byte EVM word. +#[cfg(feature = "payments")] +fn address_word(addr: &str) -> Result<[u8; 32], SdkError> { + let cleaned = addr.strip_prefix("0x").unwrap_or(addr); + let bytes = + hex::decode(cleaned).map_err(|_| SdkError::Config(format!("invalid address: {addr}")))?; + if bytes.len() != 20 { + return Err(SdkError::Config(format!( + "address must be 20 bytes, got {}", + bytes.len() + ))); + } + let mut word = [0u8; 32]; + word[12..].copy_from_slice(&bytes); + Ok(word) +} + +// ── x402/Solana (SPL TransferChecked) ──────────────────────────────────────── +#[cfg(feature = "payments-svm")] +mod svm; + +// ── MPP/Tempo (native type-0x76 tx) ────────────────────────────────────────── +#[cfg(feature = "payments-tempo")] +pub(crate) mod tempo; + +#[cfg(feature = "payments-tempo")] +pub use tempo::TempoChargeRequest; + +#[cfg(feature = "payments-svm")] +pub use svm::SvmTransferRequest; + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + // Offline EIP-712 vector from an unfunded test key. + const THROWAWAY_KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + const THROWAWAY_ADDR: &str = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"; + + #[test] + fn evm_address_derivation() { + let signer = Signer::Evm(SecretString::new(THROWAWAY_KEY.to_string())); + assert_eq!(signer.address().unwrap(), THROWAWAY_ADDR); + } + + #[test] + fn redacted_debug_never_prints_key() { + let signer = Signer::Evm(SecretString::new(THROWAWAY_KEY.to_string())); + let rendered = format!("{signer:?}"); + assert!(rendered.contains("[redacted]")); + assert!(!rendered.contains(THROWAWAY_KEY)); + } + + // Generated keys must round-trip and preserve their address. + #[test] + fn generated_evm_wallet_round_trips() { + let w = generate_payment_wallet(ChainKind::Evm).unwrap(); + assert!(matches!(w.chain, ChainKind::Evm)); + let raw = w.key.expose_secret(); + assert!(raw.starts_with("0x")); + let reparsed = Signer::Evm(SecretString::new(raw.to_string())); + assert_eq!(reparsed.address().unwrap(), w.address); + assert!(w.address.starts_with("0x") && w.address.len() == 42); + } + + #[test] + fn generated_tempo_wallet_round_trips() { + let w = generate_payment_wallet(ChainKind::Tempo).unwrap(); + let raw = w.key.expose_secret(); + let reparsed = Signer::Tempo(SecretString::new(raw.to_string())); + assert_eq!(reparsed.address().unwrap(), w.address); + } + + #[cfg(feature = "payments-svm")] + #[test] + fn generated_svm_wallet_round_trips() { + let w = generate_payment_wallet(ChainKind::Svm).unwrap(); + assert!(matches!(w.chain, ChainKind::Svm)); + let raw = w.key.expose_secret(); + let reparsed = Signer::Svm(SecretString::new(raw.to_string())); + assert_eq!(reparsed.address().unwrap(), w.address); + // base58 32-byte pubkey. + assert_eq!(bs58::decode(&w.address).into_vec().unwrap().len(), 32); + } + + // Generated keys should differ. + #[test] + fn generated_wallets_are_unique() { + let a = generate_payment_wallet(ChainKind::Evm).unwrap(); + let b = generate_payment_wallet(ChainKind::Evm).unwrap(); + assert_ne!(a.address, b.address); + } + + #[test] + fn eip712_digest_is_deterministic_and_domain_bound() { + // The digest is stable for identical inputs and domain-bound. + let domain = Eip712Domain { + name: "USDC".into(), + version: "2".into(), + chain_id: 84532, + verifying_contract: "0x036CbD53842c5426634e7929541eC2318f3dCF7e".into(), + }; + let message = TransferWithAuthorization { + from: THROWAWAY_ADDR.into(), + to: "0xF46D6C4Bf5F5F0Bf5F5F0Bf5F5F0Bf5F5F0Bf623C" + .chars() + .take(42) + .collect::(), + value: 1000, + valid_after: 0, + valid_before: 1_783_907_686, + nonce: [0x76; 32], + }; + let d1 = eip712_digest(&domain, &message).unwrap(); + let d2 = eip712_digest(&domain, &message).unwrap(); + assert_eq!(d1, d2); + let mut domain2 = domain.clone(); + domain2.chain_id = 1; + let d3 = eip712_digest(&domain2, &message).unwrap(); + assert_ne!(d1, d3); + } + + #[test] + fn eip712_reproduces_known_good_vector() { + // Match the offline viem signature vector. + const EXPECTED_SIG: &str = "0xc3a69d1a9043a75d840f66ccc9a95cdbc690bdd669424f00ba955ee7bcdb4a1e3293d7ab2e9663fc3486215be0cbb3da6c3cdcb71cf811b8b612c004014f0ba71b"; + let signer = Signer::Evm(SecretString::new(THROWAWAY_KEY.to_string())); + let domain = Eip712Domain { + name: "USDC".into(), + version: "2".into(), + chain_id: 84532, + verifying_contract: "0x036CbD53842c5426634e7929541eC2318f3dCF7e".into(), + }; + let message = TransferWithAuthorization { + from: THROWAWAY_ADDR.into(), + to: "0x0000000000000000000000000000000000000001".into(), + value: 1000, + valid_after: 0, + valid_before: 1_783_907_686, + nonce: [0x11; 32], + }; + let sig = signer.sign_eip712(&domain, &message).unwrap(); + assert_eq!(format!("0x{}", hex::encode(sig)), EXPECTED_SIG); + } + + #[test] + fn session_voucher_digest_reproduces_reference_vector() { + // Match the legacy escrow voucher digest vector. + const CHANNEL_ID: &str = + "0xfb56137dcb0089f01877bcdb72d5e028ef04aec578fb00a642f65ee293c73dec"; + const ESCROW: &str = "0x33b901018174DDabE4841042ab76ba85D4e24f25"; + const EXPECTED: &str = "0xac624e7cd65dbba54630326d204807b64c2666a9c07b19bffd86f7b7b1e27d17"; + let digest = session_voucher_digest(CHANNEL_ID, 10, 42431, ESCROW).unwrap(); + assert_eq!(format!("0x{}", hex::encode(digest)), EXPECTED); + } + + #[test] + fn session_voucher_signature_reproduces_reference_vector() { + // Match the legacy escrow signature vector. + const CHANNEL_ID: &str = + "0xfb56137dcb0089f01877bcdb72d5e028ef04aec578fb00a642f65ee293c73dec"; + const ESCROW: &str = "0x33b901018174DDabE4841042ab76ba85D4e24f25"; + const EXPECTED_SIG: &str = "0x44bb3c206a8cbabadced98ad8f87d6191d7ab81577efe41830acdd77f8a981020791643240da6648fbe09ba1e7707bff5727f05e4d82b004b427652dd594e1fe1b"; + let signer = Signer::Tempo(SecretString::new( + "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80".to_string(), + )); + let sig = signer + .sign_session_voucher(CHANNEL_ID, 10, 42431, ESCROW) + .unwrap(); + assert_eq!(sig, EXPECTED_SIG); + } + + #[test] + fn session_voucher_digest_is_domain_and_amount_bound() { + let ch = "0x1111111111111111111111111111111111111111111111111111111111111111"; + let escrow = "0x33b901018174DDabE4841042ab76ba85D4e24f25"; + let base = session_voucher_digest(ch, 1000, 42431, escrow).unwrap(); + // Changing the cumulative amount changes the digest. + assert_ne!( + base, + session_voucher_digest(ch, 1001, 42431, escrow).unwrap() + ); + // Changing the chain id changes the digest. + assert_ne!(base, session_voucher_digest(ch, 1000, 1, escrow).unwrap()); + // Changing the verifying contract changes the digest. + assert_ne!( + base, + session_voucher_digest( + ch, + 1000, + 42431, + "0x4d50500000000000000000000000000000000000" + ) + .unwrap() + ); + } +} diff --git a/crates/core/src/rpc/payment/signer/svm.rs b/crates/core/src/rpc/payment/signer/svm.rs new file mode 100644 index 0000000..a6aaa86 --- /dev/null +++ b/crates/core/src/rpc/payment/signer/svm.rs @@ -0,0 +1,463 @@ +//! x402/Solana SPL `TransferChecked` signer. +//! +//! Builds a partially-signed Solana transaction: the gateway's `feePayer` +//! (from the challenge `extra.feePayer`) is the transaction fee payer and the +//! first required signature slot, so the payer needs no SOL — only the token. +//! The payer signs its own slot; the gateway co-signs the fee-payer slot +//! server-side before submitting. +//! +//! The instructions are hand-rolled rather than pulling `spl-token`, which +//! drags `solana-program` → curve25519/MSRV conflicts under cross+zig at +//! glibc-2.17/musl. +//! +//! The message is a **v0** message carrying four instructions, matching the +//! canonical x402 Solana scheme: +//! +//! 1. `SetComputeUnitLimit` +//! 2. `SetComputeUnitPrice` +//! 3. SPL `TransferChecked` +//! 4. `Memo` — the challenge's `extra.memo`, else a random nonce. This is the +//! payment's replay-protection nonce, so it is not optional. +//! +//! Async: the mint (for decimals and its owning token program) and a recent +//! blockhash are read from a Solana RPC (source precedence resolved by the +//! driver: explicit override → tooling endpoint → public default). The gateway +//! 402s keyless sub-reads, so these reads go to a plain Solana RPC, not the +//! gateway. + +use ed25519_dalek::{Signer as _, SigningKey}; +use sha2::{Digest, Sha256}; + +use super::Signer; +use crate::errors::SdkError; + +// Program ids are embedded to avoid the spl-token dependency. +const TOKEN_PROGRAM_ID: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; +const TOKEN_2022_PROGRAM_ID: &str = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"; +const ASSOCIATED_TOKEN_PROGRAM_ID: &str = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"; +const COMPUTE_BUDGET_PROGRAM_ID: &str = "ComputeBudget111111111111111111111111111111"; +const MEMO_PROGRAM_ID: &str = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"; + +// SPL TransferChecked instruction discriminant. +const TRANSFER_CHECKED: u8 = 12; +// ComputeBudget instruction discriminants. +const SET_COMPUTE_UNIT_LIMIT: u8 = 2; +const SET_COMPUTE_UNIT_PRICE: u8 = 3; + +// Compute-budget defaults, matching the canonical scheme. +const DEFAULT_COMPUTE_UNIT_LIMIT: u32 = 20_000; +const DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS: u64 = 1; + +/// Upper bound on the memo payload, matching the canonical scheme. +pub(crate) const MAX_MEMO_BYTES: usize = 256; + +// High bit marks a versioned message; legacy messages start with a signature count. +const V0_MESSAGE_PREFIX: u8 = 0x80; + +/// Inputs for one x402/Solana payment, derived from the decoded challenge. +#[derive(Debug, Clone)] +pub struct SvmTransferRequest { + /// Token mint (challenge `asset`), base58. + pub mint: String, + /// Payment recipient owner (challenge `payTo`), base58. + pub pay_to: String, + /// Gateway fee payer (challenge `extra.feePayer`), base58. + pub fee_payer: String, + /// Amount in token base units. + pub amount: u64, + /// Token decimals (TransferChecked requires them). + pub decimals: u8, + /// Recent blockhash (base58), read from the Solana RPC by the driver. + pub recent_blockhash: String, + /// The mint's owning token program (base58), read from the mint account by + /// the driver — SPL Token or Token-2022. + pub token_program: String, + /// Memo payload: the challenge's `extra.memo` when present, else a random + /// nonce minted by the driver. Carries the payment's replay protection. + pub memo: String, +} + +impl Signer { + // base58 ed25519 pubkey (Solana address) of the payer. + pub(super) fn svm_address(&self) -> Result { + let key = svm_signing_key(self)?; + Ok(bs58::encode(key.verifying_key().to_bytes()).into_string()) + } + + /// Build a partially-signed x402/Solana payment transaction: a v0 message + /// carrying compute-budget, SPL `TransferChecked` and memo instructions. + /// Returns the serialized signed transaction bytes (the driver base64s them + /// into the payment envelope's `payload.transaction`). + pub fn sign_svm_transfer(&self, req: &SvmTransferRequest) -> Result, SdkError> { + let key = svm_signing_key(self)?; + let payer = key.verifying_key().to_bytes(); + + // TransferChecked is supported only by these token programs. + if req.token_program != TOKEN_PROGRAM_ID && req.token_program != TOKEN_2022_PROGRAM_ID { + return Err(SdkError::Config(format!( + "mint {} is owned by {}, which is not a known SPL token program", + req.mint, req.token_program + ))); + } + let token_program = decode_pubkey(&req.token_program)?; + let mint = decode_pubkey(&req.mint)?; + let pay_to_owner = decode_pubkey(&req.pay_to)?; + let fee_payer = decode_pubkey(&req.fee_payer)?; + let compute_budget = decode_pubkey(COMPUTE_BUDGET_PROGRAM_ID)?; + let memo_program = decode_pubkey(MEMO_PROGRAM_ID)?; + + let memo_data = req.memo.as_bytes(); + if memo_data.len() > MAX_MEMO_BYTES { + return Err(SdkError::Config(format!( + "x402 Solana memo exceeds the maximum {MAX_MEMO_BYTES} bytes" + ))); + } + + // Derive the source and destination ATAs. + let source_ata = associated_token_address(&payer, &token_program, &mint)?; + let dest_ata = associated_token_address(&pay_to_owner, &token_program, &mint)?; + + // Runtime-required order: writable signers, readonly signers, + // writable nonsigners, readonly nonsigners. + let accounts = vec![ + fee_payer, + payer, + source_ata, + dest_ata, + mint, + token_program, + compute_budget, + memo_program, + ]; + let header = MessageHeader { + num_required_signatures: 2, + num_readonly_signed: 0, + // mint, token_program, compute_budget, memo_program + num_readonly_unsigned: 4, + }; + let index = + |pk: &[u8; 32]| -> u8 { accounts.iter().position(|a| a == pk).map_or(0, |p| p as u8) }; + + // Compute budget instructions must come first. + let mut cu_limit_data = Vec::with_capacity(5); + cu_limit_data.push(SET_COMPUTE_UNIT_LIMIT); + cu_limit_data.extend_from_slice(&DEFAULT_COMPUTE_UNIT_LIMIT.to_le_bytes()); + + let mut cu_price_data = Vec::with_capacity(9); + cu_price_data.push(SET_COMPUTE_UNIT_PRICE); + cu_price_data.extend_from_slice(&DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS.to_le_bytes()); + + // TransferChecked accounts and data layout. + let mut transfer_data = Vec::with_capacity(10); + transfer_data.push(TRANSFER_CHECKED); + transfer_data.extend_from_slice(&req.amount.to_le_bytes()); + transfer_data.push(req.decimals); + + let instructions = vec![ + Instruction { + program_index: index(&compute_budget), + account_indexes: Vec::new(), + data: cu_limit_data, + }, + Instruction { + program_index: index(&compute_budget), + account_indexes: Vec::new(), + data: cu_price_data, + }, + Instruction { + program_index: index(&token_program), + account_indexes: vec![ + index(&source_ata), + index(&mint), + index(&dest_ata), + index(&payer), + ], + data: transfer_data, + }, + Instruction { + program_index: index(&memo_program), + account_indexes: Vec::new(), + data: memo_data.to_vec(), + }, + ]; + + let message = build_message(&header, &accounts, &req.recent_blockhash, &instructions)?; + + // Leave the gateway's fee-payer signature slot empty. + let payer_sig = key.sign(&message).to_bytes(); + let mut tx = Vec::new(); + write_compact_u16(&mut tx, 2); + tx.extend_from_slice(&[0u8; 64]); // fee-payer slot (gateway fills) + tx.extend_from_slice(&payer_sig); // payer slot + tx.extend_from_slice(&message); + Ok(tx) + } +} + +/// A v0 message's account-permission counts. +struct MessageHeader { + num_required_signatures: u8, + num_readonly_signed: u8, + num_readonly_unsigned: u8, +} + +/// One compiled instruction: indexes into the message's account list. +struct Instruction { + program_index: u8, + account_indexes: Vec, + data: Vec, +} + +/// Generates a fresh Solana keypair. Returns the base58-encoded 64-byte +/// `[seed(32) || public(32)]` secret key (the format `svm_signing_key` reads). +/// Randomness comes from `rand::thread_rng` (the OS CSPRNG), matching the +/// nonce generator used elsewhere in this module. +pub(super) fn generate_svm_key() -> String { + use rand::RngCore; + let mut seed = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut seed); + let key = SigningKey::from_bytes(&seed); + let mut full = Vec::with_capacity(64); + full.extend_from_slice(&seed); + full.extend_from_slice(&key.verifying_key().to_bytes()); + bs58::encode(full).into_string() +} + +fn svm_signing_key(signer: &Signer) -> Result { + let Signer::Svm(secret) = signer else { + return Err(SdkError::Config( + "sign_svm_transfer requires an Svm signer".into(), + )); + }; + use secrecy::ExposeSecret; + let raw = secret.expose_secret(); + let bytes = bs58::decode(raw.trim()) + .into_vec() + .map_err(|_| SdkError::Config("Solana key is not valid base58".into()))?; + // Solana secret keys are the 64-byte [secret(32) || public(32)] form. + let seed: [u8; 32] = bytes + .get(..32) + .and_then(|s| s.try_into().ok()) + .ok_or_else(|| SdkError::Config("Solana key must be at least 32 bytes".into()))?; + Ok(SigningKey::from_bytes(&seed)) +} + +fn decode_pubkey(b58: &str) -> Result<[u8; 32], SdkError> { + let bytes = bs58::decode(b58) + .into_vec() + .map_err(|_| SdkError::Config(format!("invalid base58 pubkey: {b58}")))?; + bytes + .try_into() + .map_err(|_| SdkError::Config(format!("pubkey must be 32 bytes: {b58}"))) +} + +// Derive the ATA PDA by searching bump values from 255 down. +fn associated_token_address( + owner: &[u8; 32], + token_program: &[u8; 32], + mint: &[u8; 32], +) -> Result<[u8; 32], SdkError> { + let ata_program = decode_pubkey(ASSOCIATED_TOKEN_PROGRAM_ID)?; + for bump in (0u8..=255).rev() { + let mut hasher = Sha256::new(); + hasher.update(owner); + hasher.update(token_program); + hasher.update(mint); + hasher.update([bump]); + hasher.update(ata_program); + hasher.update(b"ProgramDerivedAddress"); + let candidate: [u8; 32] = hasher.finalize().into(); + // PDAs must be off-curve. + if !is_on_curve(&candidate) { + return Ok(candidate); + } + } + Err(SdkError::Config( + "could not derive associated token account (no off-curve bump)".into(), + )) +} + +// VerifyingKey parsing distinguishes on-curve points without another curve +// dependency. +fn is_on_curve(bytes: &[u8; 32]) -> bool { + ed25519_dalek::VerifyingKey::from_bytes(bytes).is_ok() +} + +// Build a versioned message for the payment instructions. +fn build_message( + header: &MessageHeader, + accounts: &[[u8; 32]], + recent_blockhash: &str, + instructions: &[Instruction], +) -> Result, SdkError> { + let blockhash = decode_pubkey(recent_blockhash)?; // 32-byte hash, base58 + + // Version prefix followed by account-permission counts. + let mut msg = vec![ + V0_MESSAGE_PREFIX, + header.num_required_signatures, + header.num_readonly_signed, + header.num_readonly_unsigned, + ]; + write_compact_u16(&mut msg, accounts.len() as u16); + for acct in accounts { + msg.extend_from_slice(acct); + } + msg.extend_from_slice(&blockhash); + write_compact_u16(&mut msg, instructions.len() as u16); + for ix in instructions { + msg.push(ix.program_index); + write_compact_u16(&mut msg, ix.account_indexes.len() as u16); + msg.extend_from_slice(&ix.account_indexes); + write_compact_u16(&mut msg, ix.data.len() as u16); + msg.extend_from_slice(&ix.data); + } + // No address-table lookups. + write_compact_u16(&mut msg, 0); + Ok(msg) +} + +// Encode a Solana compact-u16 length. +fn write_compact_u16(out: &mut Vec, mut value: u16) { + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + out.push(byte); + if value == 0 { + break; + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + // Deterministic, unfunded test keypair. + fn throwaway_signer() -> Signer { + // Fixed seed for deterministic tests. + let seed = [1u8; 32]; + let key = SigningKey::from_bytes(&seed); + let mut full = Vec::with_capacity(64); + full.extend_from_slice(&seed); + full.extend_from_slice(&key.verifying_key().to_bytes()); + Signer::Svm(bs58::encode(full).into_string().into()) + } + + #[test] + fn compact_u16_encoding() { + let mut buf = Vec::new(); + write_compact_u16(&mut buf, 1); + assert_eq!(buf, vec![1]); + buf.clear(); + write_compact_u16(&mut buf, 128); + assert_eq!(buf, vec![0x80, 0x01]); + } + + #[test] + fn svm_address_is_base58_pubkey() { + let signer = throwaway_signer(); + let addr = signer.svm_address().unwrap(); + // 32-byte pubkey → 43-44 base58 chars. + assert!(addr.len() >= 43 && addr.len() <= 44, "addr: {addr}"); + assert!(bs58::decode(&addr).into_vec().unwrap().len() == 32); + } + + #[test] + fn ata_is_deterministic_and_off_curve() { + let owner = [2u8; 32]; + let token_program = decode_pubkey(TOKEN_PROGRAM_ID).unwrap(); + let mint = [3u8; 32]; + let a = associated_token_address(&owner, &token_program, &mint).unwrap(); + let b = associated_token_address(&owner, &token_program, &mint).unwrap(); + assert_eq!(a, b); + assert!(!is_on_curve(&a)); + } + + fn test_request() -> SvmTransferRequest { + SvmTransferRequest { + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(), + pay_to: bs58::encode([4u8; 32]).into_string(), + fee_payer: bs58::encode([5u8; 32]).into_string(), + amount: 1000, + decimals: 6, + recent_blockhash: bs58::encode([6u8; 32]).into_string(), + token_program: TOKEN_PROGRAM_ID.into(), + memo: "0123456789abcdef".into(), + } + } + + #[test] + fn transfer_produces_two_sig_slots_with_payer_filled() { + let signer = throwaway_signer(); + let tx = signer.sign_svm_transfer(&test_request()).unwrap(); + // Two signatures precede the message; the gateway slot is empty. + assert_eq!(tx[0], 2); + assert_eq!(&tx[1..65], &[0u8; 64]); + assert!(tx[65..129].iter().any(|&b| b != 0)); + } + + // Lock the gateway-required v0 message shape. + #[test] + fn message_is_v0_with_four_instructions() { + let signer = throwaway_signer(); + let tx = signer.sign_svm_transfer(&test_request()).unwrap(); + let msg = &tx[129..]; + + assert_eq!(msg[0], V0_MESSAGE_PREFIX, "v0 version prefix"); + assert_eq!(&msg[1..4], &[2, 0, 4], "header: 2 signers, 4 readonly"); + assert_eq!(msg[4], 8, "account count"); + + // Prefix, header, and account count. + let after_accounts = 5 + 8 * 32; + let after_blockhash = after_accounts + 32; + assert_eq!(msg[after_blockhash], 4, "four instructions"); + + // Collect each instruction's program and opcode. + let mut cursor = after_blockhash + 1; + let mut seen = Vec::new(); + for _ in 0..4 { + let program_index = msg[cursor]; + cursor += 1; + let n_accounts = msg[cursor] as usize; + cursor += 1 + n_accounts; + let n_data = msg[cursor] as usize; + cursor += 1; + seen.push((program_index, msg[cursor], n_accounts)); + cursor += n_data; + } + + // 6 = ComputeBudget, 5 = token program, 7 = Memo. + assert_eq!( + seen, + vec![ + (6, SET_COMPUTE_UNIT_LIMIT, 0), + (6, SET_COMPUTE_UNIT_PRICE, 0), + (5, TRANSFER_CHECKED, 4), + (7, b'0', 0), + ] + ); + + // Empty address-table-lookup vector. + assert_eq!(msg[cursor], 0, "no address table lookups"); + assert_eq!(cursor + 1, msg.len(), "message fully consumed"); + } + + #[test] + fn oversized_memo_is_rejected() { + let signer = throwaway_signer(); + let req = SvmTransferRequest { + memo: "x".repeat(MAX_MEMO_BYTES + 1), + ..test_request() + }; + let err = signer.sign_svm_transfer(&req).unwrap_err(); + assert!( + matches!(err, SdkError::Config(msg) if msg.contains("memo")), + "expected a memo-size Config error" + ); + } +} diff --git a/crates/core/src/rpc/payment/signer/tempo.rs b/crates/core/src/rpc/payment/signer/tempo.rs new file mode 100644 index 0000000..5962be9 --- /dev/null +++ b/crates/core/src/rpc/payment/signer/tempo.rs @@ -0,0 +1,641 @@ +//! MPP/Tempo native type-0x76 transaction signer. +//! +//! Matches the wire format produced by the `ox/tempo` (viem) reference encoder. +//! The credential's `payload.signature` is the **0x78 fee-payer handoff +//! envelope**: the sender signs a type-0x76 preimage (fee-payer slot = `0x00` +//! placeholder, `feeToken` skipped — the gateway sponsors gas), then +//! re-serializes with its own address in the fee-payer slot and the sig +//! appended. The gateway relay co-signs server-side. +//! +//! Sync, zero chain reads: `nonceKey:"expiring"` resolves locally +//! (`nonceKey = U256::MAX`, `nonce = 0`, `validBefore = min(now+25s, expiry)`) +//! and gas/fee caps are preset generous constants (the sponsor pays the fee, so +//! the caps cost the payer nothing — they only need to clear inclusion). + +use std::num::NonZeroU64; + +use alloy_primitives::{Address, Bytes, Signature, TxKind, U256}; +use alloy_rlp::Encodable; +use secrecy::ExposeSecret; +use sha3::{Digest, Keccak256}; +use tempo_primitives::transaction::tempo_transaction::{Call, TempoTransaction}; + +use super::secp; +use super::Signer; +use crate::errors::SdkError; + +// TIP20 transferWithMemo(address,uint256,bytes32) selector. +const TRANSFER_WITH_MEMO_SELECTOR: [u8; 4] = [0x95, 0x77, 0x7d, 0x59]; + +// ERC-20/TIP-20 approve(address,uint256) selector. +const APPROVE_SELECTOR: [u8; 4] = [0x09, 0x5e, 0xa7, 0xb3]; + +// Fixed caps; the gateway sponsors the fee. +const DEFAULT_GAS_LIMIT: u64 = 150_000; +const DEFAULT_MAX_FEE_PER_GAS: u128 = 10_000_000_000; // 10 gwei +const DEFAULT_MAX_PRIORITY_FEE_PER_GAS: u128 = 2_000_000_000; // 2 gwei + +// Escrow open/topUp use two calls and require the sponsor's 2M gas cap. +const ESCROW_GAS_LIMIT: u64 = 2_000_000; + +/// Inputs for one MPP/Tempo charge, derived from the decoded challenge. +#[derive(Debug, Clone)] +pub struct TempoChargeRequest { + pub chain_id: u64, + /// TIP20 token id (challenge `currency`), `0x`-hex. + pub currency: String, + /// Payment recipient (challenge `request.recipient`), `0x`-hex. + pub recipient: String, + /// Amount in token base units (challenge `request.amount`). + pub amount: u128, + /// Challenge id (for the attribution memo). + pub challenge_id: String, + /// Challenge realm / server id (for the attribution memo). + pub realm: String, + /// `validBefore` = min(now+25s, challenge expiry) as unix seconds, + /// computed by the driver against the local clock. + pub valid_before: u64, + /// Optional overrides for the fixed gas/fee caps. + pub gas_limit: Option, + pub max_fee_per_gas: Option, + pub max_priority_fee_per_gas: Option, +} + +impl Signer { + /// Sign an MPP/Tempo charge. Returns the 0x78 fee-payer handoff envelope + /// bytes (the credential's `payload.signature`). Sync, no chain reads. + pub fn sign_tempo_tx(&self, req: &TempoChargeRequest) -> Result, SdkError> { + let Signer::Tempo(secret) = self else { + return Err(SdkError::Config( + "sign_tempo_tx requires a Tempo signer".into(), + )); + }; + let key = secp::signing_key(secret.expose_secret())?; + let sender_hex = secp::evm_address(&key); + let sender: Address = sender_hex + .parse() + .map_err(|_| SdkError::Config("derived sender address is invalid".into()))?; + + let token: Address = parse_address(&req.currency)?; + let calldata = transfer_with_memo_calldata(req)?; + let gas_limit = req.gas_limit.unwrap_or(DEFAULT_GAS_LIMIT); + let max_fee = req.max_fee_per_gas.unwrap_or(DEFAULT_MAX_FEE_PER_GAS); + let max_prio = req + .max_priority_fee_per_gas + .unwrap_or(DEFAULT_MAX_PRIORITY_FEE_PER_GAS); + let valid_before = NonZeroU64::new(req.valid_before) + .ok_or_else(|| SdkError::Config("validBefore must be non-zero".into()))?; + + let tx = TempoTransaction { + chain_id: req.chain_id, + fee_token: None, + max_priority_fee_per_gas: max_prio, + max_fee_per_gas: max_fee, + gas_limit, + calls: vec![Call { + to: TxKind::Call(token), + value: U256::ZERO, + input: Bytes::from(calldata), + }], + access_list: Default::default(), + nonce_key: U256::MAX, // TEMPO_EXPIRING_NONCE_KEY (TIP-1009) + nonce: 0, + // A fee-payer signature selects the placeholder signing format. + fee_payer_signature: Some(Signature::new(U256::from(1), U256::from(1), false)), + valid_before: Some(valid_before), + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + // Sign the sender preimage, then build the 0x78 handoff. + let sign_hash = tx.signature_hash(); + let sig65 = secp::sign_prehash_65(&key, &sign_hash.0); + + Ok(encode_handoff( + req.chain_id, + max_prio, + max_fee, + gas_limit, + &tx.calls, + &tx.access_list, + req.valid_before, + sender, + &sig65, + )) + } + + /// Sign a legacy contract-backed escrow channel `open` or `topUp` + /// transaction. The tx carries two calls — a token `approve(escrow, amount)` + /// plus the escrow call — and rides the same fee-sponsored 0x78 handoff + /// envelope as a charge. Returns the envelope bytes (the credential's + /// `transaction`) plus, for `open`, the derived channelId. Sync, no chain + /// reads. + pub fn sign_escrow_tx(&self, req: &TempoEscrowRequest) -> Result { + let Signer::Tempo(secret) = self else { + return Err(SdkError::Config( + "sign_escrow_tx requires a Tempo signer".into(), + )); + }; + let key = secp::signing_key(secret.expose_secret())?; + let sender_hex = secp::evm_address(&key); + let sender: Address = sender_hex + .parse() + .map_err(|_| SdkError::Config("derived sender address is invalid".into()))?; + + let escrow: Address = parse_address(&req.escrow_contract)?; + let token: Address = parse_address(req.action.token())?; + let approve = approve_calldata(&req.escrow_contract, req.action.amount())?; + let escrow_call = req.action.calldata()?; + let gas_limit = ESCROW_GAS_LIMIT; + let max_fee = DEFAULT_MAX_FEE_PER_GAS; + let max_prio = DEFAULT_MAX_PRIORITY_FEE_PER_GAS; + let valid_before = NonZeroU64::new(req.valid_before) + .ok_or_else(|| SdkError::Config("validBefore must be non-zero".into()))?; + + let tx = TempoTransaction { + chain_id: req.chain_id, + fee_token: None, + max_priority_fee_per_gas: max_prio, + max_fee_per_gas: max_fee, + gas_limit, + calls: vec![ + Call { + to: TxKind::Call(token), + value: U256::ZERO, + input: Bytes::from(approve), + }, + Call { + to: TxKind::Call(escrow), + value: U256::ZERO, + input: Bytes::from(escrow_call), + }, + ], + access_list: Default::default(), + nonce_key: U256::MAX, + nonce: 0, + fee_payer_signature: Some(Signature::new(U256::from(1), U256::from(1), false)), + valid_before: Some(valid_before), + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sign_hash = tx.signature_hash(); + let sig65 = secp::sign_prehash_65(&key, &sign_hash.0); + let transaction = encode_handoff( + req.chain_id, + max_prio, + max_fee, + gas_limit, + &tx.calls, + &tx.access_list, + req.valid_before, + sender, + &sig65, + ); + + // Only open derives a channelId. + let channel_id = match &req.action { + EscrowAction::Open { + payee, + token, + salt, + authorized_signer, + .. + } => Some(compute_channel_id( + &sender_hex, + payee, + token, + salt, + authorized_signer, + &req.escrow_contract, + req.chain_id, + )?), + EscrowAction::TopUp { .. } => None, + }; + + Ok(TempoEscrowSigned { + transaction, + channel_id, + }) + } +} + +/// A legacy contract-backed escrow channel management transaction to sign. +#[derive(Debug, Clone)] +pub struct TempoEscrowRequest { + pub chain_id: u64, + /// `validBefore` = min(now+25s, expiry), computed by the caller. + pub valid_before: u64, + /// The escrow contract from the session challenge's `methodDetails`. + pub escrow_contract: String, + pub action: EscrowAction, +} + +/// The escrow contract call carried by a [`TempoEscrowRequest`]. +#[derive(Debug, Clone)] +pub enum EscrowAction { + /// `open(payee, token, deposit, salt, authorizedSigner)`. + Open { + payee: String, + token: String, + deposit: u128, + /// 32-byte payer entropy, `0x`-hex. + salt: String, + authorized_signer: String, + }, + /// `topUp(channelId, additionalDeposit)`. + TopUp { + /// TIP-1034-style channel id (`0x`-hex bytes32). + channel_id: String, + /// The channel token, needed for the paired `approve` call. + token: String, + additional_deposit: u128, + }, +} + +/// The result of signing an escrow management transaction. +#[derive(Debug, Clone)] +pub struct TempoEscrowSigned { + /// 0x78 fee-payer handoff envelope bytes (the credential `transaction`). + pub transaction: Vec, + /// Derived channelId (`open` only; `None` for `topUp`). + pub channel_id: Option<[u8; 32]>, +} + +impl EscrowAction { + // Token targeted by the paired approve call. + fn token(&self) -> &str { + match self { + EscrowAction::Open { token, .. } => token, + EscrowAction::TopUp { token, .. } => token, + } + } + + // Amount covered by approve. + fn amount(&self) -> u128 { + match self { + EscrowAction::Open { deposit, .. } => *deposit, + EscrowAction::TopUp { + additional_deposit, .. + } => *additional_deposit, + } + } + + // ABI-encode static arguments as selector plus head words. + fn calldata(&self) -> Result, SdkError> { + match self { + EscrowAction::Open { + payee, + token, + deposit, + salt, + authorized_signer, + } => { + let selector = fn_selector(b"open(address,address,uint128,bytes32,address)"); + let mut data = Vec::with_capacity(4 + 5 * 32); + data.extend_from_slice(&selector); + data.extend_from_slice(&super::address_word(payee)?); + data.extend_from_slice(&super::address_word(token)?); + data.extend_from_slice(&u128_word(*deposit)); + data.extend_from_slice(&bytes32(salt)?); + data.extend_from_slice(&super::address_word(authorized_signer)?); + Ok(data) + } + EscrowAction::TopUp { + channel_id, + additional_deposit, + .. + } => { + let selector = fn_selector(b"topUp(bytes32,uint256)"); + let mut data = Vec::with_capacity(4 + 2 * 32); + data.extend_from_slice(&selector); + data.extend_from_slice(&bytes32(channel_id)?); + data.extend_from_slice(&u128_word(*additional_deposit)); + Ok(data) + } + } + } +} + +// ERC-20/TIP-20 approve(spender, amount): selector ++ 2×32-byte words. +fn approve_calldata(spender: &str, amount: u128) -> Result, SdkError> { + let mut data = Vec::with_capacity(4 + 2 * 32); + data.extend_from_slice(&APPROVE_SELECTOR); + data.extend_from_slice(&super::address_word(spender)?); + data.extend_from_slice(&u128_word(amount)); + Ok(data) +} + +// keccak256(signature)[..4] function selector. +fn fn_selector(signature: &[u8]) -> [u8; 4] { + let h = keccak(signature); + [h[0], h[1], h[2], h[3]] +} + +// Encode a u128 as a left-padded EVM word. +fn u128_word(value: u128) -> [u8; 32] { + let mut word = [0u8; 32]; + word[16..].copy_from_slice(&value.to_be_bytes()); + word +} + +// A bytes32 hex value as a raw 32-byte word. +fn bytes32(hex_str: &str) -> Result<[u8; 32], SdkError> { + let cleaned = hex_str.strip_prefix("0x").unwrap_or(hex_str); + let bytes = hex::decode(cleaned) + .map_err(|_| SdkError::Config(format!("invalid bytes32: {hex_str}")))?; + if bytes.len() != 32 { + return Err(SdkError::Config(format!( + "bytes32 must be 32 bytes, got {}", + bytes.len() + ))); + } + let mut word = [0u8; 32]; + word.copy_from_slice(&bytes); + Ok(word) +} + +// Match the escrow contract's channelId derivation. +fn compute_channel_id( + payer: &str, + payee: &str, + token: &str, + salt: &str, + authorized_signer: &str, + escrow: &str, + chain_id: u64, +) -> Result<[u8; 32], SdkError> { + let mut buf = Vec::with_capacity(7 * 32); + buf.extend_from_slice(&super::address_word(payer)?); + buf.extend_from_slice(&super::address_word(payee)?); + buf.extend_from_slice(&super::address_word(token)?); + buf.extend_from_slice(&bytes32(salt)?); + buf.extend_from_slice(&super::address_word(authorized_signer)?); + buf.extend_from_slice(&super::address_word(escrow)?); + let mut chain_word = [0u8; 32]; + chain_word[24..].copy_from_slice(&chain_id.to_be_bytes()); + buf.extend_from_slice(&chain_word); + Ok(keccak(&buf)) +} + +fn parse_address(addr: &str) -> Result { + addr.parse() + .map_err(|_| SdkError::Config(format!("invalid address: {addr}"))) +} + +// TIP20 transferWithMemo(address,uint256,bytes32): selector ++ 3×32-byte words. +fn transfer_with_memo_calldata(req: &TempoChargeRequest) -> Result, SdkError> { + let recipient = super::address_word(&req.recipient)?; + let mut amount_word = [0u8; 32]; + amount_word[16..].copy_from_slice(&req.amount.to_be_bytes()); + let memo = attribution_memo(&req.realm, &req.challenge_id); + + let mut data = Vec::with_capacity(4 + 96); + data.extend_from_slice(&TRANSFER_WITH_MEMO_SELECTOR); + data.extend_from_slice(&recipient); + data.extend_from_slice(&amount_word); + data.extend_from_slice(&memo); + Ok(data) +} + +// Build the gateway attribution memo. +fn attribution_memo(realm: &str, challenge_id: &str) -> [u8; 32] { + let mut memo = [0u8; 32]; + let mpp = keccak(b"mpp"); + memo[0..4].copy_from_slice(&mpp[0..4]); + memo[4] = 0x01; + let realm_hash = keccak(realm.as_bytes()); + memo[5..15].copy_from_slice(&realm_hash[0..10]); + // Bytes 15..25 are reserved for clientId. + let challenge_hash = keccak(challenge_id.as_bytes()); + memo[25..32].copy_from_slice(&challenge_hash[0..7]); + memo +} + +fn keccak(bytes: &[u8]) -> [u8; 32] { + Keccak256::digest(bytes).into() +} + +// Encode the 0x78 fee-payer handoff fields. +#[allow(clippy::too_many_arguments)] +fn encode_handoff( + chain_id: u64, + max_prio: u128, + max_fee: u128, + gas_limit: u64, + calls: &[Call], + access_list: &A, + valid_before: u64, + sender: Address, + sig65: &[u8; 65], +) -> Vec { + let mut fields = Vec::new(); + chain_id.encode(&mut fields); + max_prio.encode(&mut fields); + max_fee.encode(&mut fields); + gas_limit.encode(&mut fields); + encode_calls(calls, &mut fields); + access_list.encode(&mut fields); + U256::MAX.encode(&mut fields); + 0u64.encode(&mut fields); + valid_before.encode(&mut fields); + fields.push(alloy_rlp::EMPTY_STRING_CODE); // validAfter absent + fields.push(alloy_rlp::EMPTY_STRING_CODE); // feeToken (sender didn't commit) + sender.encode(&mut fields); // fee-payer slot carries the sender address + fields.push(alloy_rlp::EMPTY_LIST_CODE); // empty authorization list + Bytes::from(sig65.to_vec()).encode(&mut fields); // sender SignatureEnvelope + + let mut out = Vec::with_capacity(fields.len() + 4); + out.push(0x78); + alloy_rlp::Header { + list: true, + payload_length: fields.len(), + } + .encode(&mut out); + out.extend_from_slice(&fields); + out +} + +// RLP-encode calls explicitly to avoid relying on slice Encodable impls. +fn encode_calls(calls: &[Call], out: &mut Vec) { + let mut inner = Vec::new(); + for call in calls { + call.encode(&mut inner); + } + alloy_rlp::Header { + list: true, + payload_length: inner.len(), + } + .encode(out); + out.extend_from_slice(&inner); +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + // Offline reference vector for the 0x78 handoff. + const KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + const EXPECTED_HANDOFF: &str = "78f9011382a5bf830f4240843b9aca0083019a28f87ef87c9420c000000000000000000000000000000000000080b86495777d59000000000000000000000000fd24114c3981aba78ae2441991b1bdb89329c55600000000000000000000000000000000000000000000000000000000000003e8ef1ed712013846ebb93fa448b84b800000000000000000000060f498736fd943c0a0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff80846a543ee5808094f39fd6e51aad88f6f4ce6ab8827279cfffb92266c0b841ca92118d9f7da00c84c2445bd3ee164cef9f60742771ca8a1700f15357f1437122ff663f076b0a54bbbfc614fb28f6c8e69a29735ad555ca71c25a889180e0c01c"; + + // Reconstruct the vector's calldata. + fn vector_request() -> TempoChargeRequest { + // The vector uses a fixed memo, so build its calldata directly. + TempoChargeRequest { + chain_id: 42431, + currency: "0x20c0000000000000000000000000000000000000".into(), + recipient: "0xfd24114c3981aba78ae2441991b1bdb89329c556".into(), + amount: 1000, + challenge_id: String::new(), + realm: String::new(), + valid_before: 1_783_906_021, + gas_limit: Some(105_000), + max_fee_per_gas: Some(1_000_000_000), + max_priority_fee_per_gas: Some(1_000_000), + } + } + + // Fixed memo from the reference vector. + const VECTOR_MEMO: &str = "ef1ed712013846ebb93fa448b84b800000000000000000000060f498736fd943"; + + #[test] + fn handoff_reproduces_stage1a_vector() { + // Isolate handoff encoding and signing from memo generation. + let key = secp::signing_key(KEY).unwrap(); + let sender: Address = secp::evm_address(&key).parse().unwrap(); + let token: Address = "0x20c0000000000000000000000000000000000000" + .parse() + .unwrap(); + + let recipient = super::super::address_word(&vector_request().recipient).unwrap(); + let mut amount_word = [0u8; 32]; + amount_word[16..].copy_from_slice(&1000u128.to_be_bytes()); + let memo = hex::decode(VECTOR_MEMO).unwrap(); + let mut calldata = Vec::new(); + calldata.extend_from_slice(&TRANSFER_WITH_MEMO_SELECTOR); + calldata.extend_from_slice(&recipient); + calldata.extend_from_slice(&amount_word); + calldata.extend_from_slice(&memo); + + let tx = TempoTransaction { + chain_id: 42431, + fee_token: None, + max_priority_fee_per_gas: 1_000_000, + max_fee_per_gas: 1_000_000_000, + gas_limit: 105_000, + calls: vec![Call { + to: TxKind::Call(token), + value: U256::ZERO, + input: Bytes::from(calldata), + }], + access_list: Default::default(), + nonce_key: U256::MAX, + nonce: 0, + fee_payer_signature: Some(Signature::new(U256::from(1), U256::from(1), false)), + valid_before: NonZeroU64::new(1_783_906_021), + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + let sign_hash = tx.signature_hash(); + let sig65 = secp::sign_prehash_65(&key, &sign_hash.0); + let handoff = encode_handoff( + 42431, + 1_000_000, + 1_000_000_000, + 105_000, + &tx.calls, + &tx.access_list, + 1_783_906_021, + sender, + &sig65, + ); + assert_eq!(hex::encode(&handoff), EXPECTED_HANDOFF); + } + + #[test] + fn attribution_memo_layout() { + // Prefix and version are fixed. + let memo = attribution_memo("mpp.quicknode.com", "challenge-1"); + assert_eq!(memo[4], 0x01); + // Bytes 15..25 are the reserved clientId gap. + assert_eq!(&memo[15..25], &[0u8; 10]); + } + + // Offline legacy escrow vectors for ABI and channelId encoding. + const V_PAYER: &str = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"; + const V_PAYEE: &str = "0xfd24114c3981aba78ae2441991b1bdb89329c556"; + const V_TOKEN: &str = "0x20c0000000000000000000000000000000000000"; + const V_ESCROW: &str = "0x33b901018174DDabE4841042ab76ba85D4e24f25"; + const V_CHANNEL_ID: &str = "0xfb56137dcb0089f01877bcdb72d5e028ef04aec578fb00a642f65ee293c73dec"; + + fn v_salt() -> String { + format!("0x{}", "22".repeat(32)) + } + + #[test] + fn channel_id_reproduces_legacy_reference_vector() { + let id = compute_channel_id( + V_PAYER, + V_PAYEE, + V_TOKEN, + &v_salt(), + V_PAYER, + V_ESCROW, + 42431, + ) + .unwrap(); + assert_eq!(format!("0x{}", hex::encode(id)), V_CHANNEL_ID); + } + + #[test] + fn escrow_open_calldata_reproduces_legacy_reference_vector() { + let action = EscrowAction::Open { + payee: V_PAYEE.into(), + token: V_TOKEN.into(), + deposit: 1_000_000, + salt: v_salt(), + authorized_signer: V_PAYER.into(), + }; + let data = action.calldata().unwrap(); + assert_eq!( + format!("0x{}", hex::encode(data)), + "0xc79ea485000000000000000000000000fd24114c3981aba78ae2441991b1bdb89329c556\ + 00000000000000000000000020c0000000000000000000000000000000000000\ + 00000000000000000000000000000000000000000000000000000000000f4240\ + 2222222222222222222222222222222222222222222222222222222222222222\ + 000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266" + .replace([' ', '\n'], "") + ); + } + + #[test] + fn escrow_top_up_calldata_reproduces_legacy_reference_vector() { + let action = EscrowAction::TopUp { + channel_id: V_CHANNEL_ID.into(), + token: V_TOKEN.into(), + additional_deposit: 500_000, + }; + let data = action.calldata().unwrap(); + assert_eq!( + format!("0x{}", hex::encode(data)), + "0xb67644b9fb56137dcb0089f01877bcdb72d5e028ef04aec578fb00a642f65ee293c73dec\ + 000000000000000000000000000000000000000000000000000000000007a120" + .replace([' ', '\n'], "") + ); + } + + #[test] + fn approve_calldata_reproduces_legacy_reference_vector() { + let data = approve_calldata(V_ESCROW, 1_000_000).unwrap(); + assert_eq!( + format!("0x{}", hex::encode(data)), + "0x095ea7b300000000000000000000000033b901018174ddabe4841042ab76ba85d4e24f25\ + 00000000000000000000000000000000000000000000000000000000000f4240" + .replace([' ', '\n'], "") + ); + } +} diff --git a/crates/core/src/sql/mod.rs b/crates/core/src/sql/mod.rs index 56f302e..56fe690 100644 --- a/crates/core/src/sql/mod.rs +++ b/crates/core/src/sql/mod.rs @@ -300,7 +300,7 @@ mod tests { fn make_sdk(base_url: String) -> QuicknodeSdk { QuicknodeSdk::new(&SdkFullConfig { - api_key: "test-key".to_string(), + api_key: Some("test-key".to_string()), http: None, admin: None, streams: None, diff --git a/crates/core/src/streams/mod.rs b/crates/core/src/streams/mod.rs index b040b39..e2566f0 100644 --- a/crates/core/src/streams/mod.rs +++ b/crates/core/src/streams/mod.rs @@ -319,7 +319,7 @@ mod tests { fn make_sdk(base_url: String) -> QuicknodeSdk { QuicknodeSdk::new(&SdkFullConfig { - api_key: "test-key".to_string(), + api_key: Some("test-key".to_string()), http: None, admin: None, streams: Some(StreamsConfig { diff --git a/crates/core/src/webhooks/mod.rs b/crates/core/src/webhooks/mod.rs index 6096038..11e0f0d 100644 --- a/crates/core/src/webhooks/mod.rs +++ b/crates/core/src/webhooks/mod.rs @@ -338,7 +338,7 @@ mod tests { fn make_sdk(base_url: String) -> QuicknodeSdk { QuicknodeSdk::new(&SdkFullConfig { - api_key: "test-key".to_string(), + api_key: Some("test-key".to_string()), http: None, admin: None, streams: None, diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 7c78069..b207311 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -13,7 +13,7 @@ workspace = true crate-type = ["cdylib"] [dependencies] -quicknode-sdk = { path = "../core", features = ["node"] } +quicknode-sdk = { path = "../core", features = ["node", "payments", "payments-svm", "payments-tempo"] } tokio = { version = "1", features = ["rt-multi-thread"] } napi = { workspace = true, features = ["serde-json"] } napi-derive = { workspace = true } diff --git a/crates/node/src/errors.rs b/crates/node/src/errors.rs index 5c55f0c..b5bcfa7 100644 --- a/crates/node/src/errors.rs +++ b/crates/node/src/errors.rs @@ -30,6 +30,13 @@ pub fn map_sdk_err(e: SdkError) -> Error { Some(HttpKind::Connect) => ("Connect", None, None), _ => ("Http", None, None), }, + SdkError::PaymentUnsupported { .. } => ("PaymentUnsupported", None, None), + SdkError::PaymentRejected { status, body } => ( + "PaymentRejected", + Some(status.to_string()), + Some(body.clone()), + ), + SdkError::PaymentIndeterminate => ("PaymentIndeterminate", None, None), }; let status_s = status_str.unwrap_or_else(|| "-".to_string()); let body_s = body.as_deref().unwrap_or(""); diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index a8b101a..ea1772a 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -1547,6 +1547,35 @@ impl RpcApiClient { .map_err(errors::map_sdk_err) } + /// Like `call`, but also returns the crypto-micropayment settlement + /// receipt. Resolves to `{ result, paymentReceipt }` where `paymentReceipt` + /// is `{ method, status, timestamp, reference }` on the MPP payment lane and + /// `null` for x402 and every non-payment lane (identical to `call`). + #[napi] + pub async fn call_with_receipt( + &self, + method: String, + params: Option, + network: Option, + endpoint_url: Option, + ) -> Result { + let resp = self + .inner + .call_with_receipt(&method, params, network, endpoint_url) + .await + .map_err(errors::map_sdk_err)?; + // Convert the core response to the JS shape. + Ok(serde_json::json!({ + "result": resp.result, + "paymentReceipt": resp.payment_receipt.map(|r| serde_json::json!({ + "method": r.method, + "status": r.status, + "timestamp": r.timestamp, + "reference": r.reference, + })), + })) + } + /// Seeds the per-network URL map for multichain routing (network key -> /// full http_url), typically built from /// `admin.getEndpointUrls(...).multichainUrls`. @@ -1569,4 +1598,325 @@ impl RpcApiClient { pub fn current_token(&self) -> Option { self.inner.current_token() } + + // Payment amounts use decimal strings because u128 exceeds JS number + // precision. Session and channel state uses plain objects for persistence. + + /// The configured payment wallet's on-chain address (EVM/Tempo `0x…` hex, + /// Solana base58), derived offline from the key with no network round trip. + #[napi] + pub fn payment_address(&self) -> Result { + self.inner.payment_address().map_err(errors::map_sdk_err) + } + + /// Authenticates against the x402 gateway with a SIWX message and resolves + /// to `{ token, expUnix, accountId }`. Free — no funds move. Persist the + /// object and pass it back to the `gateway*` methods. + #[napi] + pub async fn gateway_authenticate(&self) -> Result { + let session = self + .inner + .gateway_authenticate() + .await + .map_err(errors::map_sdk_err)?; + Ok(gateway_session_json(&session)) + } + + /// Reads the account's current x402 credit balance. Resolves to + /// `{ accountId, credits }`. `session` comes from `gatewayAuthenticate`. + #[napi] + pub async fn gateway_credits(&self, session: serde_json::Value) -> Result { + let session = parse_gateway_session(&session)?; + let bal = self + .inner + .gateway_credits(&session) + .await + .map_err(errors::map_sdk_err)?; + Ok(serde_json::json!({ "accountId": bal.account_id, "credits": bal.credits })) + } + + /// Buys a block of credits, settling the gateway's offer with the same + /// signer construction as the per-request lane. Resolves to the + /// post-purchase `{ accountId, credits }`. Single-attempt: a paid lane never + /// blind-retries. + #[napi] + pub async fn gateway_buy_credits( + &self, + session: serde_json::Value, + network: String, + ) -> Result { + let session = parse_gateway_session(&session)?; + let bal = self + .inner + .gateway_buy_credits(&session, &network) + .await + .map_err(errors::map_sdk_err)?; + Ok(serde_json::json!({ "accountId": bal.account_id, "credits": bal.credits })) + } + + /// Requests testnet tokens from the x402 faucet. Resolves to the funding + /// transaction `{ accountId, transactionHash }` — NOT a balance; call + /// `gatewayCredits` afterwards for that. Allowed once per account. + #[napi] + pub async fn gateway_drip(&self, session: serde_json::Value) -> Result { + let session = parse_gateway_session(&session)?; + let receipt = self + .inner + .gateway_drip(&session) + .await + .map_err(errors::map_sdk_err)?; + Ok(serde_json::json!({ + "accountId": receipt.account_id, + "transactionHash": receipt.transaction_hash, + })) + } + + /// Makes one x402 drawdown JSON-RPC call with the session as a Bearer + /// token, drawing 1 credit on success. Resolves to the unwrapped JSON-RPC + /// `result`. Single-attempt; re-authenticate on a 401/403 `ApiError`. + #[napi] + pub async fn gateway_drawdown_call( + &self, + method: String, + session: serde_json::Value, + network: String, + params: Option, + ) -> Result { + let session = parse_gateway_session(&session)?; + self.inner + .gateway_drawdown_call(&method, params, &network, &session) + .await + .map_err(errors::map_sdk_err) + } + + /// Opens an MPP payment channel by depositing `deposit` base units (a + /// decimal string) into the escrow. Resolves to the channel state — persist + /// it; the gateway has no read-only channel endpoint, so a lost record means + /// opening a new channel. Moves real funds; single-attempt. + /// + /// Takes no network: the channel is scoped by the configured pay network and + /// asset, so one channel funds calls to every supported network. + #[napi] + pub async fn mpp_open(&self, deposit: String) -> Result { + let deposit = parse_base_units(&deposit, "deposit")?; + let channel = self + .inner + .mpp_open(deposit) + .await + .map_err(errors::map_sdk_err)?; + Ok(channel_state_json(&channel)) + } + + /// Adds `additionalDeposit` base units (a decimal string) to an open + /// channel. Resolves to the updated channel state. Moves real funds; + /// single-attempt. + #[napi] + pub async fn mpp_top_up( + &self, + channel: serde_json::Value, + additional_deposit: String, + ) -> Result { + let channel = parse_channel_state(&channel)?; + let extra = parse_base_units(&additional_deposit, "additionalDeposit")?; + let updated = self + .inner + .mpp_top_up(&channel, extra) + .await + .map_err(errors::map_sdk_err)?; + Ok(channel_state_json(&updated)) + } + + /// Cooperatively closes a channel: settles the final cumulative spend + /// on-chain and refunds the unused deposit. Single-attempt. + #[napi] + pub async fn mpp_close(&self, channel: serde_json::Value) -> Result<()> { + let channel = parse_channel_state(&channel)?; + self.inner + .mpp_close(&channel) + .await + .map_err(errors::map_sdk_err) + } + + /// Fetches the gateway's view of the channel as + /// `{ channelId, acceptedCumulative, spent }` (amounts are decimal strings). + /// + /// **This costs one request unit** and advances the voucher by `perCall`, + /// exactly like a session call — persist the advanced `cumulativeSpent`. + /// Rejects with `PaymentUnsupportedError` before any network I/O when the + /// channel has no room left for the probe. + #[napi] + pub async fn mpp_status(&self, channel: serde_json::Value) -> Result { + let channel = parse_channel_state(&channel)?; + let st = self + .inner + .mpp_status(&channel) + .await + .map_err(errors::map_sdk_err)?; + Ok(serde_json::json!({ + "channelId": st.channel_id, + "acceptedCumulative": st.accepted_cumulative.to_string(), + "spent": st.spent.to_string(), + })) + } + + /// Makes one MPP session-lane JSON-RPC call, authorizing it with a + /// cumulative voucher for `newCumulative` (a decimal string: the running + /// total AFTER this call). Resolves to the unwrapped JSON-RPC `result`. + /// Single-attempt; advance the persisted `cumulativeSpent` on success. + #[napi] + pub async fn mpp_session_call( + &self, + method: String, + network: String, + channel: serde_json::Value, + new_cumulative: String, + params: Option, + ) -> Result { + let channel = parse_channel_state(&channel)?; + let new_cumulative = parse_base_units(&new_cumulative, "newCumulative")?; + self.inner + .mpp_session_call(&method, params, &network, &channel, new_cumulative) + .await + .map_err(errors::map_sdk_err) + } +} + +// Payment types cross as plain objects because they contain enums, secrets, or +// u128 fields that napi cannot expose directly. + +fn config_err(message: String) -> Error { + errors::map_sdk_err(core::errors::SdkError::Config(message)) +} + +// Keep u128 amounts as decimal strings to avoid JS precision loss. +fn parse_base_units(raw: &str, field: &str) -> Result { + raw.trim().parse::().map_err(|_| { + config_err(format!( + "{field} must be a decimal base-unit amount as a string, got {raw:?}" + )) + }) +} + +fn gateway_session_json(session: &core::GatewaySession) -> serde_json::Value { + serde_json::json!({ + "token": session.token, + "expUnix": session.exp_unix, + "accountId": session.account_id, + }) +} + +// Read the camelCase keys emitted by gateway_session_json. +fn parse_gateway_session(v: &serde_json::Value) -> Result { + let token = v + .get("token") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| config_err("session is missing token".into()))?; + let exp_unix = v + .get("expUnix") + .and_then(serde_json::Value::as_i64) + .ok_or_else(|| config_err("session is missing expUnix".into()))?; + let account_id = v + .get("accountId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| config_err("session is missing accountId".into()))?; + Ok(core::GatewaySession { + token: token.to_string(), + exp_unix, + account_id: account_id.to_string(), + }) +} + +fn channel_state_json(channel: &core::ChannelState) -> serde_json::Value { + serde_json::json!({ + "channelId": channel.channel_id, + "token": channel.token, + "payee": channel.payee, + "salt": channel.salt, + "authorizedSigner": channel.authorized_signer, + "escrowContract": channel.escrow_contract, + "deposit": channel.deposit.to_string(), + "cumulativeSpent": channel.cumulative_spent.to_string(), + "perCall": channel.per_call.to_string(), + "chainId": channel.chain_id, + }) +} + +// Accept strings from channel_state_json and numeric hand-built values. +fn channel_amount(v: &serde_json::Value, field: &str) -> Result { + match v + .get(field) + .ok_or_else(|| config_err(format!("channel is missing {field}")))? + { + serde_json::Value::String(s) => parse_base_units(s, field), + serde_json::Value::Number(n) => n + .as_u128() + .ok_or_else(|| config_err(format!("channel {field} must be a non-negative integer"))), + other => Err(config_err(format!( + "channel {field} must be a decimal string or number, got {other}" + ))), + } +} + +fn channel_str(v: &serde_json::Value, field: &str) -> Result { + v.get(field) + .and_then(serde_json::Value::as_str) + .map(String::from) + .ok_or_else(|| config_err(format!("channel is missing {field}"))) +} + +fn parse_channel_state(v: &serde_json::Value) -> Result { + if !v.is_object() { + return Err(config_err( + "channel must be an object from mppOpen/mppTopUp".into(), + )); + } + Ok(core::ChannelState { + channel_id: channel_str(v, "channelId")?, + token: channel_str(v, "token")?, + payee: channel_str(v, "payee")?, + salt: channel_str(v, "salt")?, + authorized_signer: channel_str(v, "authorizedSigner")?, + escrow_contract: channel_str(v, "escrowContract")?, + deposit: channel_amount(v, "deposit")?, + cumulative_spent: channel_amount(v, "cumulativeSpent")?, + per_call: channel_amount(v, "perCall")?, + chain_id: v + .get("chainId") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| config_err("channel is missing chainId".into()))?, + }) +} + +/// Generates a fresh payment keypair for `chain` (`"evm"`, `"svm"`, or +/// `"tempo"`). Returns `{ address, chain, key }` where `key` is the raw private +/// key in the format the `keyFile` config reads. +/// +/// The key is returned exactly once, at generation: nothing in the SDK stores or +/// re-derives it, so persist it before discarding the object. Randomness comes +/// from the OS CSPRNG. +// napi requires an owned string here. +#[allow(clippy::needless_pass_by_value)] +#[napi] +pub fn generate_payment_wallet(chain: String) -> Result { + let kind = match chain.to_ascii_lowercase().as_str() { + "evm" => core::ChainKind::Evm, + "svm" | "solana" => core::ChainKind::Svm, + "tempo" => core::ChainKind::Tempo, + other => { + return Err(config_err(format!( + "unknown payment chain {other:?} (expected \"evm\", \"svm\", or \"tempo\")" + ))) + } + }; + let wallet = core::generate_payment_wallet(kind).map_err(errors::map_sdk_err)?; + let chain_label = match wallet.chain { + core::ChainKind::Evm => "evm", + core::ChainKind::Svm => "svm", + core::ChainKind::Tempo => "tempo", + }; + Ok(serde_json::json!({ + "address": wallet.address, + "chain": chain_label, + "key": wallet.into_key(), + })) } diff --git a/crates/python/Cargo.toml b/crates/python/Cargo.toml index 7de8fa0..fb10c89 100644 --- a/crates/python/Cargo.toml +++ b/crates/python/Cargo.toml @@ -17,7 +17,7 @@ crate-type = ["cdylib", "rlib"] extension-module = ["pyo3/extension-module", "quicknode-sdk/extension-module"] [dependencies] -quicknode-sdk = { path = "../core", features = ["python"] } +quicknode-sdk = { path = "../core", features = ["python", "payments", "payments-svm", "payments-tempo"] } pyo3 = { workspace = true } pyo3-async-runtimes = { workspace = true, features = ["tokio-runtime"] } pyo3-stub-gen = { workspace = true } diff --git a/crates/python/src/errors.rs b/crates/python/src/errors.rs index caf4c24..e62ac40 100644 --- a/crates/python/src/errors.rs +++ b/crates/python/src/errors.rs @@ -27,6 +27,14 @@ create_exception!(_core, ConnectionError, HttpError); create_exception!(_core, ApiError, QuicknodeError); create_exception!(_core, DecodeError, QuicknodeError); create_exception!(_core, RpcError, QuicknodeError); +// Payment-lane errors. PaymentError is the family base; PaymentRejectedError +// carries the gateway status/body like ApiError; PaymentIndeterminateError is +// its own class so a caller can catch "may have been charged — do not retry" +// distinctly from every other failure. +create_exception!(_core, PaymentError, QuicknodeError); +create_exception!(_core, PaymentUnsupportedError, PaymentError); +create_exception!(_core, PaymentRejectedError, PaymentError); +create_exception!(_core, PaymentIndeterminateError, PaymentError); #[allow(clippy::needless_pass_by_value)] pub fn map_sdk_err(e: SdkError) -> PyErr { @@ -71,6 +79,19 @@ pub fn map_sdk_err(e: SdkError) -> PyErr { Some(HttpKind::Connect) => ConnectionError::new_err(msg), _ => HttpError::new_err(msg), }, + SdkError::PaymentUnsupported { .. } => PaymentUnsupportedError::new_err(msg), + SdkError::PaymentRejected { status, body } => { + let status = *status; + let body = body.clone(); + Python::attach(|py| { + let err = PaymentRejectedError::new_err(msg); + let val = err.value(py); + let _ = val.setattr("status", status); + let _ = val.setattr("body", body); + err + }) + } + SdkError::PaymentIndeterminate => PaymentIndeterminateError::new_err(msg), } } @@ -84,5 +105,18 @@ pub fn add_to_module(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add("ApiError", py.get_type::())?; m.add("DecodeError", py.get_type::())?; m.add("RpcError", py.get_type::())?; + m.add("PaymentError", py.get_type::())?; + m.add( + "PaymentUnsupportedError", + py.get_type::(), + )?; + m.add( + "PaymentRejectedError", + py.get_type::(), + )?; + m.add( + "PaymentIndeterminateError", + py.get_type::(), + )?; Ok(()) } diff --git a/crates/python/src/lib.rs b/crates/python/src/lib.rs index a051d4f..39db131 100644 --- a/crates/python/src/lib.rs +++ b/crates/python/src/lib.rs @@ -1,7 +1,7 @@ use pyo3::prelude::*; use pyo3_stub_gen::{ define_stub_info_gatherer, - derive::{gen_stub_pyclass, gen_stub_pymethods}, + derive::{gen_stub_pyclass, gen_stub_pyfunction, gen_stub_pymethods}, }; use quicknode_sdk as core; @@ -2568,6 +2568,51 @@ impl RpcApiClient { }) } + /// Like `call`, but also returns the crypto-micropayment settlement + /// receipt. Returns a dict `{"result": , "payment_receipt": }`. + /// `payment_receipt` is a dict `{method, status, timestamp, reference}` on + /// the MPP payment lane and `None` for x402 and every non-payment lane + /// (where this behaves exactly like `call`). + #[pyo3(signature = (method, params=None, network=None, endpoint_url=None))] + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, typing.Any]" + ))] + fn call_with_receipt<'py>( + &self, + py: Python<'py>, + method: String, + params: Option>, + network: Option, + endpoint_url: Option, + ) -> PyResult> { + let client = self.inner.clone(); + let params_value = match params { + Some(obj) => Some(pythonize::depythonize(&obj).map_err(errors::map_pythonize_err)?), + None => None, + }; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let resp = client + .call_with_receipt(&method, params_value, network, endpoint_url) + .await + .map_err(errors::map_sdk_err)?; + // Convert the core response to a plain Python dict. + let json = serde_json::json!({ + "result": resp.result, + "payment_receipt": resp.payment_receipt.map(|r| serde_json::json!({ + "method": r.method, + "status": r.status, + "timestamp": r.timestamp, + "reference": r.reference, + })), + }); + Python::attach(|py| { + pythonize::pythonize(py, &json) + .map(pyo3::Bound::unbind) + .map_err(errors::map_pythonize_err) + }) + }) + } + /// Seeds the per-network URL map for multichain routing (network key -> /// full http_url), typically built from /// `admin.get_endpoint_urls(...).multichain_urls`. @@ -2588,6 +2633,407 @@ impl RpcApiClient { fn current_token(&self) -> Option { self.inner.current_token() } + + // Payment amounts use decimal strings because PyO3 cannot convert u128. + // Session and channel state uses dicts for persistence. + + /// The configured payment wallet's on-chain address (EVM/Tempo `0x…` hex, + /// Solana base58), derived offline from the key with no network round trip. + fn payment_address(&self) -> PyResult { + self.inner.payment_address().map_err(errors::map_sdk_err) + } + + /// Authenticates against the x402 gateway with a SIWX message and returns + /// the session as a dict `{token, exp_unix, account_id}`. Free — no funds + /// move. Persist the dict and pass it back to the `gateway_*` methods. + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, typing.Any]" + ))] + fn gateway_authenticate<'py>(&self, py: Python<'py>) -> PyResult> { + let client = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let session = client + .gateway_authenticate() + .await + .map_err(errors::map_sdk_err)?; + json_to_py(&gateway_session_json(&session)) + }) + } + + /// Reads the account's current x402 credit balance. Returns a dict + /// `{account_id, credits}`. `session` is a dict from `gateway_authenticate`. + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, typing.Any]" + ))] + fn gateway_credits<'py>( + &self, + py: Python<'py>, + session: &Bound<'py, PyAny>, + ) -> PyResult> { + let client = self.inner.clone(); + let session = depythonize_session(session)?; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let bal = client + .gateway_credits(&session) + .await + .map_err(errors::map_sdk_err)?; + json_to_py(&serde_json::json!({ + "account_id": bal.account_id, + "credits": bal.credits, + })) + }) + } + + /// Buys a block of credits, settling the gateway's offer with the same + /// signer construction as the per-request lane. Returns the post-purchase + /// balance dict `{account_id, credits}`. Single-attempt: a paid lane never + /// blind-retries. + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, typing.Any]" + ))] + fn gateway_buy_credits<'py>( + &self, + py: Python<'py>, + session: &Bound<'py, PyAny>, + network: String, + ) -> PyResult> { + let client = self.inner.clone(); + let session = depythonize_session(session)?; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let bal = client + .gateway_buy_credits(&session, &network) + .await + .map_err(errors::map_sdk_err)?; + json_to_py(&serde_json::json!({ + "account_id": bal.account_id, + "credits": bal.credits, + })) + }) + } + + /// Requests testnet tokens from the x402 faucet. Returns the funding + /// transaction as a dict `{account_id, transaction_hash}` — NOT a balance; + /// call `gateway_credits` afterwards for that. Allowed once per account. + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, typing.Any]" + ))] + fn gateway_drip<'py>( + &self, + py: Python<'py>, + session: &Bound<'py, PyAny>, + ) -> PyResult> { + let client = self.inner.clone(); + let session = depythonize_session(session)?; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let receipt = client + .gateway_drip(&session) + .await + .map_err(errors::map_sdk_err)?; + json_to_py(&serde_json::json!({ + "account_id": receipt.account_id, + "transaction_hash": receipt.transaction_hash, + })) + }) + } + + /// Makes one x402 drawdown JSON-RPC call with the session as a Bearer + /// token, drawing 1 credit on success. Returns the unwrapped JSON-RPC + /// `result`. Single-attempt; re-authenticate on a 401/403 `ApiError`. + #[pyo3(signature = (method, session, network, params=None))] + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, typing.Any]" + ))] + fn gateway_drawdown_call<'py>( + &self, + py: Python<'py>, + method: String, + session: &Bound<'py, PyAny>, + network: String, + params: Option>, + ) -> PyResult> { + let client = self.inner.clone(); + let session = depythonize_session(session)?; + let params_value = match params { + Some(obj) => Some(pythonize::depythonize(&obj).map_err(errors::map_pythonize_err)?), + None => None, + }; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let result = client + .gateway_drawdown_call(&method, params_value, &network, &session) + .await + .map_err(errors::map_sdk_err)?; + json_to_py(&result) + }) + } + + /// Opens an MPP payment channel by depositing `deposit` base units (a + /// decimal string) into the escrow. Returns the channel state dict — persist + /// it; the gateway has no read-only channel endpoint, so a lost record means + /// opening a new channel. Moves real funds; single-attempt. + /// + /// Takes no network: the channel is scoped by the configured pay network and + /// asset, so one channel funds calls to every supported network. + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, typing.Any]" + ))] + fn mpp_open<'py>(&self, py: Python<'py>, deposit: &str) -> PyResult> { + let client = self.inner.clone(); + let deposit = parse_base_units(deposit, "deposit")?; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let channel = client + .mpp_open(deposit) + .await + .map_err(errors::map_sdk_err)?; + json_to_py(&channel_state_json(&channel)) + }) + } + + /// Adds `additional_deposit` base units (a decimal string) to an open + /// channel. Returns the updated channel state dict. Moves real funds; + /// single-attempt. + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, typing.Any]" + ))] + fn mpp_top_up<'py>( + &self, + py: Python<'py>, + channel: &Bound<'py, PyAny>, + additional_deposit: &str, + ) -> PyResult> { + let client = self.inner.clone(); + let channel = depythonize_channel(channel)?; + let extra = parse_base_units(additional_deposit, "additional_deposit")?; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let updated = client + .mpp_top_up(&channel, extra) + .await + .map_err(errors::map_sdk_err)?; + json_to_py(&channel_state_json(&updated)) + }) + } + + /// Cooperatively closes a channel: settles the final cumulative spend + /// on-chain and refunds the unused deposit. Single-attempt. + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, typing.Any]" + ))] + fn mpp_close<'py>( + &self, + py: Python<'py>, + channel: &Bound<'py, PyAny>, + ) -> PyResult> { + let client = self.inner.clone(); + let channel = depythonize_channel(channel)?; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + client + .mpp_close(&channel) + .await + .map_err(errors::map_sdk_err)?; + Python::attach(|py| Ok(py.None())) + }) + } + + /// Fetches the gateway's view of the channel as a dict `{channel_id, + /// accepted_cumulative, spent}` (amounts are decimal strings). + /// + /// **This costs one request unit** and advances the voucher by `per_call`, + /// exactly like a session call — persist the advanced `cumulative_spent`. + /// Raises `PaymentUnsupportedError` before any network I/O when the channel + /// has no room left for the probe. + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, typing.Any]" + ))] + fn mpp_status<'py>( + &self, + py: Python<'py>, + channel: &Bound<'py, PyAny>, + ) -> PyResult> { + let client = self.inner.clone(); + let channel = depythonize_channel(channel)?; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let st = client + .mpp_status(&channel) + .await + .map_err(errors::map_sdk_err)?; + json_to_py(&serde_json::json!({ + "channel_id": st.channel_id, + "accepted_cumulative": st.accepted_cumulative.to_string(), + "spent": st.spent.to_string(), + })) + }) + } + + /// Makes one MPP session-lane JSON-RPC call, authorizing it with a + /// cumulative voucher for `new_cumulative` (a decimal string: the running + /// total AFTER this call). Returns the unwrapped JSON-RPC `result`. + /// Single-attempt; advance the persisted `cumulative_spent` on success. + #[pyo3(signature = (method, network, channel, new_cumulative, params=None))] + #[gen_stub(override_return_type( + type_repr = "typing.Coroutine[typing.Any, typing.Any, typing.Any]" + ))] + fn mpp_session_call<'py>( + &self, + py: Python<'py>, + method: String, + network: String, + channel: &Bound<'py, PyAny>, + new_cumulative: &str, + params: Option>, + ) -> PyResult> { + let client = self.inner.clone(); + let channel = depythonize_channel(channel)?; + let new_cumulative = parse_base_units(new_cumulative, "new_cumulative")?; + let params_value = match params { + Some(obj) => Some(pythonize::depythonize(&obj).map_err(errors::map_pythonize_err)?), + None => None, + }; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let result = client + .mpp_session_call(&method, params_value, &network, &channel, new_cumulative) + .await + .map_err(errors::map_sdk_err)?; + json_to_py(&result) + }) + } +} + +// Payment types cross as dicts because they contain enums, secrets, or u128 +// fields that PyO3 cannot expose directly. + +// Keep u128 amounts as decimal strings and reject malformed values. +fn config_err(message: String) -> PyErr { + errors::map_sdk_err(core::errors::SdkError::Config(message)) +} + +fn parse_base_units(raw: &str, field: &str) -> PyResult { + raw.trim().parse::().map_err(|_| { + config_err(format!( + "{field} must be a decimal base-unit amount as a string, got {raw:?}" + )) + }) +} + +fn json_to_py(value: &serde_json::Value) -> PyResult> { + Python::attach(|py| { + pythonize::pythonize(py, value) + .map(pyo3::Bound::unbind) + .map_err(errors::map_pythonize_err) + }) +} + +fn gateway_session_json(session: &core::GatewaySession) -> serde_json::Value { + serde_json::json!({ + "token": session.token, + "exp_unix": session.exp_unix, + "account_id": session.account_id, + }) +} + +fn depythonize_session(obj: &Bound<'_, PyAny>) -> PyResult { + let value: serde_json::Value = + pythonize::depythonize(obj).map_err(errors::map_pythonize_err)?; + serde_json::from_value(value).map_err(|e| { + errors::map_sdk_err(core::errors::SdkError::Config(format!( + "session must be a dict from gateway_authenticate ({{token, exp_unix, account_id}}): {e}" + ))) + }) +} + +fn channel_state_json(channel: &core::ChannelState) -> serde_json::Value { + serde_json::json!({ + "channel_id": channel.channel_id, + "token": channel.token, + "payee": channel.payee, + "salt": channel.salt, + "authorized_signer": channel.authorized_signer, + "escrow_contract": channel.escrow_contract, + "deposit": channel.deposit.to_string(), + "cumulative_spent": channel.cumulative_spent.to_string(), + "per_call": channel.per_call.to_string(), + "chain_id": channel.chain_id, + }) +} + +// Accept string output and integer input for channel amounts. +fn channel_amount(obj: &serde_json::Value, field: &str) -> PyResult { + let raw = obj + .get(field) + .ok_or_else(|| config_err(format!("channel is missing {field}")))?; + match raw { + serde_json::Value::String(s) => parse_base_units(s, field), + serde_json::Value::Number(n) => n + .as_u128() + .ok_or_else(|| config_err(format!("channel {field} must be a non-negative integer"))), + other => Err(config_err(format!( + "channel {field} must be a decimal string or integer, got {other}" + ))), + } +} + +fn channel_str(obj: &serde_json::Value, field: &str) -> PyResult { + obj.get(field) + .and_then(serde_json::Value::as_str) + .map(String::from) + .ok_or_else(|| config_err(format!("channel is missing {field}"))) +} + +fn depythonize_channel(obj: &Bound<'_, PyAny>) -> PyResult { + let v: serde_json::Value = pythonize::depythonize(obj).map_err(errors::map_pythonize_err)?; + if !v.is_object() { + return Err(config_err( + "channel must be a dict from mpp_open/mpp_top_up".to_string(), + )); + } + Ok(core::ChannelState { + channel_id: channel_str(&v, "channel_id")?, + token: channel_str(&v, "token")?, + payee: channel_str(&v, "payee")?, + salt: channel_str(&v, "salt")?, + authorized_signer: channel_str(&v, "authorized_signer")?, + escrow_contract: channel_str(&v, "escrow_contract")?, + deposit: channel_amount(&v, "deposit")?, + cumulative_spent: channel_amount(&v, "cumulative_spent")?, + per_call: channel_amount(&v, "per_call")?, + chain_id: v + .get("chain_id") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| config_err("channel is missing chain_id".to_string()))?, + }) +} + +/// Generates a fresh payment keypair for `chain` (`"evm"`, `"svm"`, or +/// `"tempo"`). Returns a dict `{address, chain, key}` where `key` is the raw +/// private key in the format the payment config's `key` accepts. +/// +/// The key is returned exactly once, at generation: nothing in the SDK stores or +/// re-derives it, so persist it before discarding the dict. Randomness comes +/// from the OS CSPRNG. +#[gen_stub_pyfunction] +#[pyfunction] +fn generate_payment_wallet(chain: &str) -> PyResult> { + let kind = match chain.to_ascii_lowercase().as_str() { + "evm" => core::ChainKind::Evm, + "svm" | "solana" => core::ChainKind::Svm, + "tempo" => core::ChainKind::Tempo, + other => { + return Err(errors::map_sdk_err(core::errors::SdkError::Config( + format!( + "unknown payment chain {other:?} (expected \"evm\", \"svm\", or \"tempo\")" + ), + ))) + } + }; + let wallet = core::generate_payment_wallet(kind).map_err(errors::map_sdk_err)?; + let chain_label = match wallet.chain { + core::ChainKind::Evm => "evm", + core::ChainKind::Svm => "svm", + core::ChainKind::Tempo => "tempo", + }; + json_to_py(&serde_json::json!({ + "address": wallet.address, + "chain": chain_label, + "key": wallet.into_key(), + })) } // ── Module ───────────────────────────────────────────────────── @@ -2595,6 +3041,7 @@ impl RpcApiClient { #[pymodule] fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { errors::add_to_module(m)?; + m.add_function(wrap_pyfunction!(generate_payment_wallet, m)?)?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -2679,6 +3126,8 @@ fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -2737,6 +3186,7 @@ fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/crates/ruby/Cargo.toml b/crates/ruby/Cargo.toml index 39024ba..44d781f 100644 --- a/crates/ruby/Cargo.toml +++ b/crates/ruby/Cargo.toml @@ -14,7 +14,7 @@ name = "quicknode_sdk" crate-type = ["cdylib"] [dependencies] -quicknode-sdk = { path = "../core", features = ["ruby"] } +quicknode-sdk = { path = "../core", features = ["ruby", "payments", "payments-svm", "payments-tempo"] } magnus = { workspace = true } tokio = { version = "1", features = ["rt-multi-thread"] } serde_json = "1.0" diff --git a/crates/ruby/src/errors.rs b/crates/ruby/src/errors.rs index b7af751..e5db751 100644 --- a/crates/ruby/src/errors.rs +++ b/crates/ruby/src/errors.rs @@ -19,6 +19,9 @@ struct ErrorClasses { api: Opaque, decode: Opaque, rpc: Opaque, + payment_unsupported: Opaque, + payment_rejected: Opaque, + payment_indeterminate: Opaque, } static CLASSES: OnceLock = OnceLock::new(); @@ -33,14 +36,21 @@ pub fn init(ruby: &Ruby, module: &RModule) -> Result<(), magnus::Error> { let api = module.define_error("ApiError", base)?; let decode = module.define_error("DecodeError", base)?; let rpc = module.define_error("RpcError", base)?; + // Payment-lane errors under a PaymentError family base. + let payment = module.define_error("PaymentError", base)?; + let payment_unsupported = module.define_error("PaymentUnsupportedError", payment)?; + let payment_rejected = module.define_error("PaymentRejectedError", payment)?; + let payment_indeterminate = module.define_error("PaymentIndeterminateError", payment)?; // attr_reader :status, :body on ApiError; :body on DecodeError; - // :code, :message on RpcError + // :code, :message on RpcError; :status, :body on PaymentRejectedError. api.define_method("status", magnus::method!(read_status, 0))?; api.define_method("body", magnus::method!(read_body, 0))?; decode.define_method("body", magnus::method!(read_body, 0))?; rpc.define_method("code", magnus::method!(read_code, 0))?; rpc.define_method("message", magnus::method!(read_message, 0))?; + payment_rejected.define_method("status", magnus::method!(read_status, 0))?; + payment_rejected.define_method("body", magnus::method!(read_body, 0))?; CLASSES .set(ErrorClasses { @@ -52,6 +62,9 @@ pub fn init(ruby: &Ruby, module: &RModule) -> Result<(), magnus::Error> { api: api.into(), decode: decode.into(), rpc: rpc.into(), + payment_unsupported: payment_unsupported.into(), + payment_rejected: payment_rejected.into(), + payment_indeterminate: payment_indeterminate.into(), }) .map_err(|_| { magnus::Error::new( @@ -131,6 +144,19 @@ pub fn map_err(e: SdkError) -> magnus::Error { }; magnus::Error::new(cls, msg) } + SdkError::PaymentUnsupported { .. } => { + magnus::Error::new(ruby.get_inner(c.payment_unsupported), msg) + } + SdkError::PaymentRejected { status, body } => build_with_ivars( + &ruby, + ruby.get_inner(c.payment_rejected), + &msg, + Some(*status), + Some(body.clone()), + ), + SdkError::PaymentIndeterminate => { + magnus::Error::new(ruby.get_inner(c.payment_indeterminate), msg) + } } } diff --git a/crates/ruby/src/lib.rs b/crates/ruby/src/lib.rs index 75f806a..99ec934 100644 --- a/crates/ruby/src/lib.rs +++ b/crates/ruby/src/lib.rs @@ -68,6 +68,43 @@ fn hash_require_string(h: &RHash, key: &str) -> Result { }) } +// Extract rpc.payment manually because the field is serde-skipped. +fn extract_payment_config(opts: &RHash) -> Result, Error> { + let r = ruby(); + let Some(rpc_val) = opts.get(r.to_symbol("rpc")) else { + return Ok(None); + }; + // Reject present values with the wrong type instead of ignoring them. + let rpc = RHash::from_value(rpc_val) + .ok_or_else(|| Error::new(r.exception_arg_error(), "rpc must be a Hash"))?; + let Some(payment_val) = rpc.get(r.to_symbol("payment")) else { + return Ok(None); + }; + let payment = RHash::from_value(payment_val) + .ok_or_else(|| Error::new(r.exception_arg_error(), "rpc.payment must be a Hash"))?; + validate_keys( + &payment, + &[ + "scheme", + "key", + "pay_network", + "asset", + "max_amount", + "svm_rpc_url", + "base_url_override", + ], + )?; + Ok(Some(core::PaymentConfig { + scheme: hash_require_string(&payment, "scheme")?, + key: hash_require_string(&payment, "key")?, + pay_network: hash_require_string(&payment, "pay_network")?, + asset: hash_require_string(&payment, "asset")?, + max_amount: hash_require_string(&payment, "max_amount")?, + svm_rpc_url: hash_get_string(&payment, "svm_rpc_url")?, + base_url_override: hash_get_string(&payment, "base_url_override")?, + })) +} + fn hash_get_i64(h: &RHash, key: &str) -> Result, Error> { let r = ruby(); match h.get(r.to_symbol(key)) { @@ -275,10 +312,14 @@ impl QuicknodeSdk { "api_key", "http", "admin", "streams", "webhooks", "kvstore", "sql", "rpc", ], )?; - let config: core::SdkFullConfig = + let mut config: core::SdkFullConfig = serde_magnus::deserialize(&ruby(), opts).map_err(|e| { Error::new(ruby().exception_arg_error(), format!("invalid config: {e}")) })?; + // Attach the serde-skipped payment config after deserialization. + if let Some(payment) = extract_payment_config(&opts)? { + config.rpc.get_or_insert_with(Default::default).payment = Some(payment); + } core::QuicknodeSdk::new_with_client_info(&config, Some(ruby_client_info())) .map(|inner| Self { inner }) .map_err(map_err) @@ -1916,6 +1957,37 @@ impl RpcApiClient { .and_then(to_ruby) } + // call_with_receipt(method:, params:, network:, endpoint_url:) — like call + // but also returns the crypto-micropayment settlement receipt. Returns a + // Hash {"result" => ..., "payment_receipt" => {..}|nil}. payment_receipt is + // present only on the MPP payment lane; nil for x402 and non-payment lanes. + fn call_with_receipt(&self, opts: RHash) -> Result { + validate_keys(&opts, &["method", "params", "network", "endpoint_url"])?; + let method = hash_require_string(&opts, "method")?; + let network = hash_get_string(&opts, "network")?; + let endpoint_url = hash_get_string(&opts, "endpoint_url")?; + let r = ruby(); + let params: Option = match opts.get(r.to_symbol("params")) { + Some(v) if !v.is_nil() => Some(serde_magnus::deserialize(&r, v)?), + _ => None, + }; + let client = self.inner.clone(); + let resp = runtime() + .block_on(client.call_with_receipt(&method, params, network, endpoint_url)) + .map_err(map_err)?; + // Convert the core response to the Ruby hash shape. + let json = serde_json::json!({ + "result": resp.result, + "payment_receipt": resp.payment_receipt.map(|rc| serde_json::json!({ + "method": rc.method, + "status": rc.status, + "timestamp": rc.timestamp, + "reference": rc.reference, + })), + }); + to_ruby(json) + } + // set_networks(networks:) — seed the per-network URL map (key -> http_url) // for multichain routing, from get_endpoint_urls(...)["multichain_urls"]. fn set_networks(&self, opts: RHash) -> Result<(), Error> { @@ -1934,6 +2006,275 @@ impl RpcApiClient { fn current_token(&self) -> Result { to_ruby(self.inner.current_token()) } + + // Payment amounts use decimal strings because magnus cannot convert u128. + // Session and channel state uses hashes for persistence. + + // payment_address — derive the configured wallet address locally. + fn payment_address(&self) -> Result { + self.inner.payment_address().map_err(map_err) + } + + // gateway_authenticate — authenticate and return a session hash. + fn gateway_authenticate(&self) -> Result { + let client = self.inner.clone(); + let session = runtime() + .block_on(client.gateway_authenticate()) + .map_err(map_err)?; + to_ruby(gateway_session_json(&session)) + } + + // gateway_credits(session:) — read the account credit balance. + fn gateway_credits(&self, opts: RHash) -> Result { + validate_keys(&opts, &["session"])?; + let session = require_gateway_session(&opts)?; + let client = self.inner.clone(); + let bal = runtime() + .block_on(client.gateway_credits(&session)) + .map_err(map_err)?; + to_ruby(serde_json::json!({ + "account_id": bal.account_id, "credits": bal.credits + })) + } + + // gateway_buy_credits(session:, network:) — settle a credit offer once. + fn gateway_buy_credits(&self, opts: RHash) -> Result { + validate_keys(&opts, &["session", "network"])?; + let session = require_gateway_session(&opts)?; + let network = hash_require_string(&opts, "network")?; + let client = self.inner.clone(); + let bal = runtime() + .block_on(client.gateway_buy_credits(&session, &network)) + .map_err(map_err)?; + to_ruby(serde_json::json!({ + "account_id": bal.account_id, "credits": bal.credits + })) + } + + // gateway_drip(session:) — request testnet funds; returns the tx hash. + fn gateway_drip(&self, opts: RHash) -> Result { + validate_keys(&opts, &["session"])?; + let session = require_gateway_session(&opts)?; + let client = self.inner.clone(); + let receipt = runtime() + .block_on(client.gateway_drip(&session)) + .map_err(map_err)?; + to_ruby(serde_json::json!({ + "account_id": receipt.account_id, + "transaction_hash": receipt.transaction_hash, + })) + } + + // gateway_drawdown_call(...) — spend one credit with the session token. + fn gateway_drawdown_call(&self, opts: RHash) -> Result { + validate_keys(&opts, &["method", "session", "network", "params"])?; + let method = hash_require_string(&opts, "method")?; + let session = require_gateway_session(&opts)?; + let network = hash_require_string(&opts, "network")?; + let params = hash_get_json(&opts, "params")?; + let client = self.inner.clone(); + let result = runtime() + .block_on(client.gateway_drawdown_call(&method, params, &network, &session)) + .map_err(map_err)?; + to_ruby(result) + } + + // mpp_open(deposit:) — deposit into escrow and return channel state. + fn mpp_open(&self, opts: RHash) -> Result { + validate_keys(&opts, &["deposit"])?; + let deposit = parse_base_units(&hash_require_string(&opts, "deposit")?, "deposit")?; + let client = self.inner.clone(); + let channel = runtime() + .block_on(client.mpp_open(deposit)) + .map_err(map_err)?; + to_ruby(channel_state_json(&channel)) + } + + // mpp_top_up(...) — add funds to an open channel. + fn mpp_top_up(&self, opts: RHash) -> Result { + validate_keys(&opts, &["channel", "additional_deposit"])?; + let channel = require_channel_state(&opts)?; + let extra = parse_base_units( + &hash_require_string(&opts, "additional_deposit")?, + "additional_deposit", + )?; + let client = self.inner.clone(); + let updated = runtime() + .block_on(client.mpp_top_up(&channel, extra)) + .map_err(map_err)?; + to_ruby(channel_state_json(&updated)) + } + + // mpp_close(channel:) — settle the final spend and refund the remainder. + fn mpp_close(&self, opts: RHash) -> Result<(), Error> { + validate_keys(&opts, &["channel"])?; + let channel = require_channel_state(&opts)?; + let client = self.inner.clone(); + runtime() + .block_on(client.mpp_close(&channel)) + .map_err(map_err) + } + + // mpp_status(channel:) — read channel state; costs one request unit and + // advances cumulative_spent by per_call. + fn mpp_status(&self, opts: RHash) -> Result { + validate_keys(&opts, &["channel"])?; + let channel = require_channel_state(&opts)?; + let client = self.inner.clone(); + let st = runtime() + .block_on(client.mpp_status(&channel)) + .map_err(map_err)?; + to_ruby(serde_json::json!({ + "channel_id": st.channel_id, + "accepted_cumulative": st.accepted_cumulative.to_string(), + "spent": st.spent.to_string(), + })) + } + + // mpp_session_call(...) — authorize one call with a cumulative voucher. + fn mpp_session_call(&self, opts: RHash) -> Result { + validate_keys( + &opts, + &["method", "network", "channel", "new_cumulative", "params"], + )?; + let method = hash_require_string(&opts, "method")?; + let network = hash_require_string(&opts, "network")?; + let channel = require_channel_state(&opts)?; + let new_cumulative = parse_base_units( + &hash_require_string(&opts, "new_cumulative")?, + "new_cumulative", + )?; + let params = hash_get_json(&opts, "params")?; + let client = self.inner.clone(); + let result = runtime() + .block_on(client.mpp_session_call(&method, params, &network, &channel, new_cumulative)) + .map_err(map_err)?; + to_ruby(result) + } +} + +// Payment types cross as hashes because they contain enums, secrets, or u128 +// fields that magnus cannot wrap directly. + +fn arg_err(message: String) -> Error { + Error::new(ruby().exception_arg_error(), message) +} + +// Keep u128 amounts as decimal strings and reject malformed values. +fn parse_base_units(raw: &str, field: &str) -> Result { + raw.trim().parse::().map_err(|_| { + arg_err(format!( + "{field} must be a decimal base-unit amount as a String, got {raw:?}" + )) + }) +} + +fn hash_get_json(h: &RHash, key: &str) -> Result, Error> { + let r = ruby(); + match h.get(r.to_symbol(key)) { + Some(v) if !v.is_nil() => Ok(Some(serde_magnus::deserialize(&r, v)?)), + _ => Ok(None), + } +} + +fn gateway_session_json(session: &core::GatewaySession) -> serde_json::Value { + serde_json::json!({ + "token": session.token, + "exp_unix": session.exp_unix, + "account_id": session.account_id, + }) +} + +fn require_gateway_session(opts: &RHash) -> Result { + let r = ruby(); + let value = opts + .get(r.to_symbol("session")) + .ok_or_else(|| arg_err("missing keyword: session".to_string()))?; + let h = RHash::from_value(value) + .ok_or_else(|| arg_err("session must be a Hash from gateway_authenticate".to_string()))?; + Ok(core::GatewaySession { + token: hash_require_string(&h, "token")?, + exp_unix: hash_require_i64(&h, "exp_unix")?, + account_id: hash_require_string(&h, "account_id")?, + }) +} + +fn channel_state_json(channel: &core::ChannelState) -> serde_json::Value { + serde_json::json!({ + "channel_id": channel.channel_id, + "token": channel.token, + "payee": channel.payee, + "salt": channel.salt, + "authorized_signer": channel.authorized_signer, + "escrow_contract": channel.escrow_contract, + "deposit": channel.deposit.to_string(), + "cumulative_spent": channel.cumulative_spent.to_string(), + "per_call": channel.per_call.to_string(), + "chain_id": channel.chain_id, + }) +} + +// Accept strings from channel_state_json and integer hash values. +fn channel_amount(h: &RHash, field: &str) -> Result { + let r = ruby(); + let value = h + .get(r.to_symbol(field)) + .ok_or_else(|| arg_err(format!("channel is missing {field}")))?; + if let Some(s) = magnus::RString::from_value(value) { + return parse_base_units(&s.to_string()?, field); + } + // Render integers as decimal strings to preserve values beyond i64. + let as_string: String = value.to_r_string()?.to_string()?; + parse_base_units(&as_string, field) +} + +fn require_channel_state(opts: &RHash) -> Result { + let r = ruby(); + let value = opts + .get(r.to_symbol("channel")) + .ok_or_else(|| arg_err("missing keyword: channel".to_string()))?; + let h = RHash::from_value(value) + .ok_or_else(|| arg_err("channel must be a Hash from mpp_open/mpp_top_up".to_string()))?; + Ok(core::ChannelState { + channel_id: hash_require_string(&h, "channel_id")?, + token: hash_require_string(&h, "token")?, + payee: hash_require_string(&h, "payee")?, + salt: hash_require_string(&h, "salt")?, + authorized_signer: hash_require_string(&h, "authorized_signer")?, + escrow_contract: hash_require_string(&h, "escrow_contract")?, + deposit: channel_amount(&h, "deposit")?, + cumulative_spent: channel_amount(&h, "cumulative_spent")?, + per_call: channel_amount(&h, "per_call")?, + chain_id: u64::try_from(hash_require_i64(&h, "chain_id")?) + .map_err(|_| arg_err("channel chain_id must be non-negative".to_string()))?, + }) +} + +// generate_payment_wallet(chain:) — generate a keypair offline. +fn generate_payment_wallet(opts: RHash) -> Result { + validate_keys(&opts, &["chain"])?; + let chain = hash_require_string(&opts, "chain")?; + let kind = match chain.to_ascii_lowercase().as_str() { + "evm" => core::ChainKind::Evm, + "svm" | "solana" => core::ChainKind::Svm, + "tempo" => core::ChainKind::Tempo, + other => { + return Err(arg_err(format!( + "unknown payment chain {other:?} (expected \"evm\", \"svm\", or \"tempo\")" + ))) + } + }; + let wallet = core::generate_payment_wallet(kind).map_err(map_err)?; + let chain_label = match wallet.chain { + core::ChainKind::Evm => "evm", + core::ChainKind::Svm => "svm", + core::ChainKind::Tempo => "tempo", + }; + to_ruby(serde_json::json!({ + "address": wallet.address, + "chain": chain_label, + "key": wallet.into_key(), + })) } // ── Extension init ────────────────────────────────────────────────────────── @@ -2273,12 +2614,48 @@ fn init(ruby: &Ruby) -> Result<(), Error> { // ── Rpc ─────────────────────────────────────────────────── let rpc = native.define_class("Rpc", ruby.class_object())?; rpc.define_method("call", method!(RpcApiClient::call, 1))?; + rpc.define_method( + "call_with_receipt", + method!(RpcApiClient::call_with_receipt, 1), + )?; rpc.define_method("set_networks", method!(RpcApiClient::set_networks, 1))?; rpc.define_method( "clear_cached_token", method!(RpcApiClient::clear_cached_token, 0), )?; rpc.define_method("current_token", method!(RpcApiClient::current_token, 0))?; + rpc.define_method("payment_address", method!(RpcApiClient::payment_address, 0))?; + rpc.define_method( + "gateway_authenticate", + method!(RpcApiClient::gateway_authenticate, 0), + )?; + rpc.define_method("gateway_credits", method!(RpcApiClient::gateway_credits, 1))?; + rpc.define_method( + "gateway_buy_credits", + method!(RpcApiClient::gateway_buy_credits, 1), + )?; + rpc.define_method("gateway_drip", method!(RpcApiClient::gateway_drip, 1))?; + rpc.define_method( + "gateway_drawdown_call", + method!(RpcApiClient::gateway_drawdown_call, 1), + )?; + rpc.define_method("mpp_open", method!(RpcApiClient::mpp_open, 1))?; + rpc.define_method("mpp_top_up", method!(RpcApiClient::mpp_top_up, 1))?; + rpc.define_method("mpp_close", method!(RpcApiClient::mpp_close, 1))?; + rpc.define_method("mpp_status", method!(RpcApiClient::mpp_status, 1))?; + rpc.define_method( + "mpp_session_call", + method!(RpcApiClient::mpp_session_call, 1), + )?; + + // Wallet generation is a module function, not a client method: it needs no + // configured SDK and makes no network call. It goes on Native so the + // pure-Ruby wrapper in lib/quicknode_sdk.rb can wrap the result in an + // IndifferentHash, matching every client response. + native.define_singleton_method( + "generate_payment_wallet", + function!(generate_payment_wallet, 1), + )?; Ok(()) } diff --git a/npm/README.md b/npm/README.md index 220f8aa..72612fa 100644 --- a/npm/README.md +++ b/npm/README.md @@ -11,8 +11,12 @@ This is one of four language bindings published from the same Rust core. See the - [Installation](#installation) - [Quick Start](#quick-start) - [Configuration](#configuration) + - [Option A — Pass config directly](#option-a--pass-config-directly) + - [Option B — Load from environment (`from_env()`)](#option-b--load-from-environment-from_env) + - [Custom headers and `User-Agent`](#custom-headers-and-user-agent) - [Platform Support](#platform-support) - [API Reference](#api-reference) + - [Language conventions](#language-conventions) - [Admin Client](#admin-client) - [Endpoints](#endpoints) - [Endpoint Tags](#endpoint-tags) @@ -48,6 +52,12 @@ This is one of four language bindings published from the same Rust core. See the - [Sets](#sets) - [Lists](#lists) - [SQL Client](#sql-client) + - [RPC & Tooling Access](#rpc--tooling-access) +- [Crypto-micropayment lane (`rpc.call`)](#crypto-micropayment-lane-rpccall) + - [Wallet generation](#wallet-generation) + - [x402 credit drawdown (authenticate once, then draw one credit per call)](#x402-credit-drawdown-authenticate-once-then-draw-one-credit-per-call) + - [Testnet faucet](#testnet-faucet) + - [MPP payment channel (deposit once, then vouchers)](#mpp-payment-channel-deposit-once-then-vouchers) - [Error Handling](#error-handling) - [License](#license) @@ -78,6 +88,10 @@ There are two ways to configure the SDK. // Node.js import { QuicknodeSdk } from "quicknode-sdk"; const qn = new QuicknodeSdk({ apiKey: "your-key", http: { timeoutSecs: 30 } }); + +// apiKey is optional: the crypto-micropayment lane pays per request instead, so +// omitting it builds a usable SDK. Every other client still needs one, and +// fromEnv() always requires QN_SDK__API_KEY. ``` ### Option B — Load from environment (`from_env()`) @@ -1727,6 +1741,169 @@ A host that persists across processes can snapshot the cached token with `RpcConfig.endpointUrl` to route every call to a custom HTTP URL by default (no JWT minted); a per-call `endpointUrl` overrides it. +## Crypto-micropayment lane (`rpc.call`) + +Pay per RPC request with a stablecoin instead of a provisioned account + API key, +against Quicknode's `x402.quicknode.com` and `mpp.quicknode.com` gateways. Configure +it by setting `payment` on the RPC config; the SDK runs the `402` → sign → resend +handshake for you. An API key is **not** required for this lane — build a keyless SDK. + +There are four payment paths. Two pay per request; two amortize one signature over many +calls. + +| Path | Entry point | Gateway | Signs | +|---|---|---|---| +| Per-request x402 | `call` / `callWithReceipt` with `scheme: "x402"` | x402 | once per call | +| Per-request MPP charge | `call` / `callWithReceipt` with `scheme: "mpp"` | mpp | once per call | +| [x402 credit drawdown](#x402-credit-drawdown-authenticate-once-then-draw-one-credit-per-call) | `gatewayAuthenticate` → `gatewayDrawdownCall` | x402 | once per session | +| [MPP payment channel](#mpp-payment-channel-deposit-once-then-vouchers) | `mppOpen` → `mppSessionCall` | mpp | once per channel | + +The signer construction is derived from the scheme and pay network, never stated directly: +**x402/EVM** signs an EIP-712 `TransferWithAuthorization`, **x402/Solana** an SPL +`TransferChecked` in a v0 tx (the gateway sponsors gas), and **MPP/Tempo** a native Tempo +transaction. + +`scheme` selects the gateway for `call` only. The `gateway*` drawdown methods always use +the x402 gateway and the `mpp*` channel methods always use the MPP gateway, whatever +`scheme` is set to. + +`PaymentConfig` fields: + +| Field | Meaning | +|---|---| +| `scheme` | `"x402"` (pay-per-request) or `"mpp"` (MPP charge; `"mpp-charge"` is accepted too) | +| `key` | raw private key — EVM/Tempo: hex; Solana: base58 64-byte secret | +| `payNetwork` | CAIP-2 pay network, e.g. `eip155:84532`, `solana:5eykt4…` | +| `asset` | token address/mint to pay in (matches the offered menu entry) | +| `maxAmount` | **required** spend ceiling in integer base units of `asset` | +| `svmRpcUrl` | optional Solana RPC for x402/Solana payment-build reads (mint + blockhash) | +| `baseUrlOverride` | optional gateway base (testing) | + +`network` on the call is the **query** chain (gateway path slug), independent of the +pay network. Use `callWithReceipt` to also get the settlement receipt (`reference` = +settlement tx hash) — populated on the MPP lane, `null` for x402. + +**Things to know:** + +- **Do not log your own `PaymentConfig`** — the `key` field is readable. The SDK + never prints it in its own errors, but `console.log(config)` will show it. +- **`maxAmount` is integer base units of the selected asset.** The SDK skips any offered + entry above it and refuses to sign one — a guard against an overcharging gateway. +- **`PaymentIndeterminateError` means the paid request was sent but the response was lost.** + You MAY have been charged — do **not** blindly retry. +- **x402/Solana: one payment per call.** Building a payment reads the mint and a recent + blockhash from a Solana RPC. The default is a public RPC that **rate-limits + aggressively** — set `svmRpcUrl` to your own endpoint at any volume. + +```typescript +import { QuicknodeSdk } from "@quicknode/sdk"; + +const qn = new QuicknodeSdk({ + rpc: { + payment: { + scheme: "x402", + key: process.env.QN_PAYMENT_KEY!, + payNetwork: "eip155:84532", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + maxAmount: "10000", + }, + }, +}); +const { result, paymentReceipt } = await qn.rpc.callWithReceipt("eth_blockNumber", [], "base-sepolia"); +console.log(result, paymentReceipt); +``` + +### Wallet generation + +`generatePaymentWallet("evm")` creates a fresh keypair offline — no network call, no funds — for +`"evm"`, `"svm"`, or `"tempo"`. The private key is returned **exactly once**, at +generation; nothing in the SDK stores or re-derives it, so persist it immediately. + +```typescript +import { generatePaymentWallet } from "@quicknode/sdk"; + +const wallet = generatePaymentWallet("evm"); +console.log("fund this address:", wallet.address); +// wallet.key is returned exactly once — persist it now. +``` + +### x402 credit drawdown (authenticate once, then draw one credit per call) + +Cheaper per call than paying per request: one SIWE signature mints a session JWT, then +each call draws a single credit from the account balance instead of signing a fresh +settlement. Minting the session is free and moves no funds, so a host can re-authenticate +transparently. Persist it between processes. + +Fund the payment wallet out of band — the testnet faucet below, or by sending funds to +`paymentAddress()` directly. Credits are provisioned against the account gateway-side. + +EVM signers only: SIWE is an EIP-4361 construction, so an x402/Solana key errors here. + +| Method | Cost | Returns | +|---|---|---| +| `paymentAddress()` | free, offline | the wallet address derived from the key | +| `gatewayAuthenticate()` | free | `GatewaySession { token, expUnix, accountId }` | +| `gatewayCredits(session)` | free | `CreditBalance { accountId, credits }` | +| `gatewayDrip(session)` | free (testnet) | `DripReceipt { accountId, transactionHash }` | +| `gatewayDrawdownCall(method, session, network, params?)` | 1 credit | the JSON-RPC `result` | + +```typescript +const session = await qn.rpc.gatewayAuthenticate(); +const balance = await qn.rpc.gatewayCredits(session); +console.log("credits:", balance.credits); +const result = await qn.rpc.gatewayDrawdownCall("eth_blockNumber", session, "base-sepolia"); +``` + +A `token_expired` surfaces as an `ApiError` with status 401/403; re-authenticate and retry +that call. + +#### Testnet faucet + +`gatewayDrip` requests testnet tokens for the payment **wallet** on Base Sepolia. The +gateway allows one drip per account, and it returns the on-chain funding transaction hash +— not a credit balance. + +### MPP payment channel (deposit once, then vouchers) + +Open a payment channel by depositing into the escrow, then authorize each call with a +cumulative voucher — one `ecrecover` server-side, no on-chain transaction per call. + +| Method | Cost | Returns | +|---|---|---| +| `mppOpen(deposit)` | **moves funds** | `ChannelState` — persist it | +| `mppTopUp(channel, additionalDeposit)` | **moves funds** | the updated channel state | +| `mppStatus(channel)` | **1 request unit** | `ChannelStatus { channelId, acceptedCumulative, spent }` | +| `mppSessionCall(method, network, channel, newCumulative, params?)` | 1 request unit | the JSON-RPC `result` | +| `mppClose(channel)` | settles on-chain | nothing — refunds the unused deposit | + +```typescript +const channel = await qn.rpc.mppOpen("1000000"); // persist this object +const newTotal = (BigInt(channel.cumulativeSpent) + BigInt(channel.perCall)).toString(); +const result = await qn.rpc.mppSessionCall( + "eth_blockNumber", "base-sepolia", channel, newTotal, +); +// On success, store newTotal as the channel's cumulativeSpent. +``` + +**Things to know:** + +- **Persist the channel state.** The gateway exposes no read-only channel endpoint, so a + lost local record means opening (and funding) a new channel. +- **`mppStatus` is not free.** The gateway prices every session POST as a chargeable + request and computes the balance from the *new* spend a voucher authorizes, so the + probe advances `cumulativeSpent` by `perCall` exactly like a call. Re-persist the + advanced total. It raises `PaymentUnsupportedError` before any network I/O when the + channel has no room left for the probe. +- **The lifecycle takes no query network.** A channel is scoped by the configured pay + network and asset, so one channel funds calls to every supported network. Only + `mppSessionCall` takes a network, because it routes an RPC method. +- **Amounts are decimal strings, not numbers.** They are `u128` in the core; a JS `number` is an f64 that loses precision above 2^53, so pass and store them as strings. +- **Advance `cumulativeSpent` only after a success.** A voucher authorizes the running total + *after* the call; re-presenting the current high-water mark authorizes zero and is + always refused with `insufficient-balance`. + + + ## Error Handling Every binding exposes a typed exception hierarchy derived from the core `SdkError` @@ -1743,8 +1920,12 @@ subclass to branch on transport vs. API semantics. | `ApiError` | non-2xx HTTP response | `status`, `body` | | `DecodeError` | 2xx response but JSON parse failed | `body` | | `RpcError` | JSON-RPC call returned an `error` member | `code`, `message` | +| `PaymentError` | base class for the crypto-micropayment lane | — | +| `PaymentUnsupportedError` | no offered payment option matched your selector (or all were over `max_amount`/unsupported) | — | +| `PaymentRejectedError` | the gateway rejected a signed payment (terminal, one resend only) | `status`, `body` | +| `PaymentIndeterminateError` | paid request sent but response lost — MAY have been charged; do NOT blindly retry | — | -Class names: Importable from `@quicknode/sdk`: `QuicknodeError`, `ConfigError`, `HttpError`, `TimeoutError`, `ConnectionError`, `ApiError`, `DecodeError`, `RpcError`. All extend `Error`. +Class names: Importable from `@quicknode/sdk`: `QuicknodeError`, `ConfigError`, `HttpError`, `TimeoutError`, `ConnectionError`, `ApiError`, `DecodeError`, `RpcError`, `PaymentError`, `PaymentUnsupportedError`, `PaymentRejectedError`, `PaymentIndeterminateError`. All extend `Error`. ```typescript // Node.js diff --git a/npm/errors.js b/npm/errors.js index 9570e8a..9353c71 100644 --- a/npm/errors.js +++ b/npm/errors.js @@ -62,7 +62,40 @@ class RpcError extends QuicknodeError { } } -const TAG_RE = /^\[(Config|Http|Timeout|Connect|Api|Decode|Rpc)\|([^|]+)\|([^\]]+)\](.*)$/s; +// Payment-lane errors. PaymentError is the family base; PaymentRejectedError +// carries the gateway status/body; PaymentIndeterminateError is its own class +// so callers can catch "may have been charged — do not retry" distinctly. +class PaymentError extends QuicknodeError { + constructor(message) { + super(message); + this.name = "PaymentError"; + } +} + +class PaymentUnsupportedError extends PaymentError { + constructor(message) { + super(message); + this.name = "PaymentUnsupportedError"; + } +} + +class PaymentRejectedError extends PaymentError { + constructor(message, status, body) { + super(message); + this.name = "PaymentRejectedError"; + this.status = status; + this.body = body; + } +} + +class PaymentIndeterminateError extends PaymentError { + constructor(message) { + super(message); + this.name = "PaymentIndeterminateError"; + } +} + +const TAG_RE = /^\[(Config|Http|Timeout|Connect|Api|Decode|Rpc|PaymentUnsupported|PaymentRejected|PaymentIndeterminate)\|([^|]+)\|([^\]]+)\](.*)$/s; function fromNapiError(err) { if (!(err instanceof Error)) return err; @@ -92,6 +125,9 @@ function fromNapiError(err) { case "Decode": return new DecodeError(msg, body); // For Rpc, statusStr is the JSON-RPC code and body is its message. case "Rpc": return new RpcError(body || msg, Number(statusStr)); + case "PaymentUnsupported": return new PaymentUnsupportedError(msg); + case "PaymentRejected": return new PaymentRejectedError(msg, Number(statusStr), body); + case "PaymentIndeterminate": return new PaymentIndeterminateError(msg); default: return err; } } @@ -127,6 +163,10 @@ module.exports = { ApiError, DecodeError, RpcError, + PaymentError, + PaymentUnsupportedError, + PaymentRejectedError, + PaymentIndeterminateError, fromNapiError, wrapClient, }; diff --git a/npm/examples/rpc_payment.ts b/npm/examples/rpc_payment.ts new file mode 100644 index 0000000..a65c07a --- /dev/null +++ b/npm/examples/rpc_payment.ts @@ -0,0 +1,103 @@ +// Crypto-micropayment lane for rpc.call: pay per RPC request with a stablecoin +// instead of an account API key, against Quicknode's x402/MPP gateways. +// +// ⚠️ MOVES REAL FUNDS when it settles. Use a throwaway, minimally-funded +// wallet. Reads the key from QN_PAYMENT_KEY — never hard-code it. +// +// Run (x402/EVM on Base Sepolia testnet): +// QN_PAYMENT_KEY=0x npx tsx examples/rpc_payment.ts +// +// Run the x402 drawdown lane (authenticate once, then 1 credit per call): +// QN_PAYMENT_KEY=0x QN_PAYMENT_LANE=drawdown npx tsx examples/rpc_payment.ts + +import { + QuicknodeSdk, + PaymentIndeterminateError, + PaymentRejectedError, + generatePaymentWallet, +} from "@quicknode/sdk"; + +const key = process.env.QN_PAYMENT_KEY; +if (!key) { + // Wallet generation is offline; persist the key returned here. + const wallet = generatePaymentWallet("evm"); + console.log("generated a throwaway wallet:", wallet.address); + console.log("fund it, then re-run with QN_PAYMENT_KEY set to its key"); + process.exit(0); +} + +// Keyless SDK. Do not log this config; it contains the private key. +const qn = new QuicknodeSdk({ + rpc: { + payment: { + scheme: "x402", + key, + // Base Sepolia testnet USDC (x402/EVM). + payNetwork: "eip155:84532", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + // Spend ceiling in asset base units. + maxAmount: "10000", + // Set svmRpcUrl for x402/Solana at volume. + }, + }, +}); + +// Drawdown lane: authenticate once, then spend one credit per call. +async function drawdownDemo() { + // Derived locally; use it to key a session cache. + console.log("payment wallet:", qn.rpc.paymentAddress()); + + const session = await qn.rpc.gatewayAuthenticate(); + console.log("session account:", session.accountId, "expires:", session.expUnix); + + const balance = await qn.rpc.gatewayCredits(session); + console.log("credits:", balance.credits); + + if (balance.credits === 0) { + // The faucet returns a funding transaction, not a balance. + try { + const drip = await qn.rpc.gatewayDrip(session); + console.log("faucet tx:", drip.transactionHash); + } catch (e) { + if (e instanceof PaymentRejectedError) { + console.error(`faucet refused (${e.status}):`, e.body); + } else throw e; + } + } + + const result = await qn.rpc.gatewayDrawdownCall( + "eth_blockNumber", + session, + "base-sepolia", + ); + console.log("drawdown eth_blockNumber =>", result); +} + +async function main() { + if (process.env.QN_PAYMENT_LANE === "drawdown") { + await drawdownDemo(); + return; + } + try { + // Query network is independent of the payment network. + const { result, paymentReceipt } = await qn.rpc.callWithReceipt( + "eth_blockNumber", + [], + "base-sepolia", + ); + console.log("paid eth_blockNumber =>", result); + // x402 does not return a settlement receipt. + if (paymentReceipt) console.log("settlement reference:", paymentReceipt.reference); + } catch (e) { + if (e instanceof PaymentIndeterminateError) { + // The request may have settled. Do not retry blindly. + console.error("payment indeterminate — do not retry:", e.message); + } else if (e instanceof PaymentRejectedError) { + console.error(`payment rejected (${e.status}):`, e.body); + } else { + throw e; + } + } +} + +main(); diff --git a/npm/index.d.ts b/npm/index.d.ts index fd48659..a404bcc 100644 --- a/npm/index.d.ts +++ b/npm/index.d.ts @@ -1367,6 +1367,55 @@ export interface Payment { marketplaceAmount?: string } +/** + * Binding-facing crypto-micropayment configuration. **Plain data** — all + * fields are strings so this can be a `napi(object)` / `pyclass` / Ruby hash; + * it is converted to the internal `enum Signer` + resolved config at the Rust + * boundary. The private `key` field stays readable to the caller, but the + * SDK's own `Debug` redacts it (below) so an SDK log line or panic can't leak + * it. + * + * **Do not log your own `PaymentConfig`** — `println!("{config:?}")` on the + * derived-Debug *binding* object (napi/pyclass/hash) still shows the raw key. + * Only the SDK's internal rendering is redacted. + */ +export interface PaymentConfig { + /** Payment protocol: `"x402"` (pay-per-request) or `"mpp"` (MPP charge). */ + scheme: string + /** + * Raw private key. EVM/Tempo: hex (with or without `0x`). Solana: base58 + * 64-byte secret key. + */ + key: string + /** + * CAIP-2 pay network selector, e.g. `"eip155:84532"` (x402/EVM), + * `"solana:5eykt4…"` (x402/Solana), or `"eip155:42431"` (MPP/Tempo). + */ + payNetwork: string + /** + * Asset (token) address/mint to pay in. Matches the offered menu entry's + * `asset`. EVM: token contract hex. Solana: mint base58. + */ + asset: string + /** + * Spend ceiling in base units of `asset` (integer string). **Required.** + * The selector skips any offered entry above this, and the driver refuses + * to sign one — guarding against a buggy/hostile gateway overcharging a + * custodied key. + */ + maxAmount: string + /** + * Explicit Solana RPC URL for x402/Solana payment-build reads: the mint + * (for its decimals and owning token program) and a recent blockhash, so + * two reads per payment. Optional; when unset the SDK falls back to a + * public Solana RPC matching the pay cluster. **Set this at any real + * volume** — the public default rate-limits aggressively. + */ + svmRpcUrl?: string + /** Test-only gateway base override (points the lane at a mock gateway). */ + baseUrlOverride?: string +} + /** Configuration for delivering stream batches to a PostgreSQL database. */ export interface PostgresAttributes { /** Database host. */ @@ -1512,6 +1561,19 @@ export interface RpcConfig { * call path needs no map. */ networks?: Record + /** + * Crypto-micropayment lane. When set, `rpc.call` pays per request with a + * stablecoin against Quicknode's x402/MPP gateways instead of using the + * account API key + session JWT. `#[serde(skip)]` so `from_env` can never + * populate it — an env-derived private key is exactly what we don't want; + * callers must pass this programmatically. The field is always present + * (plain data), but the payment lane is only wired into `rpc.call` when a + * crypto feature (`payments`/`payments-svm`/`payments-tempo`) is enabled; + * built without any of them, a set `payment` is ignored and `rpc.call` + * keeps its normal tooling-JWT behavior. The precompiled Python/Node/Ruby + * packages always ship with the payment features on. + */ + payment?: PaymentConfig } /** Configuration for delivering stream batches to an S3-compatible object store. */ @@ -1539,7 +1601,17 @@ export interface S3Attributes { } export interface SdkFullConfig { - apiKey: string + /** + * Account API key. **Optional** so a keyless SDK can be built for the + * crypto-micropayment lane (`rpc.call` with `RpcConfig.payment`). When + * absent, no `x-api-key` header is installed: the payment lane works, while + * the keyed surfaces (admin/streams/webhooks/kvstore/sql and tooling-JWT + * `rpc.call`) send un-authenticated requests and the gateway rejects them + * (surfacing as an `ApiError`, typically 401). `from_env` still requires + * the key (validated in `from_config`) — only programmatic construction + * may omit it. + */ + apiKey?: string http?: HttpConfig admin?: AdminConfig streams?: StreamsConfig @@ -2532,6 +2604,13 @@ export declare class RpcApiClient { * `RpcError`. */ call(method: string, params?: any | undefined | null, network?: string | undefined | null, endpointUrl?: string | undefined | null): Promise + /** + * Like `call`, but also returns the crypto-micropayment settlement + * receipt. Resolves to `{ result, paymentReceipt }` where `paymentReceipt` + * is `{ method, status, timestamp, reference }` on the MPP payment lane and + * `null` for x402 and every non-payment lane (identical to `call`). + */ + callWithReceipt(method: string, params?: any | undefined | null, network?: string | undefined | null, endpointUrl?: string | undefined | null): Promise /** * Seeds the per-network URL map for multichain routing (network key -> * full http_url), typically built from @@ -2549,6 +2628,79 @@ export declare class RpcApiClient { * token between processes. */ currentToken(): CachedToken | null + /** + * The configured payment wallet's on-chain address (EVM/Tempo `0x…` hex, + * Solana base58), derived offline from the key with no network round trip. + */ + paymentAddress(): string + /** + * Authenticates against the x402 gateway with a SIWX message and resolves + * to `{ token, expUnix, accountId }`. Free — no funds move. Persist the + * object and pass it back to the `gateway*` methods. + */ + gatewayAuthenticate(): Promise + /** + * Reads the account's current x402 credit balance. Resolves to + * `{ accountId, credits }`. `session` comes from `gatewayAuthenticate`. + */ + gatewayCredits(session: any): Promise + /** + * Buys a block of credits, settling the gateway's offer with the same + * signer construction as the per-request lane. Resolves to the + * post-purchase `{ accountId, credits }`. Single-attempt: a paid lane never + * blind-retries. + */ + gatewayBuyCredits(session: any, network: string): Promise + /** + * Requests testnet tokens from the x402 faucet. Resolves to the funding + * transaction `{ accountId, transactionHash }` — NOT a balance; call + * `gatewayCredits` afterwards for that. Allowed once per account. + */ + gatewayDrip(session: any): Promise + /** + * Makes one x402 drawdown JSON-RPC call with the session as a Bearer + * token, drawing 1 credit on success. Resolves to the unwrapped JSON-RPC + * `result`. Single-attempt; re-authenticate on a 401/403 `ApiError`. + */ + gatewayDrawdownCall(method: string, session: any, network: string, params?: any | undefined | null): Promise + /** + * Opens an MPP payment channel by depositing `deposit` base units (a + * decimal string) into the escrow. Resolves to the channel state — persist + * it; the gateway has no read-only channel endpoint, so a lost record means + * opening a new channel. Moves real funds; single-attempt. + * + * Takes no network: the channel is scoped by the configured pay network and + * asset, so one channel funds calls to every supported network. + */ + mppOpen(deposit: string): Promise + /** + * Adds `additionalDeposit` base units (a decimal string) to an open + * channel. Resolves to the updated channel state. Moves real funds; + * single-attempt. + */ + mppTopUp(channel: any, additionalDeposit: string): Promise + /** + * Cooperatively closes a channel: settles the final cumulative spend + * on-chain and refunds the unused deposit. Single-attempt. + */ + mppClose(channel: any): Promise + /** + * Fetches the gateway's view of the channel as + * `{ channelId, acceptedCumulative, spent }` (amounts are decimal strings). + * + * **This costs one request unit** and advances the voucher by `perCall`, + * exactly like a session call — persist the advanced `cumulativeSpent`. + * Rejects with `PaymentUnsupportedError` before any network I/O when the + * channel has no room left for the probe. + */ + mppStatus(channel: any): Promise + /** + * Makes one MPP session-lane JSON-RPC call, authorizing it with a + * cumulative voucher for `newCumulative` (a decimal string: the running + * total AFTER this call). Resolves to the unwrapped JSON-RPC `result`. + * Single-attempt; advance the persisted `cumulativeSpent` on success. + */ + mppSessionCall(method: string, network: string, channel: any, newCumulative: string, params?: any | undefined | null): Promise } export declare class SqlApiClient { @@ -2744,6 +2896,17 @@ export interface CreateWebhookFromTemplateParamsNode { templateArgs: any } +/** + * Generates a fresh payment keypair for `chain` (`"evm"`, `"svm"`, or + * `"tempo"`). Returns `{ address, chain, key }` where `key` is the raw private + * key in the format the `keyFile` config reads. + * + * The key is returned exactly once, at generation: nothing in the SDK stores or + * re-derives it, so persist it before discarding the object. Randomness comes + * from the OS CSPRNG. + */ +export declare function generatePaymentWallet(chain: string): any + export interface ListStreamsResponseNode { data: Array pageInfo: PageInfo diff --git a/npm/index.js b/npm/index.js index 03de3ab..9eca1c0 100644 --- a/npm/index.js +++ b/npm/index.js @@ -592,3 +592,4 @@ module.exports.RpcApiClient = nativeBinding.RpcApiClient module.exports.SqlApiClient = nativeBinding.SqlApiClient module.exports.StreamsApiClient = nativeBinding.StreamsApiClient module.exports.WebhooksApiClient = nativeBinding.WebhooksApiClient +module.exports.generatePaymentWallet = nativeBinding.generatePaymentWallet diff --git a/npm/sdk.d.ts b/npm/sdk.d.ts index a2ad1b6..f67aece 100644 --- a/npm/sdk.d.ts +++ b/npm/sdk.d.ts @@ -16,6 +16,7 @@ import { XrplWalletFilterTemplate, HyperliquidWalletEventsFilterTemplate, StellarWalletTransactionsFilterTemplate, + RpcApiClient, } from "./index"; // Stream destination attributes (input). The inner key is `attributes` rather @@ -331,8 +332,97 @@ export type { CachedToken, ToolingAccessStatus, RpcApiClient, + // payment lane + PaymentConfig, } from "./index"; +// A settlement receipt for the crypto-micropayment lane. Returned inside +// `RpcCallResponse.paymentReceipt` by `rpc.callWithReceipt`. `reference` is the +// settlement transaction hash. Present only on the MPP lane; `null` otherwise. +export interface PaymentReceipt { + method: string; + status: string; + timestamp: string; + reference: string; +} + +// The result of `rpc.callWithReceipt`: the JSON-RPC `result` plus the optional +// settlement receipt (`null` for x402 and non-payment lanes). +export interface RpcCallResponse { + result: any; + paymentReceipt: PaymentReceipt | null; +} + +// ── Payment lanes ────────────────────────────────────────────── +// +// Base-unit amounts are `string`, not `number`: they are u128 in the core and a +// JS number is an f64 that loses precision above 2^53. Pass and store them as +// decimal strings. + +// An x402 gateway session (from `rpc.gatewayAuthenticate`). `token` is a live +// bearer credential — persist it, but keep it out of logs. +export interface GatewaySession { + token: string; + expUnix: number; + accountId: string; +} + +// An x402 credit balance (`rpc.gatewayCredits` / `rpc.gatewayBuyCredits`). +export interface CreditBalance { + accountId: string; + credits: number; +} + +// The faucet result (`rpc.gatewayDrip`): the on-chain funding transaction, NOT +// a balance. Call `rpc.gatewayCredits` afterwards to read the new balance. +export interface DripReceipt { + accountId: string; + transactionHash: string; +} + +// Local state for an open MPP payment channel (`rpc.mppOpen` / +// `rpc.mppTopUp`). Persist this verbatim: the gateway has no read-only channel +// endpoint, so a lost record means opening a new channel. +export interface ChannelState { + channelId: string; + token: string; + payee: string; + salt: string; + authorizedSigner: string; + escrowContract: string; + /** Base units, decimal string. */ + deposit: string; + /** Base units, decimal string. */ + cumulativeSpent: string; + /** The gateway's per-call price, in base units, as a decimal string. */ + perCall: string; + chainId: number; +} + +// The gateway's view of a channel (`rpc.mppStatus`). +export interface ChannelStatus { + channelId: string; + /** Base units, decimal string. */ + acceptedCumulative: string; + /** Base units, decimal string. */ + spent: string; +} + +// A freshly generated payment wallet (`generatePaymentWallet`). `key` is the raw +// private key, returned exactly once at generation — nothing in the SDK stores +// or re-derives it, so persist it before discarding the object. +export interface GeneratedWallet { + address: string; + chain: "evm" | "svm" | "tempo"; + key: string; +} + +/** + * Generates a fresh payment keypair. Offline: no network call, no funds. + * Randomness comes from the OS CSPRNG. + */ +export function generatePaymentWallet(chain: "evm" | "svm" | "tempo"): GeneratedWallet; + // const enums must use `export` (not `export type`) so they are usable as values export { StreamRegion, @@ -397,6 +487,61 @@ export interface SqlApiClientTyped { getSchema(clusterId: string): Promise; } +// Retypes the payment-lane returns from napi's `any` to the interfaces above. +// napi emits `any` for every method returning a `serde_json::Value`, so without +// this the declared payment interfaces would be documentation only and a +// mistyped field would not be an error. Keep method signatures in sync with the +// napi-generated RpcApiClient in ./index.d.ts. +export interface RpcApiClientTyped + extends Omit< + RpcApiClient, + | "callWithReceipt" + | "gatewayAuthenticate" + | "gatewayCredits" + | "gatewayBuyCredits" + | "gatewayDrip" + | "gatewayDrawdownCall" + | "mppOpen" + | "mppTopUp" + | "mppClose" + | "mppStatus" + | "mppSessionCall" + > { + callWithReceipt( + method: string, + params?: any | undefined | null, + network?: string | undefined | null, + endpointUrl?: string | undefined | null + ): Promise; + gatewayAuthenticate(): Promise; + gatewayCredits(session: GatewaySession): Promise; + gatewayBuyCredits( + session: GatewaySession, + network: string + ): Promise; + gatewayDrip(session: GatewaySession): Promise; + gatewayDrawdownCall( + method: string, + session: GatewaySession, + network: string, + params?: any | undefined | null + ): Promise; + mppOpen(deposit: string): Promise; + mppTopUp( + channel: ChannelState, + additionalDeposit: string + ): Promise; + mppClose(channel: ChannelState): Promise; + mppStatus(channel: ChannelState): Promise; + mppSessionCall( + method: string, + network: string, + channel: ChannelState, + newCumulative: string, + params?: any | undefined | null + ): Promise; +} + export class QuicknodeSdk { constructor(config: SdkFullConfig); static fromEnv(): QuicknodeSdk; @@ -405,7 +550,7 @@ export class QuicknodeSdk { webhooks: WebhooksApiClientTyped; kvstore: _QuicknodeSdk["kvstore"]; sql: SqlApiClientTyped; - rpc: _QuicknodeSdk["rpc"]; + rpc: RpcApiClientTyped; } // Typed static factory methods producing each discriminated variant of @@ -459,3 +604,13 @@ export class DecodeError extends QuicknodeError { export class RpcError extends QuicknodeError { code: number; } +// Payment-lane errors (crypto-micropayment `rpc.call`). Catch PaymentError to +// handle them all. PaymentIndeterminateError means the paid request was sent +// but its response was lost — the payment MAY have settled, so do NOT retry. +export class PaymentError extends QuicknodeError {} +export class PaymentUnsupportedError extends PaymentError {} +export class PaymentRejectedError extends PaymentError { + status: number; + body: string; +} +export class PaymentIndeterminateError extends PaymentError {} diff --git a/npm/sdk.js b/npm/sdk.js index 5fce388..585075c 100644 --- a/npm/sdk.js +++ b/npm/sdk.js @@ -78,10 +78,20 @@ class TemplateArgs { } } +// Translate module-level napi errors; wrapClient only handles clients. +function generatePaymentWallet(chain) { + try { + return _index.generatePaymentWallet(chain); + } catch (e) { + throw errors.fromNapiError(e); + } +} + module.exports = { ..._index, QuicknodeSdk, TemplateArgs, + generatePaymentWallet, QuicknodeError: errors.QuicknodeError, ConfigError: errors.ConfigError, HttpError: errors.HttpError, @@ -90,4 +100,8 @@ module.exports = { ApiError: errors.ApiError, DecodeError: errors.DecodeError, RpcError: errors.RpcError, + PaymentError: errors.PaymentError, + PaymentUnsupportedError: errors.PaymentUnsupportedError, + PaymentRejectedError: errors.PaymentRejectedError, + PaymentIndeterminateError: errors.PaymentIndeterminateError, }; diff --git a/npm/sdk.mjs b/npm/sdk.mjs index 1d0e51c..12d7d09 100644 --- a/npm/sdk.mjs +++ b/npm/sdk.mjs @@ -26,6 +26,7 @@ export const { KvStoreApiClient, SqlApiClient, RpcApiClient, + generatePaymentWallet, QuicknodeError, ConfigError, HttpError, @@ -34,4 +35,8 @@ export const { ApiError, DecodeError, RpcError, + PaymentError, + PaymentUnsupportedError, + PaymentRejectedError, + PaymentIndeterminateError, } = cjs; diff --git a/npm/test.js b/npm/test.js index 450d088..0f46a93 100644 --- a/npm/test.js +++ b/npm/test.js @@ -1,8 +1,71 @@ +const assert = require("node:assert"); const sdk = require("./sdk.js"); async function main() { - // TODO: figure out testing + // Payment errors preserve the expected hierarchy. + assert(sdk.PaymentError.prototype instanceof sdk.QuicknodeError); + assert(sdk.PaymentUnsupportedError.prototype instanceof sdk.PaymentError); + assert(sdk.PaymentRejectedError.prototype instanceof sdk.PaymentError); + assert(sdk.PaymentIndeterminateError.prototype instanceof sdk.PaymentError); + + // Payment configuration does not require an API key. + const qn = new sdk.QuicknodeSdk({ + rpc: { + payment: { + scheme: "x402", + key: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + payNetwork: "eip155:84532", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + maxAmount: "10000", + }, + }, + }); + assert(typeof qn.rpc.callWithReceipt === "function"); + + // network is required for payment calls. + await assert.rejects( + () => qn.rpc.call("eth_blockNumber", []), + (e) => e instanceof sdk.ConfigError && /requires `network`/.test(e.message), + ); + + // Verify the payment methods are exposed. + for (const m of [ + "paymentAddress", "gatewayAuthenticate", "gatewayCredits", "gatewayBuyCredits", + "gatewayDrip", "gatewayDrawdownCall", "mppOpen", "mppTopUp", "mppClose", + "mppStatus", "mppSessionCall", + ]) { + assert(typeof qn.rpc[m] === "function", `rpc.${m} missing`); + } + + // Wallet generation is offline and returns the key exactly once. + const wallet = sdk.generatePaymentWallet("evm"); + assert(wallet.address.startsWith("0x") && wallet.address.length === 42); + assert.equal(wallet.chain, "evm"); + assert.equal(typeof wallet.key, "string"); + + // Module-level errors must be mapped to typed errors. + assert.throws( + () => sdk.generatePaymentWallet("dogecoin"), + (e) => e instanceof sdk.ConfigError, + ); + + // Reject non-integer base-unit amounts. + await assert.rejects( + () => qn.rpc.mppOpen("12.5"), + (e) => e instanceof sdk.ConfigError && /decimal base-unit/.test(e.message), + ); + + // Malformed channel objects report the missing field. + await assert.rejects( + () => qn.rpc.mppStatus({ channelId: "0xabc" }), + (e) => e instanceof sdk.ConfigError && /missing token/.test(e.message), + ); + + console.log("node payment surface OK"); return true; } -main(); +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/python/README.md b/python/README.md index c4aeae5..880d552 100644 --- a/python/README.md +++ b/python/README.md @@ -11,8 +11,12 @@ This is one of four language bindings published from the same Rust core. See the - [Installation](#installation) - [Quick Start](#quick-start) - [Configuration](#configuration) + - [Option A — Pass config directly](#option-a--pass-config-directly) + - [Option B — Load from environment (`from_env()`)](#option-b--load-from-environment-from_env) + - [Custom headers and `User-Agent`](#custom-headers-and-user-agent) - [Platform Support](#platform-support) - [API Reference](#api-reference) + - [Language conventions](#language-conventions) - [Admin Client](#admin-client) - [Endpoints](#endpoints) - [Endpoint Tags](#endpoint-tags) @@ -48,6 +52,12 @@ This is one of four language bindings published from the same Rust core. See the - [Sets](#sets) - [Lists](#lists) - [SQL Client](#sql-client) + - [RPC & Tooling Access](#rpc--tooling-access) +- [Crypto-micropayment lane (`rpc.call`)](#crypto-micropayment-lane-rpccall) + - [Wallet generation](#wallet-generation) + - [x402 credit drawdown (authenticate once, then draw one credit per call)](#x402-credit-drawdown-authenticate-once-then-draw-one-credit-per-call) + - [Testnet faucet](#testnet-faucet) + - [MPP payment channel (deposit once, then vouchers)](#mpp-payment-channel-deposit-once-then-vouchers) - [Error Handling](#error-handling) - [License](#license) @@ -82,6 +92,10 @@ There are two ways to configure the SDK. # Python from quicknode_sdk import QuicknodeSdk, SdkFullConfig, HttpConfig qn = QuicknodeSdk(SdkFullConfig(api_key="your-key", http=HttpConfig(timeout_secs=30))) + +# api_key is optional: the crypto-micropayment lane pays per request instead, so +# api_key=None builds a usable SDK. Every other client still needs one, and +# from_env() always requires QN_SDK__API_KEY. ``` ### Option B — Load from environment (`from_env()`) @@ -1723,6 +1737,166 @@ construction; `refresh_margin_secs` (default 60) tunes how early the token is refreshed. Set `RpcConfig(endpoint_url=...)` to route every call to a custom HTTP URL by default (no JWT minted); a per-call `endpoint_url` overrides it. +## Crypto-micropayment lane (`rpc.call`) + +Pay per RPC request with a stablecoin instead of a provisioned account + API key, +against Quicknode's `x402.quicknode.com` and `mpp.quicknode.com` gateways. Configure +it by setting `payment` on the RPC config; the SDK runs the `402` → sign → resend +handshake for you. An API key is **not** required for this lane — build a keyless SDK. + +There are four payment paths. Two pay per request; two amortize one signature over many +calls. + +| Path | Entry point | Gateway | Signs | +|---|---|---|---| +| Per-request x402 | `call` / `call_with_receipt` with `scheme="x402"` | x402 | once per call | +| Per-request MPP charge | `call` / `call_with_receipt` with `scheme="mpp"` | mpp | once per call | +| [x402 credit drawdown](#x402-credit-drawdown-authenticate-once-then-draw-one-credit-per-call) | `gateway_authenticate` → `gateway_drawdown_call` | x402 | once per session | +| [MPP payment channel](#mpp-payment-channel-deposit-once-then-vouchers) | `mpp_open` → `mpp_session_call` | mpp | once per channel | + +The signer construction is derived from the scheme and pay network, never stated directly: +**x402/EVM** signs an EIP-712 `TransferWithAuthorization`, **x402/Solana** an SPL +`TransferChecked` in a v0 tx (the gateway sponsors gas), and **MPP/Tempo** a native Tempo +transaction. + +`scheme` selects the gateway for `call` only. The `gateway_*` drawdown methods always use +the x402 gateway and the `mpp_*` channel methods always use the MPP gateway, whatever +`scheme` is set to. + +`PaymentConfig` fields: + +| Field | Meaning | +|---|---| +| `scheme` | `"x402"` (pay-per-request) or `"mpp"` (MPP charge; `"mpp-charge"` is accepted too) | +| `key` | raw private key — EVM/Tempo: hex; Solana: base58 64-byte secret | +| `pay_network` | CAIP-2 pay network, e.g. `eip155:84532`, `solana:5eykt4…` | +| `asset` | token address/mint to pay in (matches the offered menu entry) | +| `max_amount` | **required** spend ceiling in integer base units of `asset` | +| `svm_rpc_url` | optional Solana RPC for x402/Solana payment-build reads (mint + blockhash) | +| `base_url_override` | optional gateway base (testing) | + +`network` on the call is the **query** chain (gateway path slug), independent of the +pay network. Use `call_with_receipt` to also get the settlement receipt (`reference` = +settlement tx hash) — populated on the MPP lane, `null`/`None`/`nil` for x402. + +**Things to know:** + +- **Do not log your own `PaymentConfig`** — the `key` field is readable. The SDK + never prints it in its own errors/`Debug`, but a plain `print(config)` will show it. +- **`max_amount` is integer base units of the selected asset.** The SDK skips any offered + entry above it and refuses to sign one — a guard against an overcharging gateway. +- **`PaymentIndeterminateError` means the paid request was sent but the response was lost.** + You MAY have been charged — do **not** blindly retry. +- **x402/Solana: one payment per call.** Building a payment reads the mint and a recent + blockhash from a Solana RPC. The default is a public RPC that **rate-limits + aggressively** — set `svm_rpc_url` to your own endpoint at any volume. + +```python +import os +from quicknode_sdk import QuicknodeSdk, SdkFullConfig, RpcConfig, PaymentConfig + +qn = QuicknodeSdk(SdkFullConfig(api_key=None, rpc=RpcConfig(payment=PaymentConfig( + scheme="x402", + key=os.environ["QN_PAYMENT_KEY"], + pay_network="eip155:84532", + asset="0x036CbD53842c5426634e7929541eC2318f3dCF7e", + max_amount="10000", +)))) +resp = await qn.rpc.call_with_receipt("eth_blockNumber", [], "base-sepolia") +print(resp["result"], resp["payment_receipt"]) +``` + +### Wallet generation + +`generate_payment_wallet("evm")` creates a fresh keypair offline — no network call, no funds — for +`"evm"`, `"svm"`, or `"tempo"`. The private key is returned **exactly once**, at +generation; nothing in the SDK stores or re-derives it, so persist it immediately. + +```python +from quicknode_sdk import generate_payment_wallet + +wallet = generate_payment_wallet("evm") +print("fund this address:", wallet["address"]) +open("payment.key", "w").write(wallet["key"]) # returned exactly once +``` + +### x402 credit drawdown (authenticate once, then draw one credit per call) + +Cheaper per call than paying per request: one SIWE signature mints a session JWT, then +each call draws a single credit from the account balance instead of signing a fresh +settlement. Minting the session is free and moves no funds, so a host can re-authenticate +transparently. Persist it between processes. + +Fund the payment wallet out of band — the testnet faucet below, or by sending funds to +`payment_address()` directly. Credits are provisioned against the account gateway-side. + +EVM signers only: SIWE is an EIP-4361 construction, so an x402/Solana key errors here. + +| Method | Cost | Returns | +|---|---|---| +| `payment_address()` | free, offline | the wallet address derived from the key | +| `gateway_authenticate()` | free | a dict `{token, exp_unix, account_id}` | +| `gateway_credits(session)` | free | a dict `{account_id, credits}` | +| `gateway_drip(session)` | free (testnet) | a dict `{account_id, transaction_hash}` | +| `gateway_drawdown_call(method, session, network, params=None)` | 1 credit | the JSON-RPC `result` | + +```python +session = await qn.rpc.gateway_authenticate() +balance = await qn.rpc.gateway_credits(session) +print("credits:", balance["credits"]) +result = await qn.rpc.gateway_drawdown_call("eth_blockNumber", session, "base-sepolia") +``` + +A `token_expired` surfaces as an `ApiError` with status 401/403; re-authenticate and retry +that call. + +#### Testnet faucet + +`gateway_drip` requests testnet tokens for the payment **wallet** on Base Sepolia. The +gateway allows one drip per account, and it returns the on-chain funding transaction hash +— not a credit balance. + +### MPP payment channel (deposit once, then vouchers) + +Open a payment channel by depositing into the escrow, then authorize each call with a +cumulative voucher — one `ecrecover` server-side, no on-chain transaction per call. + +| Method | Cost | Returns | +|---|---|---| +| `mpp_open(deposit)` | **moves funds** | the channel state dict | +| `mpp_top_up(channel, additional_deposit)` | **moves funds** | the updated channel state | +| `mpp_status(channel)` | **1 request unit** | a dict `{channel_id, accepted_cumulative, spent}` | +| `mpp_session_call(method, network, channel, new_cumulative, params=None)` | 1 request unit | the JSON-RPC `result` | +| `mpp_close(channel)` | settles on-chain | nothing — refunds the unused deposit | + +```python +channel = await qn.rpc.mpp_open("1000000") # persist this dict +new_total = str(int(channel["cumulative_spent"]) + int(channel["per_call"])) +result = await qn.rpc.mpp_session_call( + "eth_blockNumber", "base-sepolia", channel, new_total +) +# On success, store new_total as the channel's cumulative_spent. +``` + +**Things to know:** + +- **Persist the channel state.** The gateway exposes no read-only channel endpoint, so a + lost local record means opening (and funding) a new channel. +- **`mpp_status` is not free.** The gateway prices every session POST as a chargeable + request and computes the balance from the *new* spend a voucher authorizes, so the + probe advances `cumulative_spent` by `per_call` exactly like a call. Re-persist the + advanced total. It raises `PaymentUnsupportedError` before any network I/O when the + channel has no room left for the probe. +- **The lifecycle takes no query network.** A channel is scoped by the configured pay + network and asset, so one channel funds calls to every supported network. Only + `mpp_session_call` takes a network, because it routes an RPC method. +- **Amounts are decimal strings, not numbers.** They are `u128` in the core; a Python int has no lossless `u128` conversion, so pass and store them as strings. +- **Advance `cumulative_spent` only after a success.** A voucher authorizes the running total + *after* the call; re-presenting the current high-water mark authorizes zero and is + always refused with `insufficient-balance`. + + + ## Error Handling Every binding exposes a typed exception hierarchy derived from the core `SdkError` @@ -1739,8 +1913,12 @@ subclass to branch on transport vs. API semantics. | `ApiError` | non-2xx HTTP response | `status`, `body` | | `DecodeError` | 2xx response but JSON parse failed | `body` | | `RpcError` | JSON-RPC call returned an `error` member | `code`, `message` | +| `PaymentError` | base class for the crypto-micropayment lane | — | +| `PaymentUnsupportedError` | no offered payment option matched your selector (or all were over `max_amount`/unsupported) | — | +| `PaymentRejectedError` | the gateway rejected a signed payment (terminal, one resend only) | `status`, `body` | +| `PaymentIndeterminateError` | paid request sent but response lost — MAY have been charged; do NOT blindly retry | — | -Class names: Importable from `quicknode_sdk`: `QuicknodeError`, `ConfigError`, `HttpError`, `TimeoutError`, `ConnectionError`, `ApiError`, `DecodeError`, `RpcError`. +Class names: Importable from `quicknode_sdk`: `QuicknodeError`, `ConfigError`, `HttpError`, `TimeoutError`, `ConnectionError`, `ApiError`, `DecodeError`, `RpcError`, `PaymentError`, `PaymentUnsupportedError`, `PaymentRejectedError`, `PaymentIndeterminateError`. ```python # Python diff --git a/python/examples/rpc_payment.py b/python/examples/rpc_payment.py new file mode 100644 index 0000000..5c51cee --- /dev/null +++ b/python/examples/rpc_payment.py @@ -0,0 +1,191 @@ +"""Crypto-micropayment lane for rpc.call: pay per RPC request with a stablecoin +instead of an account API key, against Quicknode's x402/MPP gateways. + +⚠️ MOVES REAL FUNDS when it settles. Use a throwaway, minimally-funded wallet. +Reads the key from QN_PAYMENT_KEY — never hard-code it. + +Run (x402/EVM on Base Sepolia testnet): + QN_PAYMENT_KEY=0x python examples/rpc_payment.py + +Run the x402 drawdown lane (authenticate once, then 1 credit per call): + QN_PAYMENT_KEY=0x QN_PAYMENT_LANE=drawdown python examples/rpc_payment.py + +With no QN_PAYMENT_KEY set, only the no-funds selfcheck runs. +""" + +import asyncio +import os + +from quicknode_sdk import ( + QuicknodeSdk, + SdkFullConfig, + RpcConfig, + PaymentConfig, + ConfigError, + PaymentError, + PaymentIndeterminateError, + PaymentRejectedError, + PaymentUnsupportedError, + generate_payment_wallet, +) + + +async def selfcheck() -> None: + """No-funds checks that always run: error hierarchy + the network-required + ConfigError. Asserts the payment surface is wired without moving money.""" + assert issubclass(PaymentIndeterminateError, PaymentError) + assert issubclass(PaymentRejectedError, PaymentError) + qn = QuicknodeSdk( + SdkFullConfig( + api_key=None, + rpc=RpcConfig( + payment=PaymentConfig( + scheme="x402", + key="0xabc", + pay_network="eip155:84532", + asset="0xUSDC", + max_amount="10000", + ) + ), + ) + ) + try: + await qn.rpc.call("eth_blockNumber") + raise SystemExit("expected a ConfigError (payment lane requires network)") + except ConfigError as e: + assert "requires" in str(e), str(e) + + # Wallet generation is offline; persist the returned key. + wallet = generate_payment_wallet("evm") + assert wallet["address"].startswith("0x") and len(wallet["address"]) == 42 + assert wallet["chain"] == "evm" + assert isinstance(wallet["key"], str) + try: + generate_payment_wallet("dogecoin") + raise SystemExit("expected a ConfigError for an unknown chain") + except ConfigError: + pass + + # Amounts are decimal strings; reject non-integers. + try: + await qn.rpc.mpp_open("12.5") + raise SystemExit("expected a ConfigError for a non-integer deposit") + except ConfigError as e: + assert "decimal base-unit" in str(e), str(e) + + # A full channel rejects the status probe before network I/O. + full_channel = { + "channel_id": "0x" + "11" * 32, + "token": "0x20c0000000000000000000000000000000000000", + "payee": "0xfd24114c3981aba78ae2441991b1bdb89329c556", + "salt": "0x" + "22" * 32, + "authorized_signer": wallet["address"], + "escrow_contract": "0x33b901018174DDabE4841042ab76ba85D4e24f25", + "deposit": "1000", + "cumulative_spent": "1000", + "per_call": "500", + "chain_id": 42431, + } + try: + await qn.rpc.mpp_status(full_channel) + raise SystemExit("expected a PaymentUnsupportedError (no room to probe)") + except PaymentUnsupportedError as e: + assert "no room" in str(e), str(e) + + print("selfcheck OK: error classes, wallet generation, u128 string amounts") + + +async def drawdown_demo(key: str) -> None: + """The x402 drawdown lane: authenticate once, then draw 1 credit per call. + + Cheaper per call than the per-request lane (one signature buys a block of + credits), and the session JWT is free to mint — so a host can re-auth + transparently. Persist the session dict between runs. + """ + qn = QuicknodeSdk( + SdkFullConfig( + api_key=None, + rpc=RpcConfig( + payment=PaymentConfig( + scheme="x402", + key=key, + pay_network="eip155:84532", + asset="0x036CbD53842c5426634e7929541eC2318f3dCF7e", + max_amount="10000", + ) + ), + ) + ) + + # Derived locally; use it to key a session cache. + print("payment wallet:", qn.rpc.payment_address()) + + session = await qn.rpc.gateway_authenticate() + print("session account:", session["account_id"], "expires:", session["exp_unix"]) + + balance = await qn.rpc.gateway_credits(session) + print("credits:", balance["credits"]) + + if balance["credits"] == 0: + # The faucet returns a funding transaction, not a balance. + try: + drip = await qn.rpc.gateway_drip(session) + print("faucet tx:", drip["transaction_hash"]) + except PaymentRejectedError as e: + print(f"faucet refused ({e.status}):", e.body) + + result = await qn.rpc.gateway_drawdown_call( + "eth_blockNumber", session, "base-sepolia" + ) + print("drawdown eth_blockNumber =>", result) + + +async def main() -> None: + await selfcheck() + + key = os.environ.get("QN_PAYMENT_KEY") + if not key: + print("set QN_PAYMENT_KEY to a throwaway key to run the live payment call") + return + + # QN_PAYMENT_LANE=drawdown runs the credit lane instead of per-request. + if os.environ.get("QN_PAYMENT_LANE") == "drawdown": + await drawdown_demo(key) + return + + # Keyless SDK. Do not log this config; it contains the private key. + config = SdkFullConfig( + api_key=None, + rpc=RpcConfig( + payment=PaymentConfig( + scheme="x402", + key=key, + # Base Sepolia testnet USDC (x402/EVM). + pay_network="eip155:84532", + asset="0x036CbD53842c5426634e7929541eC2318f3dCF7e", + # Spend ceiling in asset base units. + max_amount="10000", + # Set svm_rpc_url for x402/Solana at volume. + ) + ), + ) + qn = QuicknodeSdk(config) + + try: + # Query network is independent of the payment network. + resp = await qn.rpc.call_with_receipt( + "eth_blockNumber", [], "base-sepolia" + ) + print("paid eth_blockNumber =>", resp["result"]) + # x402 does not return a settlement receipt. + if resp["payment_receipt"]: + print("settlement reference:", resp["payment_receipt"]["reference"]) + except PaymentIndeterminateError as e: + # The request may have settled. Do not retry blindly. + print("payment indeterminate — do not retry:", e) + except PaymentRejectedError as e: + print(f"payment rejected ({e.status}):", e.body) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/quicknode_sdk/__init__.py b/python/quicknode_sdk/__init__.py index 62dcf55..f19c3a9 100644 --- a/python/quicknode_sdk/__init__.py +++ b/python/quicknode_sdk/__init__.py @@ -116,6 +116,8 @@ KvStoreConfig, SqlConfig, RpcConfig, + PaymentConfig, + generate_payment_wallet, CachedToken, SdkFullConfig, RpcApiClient, @@ -213,6 +215,10 @@ ApiError, DecodeError, RpcError, + PaymentError, + PaymentUnsupportedError, + PaymentRejectedError, + PaymentIndeterminateError, ) __all__ = [ @@ -332,6 +338,8 @@ "KvStoreConfig", "SqlConfig", "RpcConfig", + "PaymentConfig", + "generate_payment_wallet", "CachedToken", "SdkFullConfig", "RpcApiClient", @@ -429,4 +437,8 @@ "ApiError", "DecodeError", "RpcError", + "PaymentError", + "PaymentUnsupportedError", + "PaymentRejectedError", + "PaymentIndeterminateError", ] diff --git a/python/quicknode_sdk/__init__.pyi b/python/quicknode_sdk/__init__.pyi index 2d0b5b4..e33e115 100644 --- a/python/quicknode_sdk/__init__.pyi +++ b/python/quicknode_sdk/__init__.pyi @@ -118,6 +118,8 @@ from quicknode_sdk._core import ( KvStoreConfig, SqlConfig, RpcConfig, + PaymentConfig, + generate_payment_wallet, CachedToken, SdkFullConfig, RpcApiClient, @@ -231,6 +233,10 @@ from quicknode_sdk._core import ( ApiError, DecodeError, RpcError, + PaymentError, + PaymentUnsupportedError, + PaymentRejectedError, + PaymentIndeterminateError, ) __all__ = [ @@ -350,6 +356,8 @@ __all__ = [ "KvStoreConfig", "SqlConfig", "RpcConfig", + "PaymentConfig", + "generate_payment_wallet", "CachedToken", "SdkFullConfig", "RpcApiClient", @@ -463,4 +471,8 @@ __all__ = [ "ApiError", "DecodeError", "RpcError", + "PaymentError", + "PaymentUnsupportedError", + "PaymentRejectedError", + "PaymentIndeterminateError", ] diff --git a/python/quicknode_sdk/_core/__init__.pyi b/python/quicknode_sdk/_core/__init__.pyi index ca6c4cf..e6172d6 100644 --- a/python/quicknode_sdk/_core/__init__.pyi +++ b/python/quicknode_sdk/_core/__init__.pyi @@ -150,6 +150,7 @@ __all__ = [ "PageInfo", "Pagination", "Payment", + "PaymentConfig", "PostgresAttributes", "QueryResponse", "QueryStatistics", @@ -225,6 +226,7 @@ __all__ = [ "XrplWalletFilterByListArgs", "XrplWalletFilterByListTemplate", "XrplWalletFilterTemplate", + "generate_payment_wallet", ] @typing.final @@ -5059,6 +5061,112 @@ class Payment: Portion of the payment attributed to marketplace spending. """ +@typing.final +class PaymentConfig: + r""" + Binding-facing crypto-micropayment configuration. **Plain data** — all + fields are strings so this can be a `napi(object)` / `pyclass` / Ruby hash; + it is converted to the internal `enum Signer` + resolved config at the Rust + boundary. The private `key` field stays readable to the caller, but the + SDK's own `Debug` redacts it (below) so an SDK log line or panic can't leak + it. + + **Do not log your own `PaymentConfig`** — `println!("{config:?}")` on the + derived-Debug *binding* object (napi/pyclass/hash) still shows the raw key. + Only the SDK's internal rendering is redacted. + """ + @property + def scheme(self) -> builtins.str: + r""" + Payment protocol: `"x402"` (pay-per-request) or `"mpp"` (MPP charge). + """ + @scheme.setter + def scheme(self, value: builtins.str) -> None: + r""" + Payment protocol: `"x402"` (pay-per-request) or `"mpp"` (MPP charge). + """ + @property + def key(self) -> builtins.str: + r""" + Raw private key. EVM/Tempo: hex (with or without `0x`). Solana: base58 + 64-byte secret key. + """ + @key.setter + def key(self, value: builtins.str) -> None: + r""" + Raw private key. EVM/Tempo: hex (with or without `0x`). Solana: base58 + 64-byte secret key. + """ + @property + def pay_network(self) -> builtins.str: + r""" + CAIP-2 pay network selector, e.g. `"eip155:84532"` (x402/EVM), + `"solana:5eykt4…"` (x402/Solana), or `"eip155:42431"` (MPP/Tempo). + """ + @pay_network.setter + def pay_network(self, value: builtins.str) -> None: + r""" + CAIP-2 pay network selector, e.g. `"eip155:84532"` (x402/EVM), + `"solana:5eykt4…"` (x402/Solana), or `"eip155:42431"` (MPP/Tempo). + """ + @property + def asset(self) -> builtins.str: + r""" + Asset (token) address/mint to pay in. Matches the offered menu entry's + `asset`. EVM: token contract hex. Solana: mint base58. + """ + @asset.setter + def asset(self, value: builtins.str) -> None: + r""" + Asset (token) address/mint to pay in. Matches the offered menu entry's + `asset`. EVM: token contract hex. Solana: mint base58. + """ + @property + def max_amount(self) -> builtins.str: + r""" + Spend ceiling in base units of `asset` (integer string). **Required.** + The selector skips any offered entry above this, and the driver refuses + to sign one — guarding against a buggy/hostile gateway overcharging a + custodied key. + """ + @max_amount.setter + def max_amount(self, value: builtins.str) -> None: + r""" + Spend ceiling in base units of `asset` (integer string). **Required.** + The selector skips any offered entry above this, and the driver refuses + to sign one — guarding against a buggy/hostile gateway overcharging a + custodied key. + """ + @property + def svm_rpc_url(self) -> typing.Optional[builtins.str]: + r""" + Explicit Solana RPC URL for x402/Solana payment-build reads: the mint + (for its decimals and owning token program) and a recent blockhash, so + two reads per payment. Optional; when unset the SDK falls back to a + public Solana RPC matching the pay cluster. **Set this at any real + volume** — the public default rate-limits aggressively. + """ + @svm_rpc_url.setter + def svm_rpc_url(self, value: typing.Optional[builtins.str]) -> None: + r""" + Explicit Solana RPC URL for x402/Solana payment-build reads: the mint + (for its decimals and owning token program) and a recent blockhash, so + two reads per payment. Optional; when unset the SDK falls back to a + public Solana RPC matching the pay cluster. **Set this at any real + volume** — the public default rate-limits aggressively. + """ + @property + def base_url_override(self) -> typing.Optional[builtins.str]: + r""" + Test-only gateway base override (points the lane at a mock gateway). + """ + @base_url_override.setter + def base_url_override(self, value: typing.Optional[builtins.str]) -> None: + r""" + Test-only gateway base override (points the lane at a mock gateway). + """ + def __new__(cls, scheme: builtins.str, key: builtins.str, pay_network: builtins.str, asset: builtins.str, max_amount: builtins.str, svm_rpc_url: typing.Optional[builtins.str] = None, base_url_override: typing.Optional[builtins.str] = None) -> PaymentConfig: ... + @typing.final class PostgresAttributes: r""" @@ -5448,6 +5556,14 @@ class RpcApiClient: mutually exclusive). Returns the JSON-RPC `result`; a JSON-RPC error is raised as `RpcError`. """ + def call_with_receipt(self, method: builtins.str, params: typing.Optional[typing.Any] = None, network: typing.Optional[builtins.str] = None, endpoint_url: typing.Optional[builtins.str] = None) -> typing.Coroutine[typing.Any, typing.Any, typing.Any]: + r""" + Like `call`, but also returns the crypto-micropayment settlement + receipt. Returns a dict `{"result": , "payment_receipt": }`. + `payment_receipt` is a dict `{method, status, timestamp, reference}` on + the MPP payment lane and `None` for x402 and every non-payment lane + (where this behaves exactly like `call`). + """ def set_networks(self, networks: typing.Mapping[builtins.str, builtins.str]) -> None: r""" Seeds the per-network URL map for multichain routing (network key -> @@ -5465,6 +5581,79 @@ class RpcApiClient: no token has been minted or seeded yet. Hosts use this to persist the token between processes. """ + def payment_address(self) -> builtins.str: + r""" + The configured payment wallet's on-chain address (EVM/Tempo `0x…` hex, + Solana base58), derived offline from the key with no network round trip. + """ + def gateway_authenticate(self) -> typing.Coroutine[typing.Any, typing.Any, typing.Any]: + r""" + Authenticates against the x402 gateway with a SIWX message and returns + the session as a dict `{token, exp_unix, account_id}`. Free — no funds + move. Persist the dict and pass it back to the `gateway_*` methods. + """ + def gateway_credits(self, session: typing.Any) -> typing.Coroutine[typing.Any, typing.Any, typing.Any]: + r""" + Reads the account's current x402 credit balance. Returns a dict + `{account_id, credits}`. `session` is a dict from `gateway_authenticate`. + """ + def gateway_buy_credits(self, session: typing.Any, network: builtins.str) -> typing.Coroutine[typing.Any, typing.Any, typing.Any]: + r""" + Buys a block of credits, settling the gateway's offer with the same + signer construction as the per-request lane. Returns the post-purchase + balance dict `{account_id, credits}`. Single-attempt: a paid lane never + blind-retries. + """ + def gateway_drip(self, session: typing.Any) -> typing.Coroutine[typing.Any, typing.Any, typing.Any]: + r""" + Requests testnet tokens from the x402 faucet. Returns the funding + transaction as a dict `{account_id, transaction_hash}` — NOT a balance; + call `gateway_credits` afterwards for that. Allowed once per account. + """ + def gateway_drawdown_call(self, method: builtins.str, session: typing.Any, network: builtins.str, params: typing.Optional[typing.Any] = None) -> typing.Coroutine[typing.Any, typing.Any, typing.Any]: + r""" + Makes one x402 drawdown JSON-RPC call with the session as a Bearer + token, drawing 1 credit on success. Returns the unwrapped JSON-RPC + `result`. Single-attempt; re-authenticate on a 401/403 `ApiError`. + """ + def mpp_open(self, deposit: builtins.str) -> typing.Coroutine[typing.Any, typing.Any, typing.Any]: + r""" + Opens an MPP payment channel by depositing `deposit` base units (a + decimal string) into the escrow. Returns the channel state dict — persist + it; the gateway has no read-only channel endpoint, so a lost record means + opening a new channel. Moves real funds; single-attempt. + + Takes no network: the channel is scoped by the configured pay network and + asset, so one channel funds calls to every supported network. + """ + def mpp_top_up(self, channel: typing.Any, additional_deposit: builtins.str) -> typing.Coroutine[typing.Any, typing.Any, typing.Any]: + r""" + Adds `additional_deposit` base units (a decimal string) to an open + channel. Returns the updated channel state dict. Moves real funds; + single-attempt. + """ + def mpp_close(self, channel: typing.Any) -> typing.Coroutine[typing.Any, typing.Any, typing.Any]: + r""" + Cooperatively closes a channel: settles the final cumulative spend + on-chain and refunds the unused deposit. Single-attempt. + """ + def mpp_status(self, channel: typing.Any) -> typing.Coroutine[typing.Any, typing.Any, typing.Any]: + r""" + Fetches the gateway's view of the channel as a dict `{channel_id, + accepted_cumulative, spent}` (amounts are decimal strings). + + **This costs one request unit** and advances the voucher by `per_call`, + exactly like a session call — persist the advanced `cumulative_spent`. + Raises `PaymentUnsupportedError` before any network I/O when the channel + has no room left for the probe. + """ + def mpp_session_call(self, method: builtins.str, network: builtins.str, channel: typing.Any, new_cumulative: builtins.str, params: typing.Optional[typing.Any] = None) -> typing.Coroutine[typing.Any, typing.Any, typing.Any]: + r""" + Makes one MPP session-lane JSON-RPC call, authorizing it with a + cumulative voucher for `new_cumulative` (a decimal string: the running + total AFTER this call). Returns the unwrapped JSON-RPC `result`. + Single-attempt; advance the persisted `cumulative_spent` on success. + """ @typing.final class RpcConfig: @@ -5534,7 +5723,35 @@ class RpcConfig: a `network` resolves the target URL here. Optional; the default-network call path needs no map. """ - def __new__(cls, endpoint_url: typing.Optional[builtins.str] = None, seed: typing.Optional[CachedToken] = None, refresh_margin_secs: typing.Optional[builtins.int] = None, networks: typing.Optional[typing.Mapping[builtins.str, builtins.str]] = None) -> RpcConfig: ... + @property + def payment(self) -> typing.Optional[PaymentConfig]: + r""" + Crypto-micropayment lane. When set, `rpc.call` pays per request with a + stablecoin against Quicknode's x402/MPP gateways instead of using the + account API key + session JWT. `#[serde(skip)]` so `from_env` can never + populate it — an env-derived private key is exactly what we don't want; + callers must pass this programmatically. The field is always present + (plain data), but the payment lane is only wired into `rpc.call` when a + crypto feature (`payments`/`payments-svm`/`payments-tempo`) is enabled; + built without any of them, a set `payment` is ignored and `rpc.call` + keeps its normal tooling-JWT behavior. The precompiled Python/Node/Ruby + packages always ship with the payment features on. + """ + @payment.setter + def payment(self, value: typing.Optional[PaymentConfig]) -> None: + r""" + Crypto-micropayment lane. When set, `rpc.call` pays per request with a + stablecoin against Quicknode's x402/MPP gateways instead of using the + account API key + session JWT. `#[serde(skip)]` so `from_env` can never + populate it — an env-derived private key is exactly what we don't want; + callers must pass this programmatically. The field is always present + (plain data), but the payment lane is only wired into `rpc.call` when a + crypto feature (`payments`/`payments-svm`/`payments-tempo`) is enabled; + built without any of them, a set `payment` is ignored and `rpc.call` + keeps its normal tooling-JWT behavior. The precompiled Python/Node/Ruby + packages always ship with the payment features on. + """ + def __new__(cls, endpoint_url: typing.Optional[builtins.str] = None, seed: typing.Optional[CachedToken] = None, refresh_margin_secs: typing.Optional[builtins.int] = None, networks: typing.Optional[typing.Mapping[builtins.str, builtins.str]] = None, payment: typing.Optional[PaymentConfig] = None) -> RpcConfig: ... @typing.final class S3Attributes: @@ -5646,9 +5863,29 @@ class S3Attributes: @typing.final class SdkFullConfig: @property - def api_key(self) -> builtins.str: ... + def api_key(self) -> typing.Optional[builtins.str]: + r""" + Account API key. **Optional** so a keyless SDK can be built for the + crypto-micropayment lane (`rpc.call` with `RpcConfig.payment`). When + absent, no `x-api-key` header is installed: the payment lane works, while + the keyed surfaces (admin/streams/webhooks/kvstore/sql and tooling-JWT + `rpc.call`) send un-authenticated requests and the gateway rejects them + (surfacing as an `ApiError`, typically 401). `from_env` still requires + the key (validated in `from_config`) — only programmatic construction + may omit it. + """ @api_key.setter - def api_key(self, value: builtins.str) -> None: ... + def api_key(self, value: typing.Optional[builtins.str]) -> None: + r""" + Account API key. **Optional** so a keyless SDK can be built for the + crypto-micropayment lane (`rpc.call` with `RpcConfig.payment`). When + absent, no `x-api-key` header is installed: the payment lane works, while + the keyed surfaces (admin/streams/webhooks/kvstore/sql and tooling-JWT + `rpc.call`) send un-authenticated requests and the gateway rejects them + (surfacing as an `ApiError`, typically 401). `from_env` still requires + the key (validated in `from_config`) — only programmatic construction + may omit it. + """ @property def http(self) -> typing.Optional[HttpConfig]: ... @http.setter @@ -5677,7 +5914,7 @@ class SdkFullConfig: def rpc(self) -> typing.Optional[RpcConfig]: ... @rpc.setter def rpc(self, value: typing.Optional[RpcConfig]) -> None: ... - def __new__(cls, api_key: builtins.str, http: typing.Optional[HttpConfig] = None, admin: typing.Optional[AdminConfig] = None, streams: typing.Optional[StreamsConfig] = None, webhooks: typing.Optional[WebhooksConfig] = None, kvstore: typing.Optional[KvStoreConfig] = None, sql: typing.Optional[SqlConfig] = None, rpc: typing.Optional[RpcConfig] = None) -> SdkFullConfig: ... + def __new__(cls, api_key: typing.Optional[builtins.str] = None, http: typing.Optional[HttpConfig] = None, admin: typing.Optional[AdminConfig] = None, streams: typing.Optional[StreamsConfig] = None, webhooks: typing.Optional[WebhooksConfig] = None, kvstore: typing.Optional[KvStoreConfig] = None, sql: typing.Optional[SqlConfig] = None, rpc: typing.Optional[RpcConfig] = None) -> SdkFullConfig: ... @typing.final class SecurityOption: @@ -7534,3 +7771,14 @@ class XrplWalletFilterTemplate: """ def __new__(cls, wallets: typing.Sequence[builtins.str]) -> XrplWalletFilterTemplate: ... +def generate_payment_wallet(chain: builtins.str) -> typing.Any: + r""" + Generates a fresh payment keypair for `chain` (`"evm"`, `"svm"`, or + `"tempo"`). Returns a dict `{address, chain, key}` where `key` is the raw + private key in the format the payment config's `key` accepts. + + The key is returned exactly once, at generation: nothing in the SDK stores or + re-derives it, so persist it before discarding the dict. Randomness comes + from the OS CSPRNG. + """ + diff --git a/python/quicknode_sdk/init_manual_override.pyi b/python/quicknode_sdk/init_manual_override.pyi index 2d0b5b4..e33e115 100644 --- a/python/quicknode_sdk/init_manual_override.pyi +++ b/python/quicknode_sdk/init_manual_override.pyi @@ -118,6 +118,8 @@ from quicknode_sdk._core import ( KvStoreConfig, SqlConfig, RpcConfig, + PaymentConfig, + generate_payment_wallet, CachedToken, SdkFullConfig, RpcApiClient, @@ -231,6 +233,10 @@ from quicknode_sdk._core import ( ApiError, DecodeError, RpcError, + PaymentError, + PaymentUnsupportedError, + PaymentRejectedError, + PaymentIndeterminateError, ) __all__ = [ @@ -350,6 +356,8 @@ __all__ = [ "KvStoreConfig", "SqlConfig", "RpcConfig", + "PaymentConfig", + "generate_payment_wallet", "CachedToken", "SdkFullConfig", "RpcApiClient", @@ -463,4 +471,8 @@ __all__ = [ "ApiError", "DecodeError", "RpcError", + "PaymentError", + "PaymentUnsupportedError", + "PaymentRejectedError", + "PaymentIndeterminateError", ] diff --git a/ruby/README.md b/ruby/README.md index 7da6498..ec55492 100644 --- a/ruby/README.md +++ b/ruby/README.md @@ -11,8 +11,12 @@ This is one of four language bindings published from the same Rust core. See the - [Installation](#installation) - [Quick Start](#quick-start) - [Configuration](#configuration) + - [Option A — Pass config directly](#option-a--pass-config-directly) + - [Option B — Load from environment (`from_env()`)](#option-b--load-from-environment-from_env) + - [Custom headers and `User-Agent`](#custom-headers-and-user-agent) - [Platform Support](#platform-support) - [API Reference](#api-reference) + - [Language conventions](#language-conventions) - [Admin Client](#admin-client) - [Endpoints](#endpoints) - [Endpoint Tags](#endpoint-tags) @@ -38,6 +42,7 @@ This is one of four language bindings published from the same Rust core. See the - [Billing](#billing) - [Bulk Operations](#bulk-operations) - [Account Tags](#account-tags) + - [Tag / delete method parameter quick-reference](#tag--delete-method-parameter-quick-reference) - [Streams Client](#streams-client) - [Datasets, Regions, and Destinations](#datasets-regions-and-destinations) - [Streams methods](#streams-methods) @@ -48,6 +53,12 @@ This is one of four language bindings published from the same Rust core. See the - [Sets](#sets) - [Lists](#lists) - [SQL Client](#sql-client) + - [RPC & Tooling Access](#rpc--tooling-access) +- [Crypto-micropayment lane (`rpc.call`)](#crypto-micropayment-lane-rpccall) + - [Wallet generation](#wallet-generation) + - [x402 credit drawdown (authenticate once, then draw one credit per call)](#x402-credit-drawdown-authenticate-once-then-draw-one-credit-per-call) + - [Testnet faucet](#testnet-faucet) + - [MPP payment channel (deposit once, then vouchers)](#mpp-payment-channel-deposit-once-then-vouchers) - [Error Handling](#error-handling) - [License](#license) @@ -76,6 +87,10 @@ There are two ways to configure the SDK. ```ruby qn = QuicknodeSdk::SDK.from_config(api_key: "your-key") + +# api_key is optional: the crypto-micropayment lane pays per request instead, so +# api_key: nil builds a usable SDK. Every other client still needs one, and +# from_env always requires QN_SDK__API_KEY. ``` ### Option B — Load from environment (`from_env()`) @@ -1730,6 +1745,169 @@ re-seed it via the `rpc: { seed: ... }` config key; `refresh_margin_secs` (defau tunes how early the token is refreshed. Set `rpc: { endpoint_url: ... }` to route every call to a custom HTTP URL by default (no JWT minted); a per-call `endpoint_url` overrides it. +## Crypto-micropayment lane (`rpc.call`) + +Pay per RPC request with a stablecoin instead of a provisioned account + API key, +against Quicknode's `x402.quicknode.com` and `mpp.quicknode.com` gateways. Configure +it by setting `payment` on the RPC config; the SDK runs the `402` → sign → resend +handshake for you. An API key is **not** required for this lane — build a keyless SDK. + +There are four payment paths. Two pay per request; two amortize one signature over many +calls. + +| Path | Entry point | Gateway | Signs | +|---|---|---|---| +| Per-request x402 | `call` / `call_with_receipt` with `scheme: "x402"` | x402 | once per call | +| Per-request MPP charge | `call` / `call_with_receipt` with `scheme: "mpp"` | mpp | once per call | +| [x402 credit drawdown](#x402-credit-drawdown-authenticate-once-then-draw-one-credit-per-call) | `gateway_authenticate` → `gateway_drawdown_call` | x402 | once per session | +| [MPP payment channel](#mpp-payment-channel-deposit-once-then-vouchers) | `mpp_open` → `mpp_session_call` | mpp | once per channel | + +The signer construction is derived from the scheme and pay network, never stated directly: +**x402/EVM** signs an EIP-712 `TransferWithAuthorization`, **x402/Solana** an SPL +`TransferChecked` in a v0 tx (the gateway sponsors gas), and **MPP/Tempo** a native Tempo +transaction. + +`scheme` selects the gateway for `call` only. The `gateway_*` drawdown methods always use +the x402 gateway and the `mpp_*` channel methods always use the MPP gateway, whatever +`scheme` is set to. + +`PaymentConfig` fields: + +| Field | Meaning | +|---|---| +| `scheme` | `"x402"` (pay-per-request) or `"mpp"` (MPP charge; `"mpp-charge"` is accepted too) | +| `key` | raw private key — EVM/Tempo: hex; Solana: base58 64-byte secret | +| `pay_network` | CAIP-2 pay network, e.g. `eip155:84532`, `solana:5eykt4…` | +| `asset` | token address/mint to pay in (matches the offered menu entry) | +| `max_amount` | **required** spend ceiling in integer base units of `asset` | +| `svm_rpc_url` | optional Solana RPC for x402/Solana payment-build reads (mint + blockhash) | +| `base_url_override` | optional gateway base (testing) | + +`network` on the call is the **query** chain (gateway path slug), independent of the +pay network. Use `call_with_receipt` to also get the settlement receipt (`reference` = +settlement tx hash) — populated on the MPP lane, `null`/`None`/`nil` for x402. + +**Things to know:** + +- **Do not log your own `PaymentConfig`** — the `key` field is readable. The SDK + never prints it in its own errors/`Debug`, but a plain `p config` will show it. +- **`max_amount` is integer base units of the selected asset.** The SDK skips any offered + entry above it and refuses to sign one — a guard against an overcharging gateway. +- **`PaymentIndeterminateError` means the paid request was sent but the response was lost.** + You MAY have been charged — do **not** blindly retry. +- **x402/Solana: one payment per call.** Building a payment reads the mint and a recent + blockhash from a Solana RPC. The default is a public RPC that **rate-limits + aggressively** — set `svm_rpc_url` to your own endpoint at any volume. + +```ruby +sdk = QuicknodeSdk::SDK.from_config( + api_key: nil, + rpc: { + payment: { + scheme: "x402", + key: ENV.fetch("QN_PAYMENT_KEY"), + pay_network: "eip155:84532", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + max_amount: "10000" + } + } +) +resp = sdk.rpc.call_with_receipt(method: "eth_blockNumber", params: [], network: "base-sepolia") +puts resp["result"] +``` + +### Wallet generation + +`QuicknodeSdk.generate_payment_wallet(chain: "evm")` creates a fresh keypair offline — no network call, no funds — for +`"evm"`, `"svm"`, or `"tempo"`. The private key is returned **exactly once**, at +generation; nothing in the SDK stores or re-derives it, so persist it immediately. + +```ruby +wallet = QuicknodeSdk.generate_payment_wallet(chain: "evm") +puts "fund this address: #{wallet[:address]}" +File.write("payment.key", wallet[:key]) # returned exactly once +``` + +### x402 credit drawdown (authenticate once, then draw one credit per call) + +Cheaper per call than paying per request: one SIWE signature mints a session JWT, then +each call draws a single credit from the account balance instead of signing a fresh +settlement. Minting the session is free and moves no funds, so a host can re-authenticate +transparently. Persist it between processes. + +Fund the payment wallet out of band — the testnet faucet below, or by sending funds to +`payment_address` directly. Credits are provisioned against the account gateway-side. + +EVM signers only: SIWE is an EIP-4361 construction, so an x402/Solana key errors here. + +| Method | Cost | Returns | +|---|---|---| +| `payment_address` | free, offline | the wallet address derived from the key | +| `gateway_authenticate` | free | a Hash `{token:, exp_unix:, account_id:}` | +| `gateway_credits(session:)` | free | a Hash `{account_id:, credits:}` | +| `gateway_drip(session:)` | free (testnet) | a Hash `{account_id:, transaction_hash:}` | +| `gateway_drawdown_call(method:, session:, network:, params:)` | 1 credit | the JSON-RPC `result` | + +```ruby +session = sdk.rpc.gateway_authenticate +balance = sdk.rpc.gateway_credits(session: session) +puts "credits: #{balance[:credits]}" +result = sdk.rpc.gateway_drawdown_call( + method: "eth_blockNumber", session: session, network: "base-sepolia" +) +``` + +A `token_expired` surfaces as an `ApiError` with status 401/403; re-authenticate and retry +that call. + +#### Testnet faucet + +`gateway_drip` requests testnet tokens for the payment **wallet** on Base Sepolia. The +gateway allows one drip per account, and it returns the on-chain funding transaction hash +— not a credit balance. + +### MPP payment channel (deposit once, then vouchers) + +Open a payment channel by depositing into the escrow, then authorize each call with a +cumulative voucher — one `ecrecover` server-side, no on-chain transaction per call. + +| Method | Cost | Returns | +|---|---|---| +| `mpp_open(deposit:)` | **moves funds** | the channel state Hash | +| `mpp_top_up(channel:, additional_deposit:)` | **moves funds** | the updated channel state | +| `mpp_status(channel:)` | **1 request unit** | a Hash `{channel_id:, accepted_cumulative:, spent:}` | +| `mpp_session_call(method:, network:, channel:, new_cumulative:, params:)` | 1 request unit | the JSON-RPC `result` | +| `mpp_close(channel:)` | settles on-chain | nothing — refunds the unused deposit | + +```ruby +channel = sdk.rpc.mpp_open(deposit: "1000000") # persist this Hash +new_total = (channel[:cumulative_spent].to_i + channel[:per_call].to_i).to_s +result = sdk.rpc.mpp_session_call( + method: "eth_blockNumber", network: "base-sepolia", + channel: channel, new_cumulative: new_total +) +# On success, store new_total as the channel's cumulative_spent. +``` + +**Things to know:** + +- **Persist the channel state.** The gateway exposes no read-only channel endpoint, so a + lost local record means opening (and funding) a new channel. +- **`mpp_status` is not free.** The gateway prices every session POST as a chargeable + request and computes the balance from the *new* spend a voucher authorizes, so the + probe advances `cumulative_spent` by `per_call` exactly like a call. Re-persist the + advanced total. It raises `PaymentUnsupportedError` before any network I/O when the + channel has no room left for the probe. +- **The lifecycle takes no query network.** A channel is scoped by the configured pay + network and asset, so one channel funds calls to every supported network. Only + `mpp_session_call` takes a network, because it routes an RPC method. +- **Amounts are decimal strings, not numbers.** They are `u128` in the core; magnus has no `u128` conversion, so pass and store them as Strings. +- **Advance `cumulative_spent` only after a success.** A voucher authorizes the running total + *after* the call; re-presenting the current high-water mark authorizes zero and is + always refused with `insufficient-balance`. + + + ## Error Handling Every binding exposes a typed exception hierarchy derived from the core `SdkError` @@ -1746,8 +1924,12 @@ subclass to branch on transport vs. API semantics. | `ApiError` | non-2xx HTTP response | `status`, `body` | | `DecodeError` | 2xx response but JSON parse failed | `body` | | `RpcError` | JSON-RPC call returned an `error` member | `code`, `message` | +| `PaymentError` | base class for the crypto-micropayment lane | — | +| `PaymentUnsupportedError` | no offered payment option matched your selector (or all were over `max_amount`/unsupported) | — | +| `PaymentRejectedError` | the gateway rejected a signed payment (terminal, one resend only) | `status`, `body` | +| `PaymentIndeterminateError` | paid request sent but response lost — MAY have been charged; do NOT blindly retry | — | -Class names: `QuicknodeSdk::Error`, `QuicknodeSdk::ConfigError`, `QuicknodeSdk::HttpError`, `QuicknodeSdk::TimeoutError`, `QuicknodeSdk::ConnectionError`, `QuicknodeSdk::ApiError`, `QuicknodeSdk::DecodeError`, `QuicknodeSdk::RpcError`. All extend `StandardError`. Hash-key validation still raises `ArgumentError`. +Class names: `QuicknodeSdk::Error`, `QuicknodeSdk::ConfigError`, `QuicknodeSdk::HttpError`, `QuicknodeSdk::TimeoutError`, `QuicknodeSdk::ConnectionError`, `QuicknodeSdk::ApiError`, `QuicknodeSdk::DecodeError`, `QuicknodeSdk::RpcError`, `QuicknodeSdk::PaymentError`, `QuicknodeSdk::PaymentUnsupportedError`, `QuicknodeSdk::PaymentRejectedError`, `QuicknodeSdk::PaymentIndeterminateError`. All extend `StandardError`. Hash-key validation still raises `ArgumentError`. ```ruby # Ruby diff --git a/ruby/examples/rpc_payment.rb b/ruby/examples/rpc_payment.rb new file mode 100644 index 0000000..dd64ef6 --- /dev/null +++ b/ruby/examples/rpc_payment.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +# Crypto-micropayment lane for rpc.call: pay per RPC request with a stablecoin +# instead of an account API key, against Quicknode's x402/MPP gateways. +# +# ⚠️ MOVES REAL FUNDS when it settles. Use a throwaway, minimally-funded wallet. +# Reads the key from QN_PAYMENT_KEY — never hard-code it. +# +# Run (x402/EVM on Base Sepolia testnet): +# QN_PAYMENT_KEY=0x ruby -Ilib examples/rpc_payment.rb +# +# Run the x402 drawdown lane (authenticate once, then 1 credit per call): +# QN_PAYMENT_KEY=0x QN_PAYMENT_LANE=drawdown ruby -Ilib examples/rpc_payment.rb + +require "quicknode_sdk" + +# No-funds selfcheck that always runs: error hierarchy + the network-required +# ConfigError. Asserts the payment surface is wired without moving money. +raise "hierarchy" unless QuicknodeSdk::PaymentIndeterminateError < QuicknodeSdk::PaymentError + +check_sdk = QuicknodeSdk::SDK.from_config( + api_key: nil, + rpc: { payment: { scheme: "x402", key: "0xabc", pay_network: "eip155:84532", + asset: "0xUSDC", max_amount: "10000" } } +) +begin + check_sdk.rpc.call(method: "eth_blockNumber") + raise "expected a ConfigError (payment lane requires network)" +rescue QuicknodeSdk::ConfigError => e + raise "wrong message: #{e.message}" unless e.message.include?("requires") +end + +# Wallet generation is offline; persist the returned key. +wallet = QuicknodeSdk.generate_payment_wallet(chain: "evm") +raise "address" unless wallet[:address].start_with?("0x") && wallet[:address].length == 42 +raise "chain" unless wallet[:chain] == "evm" +raise "key" unless wallet[:key].is_a?(String) +begin + QuicknodeSdk.generate_payment_wallet(chain: "dogecoin") + raise "expected an ArgumentError for an unknown chain" +rescue ArgumentError + # expected +end + +# Amounts are decimal strings; reject non-integers. +begin + check_sdk.rpc.mpp_open(deposit: "12.5") + raise "expected an ArgumentError for a non-integer deposit" +rescue ArgumentError => e + raise "wrong message: #{e.message}" unless e.message.include?("decimal base-unit") +end + +puts "selfcheck OK: error classes, wallet generation, u128 String amounts" + +key = ENV["QN_PAYMENT_KEY"] +unless key + puts "set QN_PAYMENT_KEY to a throwaway key to run the live payment call" + exit 0 +end + +# Keyless SDK. Do not log this config; it contains the private key. +sdk = QuicknodeSdk::SDK.from_config( + api_key: nil, + rpc: { + payment: { + scheme: "x402", + key: key, + # Base Sepolia testnet USDC (x402/EVM). + pay_network: "eip155:84532", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + # Spend ceiling in asset base units. + max_amount: "10000" + # Set svm_rpc_url: for x402/Solana at volume. + } + } +) + +begin + # Query network is independent of the payment network. + resp = sdk.rpc.call_with_receipt(method: "eth_blockNumber", params: [], network: "base-sepolia") + puts "paid eth_blockNumber => #{resp["result"]}" + # x402 does not return a settlement receipt. + puts "settlement reference: #{resp.dig("payment_receipt", "reference")}" if resp["payment_receipt"] +rescue QuicknodeSdk::PaymentIndeterminateError => e + # The request may have settled. Do not retry blindly. + warn "payment indeterminate — do not retry: #{e.message}" +rescue QuicknodeSdk::PaymentRejectedError => e + warn "payment rejected (#{e.status}): #{e.body}" +end + +# Drawdown lane: authenticate once, then spend one credit per call. +if ENV["QN_PAYMENT_LANE"] == "drawdown" + # Derived locally; use it to key a session cache. + puts "payment wallet: #{sdk.rpc.payment_address}" + + session = sdk.rpc.gateway_authenticate + puts "session account: #{session[:account_id]} expires: #{session[:exp_unix]}" + + balance = sdk.rpc.gateway_credits(session: session) + puts "credits: #{balance[:credits]}" + + if balance[:credits].zero? + # The faucet returns a funding transaction, not a balance. + begin + drip = sdk.rpc.gateway_drip(session: session) + puts "faucet tx: #{drip[:transaction_hash]}" + rescue QuicknodeSdk::PaymentRejectedError => e + warn "faucet refused (#{e.status}): #{e.body}" + end + end + + result = sdk.rpc.gateway_drawdown_call( + method: "eth_blockNumber", session: session, network: "base-sepolia" + ) + puts "drawdown eth_blockNumber => #{result}" +end diff --git a/ruby/lib/quicknode_sdk.rb b/ruby/lib/quicknode_sdk.rb index 232684a..ea9a9d6 100644 --- a/ruby/lib/quicknode_sdk.rb +++ b/ruby/lib/quicknode_sdk.rb @@ -16,3 +16,15 @@ require_relative "quicknode_sdk/clients/sql" require_relative "quicknode_sdk/clients/rpc" require_relative "quicknode_sdk/sdk" + +module QuicknodeSdk + # Generates a fresh payment keypair for :evm, :svm, or :tempo. Offline — no + # network call, no funds. Returns {address:, chain:, key:}; `key` is the raw + # private key in the format the payment config's key: accepts. + # + # The key is returned exactly once, at generation: nothing in the SDK stores + # or re-derives it, so persist it before discarding the Hash. + def self.generate_payment_wallet(**opts) + wrap(Native.generate_payment_wallet(opts)) + end +end diff --git a/ruby/sig/quicknode_sdk.rbs b/ruby/sig/quicknode_sdk.rbs index 0bfed8e..ba1bd07 100644 --- a/ruby/sig/quicknode_sdk.rbs +++ b/ruby/sig/quicknode_sdk.rbs @@ -1,6 +1,11 @@ module QuicknodeSdk def self.wrap: (untyped v) -> untyped + # Generates a fresh payment keypair for "evm", "svm", or "tempo". Offline: no + # network call. Returns {address:, chain:, key:} — `key` is the raw private + # key, returned exactly once at generation. + def self.generate_payment_wallet: (chain: String) -> untyped + class Error < StandardError end @@ -30,6 +35,35 @@ module QuicknodeSdk attr_reader message: String end + # Payment-lane errors. PaymentIndeterminateError means the paid request was + # sent but its response was lost — do not retry (may have been charged). + class PaymentError < Error + end + + class PaymentUnsupportedError < PaymentError + end + + class PaymentRejectedError < PaymentError + attr_reader status: Integer + attr_reader body: String + end + + class PaymentIndeterminateError < PaymentError + end + + # The `rpc: {payment: {...}}` sub-hash accepted by SDK.from_config. An alias + # rather than a class because the config crosses the boundary as a plain + # Hash, not a wrapper object. + type payment_config = { + scheme: String, + key: String, + pay_network: String, + asset: String, + max_amount: String, + ?svm_rpc_url: String, + ?base_url_override: String + } + class SDK def self.from_env: () -> SDK def self.from_config: (Hash[Symbol | String, untyped] opts) -> SDK @@ -183,8 +217,25 @@ module QuicknodeSdk def initialize: (untyped native) -> void def call: (method: String, ?params: untyped, ?network: String, ?endpoint_url: String) -> untyped + def call_with_receipt: (method: String, ?params: untyped, ?network: String, ?endpoint_url: String) -> untyped def set_networks: (networks: Hash[String, String]) -> void def clear_cached_token: () -> void def current_token: () -> untyped + + # Payment lanes. Base-unit amounts are decimal Strings, not Integers: they + # are u128 in the core and magnus has no u128 conversion. `session` and + # `channel` are the Hashes returned by gateway_authenticate and + # mpp_open/mpp_top_up — persist them and hand them straight back. + def payment_address: () -> String + def gateway_authenticate: () -> untyped + def gateway_credits: (session: untyped) -> untyped + def gateway_buy_credits: (session: untyped, network: String) -> untyped + def gateway_drip: (session: untyped) -> untyped + def gateway_drawdown_call: (method: String, session: untyped, network: String, ?params: untyped) -> untyped + def mpp_open: (deposit: String) -> untyped + def mpp_top_up: (channel: untyped, additional_deposit: String) -> untyped + def mpp_close: (channel: untyped) -> void + def mpp_status: (channel: untyped) -> untyped + def mpp_session_call: (method: String, network: String, channel: untyped, new_cumulative: String, ?params: untyped) -> untyped end end