Skip to content

feat(rpc): x402/MPP crypto-micropayment lane for rpc.call - #54

Merged
johnpmitsch merged 23 commits into
mainfrom
x402_MPP
Aug 4, 2026
Merged

feat(rpc): x402/MPP crypto-micropayment lane for rpc.call#54
johnpmitsch merged 23 commits into
mainfrom
x402_MPP

Conversation

@johnpmitsch

Copy link
Copy Markdown
Collaborator

Adds a crypto-micropayment payment lane to rpc.call: pay per RPC request with a stablecoin instead of an account API key, against Quicknode's x402/MPP gateways. The SDK runs the 402 -> sign -> resend handshake, guards against overcharge, and surfaces a typed payment-error hierarchy across all four languages.

What's new

  • Three signing constructions, feature-gated (payments / payments-svm / payments-tempo): x402/EVM (EIP-712 TransferWithAuthorization), x402/Solana (SPL TransferChecked), and MPP/Tempo (native type-0x76 tx). Internal enum Signer { Evm, Svm, Tempo } over a SecretString with a redacting Debug.
  • Keyless SDK: api_key is now Option (SdkFullConfig::keyless()); the payment lane needs no account key. from_env stays strict.
  • call_with_receipt returns RpcCallResponse { result, payment_receipt }. call is unchanged.
  • Guards: max_amount is a required spend ceiling (the SDK refuses to sign above it); PaymentIndeterminate on a lost response after paying so callers don't blind-retry into a double charge.
  • New error variants PaymentUnsupported / PaymentRejected / PaymentIndeterminate fanned out to the Python/Node/Ruby typed hierarchies (base PaymentError).
  • CI feature-matrix job (5 combos).

Examples added (one per language)

Each opens a keyless SDK, configures a payment block on rpc, reads the key from QN_PAYMENT_KEY (never hard-coded), pays for eth_blockNumber on Base Sepolia testnet, and handles PaymentIndeterminate / PaymentRejected.

Rustcrates/core/examples/rpc_payment.rs

let mut config = SdkFullConfig::keyless();
config.rpc = Some(RpcConfig {
    payment: Some(PaymentConfig {
        scheme: "x402".into(),
        key,
        pay_network: "eip155:84532".into(),       // Base Sepolia USDC
        asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e".into(),
        max_amount: "10000".into(),               // spend ceiling (required)
        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?;

Node/TypeScriptnpm/examples/rpc_payment.ts

const qn = new QuicknodeSdk({
  rpc: { payment: {
    scheme: "x402", key,
    payNetwork: "eip155:84532",
    asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
    maxAmount: "10000",
  } },
});
const { result, paymentReceipt } = await qn.rpc.callWithReceipt("eth_blockNumber", [], "base-sepolia");

Pythonpython/examples/rpc_payment.py — same shape via PaymentConfig(...) keyword args, except PaymentIndeterminateError / PaymentRejectedError.

Rubyruby/examples/rpc_payment.rb — same shape via a payment hash on rpc:, rescue QuicknodeSdk::Error::PaymentIndeterminateError.

Notes

  • The examples move real funds when they settle — use a throwaway, minimally-funded wallet. Do not log the config object; its key field is readable (like ethers .privateKey). The SDK never prints the key in its own errors/Debug.
  • Live end-to-end smoke (one real settled payment per path) remains a gated manual step; mock/unit coverage is complete (275 core tests, byte-for-byte EIP-712 and Tempo vectors).

The menu selector admits payment amounts as u128, but SPL TransferChecked
encodes the amount as a u64. An amount the selector accepted that overflows
u64 was silently mapped to a vague "missing/invalid amount" config error.
Parse as u128 and narrow explicitly so the overflow surfaces as a clear
message. Adds a regression test.
Adds `generate_payment_wallet(ChainKind) -> GeneratedWallet`, returning the
raw private key (in the same format `--payment-key-file`/`key_file` reads)
and its derived address in one call. EVM/Tempo generate a secp256k1 key
(0x-prefixed hex); SVM generates an ed25519 keypair (base58 64-byte).
Randomness comes from the OS CSPRNG via `rand::thread_rng`.

Re-exports `generate_payment_wallet`, `GeneratedWallet`, and `ChainKind`
through the curated public API so callers can create and display a wallet
without touching the internal `Signer` type. Round-trip tests confirm a
generated key reparses and re-derives the same address on all three chains.
Consuming accessor that returns the raw private key string, for callers
that must write it to a file. Consuming (not borrowing) keeps the key
exposure a deliberate one-shot step.
When a paid request is rejected and the gateway returns its JSON error shape
(`{"error", "message"}`), reduce the rejection body to that reason so callers
can lead their message with it (e.g. "auth_required: SIWX authentication
required" vs a verification failure). A body that isn't that shape — a full
402 payment menu, or plain text — is returned unchanged. Unit-tested.
Adds the SIWX-authenticated credit-drawdown model alongside the existing
per-request 402 loop, in a new rpc::payment::drawdown module that leaves the
per-request paths untouched.

- GatewaySession: cached session JWT (redacted Debug, serde for host
  persistence), mirroring CachedToken.
- authenticate: builds and personal-signs a SIWE (EIP-4361) message, POSTs
  /auth, returns the session. Free, so hosts may re-auth transparently.
- buy_credits: settles the gateway's 402 credit offer via the same x402
  signer construction as per-request (single-attempt), returns the balance.
- credits / drip: GET /credits and the testnet faucet POST /drip.
- drawdown_call: POST /:network with the Bearer JWT, 1 credit per success.

Signer gains sign_siwe (EIP-191 personal_sign, EVM-only). RpcApiClient exposes
gateway_* methods that resolve the payment config and delegate. 12 new wiremock
unit tests; all 294 lib tests pass.
Adds the TIP-1034 MPP payment-channel model alongside the per-request charge,
in a new rpc::payment::session module (payments-tempo). The per-request charge
path is untouched.

Follows the mppx reference client (wevm/mppx): open a channel by depositing
into the TIP-20 Channel Reserve escrow precompile
(0x4d50500000000000000000000000000000000000), then authorize spend with
cumulative EIP-712 vouchers in Authorization: Payment — one ecrecover
server-side, no on-chain tx per call.

- ChannelState: local channel record (channelId, descriptor fields, deposit,
  cumulative spend), serde for host persistence; status is the recovery path.
- open / top_up: sign a fee-sponsored Tempo tx calling the escrow precompile
  (reusing the handoff encoder), derive the channelId, POST the credential.
- close: cooperative close voucher (settle + refund).
- voucher_call: attach a cumulative voucher per session call; refuses a
  cumulative above the deposit before signing.

Signer gains sign_session_voucher (TIP-20 Channel Reserve EIP-712) and
sign_escrow_tx (open/topUp). RpcApiClient exposes mpp_open/top_up/close/status/
session_call. Byte-exact tests reproduce viem-computed voucher digest and
channelId reference vectors; 303 lib tests pass.
open() now records the gateway's per-call price on the returned ChannelState
(per_call), so a session-lane caller can advance the cumulative voucher amount
by exactly one unit per call without re-reading the challenge. Field only;
open/voucher signing unchanged.
The SIWE (EIP-4361) auth message put the CAIP-2 pay_network (eip155:84532) in
the Chain ID field, but the gateway matches it as a decimal EIP-155 chain id
and rejected it as unsupported_chain. Derive the numeric chain id from the
eip155 prefix for the message. Also fixes the MPP session credential `source`
to the full CAIP-10 did:pkh:eip155:<chainId>:<address> the gateway expects.
The /auth endpoint rejects any SIWX statement other than its exact ToS text
(invalid_statement). Use the required verbatim string:
"I accept the Quicknode Terms of Service: https://www.quicknode.com/terms".
The gateway recovers the SIWE signer and compares it to the address in the
message, so the message must carry the EIP-55 checksummed address (the signer
derives lowercase) — a lowercase address failed as invalid_signature. Also emit
issuedAt with millisecond precision (.000Z) to match the canonical EIP-4361
format the reference SIWE libraries produce.
Two gateway-shape corrections found against the live x402 gateway:

- /drip returns the on-chain funding transaction ({accountId, walletAddress,
  transactionHash}), not a credit balance. drip() now returns a DripReceipt;
  the balance is read separately via GET /credits.
- There is no dedicated POST /credits purchase endpoint. Credits are bought by
  settling the credit-drawdown offer on a network-scoped RPC request
  (POST /:network): the gateway 402s an `accepts` menu whose largest tier is the
  credit block. buy_credits now takes a query_network, POSTs the RPC body,
  selects the LARGEST eligible offer (new prefer_largest path in the x402 entry
  selector), settles once, then reads the funded balance from GET /credits.
…ycle

Three payment-lane paths turned a local error into a silent wrong value:

- An x402/MPP credential that failed to serialize fell back to
  `unwrap_or_default()`, signing and sending zero bytes. The caller saw an
  opaque gateway rejection instead of the real fault. Now an error.
- An unparseable challenge `expires` fell back to `u64::MAX`, so the charge
  lane would sign an authorization that never expires. Now an error.
- `open` and `top_up` set validBefore to now+25s while ignoring the challenge
  expiry, unlike the charge lane. Both now clamp to min(now+25s, expiry).

`mpp_status`' docs claimed the probe was free; it costs one request unit and
advances the voucher, which the implementation already did. The Ruby
`extract_payment_config` silently ignored a non-Hash `rpc`, so a typo'd config
dropped the payment lane without a word; it now raises ArgumentError.

Adds 15 tests: the session lifecycle (open/top_up/close/status/voucher_call)
had no wiremock coverage, and the SIWX byte-exact test took issuedAt as a
parameter, so it could not see the `.000Z` precision the gateway requires.

The channel lifecycle no longer takes a query network. The channel is scoped by
the configured pay network and asset, so one open channel funds calls to every
supported network; only the session RPC call still routes by network.
The x402 drawdown and MPP channel lanes existed only in Rust: Python could
reach `call_with_receipt` but none of the lifecycle. Adds all 12 methods to
`rpc` plus a module-level `generate_payment_wallet(chain)`.

Base-unit amounts cross as decimal strings, not ints. They are u128 in the
core and PyO3 has no lossless conversion, so a string is the only shape that
cannot silently truncate a large deposit. A non-integer is refused with the
field name rather than coerced.

Session and channel state cross as dicts so a host can persist them verbatim
and hand them back. Reading them back needs explicit conversion: serde does
not deserialize u128 from a string, so a dict built from our own output would
otherwise be rejected. Each field reports itself by name when missing or
malformed.

`generate_payment_wallet` returns the private key exactly once, at generation.
Nothing in the SDK stores or re-derives it, matching the SecretString custody
rule that keeps keys out of Debug output.
Brings both to parity with Python: all 12 payment methods on `rpc` plus a
module-level wallet generator. Base-unit amounts cross as decimal strings
because they are u128 in the core — a JS number is an f64 that loses precision
above 2^53, and magnus has no u128 conversion. Session and channel state
crosses as a plain object/Hash so a host can persist it verbatim.

Two boundary bugs this surfaced:

`npm/sdk.js` spreads the napi index directly, so a module-level function's
error reached callers as a bare Error rather than a typed ConfigError. Only
client instances go through `wrapClient`. Module functions now translate their
own errors, with a regression test.

The Ruby wallet generator returned a plain string-keyed Hash while every client
response is an IndifferentHash. It now goes through the same wrap step, so
symbol and string keys both work.

Adds the payment shapes to npm/sdk.d.ts (amounts typed as string, not number)
and the method signatures to ruby/sig/quicknode_sdk.rbs. Both examples gain the
drawdown lane behind QN_PAYMENT_LANE=drawdown and a no-funds selfcheck.
The four per-language READMEs described only the per-request lane, so the
drawdown and MPP channel methods were undocumented in every language. Each
README gains three subsections: wallet generation, the drawdown lane, and the
channel lane, with method tables that state what each call costs — free,
one credit, one request unit, or moves funds.

Two things callers get wrong without being told: `mppStatus` is not free (it
advances the voucher like any session call), and base-unit amounts are strings
rather than numbers because they are u128.

Also records that `api_key` is optional on the direct-config path, since the
payment lane needs no account key. `from_env` still requires it.

The Tempo escrow gas comment stated the constraint via a narrated live trace;
it now just states the constraint.
@johnpmitsch
johnpmitsch merged commit 4c9ac01 into main Aug 4, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants