Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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`.
Expand Down
40 changes: 40 additions & 0 deletions crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand All @@ -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"]
Expand All @@ -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"
82 changes: 81 additions & 1 deletion crates/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`
Expand All @@ -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
Expand Down
84 changes: 84 additions & 0 deletions crates/core/examples/rpc_payment.rs
Original file line number Diff line number Diff line change
@@ -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<throwaway-key> \
//! 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}"),
}
}
4 changes: 2 additions & 2 deletions crates/core/src/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading