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..eca1d47 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: spike probes reference throwaway funded wallets. Never commit. +scratch/ + +# Local working plan doc — kept out of this public repo (internal notes). +IMPLEMENTATION_PLAN.md diff --git a/CLAUDE.md b/CLAUDE.md index a9422f7..42c8c96 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`. diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 71de92d..710da7c 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -19,6 +19,21 @@ 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 SPL TransferChecked, 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 +54,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 +99,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..f6e462b 100644 --- a/crates/core/README.md +++ b/crates/core/README.md @@ -55,6 +55,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. @@ -1821,6 +1838,65 @@ 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. + +Confirmed paths: **x402/EVM** (EIP-712 `TransferWithAuthorization`), **x402/Solana** +(SPL `TransferChecked`, gateway sponsors gas), and **MPP/Tempo** (native Tempo tx). + +`PaymentConfig` fields: + +| Field | Meaning | +|---|---| +| `scheme` | `"x402"` (pay-per-request) or `"mpp"` (MPP charge) | +| `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 blockhash reads | +| `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.** The blockhash read defaults to a public Solana + 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); +``` + + ## Error Handling Every binding exposes a typed exception hierarchy derived from the core `SdkError` @@ -1837,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 | — | -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..a24d41a --- /dev/null +++ b/crates/core/examples/rpc_payment.rs @@ -0,0 +1,84 @@ +//! 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"); + + // A keyless SDK: no account API key is needed for the payment lane. Every + // other surface (admin/streams/…) would error without a key — that's fine, + // this SDK only pays per request. + let mut config = SdkFullConfig::keyless(); + config.rpc = Some(RpcConfig { + // The payment config is plain data; the private key stays in `key`. + // WARNING: do not log this object — the `key` field is readable. The + // SDK never prints it in its own errors/Debug. + payment: Some(PaymentConfig { + scheme: "x402".into(), + key, + // Base Sepolia testnet USDC (x402/EVM). + pay_network: "eip155:84532".into(), + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e".into(), + // Spend ceiling in base units of the asset (required). The SDK + // refuses to sign any offered amount above this. + max_amount: "10000".into(), + svm_rpc_url: None, + base_url_override: None, + }), + ..Default::default() + }); + + let qn = QuicknodeSdk::new(&config).expect("sdk failed to initialize"); + + // `network` is the QUERY chain (path slug on the gateway), independent of + // the pay network. The SDK does the 402 → sign → resend handshake. + 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}"), + } + + // `call_with_receipt` also returns the settlement receipt. It is `Some` on + // the MPP lane (the reference is the settlement tx hash) and `None` for + // x402. On a lost response after paying, the error is `PaymentIndeterminate` + // — do NOT blindly retry (you may have already been charged). + 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..265498e 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -213,6 +213,104 @@ 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 (recent + /// blockhash). 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 +318,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 +362,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 +384,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 +424,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 +445,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 +496,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 +510,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..16b256f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -19,6 +19,8 @@ pub use kvstore::{ UpdateListParams, }; pub use rpc::RpcApiClient; +#[cfg(feature = "payments")] +pub use rpc::{PaymentConfig, PaymentReceipt, PaymentScheme, RpcCallResponse}; pub use sql::{ ChainSchema, ColumnMeta, ColumnSchema, QueryParams, QueryResponse, QueryStatistics, SqlApiClient, TableSchema, @@ -171,11 +173,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 +305,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 +389,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..ce0652b 100644 --- a/crates/core/src/rpc/mod.rs +++ b/crates/core/src/rpc/mod.rs @@ -17,6 +17,14 @@ 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::{PaymentReceipt, PaymentScheme}; + use crate::admin::AdminApiClient; use crate::config::{CachedToken, RpcConfig}; use crate::errors::SdkError; @@ -50,6 +58,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 +100,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 +113,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 +168,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 +217,136 @@ 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)) + } + + // 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 +567,7 @@ mod tests { }), refresh_margin_secs: None, networks: None, + payment: None, }); QuicknodeSdk::new(&cfg).unwrap() } @@ -463,6 +636,7 @@ mod tests { seed: None, refresh_margin_secs: None, networks: None, + payment: None, }); QuicknodeSdk::new(&cfg).unwrap() } @@ -671,6 +845,7 @@ mod tests { }), refresh_margin_secs: None, networks: None, + payment: None, }); let sdk = QuicknodeSdk::new(&cfg).unwrap(); @@ -715,6 +890,7 @@ mod tests { }), refresh_margin_secs: None, networks: Some(networks), + payment: None, }); let sdk = QuicknodeSdk::new(&cfg).unwrap(); @@ -764,3 +940,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/mod.rs b/crates/core/src/rpc/payment/mod.rs new file mode 100644 index 0000000..fa648f7 --- /dev/null +++ b/crates/core/src/rpc/payment/mod.rs @@ -0,0 +1,1422 @@ +//! 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 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 + )) + })?; + + // Resolve the Solana RPC source for x402/Solana payment-build reads. The + // caller's explicit override wins; otherwise fall back to a public + // Solana RPC matching the pay cluster. (The tooling-endpoint step is + // wired by RpcApiClient, which has the network map; this default is the + // last resort — the READMEs push the explicit override at any volume.) + 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, + }) + } +} + +// Solana CAIP-2 ids are `solana:`. Devnet's genesis hash +// begins `EtWTRAB…`; the literal string "devnet" never appears in a CAIP-2 id, +// so both the RPC default and the tooling-key resolution must key off this +// prefix (not `contains("devnet")`). Returns true for the devnet cluster. +pub(crate) fn solana_pay_network_is_devnet(pay_network: &str) -> bool { + pay_network.contains("EtWTRABZaYq6iMfeYKouRu166VU2xqa1") +} + +// Public Solana RPC default matching the pay cluster. Rate-limits aggressively; +// callers at any volume should set an explicit `svm_rpc_url`. +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); + + // 1. Unpaid probe. A transport error here is a plain Http error — no + // payment exists yet. + let first = client + .post(&url) + .json(body) + .send() + .await + .map_err(SdkError::Http)?; + let status = first.status().as_u16(); + + // A non-402 first response means the gateway did not demand payment (or + // errored). Pass it back to the caller's JSON-RPC parser via the text. + if status != 402 { + let text = first.text().await.map_err(SdkError::Http)?; + return Ok((text, None)); + } + + // 2. Parse the challenge and build a credential for the matching entry. + 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)? + } + }; + + // 3. Paid resend — exactly once. Transport errors here are classified so a + // lost response after the bytes may have reached the gateway surfaces as + // PaymentIndeterminate (do not blind-retry), while a refused connection + // (nothing sent) stays a plain retryable Http error. + 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 on the paid resend is terminal: the payment credential was + // submitted and the gateway did not accept it. This covers a second 402 + // (rejected credential) AND a 5xx/other settlement failure — both must + // surface as PaymentRejected so the caller keeps the "payment was + // submitted" signal, rather than the 5xx body falling through to a Decode + // error on a non-JSON-RPC response. + 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), + }); + } + + // Capture the MPP receipt before consuming the body. + let receipt = paid + .headers() + .get("payment-receipt") + .and_then(|v| v.to_str().ok()) + .and_then(parse_receipt); + + // Reading the body can itself fail on a lost connection after headers. + 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)) +} + +enum Authorized { + X402 { + header: String, + }, + #[cfg(feature = "payments-tempo")] + Mpp { + credential: String, + }, +} + +// ── x402 authorize (EVM + Solana) ──────────────────────────────────────────── + +async fn authorize_x402( + client: &reqwest::Client, + payment: &ResolvedPayment, + challenge_body: &str, +) -> Result { + // Pre-payment: nothing has been signed or sent yet, so an unreadable menu + // is "no usable offer" (PaymentUnsupported), never a Decode — paid-lane + // callers treat Decode as a post-payment failure whose outcome is unknown. + 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 = Vec::new(); + let chosen = select_x402_entry(payment, &parsed.accepts, &mut skipped); + let Some(entry) = chosen else { + return Err(SdkError::PaymentUnsupported { + offered: describe_offered(&parsed.accepts, &skipped), + }); + }; + + match payment.signer.kind() { + signer::ChainKind::Evm => authorize_x402_evm(payment, &parsed.x402_version, &entry), + signer::ChainKind::Svm => { + authorize_x402_svm(client, payment, &parsed.x402_version, &entry).await + } + signer::ChainKind::Tempo => Err(SdkError::PaymentUnsupported { + offered: "a Tempo signer cannot pay an x402 challenge (use the MPP scheme)".into(), + }), + } +} + +// Select the first accepts[] entry that matches {pay_network, asset}, has a +// supported `extra` shape, and whose amount is a non-negative integer ≤ +// max_amount. Records skip reasons for the PaymentUnsupported message. +fn select_x402_entry( + payment: &ResolvedPayment, + accepts: &[Value], + skipped: &mut Vec, +) -> Option { + 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; + } + // Skip Circle Gateway nanopayment (GatewayWalletBatched): its + // verifyingContract is a separate field, not the asset — a different + // signing construction, deferred from v1. + if let Some(name) = entry.pointer("/extra/name").and_then(Value::as_str) { + if name == "GatewayWalletBatched" { + skipped.push(format!( + "{network}/{asset}: GatewayWalletBatched (deferred)" + )); + continue; + } + } + // Amount must be an integer base-unit string ≤ max_amount. + let amount_str = entry.get("amount").and_then(Value::as_str).unwrap_or(""); + match amount_str.parse::() { + Ok(amount) if amount <= payment.max_amount => return Some(entry.clone()), + 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" + )), + } + } + None +} + +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)?; + + // Envelope: {x402Version, accepted:, payload:{signature, 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)), + } + } + }); + let header = base64_std(serde_json::to_vec(&envelope).unwrap_or_default()); + 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 menu selector admits amounts as u128, but SPL TransferChecked encodes + // the amount as a u64 (the Solana token-program ABI ceiling). Parse as u128 + // and narrow explicitly so an over-u64 amount surfaces as a clear overflow + // error rather than being conflated with a missing/malformed field. + 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" + )) + })?; + // Decimals may be carried in the entry's extra; default to 6 (USDC). + let decimals = entry + .pointer("/extra/decimals") + .and_then(Value::as_u64) + .unwrap_or(6) as u8; + let token_2022 = entry + .pointer("/extra/tokenProgram") + .and_then(Value::as_str) + .is_some_and(|p| p.contains("Token2022") || p.starts_with("TokenzQd")); + + // The gateway 402s keyless sub-reads, so the recent blockhash comes from a + // plain Solana RPC (resolved source: override → tooling → public default). + let rpc_url = payment + .svm_rpc_url + .as_deref() + .ok_or_else(|| SdkError::Config("x402/Solana requires a resolved Solana RPC URL".into()))?; + let recent_blockhash = fetch_latest_blockhash(client, rpc_url).await?; + + let req = SvmTransferRequest { + mint: payment.asset.clone(), + pay_to: pay_to.to_string(), + fee_payer: fee_payer.to_string(), + amount, + decimals, + recent_blockhash, + token_2022, + }; + let tx = payment.signer.sign_svm_transfer(&req)?; + + // Envelope: {x402Version, accepted:, payload:}. + let envelope = serde_json::json!({ + "x402Version": x402_version, + "accepted": entry, + "payload": base64_std(tx), + }); + let header = base64_std(serde_json::to_vec(&envelope).unwrap_or_default()); + 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)?; + // Also pre-payment (the blockhash goes into a transaction that has not + // been signed yet): a bad RPC response is a Config-class failure, not a + // Decode. + 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}")) + }) +} + +#[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)?; + + // Find the tempo challenge for our chain id. + 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 + ), + }); + } + + // validBefore = min(now+25s, challenge expiry) — TIP-1009 expiring nonce. + let expiry = parse_iso_unix(&challenge.expires).unwrap_or(u64::MAX); + 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).unwrap_or_default()); + 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")] +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")] +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(), + }) +} + +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) +} + +// Only the MPP/Tempo credential builder uses this in non-test code; the +// receipt-parse test exercises it regardless of features. +#[cfg_attr(not(feature = "payments-tempo"), allow(dead_code))] +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 either an eip155 CAIP-2 id or a bare numeric chain id (MPP/Tempo +// selectors are sometimes stated as the bare Tempo chain id). Only the MPP +// path uses this in non-test code. +#[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:?}" + )) + }) +} + +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(", ") + ) + } +} + +// Append a clock-skew hint when a Tempo credential's window has already passed +// at response time — a skewed local clock (>~25s behind) signs already-expired +// credentials and every call ends in PaymentRejected. +fn enrich_rejection(payment: &ResolvedPayment, body: String) -> String { + if payment.signer.kind() == signer::ChainKind::Tempo { + format!( + "{body} (if this persists, check the system clock — Tempo payment windows are ~25s)" + ) + } else { + body + } +} + +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. The challenge uses +// "2026-07-13T02:05:10.119Z"; we only need whole seconds. Minimal parser to +// avoid a chrono dependency. +#[cfg(feature = "payments-tempo")] +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() +} + +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 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"); + } + + // ── Driver wiremock tests ──────────────────────────────────────────────── + // + // These exercise the 402 loop end-to-end against a mock gateway. Signing + // correctness is covered byte-for-byte by the signer unit tests; here we + // assert the parse → select → authorize → resend → capture 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; + // First (unpaid) POST -> 402 with a menu; the paid POST carries a + // PAYMENT-SIGNATURE header and gets a 200 result. + 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; + + // max_amount below the only offered entry. + 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; + // Only a GatewayWalletBatched entry is offered -> nothing to sign. + 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; + // An 18-decimal asset amount that overflows u64 but fits u128, below a + // large max_amount -> must be selectable (proves u128 comparison). + 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; + // Every POST returns 402 -> the paid resend also 402s -> 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; + // The 402 challenge body is not JSON. Nothing has been signed, so this + // must surface as PaymentUnsupported (nothing charged), never Decode. + 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() { + // The unpaid probe 402s; the paid resend returns a 500 with a non-JSON + // body. This must surface as PaymentRejected (payment was submitted), + // NOT fall through to a Decode error. + 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() { + // Assert the paid resend carries PAYMENT-SIGNATURE and the flow stops + // after one resend (mock counts total POSTs = 2). + 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() { + // The paid resend times out (mock delays past the client timeout) AFTER + // the request was sent -> PaymentIndeterminate (do not blind-retry). + 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 { + // Delay well past the client timeout to simulate a lost + // response after the paid bytes were sent. + 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; + // The tempo challenge request (base64url JSON) 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 menu selector compares amounts as u128, but SPL TransferChecked can + // only encode a u64. An amount the selector admits but that overflows u64 + // must fail with a clear overflow message, not a vague "missing amount". + #[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(); + // Amount check runs before the blockhash RPC fetch, so the unreachable + // svm_rpc_url is never contacted. + 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}" + ); + } +} 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..2a95396 --- /dev/null +++ b/crates/core/src/rpc/payment/signer/mod.rs @@ -0,0 +1,335 @@ +//! 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 print the key. A leaked private key is catastrophic; the SDK's own +// Debug output, error context, and panics must all render `[redacted]`. +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())) + } + + // 20-byte EVM address (keccak of the uncompressed pubkey, last 20 bytes), + // lowercase hex with `0x`. + pub(super) fn evm_address(key: &SigningKey) -> String { + let verifying = key.verifying_key(); + let point = verifying.to_sec1_point(false); + // Skip the 0x04 prefix byte of the uncompressed point. + 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() + } + + // Sign a 32-byte prehash, returning 65 bytes r||s||v where v is 27/28 + // (the encoding both ox and viem emit for EIP-712 sigs and Tempo handoffs). + 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)) + } +} + +// 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")] +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::*; + + // Known-good EIP-712 vector from a publicly-known throwaway key (anvil test + // key #0, never funded) — no real wallet's credentials enter the repo. The + // expected signature below was produced offline with viem's `signTypedData` + // over the exact domain/message in `eip712_reproduces_known_good_vector`. + 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)); + } + + #[test] + fn eip712_digest_is_deterministic_and_domain_bound() { + // The digest must change when any domain/message field changes, and be + // stable for identical inputs — locks that the EIP-712 encoding is + // domain-bound. Byte-level acceptance is covered by the known-good + // vector test below. + 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() { + // Known-good signature produced by viem's `signTypedData` over the + // exact domain/message below, using the throwaway anvil key #0 (never + // funded). Reproducing it byte-for-byte proves the x402/EVM EIP-712 + // construction matches the reference wallet libraries. + 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); + } +} 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..bdd494c --- /dev/null +++ b/crates/core/src/rpc/payment/signer/svm.rs @@ -0,0 +1,340 @@ +//! 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 SPL `TransferChecked` instruction is hand-rolled (a 4-account, +//! 10-byte-data instruction) rather than pulling `spl-token`, which drags +//! `solana-program` → curve25519/MSRV conflicts under cross+zig at +//! glibc-2.17/musl. +//! +//! Async: the payer's associated token account 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; + +// SPL Token and Token-2022 program ids (base58), and the Associated Token +// Account program id. Hand-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 SYSTEM_PROGRAM_ID: &str = "11111111111111111111111111111111"; + +// SPL TransferChecked instruction discriminant. +const TRANSFER_CHECKED: u8 = 12; + +/// 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, + /// Whether the mint is a Token-2022 mint (selects the token program). + pub token_2022: bool, +} + +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 SPL `TransferChecked` transaction (x402/Solana). + /// Returns the serialized signed transaction bytes (the gateway base64s + /// them into the payment envelope's `payload`). + pub fn sign_svm_transfer(&self, req: &SvmTransferRequest) -> Result, SdkError> { + let key = svm_signing_key(self)?; + let payer = key.verifying_key().to_bytes(); + + let token_program = decode_pubkey(if req.token_2022 { + TOKEN_2022_PROGRAM_ID + } else { + TOKEN_PROGRAM_ID + })?; + let mint = decode_pubkey(&req.mint)?; + let pay_to_owner = decode_pubkey(&req.pay_to)?; + let fee_payer = decode_pubkey(&req.fee_payer)?; + + // Derive the source and destination associated token accounts. + let source_ata = associated_token_address(&payer, &token_program, &mint)?; + let dest_ata = associated_token_address(&pay_to_owner, &token_program, &mint)?; + + // TransferChecked: accounts = [source, mint, dest, owner(=payer signer)]. + // data = discriminant(1) || amount(u64 LE) || decimals(1). + let mut data = Vec::with_capacity(10); + data.push(TRANSFER_CHECKED); + data.extend_from_slice(&req.amount.to_le_bytes()); + data.push(req.decimals); + + let message = build_message( + &fee_payer, + &payer, + &token_program, + &decode_pubkey(SYSTEM_PROGRAM_ID)?, + &source_ata, + &mint, + &dest_ata, + &req.recent_blockhash, + &data, + )?; + + // Legacy transaction wire format: + // compact-u16 signature count || signatures(64B each) || message. + // Two signers (fee payer + payer); we fill the payer's slot and leave + // the fee-payer slot zeroed for the gateway to co-sign. + 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) + } +} + +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}"))) +} + +// Associated Token Account = find_program_address([owner, token_program, mint], +// ATA program). We search for the off-curve PDA by decrementing the bump. +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(); + // A valid PDA must be OFF the ed25519 curve. + if !is_on_curve(&candidate) { + return Ok(candidate); + } + } + Err(SdkError::Config( + "could not derive associated token account (no off-curve bump)".into(), + )) +} + +// A point is on the ed25519 curve if it decompresses to a valid point. A valid +// PDA must be OFF the curve; `VerifyingKey::from_bytes` succeeds exactly when +// the bytes decompress to a curve point, so we reuse it (no direct +// curve25519-dalek dependency). +fn is_on_curve(bytes: &[u8; 32]) -> bool { + ed25519_dalek::VerifyingKey::from_bytes(bytes).is_ok() +} + +// Build a legacy Solana transaction message for a single TransferChecked ix. +// Account ordering (writable-signers, readonly-signers, writable-nonsigners, +// readonly-nonsigners) is required by the runtime's header semantics. +#[allow(clippy::too_many_arguments)] +fn build_message( + fee_payer: &[u8; 32], + payer_signer: &[u8; 32], + token_program: &[u8; 32], + _system_program: &[u8; 32], + source_ata: &[u8; 32], + mint: &[u8; 32], + dest_ata: &[u8; 32], + recent_blockhash: &str, + ix_data: &[u8], +) -> Result, SdkError> { + // Ordered account list: + // 0: fee_payer (writable signer) — gateway + // 1: payer_signer (writable signer) — the SPL token owner + // 2: source_ata (writable nonsigner) + // 3: dest_ata (writable nonsigner) + // 4: mint (readonly nonsigner) + // 5: token_program (readonly nonsigner) + let accounts: Vec<[u8; 32]> = vec![ + *fee_payer, + *payer_signer, + *source_ata, + *dest_ata, + *mint, + *token_program, + ]; + let num_required_signatures: u8 = 2; + let num_readonly_signed: u8 = 0; + let num_readonly_unsigned: u8 = 2; // mint + token_program + + let index = + |pk: &[u8; 32]| -> u8 { accounts.iter().position(|a| a == pk).map_or(0, |p| p as u8) }; + + // TransferChecked account metas: [source, mint, dest, owner]. + let ix_accounts = [ + index(source_ata), + index(mint), + index(dest_ata), + index(payer_signer), + ]; + let program_index = index(token_program); + + let blockhash = decode_pubkey(recent_blockhash)?; // 32-byte hash, base58 + + let mut msg = Vec::new(); + msg.push(num_required_signatures); + msg.push(num_readonly_signed); + msg.push(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); + // One instruction. + write_compact_u16(&mut msg, 1); + msg.push(program_index); + write_compact_u16(&mut msg, ix_accounts.len() as u16); + msg.extend_from_slice(&ix_accounts); + write_compact_u16(&mut msg, ix_data.len() as u16); + msg.extend_from_slice(ix_data); + Ok(msg) +} + +// Solana compact-u16 (shortvec) length encoding. +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::*; + + // A throwaway Solana keypair (32-byte seed, publicly known anvil-style + // filler — never funded). base58 of 64 bytes [seed||pub]. + fn throwaway_signer() -> Signer { + // Seed of all 1s; deterministic for the test. + 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)); + } + + #[test] + fn transfer_produces_two_sig_slots_with_payer_filled() { + let signer = throwaway_signer(); + let req = SvmTransferRequest { + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(), + pay_to: "2LWbc9MihDfP4JR7YrE5MNrCq4Yd6qcT57tAt1v1qcT5" + .chars() + .take(44) + .collect(), + fee_payer: "GVJJ7rdGqjNjBqKxY9YqZ3xQ5vN8dKZ8Q9dVebDveb1" + .chars() + .take(43) + .collect(), + amount: 1000, + decimals: 6, + recent_blockhash: "11111111111111111111111111111111".into(), + token_2022: false, + }; + // pay_to / fee_payer above may not be valid base58 pubkeys; use real + // 32-byte-decodable values instead. + let req = SvmTransferRequest { + pay_to: bs58::encode([4u8; 32]).into_string(), + fee_payer: bs58::encode([5u8; 32]).into_string(), + recent_blockhash: bs58::encode([6u8; 32]).into_string(), + ..req + }; + let tx = signer.sign_svm_transfer(&req).unwrap(); + // compact-u16(2) = 1 byte, then 2×64 sig bytes, then message. + assert_eq!(tx[0], 2); + // Fee-payer slot (bytes 1..65) is zeroed for the gateway. + assert_eq!(&tx[1..65], &[0u8; 64]); + // Payer slot (65..129) is filled (non-zero). + assert!(tx[65..129].iter().any(|&b| b != 0)); + } +} 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..69b995e --- /dev/null +++ b/crates/core/src/rpc/payment/signer/tempo.rs @@ -0,0 +1,328 @@ +//! 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]; + +// Generous fixed gas/fee caps. Under `feePayer:true` the gateway sponsors the +// fee, so the sender's caps cost it nothing and only need to exceed inclusion +// cost — no fee/gas RPC estimation is required. +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 + +/// 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, + // Presence of a fee-payer signature drives the 0x00 placeholder + + // feeToken skip in encode_for_signing; the value is not encoded. + 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![], + }; + + // 1. Sender preimage (0x76, fee-payer placeholder, feeToken skipped). + let sign_hash = tx.signature_hash(); + let sig65 = secp::sign_prehash_65(&key, &sign_hash.0); + + // 2. Fee-payer handoff envelope (0x78): the same fields with the sender + // address in the fee-payer slot and the sender sig appended. + // `tempo-primitives` has no public serializer for this exact form, so + // it is assembled field-by-field with alloy-rlp (see encode_handoff). + Ok(encode_handoff( + req.chain_id, + max_prio, + max_fee, + gas_limit, + &tx.calls, + &tx.access_list, + req.valid_before, + sender, + &sig65, + )) + } +} + +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) +} + +// mppx Attribution memo (bytes32): +// keccak("mpp")[0..4] ++ 0x01 ++ keccak(realm)[0..10] ++ zeros[10] ++ keccak(challengeId)[0..7] +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 stay zero (no 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() +} + +// 0x78 || rlp([chainId, maxPrioFee, maxFee, gas, calls, accessList, nonceKey, +// nonce, validBefore, validAfter='', feeToken='', senderAddr, +// authList=[], senderSig(65B)]). +#[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 the calls as a list: header(list, sum of encoded lengths) ++ each +// Call. Done explicitly rather than relying on a slice `Encodable` blanket so +// the encoding is independent of alloy-rlp's slice-impl surface. +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::*; + + // Reference vector generated offline by the `ox/tempo` encoder with the + // publicly-known throwaway anvil key #0 (never funded) and fixed + // validBefore/gas/fee inputs. Reproducing the 0x78 handoff bytes exactly + // proves the MPP/Tempo construction matches the reference encoder. + const KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + const EXPECTED_HANDOFF: &str = "78f9011382a5bf830f4240843b9aca0083019a28f87ef87c9420c000000000000000000000000000000000000080b86495777d59000000000000000000000000fd24114c3981aba78ae2441991b1bdb89329c55600000000000000000000000000000000000000000000000000000000000003e8ef1ed712013846ebb93fa448b84b800000000000000000000060f498736fd943c0a0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff80846a543ee5808094f39fd6e51aad88f6f4ce6ab8827279cfffb92266c0b841ca92118d9f7da00c84c2445bd3ee164cef9f60742771ca8a1700f15357f1437122ff663f076b0a54bbbfc614fb28f6c8e69a29735ad555ca71c25a889180e0c01c"; + + // Reconstruct the exact calldata the vector used: transferWithMemo to + // 0xfd24…c556, amount 1000, memo ef1e…d943. + fn vector_request() -> TempoChargeRequest { + // The vector's memo was computed from specific realm/challenge inputs; + // to reproduce the exact bytes we bypass the memo builder by encoding + // calldata directly in this test via a crafted request is not possible + // (memo is derived). Instead we assert the handoff for the known memo + // by constructing calldata to match. See below. + 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), + } + } + + // The vector's memo bytes (ef1e…d943) — fixed by the captured challenge. + const VECTOR_MEMO: &str = "ef1ed712013846ebb93fa448b84b800000000000000000000060f498736fd943"; + + #[test] + fn handoff_reproduces_stage1a_vector() { + // Build calldata with the vector's exact memo (the builder is exercised + // separately below); this isolates the tx-encoding + signing path. + 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 + version byte are fixed regardless of inputs. + let memo = attribution_memo("mpp.quicknode.com", "challenge-1"); + assert_eq!(memo[4], 0x01); + // bytes 15..25 are the zero clientId gap. + assert_eq!(&memo[15..25], &[0u8; 10]); + } +} 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..50bec58 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)?; + // RpcCallResponse holds a serde_json::Value; build the JS object here. + 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`. 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..9235dc2 100644 --- a/crates/python/src/lib.rs +++ b/crates/python/src/lib.rs @@ -2568,6 +2568,52 @@ 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)?; + // Build a plain dict at the FFI boundary: RpcCallResponse holds a + // serde_json::Value, which cannot be a pyclass field. + 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`. @@ -2679,6 +2725,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 +2785,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..3d4222c 100644 --- a/crates/ruby/src/lib.rs +++ b/crates/ruby/src/lib.rs @@ -68,6 +68,46 @@ fn hash_require_string(h: &RHash, key: &str) -> Result { }) } +// Pull the payment lane out of `config[:rpc][:payment]`. RpcConfig.payment is +// serde-skipped (never env-derived), so serde_magnus won't populate it — this +// builds the PaymentConfig from the hash so Ruby callers can set it +// programmatically. Returns None when no rpc.payment sub-hash is present. +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); + }; + let Some(rpc) = RHash::from_value(rpc_val) else { + return Ok(None); + }; + 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 +315,17 @@ 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}")) })?; + // RpcConfig.payment is `#[serde(skip)]` (so it can never be populated + // from the environment), which also means serde_magnus won't pick it up + // from the config hash. Extract it manually and attach it here so Ruby + // callers can configure the payment lane programmatically. + 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 +1963,38 @@ 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)?; + // Build a JSON value at the boundary (RpcCallResponse holds a + // serde_json::Value) and hand it to Ruby as an IndifferentHash. + 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> { @@ -2273,6 +2352,10 @@ 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", diff --git a/npm/README.md b/npm/README.md index 220f8aa..1ec0b74 100644 --- a/npm/README.md +++ b/npm/README.md @@ -1727,6 +1727,62 @@ 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. + +Confirmed paths: **x402/EVM** (EIP-712 `TransferWithAuthorization`), **x402/Solana** +(SPL `TransferChecked`, gateway sponsors gas), and **MPP/Tempo** (native Tempo tx). + +`PaymentConfig` fields: + +| Field | Meaning | +|---|---| +| `scheme` | `"x402"` (pay-per-request) or `"mpp"` (MPP charge) | +| `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 blockhash reads | +| `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.** The blockhash read defaults to a public Solana + 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); +``` + + ## Error Handling Every binding exposes a typed exception hierarchy derived from the core `SdkError` @@ -1743,8 +1799,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..7c08d30 --- /dev/null +++ b/npm/examples/rpc_payment.ts @@ -0,0 +1,63 @@ +// 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 + +import { + QuicknodeSdk, + PaymentIndeterminateError, + PaymentRejectedError, +} from "@quicknode/sdk"; + +const key = process.env.QN_PAYMENT_KEY; +if (!key) throw new Error("set QN_PAYMENT_KEY to a throwaway key"); + +// A keyless SDK: the payment lane needs no account API key. Do NOT log the +// config object — the `key` field is readable. +const qn = new QuicknodeSdk({ + rpc: { + payment: { + scheme: "x402", + key, + // Base Sepolia testnet USDC (x402/EVM). + payNetwork: "eip155:84532", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + // Spend ceiling in base units of the asset (required). + maxAmount: "10000", + // For x402/Solana at any volume, set svmRpcUrl to your own Solana RPC — + // the public default rate-limits aggressively. + }, + }, +}); + +async function main() { + try { + // `network` is the QUERY chain (gateway path slug), independent of the pay + // network. The SDK runs the 402 -> sign -> resend handshake. + const { result, paymentReceipt } = await qn.rpc.callWithReceipt( + "eth_blockNumber", + [], + "base-sepolia", + ); + console.log("paid eth_blockNumber =>", result); + // paymentReceipt is set on the MPP lane (reference = settlement tx hash), + // null for x402. + if (paymentReceipt) console.log("settlement reference:", paymentReceipt.reference); + } catch (e) { + if (e instanceof PaymentIndeterminateError) { + // The paid request was sent but the response was lost — you may already + // have been charged. Do NOT blindly retry. + 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..e8984ed 100644 --- a/npm/index.d.ts +++ b/npm/index.d.ts @@ -1367,6 +1367,54 @@ 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 (recent + * blockhash). 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 +1560,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 +1600,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 +2603,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 diff --git a/npm/sdk.d.ts b/npm/sdk.d.ts index a2ad1b6..8c15e2d 100644 --- a/npm/sdk.d.ts +++ b/npm/sdk.d.ts @@ -331,8 +331,27 @@ 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; +} + // const enums must use `export` (not `export type`) so they are usable as values export { StreamRegion, @@ -459,3 +478,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..5cda249 100644 --- a/npm/sdk.js +++ b/npm/sdk.js @@ -90,4 +90,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..700b650 100644 --- a/npm/sdk.mjs +++ b/npm/sdk.mjs @@ -34,4 +34,8 @@ export const { ApiError, DecodeError, RpcError, + PaymentError, + PaymentUnsupportedError, + PaymentRejectedError, + PaymentIndeterminateError, } = cjs; diff --git a/npm/test.js b/npm/test.js index 450d088..2f4479c 100644 --- a/npm/test.js +++ b/npm/test.js @@ -1,8 +1,38 @@ +const assert = require("node:assert"); const sdk = require("./sdk.js"); async function main() { - // TODO: figure out testing + // Payment-lane error classes are exported and form 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); + + // A keyless SDK with a payment lane constructs without 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"); + + // The payment lane requires a `network`; omitting it is a ConfigError. + await assert.rejects( + () => qn.rpc.call("eth_blockNumber", []), + (e) => e instanceof sdk.ConfigError && /requires `network`/.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..7650d52 100644 --- a/python/README.md +++ b/python/README.md @@ -1723,6 +1723,59 @@ 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. + +Confirmed paths: **x402/EVM** (EIP-712 `TransferWithAuthorization`), **x402/Solana** +(SPL `TransferChecked`, gateway sponsors gas), and **MPP/Tempo** (native Tempo tx). + +`PaymentConfig` fields: + +| Field | Meaning | +|---|---| +| `scheme` | `"x402"` (pay-per-request) or `"mpp"` (MPP charge) | +| `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 blockhash reads | +| `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.** The blockhash read defaults to a public Solana + 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"]) +``` + + ## Error Handling Every binding exposes a typed exception hierarchy derived from the core `SdkError` @@ -1739,8 +1792,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..50b999c --- /dev/null +++ b/python/examples/rpc_payment.py @@ -0,0 +1,101 @@ +"""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 +""" + +import asyncio +import os + +from quicknode_sdk import ( + QuicknodeSdk, + SdkFullConfig, + RpcConfig, + PaymentConfig, + ConfigError, + PaymentError, + PaymentIndeterminateError, + PaymentRejectedError, +) + + +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) + print("selfcheck OK: payment error classes + network-required ConfigError") + + +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 + + # A keyless SDK: the payment lane needs no account API key. Do NOT log the + # config object — the `key` field is readable. + 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 base units of the asset (required). + max_amount="10000", + # For x402/Solana at any volume, set svm_rpc_url to your own + # Solana RPC — the public default rate-limits aggressively. + ) + ), + ) + qn = QuicknodeSdk(config) + + try: + # `network` is the QUERY chain (gateway path slug), independent of the + # pay network. The SDK runs the 402 -> sign -> resend handshake. + resp = await qn.rpc.call_with_receipt( + "eth_blockNumber", [], "base-sepolia" + ) + print("paid eth_blockNumber =>", resp["result"]) + # payment_receipt is set on the MPP lane (reference = settlement tx + # hash), None for x402. + if resp["payment_receipt"]: + print("settlement reference:", resp["payment_receipt"]["reference"]) + except PaymentIndeterminateError as e: + # The paid request was sent but the response was lost — you may already + # have been charged. Do NOT blindly retry. + 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..bacc61d 100644 --- a/python/quicknode_sdk/__init__.py +++ b/python/quicknode_sdk/__init__.py @@ -116,6 +116,7 @@ KvStoreConfig, SqlConfig, RpcConfig, + PaymentConfig, CachedToken, SdkFullConfig, RpcApiClient, @@ -213,6 +214,10 @@ ApiError, DecodeError, RpcError, + PaymentError, + PaymentUnsupportedError, + PaymentRejectedError, + PaymentIndeterminateError, ) __all__ = [ @@ -332,6 +337,7 @@ "KvStoreConfig", "SqlConfig", "RpcConfig", + "PaymentConfig", "CachedToken", "SdkFullConfig", "RpcApiClient", @@ -429,4 +435,8 @@ "ApiError", "DecodeError", "RpcError", + "PaymentError", + "PaymentUnsupportedError", + "PaymentRejectedError", + "PaymentIndeterminateError", ] diff --git a/python/quicknode_sdk/__init__.pyi b/python/quicknode_sdk/__init__.pyi index 2d0b5b4..c1a425a 100644 --- a/python/quicknode_sdk/__init__.pyi +++ b/python/quicknode_sdk/__init__.pyi @@ -118,6 +118,7 @@ from quicknode_sdk._core import ( KvStoreConfig, SqlConfig, RpcConfig, + PaymentConfig, CachedToken, SdkFullConfig, RpcApiClient, @@ -231,6 +232,10 @@ from quicknode_sdk._core import ( ApiError, DecodeError, RpcError, + PaymentError, + PaymentUnsupportedError, + PaymentRejectedError, + PaymentIndeterminateError, ) __all__ = [ @@ -350,6 +355,7 @@ __all__ = [ "KvStoreConfig", "SqlConfig", "RpcConfig", + "PaymentConfig", "CachedToken", "SdkFullConfig", "RpcApiClient", @@ -463,4 +469,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..2e29b99 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", @@ -5059,6 +5060,110 @@ 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 (recent + blockhash). 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 (recent + blockhash). 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 +5553,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 -> @@ -5534,7 +5647,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 +5787,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 +5838,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: diff --git a/python/quicknode_sdk/init_manual_override.pyi b/python/quicknode_sdk/init_manual_override.pyi index 2d0b5b4..c1a425a 100644 --- a/python/quicknode_sdk/init_manual_override.pyi +++ b/python/quicknode_sdk/init_manual_override.pyi @@ -118,6 +118,7 @@ from quicknode_sdk._core import ( KvStoreConfig, SqlConfig, RpcConfig, + PaymentConfig, CachedToken, SdkFullConfig, RpcApiClient, @@ -231,6 +232,10 @@ from quicknode_sdk._core import ( ApiError, DecodeError, RpcError, + PaymentError, + PaymentUnsupportedError, + PaymentRejectedError, + PaymentIndeterminateError, ) __all__ = [ @@ -350,6 +355,7 @@ __all__ = [ "KvStoreConfig", "SqlConfig", "RpcConfig", + "PaymentConfig", "CachedToken", "SdkFullConfig", "RpcApiClient", @@ -463,4 +469,8 @@ __all__ = [ "ApiError", "DecodeError", "RpcError", + "PaymentError", + "PaymentUnsupportedError", + "PaymentRejectedError", + "PaymentIndeterminateError", ] diff --git a/ruby/README.md b/ruby/README.md index 7da6498..cd2e96e 100644 --- a/ruby/README.md +++ b/ruby/README.md @@ -1730,6 +1730,61 @@ 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. + +Confirmed paths: **x402/EVM** (EIP-712 `TransferWithAuthorization`), **x402/Solana** +(SPL `TransferChecked`, gateway sponsors gas), and **MPP/Tempo** (native Tempo tx). + +`PaymentConfig` fields: + +| Field | Meaning | +|---|---| +| `scheme` | `"x402"` (pay-per-request) or `"mpp"` (MPP charge) | +| `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 blockhash reads | +| `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.** The blockhash read defaults to a public Solana + 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"] +``` + + ## Error Handling Every binding exposes a typed exception hierarchy derived from the core `SdkError` @@ -1746,8 +1801,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..9b6b3e8 --- /dev/null +++ b/ruby/examples/rpc_payment.rb @@ -0,0 +1,71 @@ +# 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 + +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") + + puts "selfcheck OK: payment error classes + network-required ConfigError" +end + +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 + +# A keyless SDK: the payment lane needs no account API key. Do NOT log the +# config hash — the `key` field is readable. +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 base units of the asset (required). + max_amount: "10000" + # For x402/Solana at any volume, set svm_rpc_url: to your own Solana RPC — + # the public default rate-limits aggressively. + } + } +) + +begin + # `network` is the QUERY chain (gateway path slug), independent of the pay + # network. The SDK runs the 402 -> sign -> resend handshake. + resp = sdk.rpc.call_with_receipt(method: "eth_blockNumber", params: [], network: "base-sepolia") + puts "paid eth_blockNumber => #{resp["result"]}" + # payment_receipt is set on the MPP lane (reference = settlement tx hash), + # nil for x402. + puts "settlement reference: #{resp.dig("payment_receipt", "reference")}" if resp["payment_receipt"] +rescue QuicknodeSdk::PaymentIndeterminateError => e + # The paid request was sent but the response was lost — you may already have + # been charged. Do NOT blindly retry. + warn "payment indeterminate — do not retry: #{e.message}" +rescue QuicknodeSdk::PaymentRejectedError => e + warn "payment rejected (#{e.status}): #{e.body}" +end diff --git a/ruby/sig/quicknode_sdk.rbs b/ruby/sig/quicknode_sdk.rbs index 0bfed8e..3d4eebd 100644 --- a/ruby/sig/quicknode_sdk.rbs +++ b/ruby/sig/quicknode_sdk.rbs @@ -30,6 +30,22 @@ 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 + class SDK def self.from_env: () -> SDK def self.from_config: (Hash[Symbol | String, untyped] opts) -> SDK @@ -183,6 +199,7 @@ 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