feat(rpc): x402/MPP crypto-micropayment lane for rpc.call - #54
Merged
Conversation
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.
Zlerp
approved these changes
Aug 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 the402 -> sign -> resendhandshake, guards against overcharge, and surfaces a typed payment-error hierarchy across all four languages.What's new
payments/payments-svm/payments-tempo): x402/EVM (EIP-712TransferWithAuthorization), x402/Solana (SPLTransferChecked), and MPP/Tempo (native type-0x76tx). Internalenum Signer { Evm, Svm, Tempo }over aSecretStringwith a redactingDebug.api_keyis nowOption(SdkFullConfig::keyless()); the payment lane needs no account key.from_envstays strict.call_with_receiptreturnsRpcCallResponse { result, payment_receipt }.callis unchanged.max_amountis a required spend ceiling (the SDK refuses to sign above it);PaymentIndeterminateon a lost response after paying so callers don't blind-retry into a double charge.PaymentUnsupported/PaymentRejected/PaymentIndeterminatefanned out to the Python/Node/Ruby typed hierarchies (basePaymentError).Examples added (one per language)
Each opens a keyless SDK, configures a
paymentblock onrpc, reads the key fromQN_PAYMENT_KEY(never hard-coded), pays foreth_blockNumberon Base Sepolia testnet, and handlesPaymentIndeterminate/PaymentRejected.Rust —
crates/core/examples/rpc_payment.rsNode/TypeScript —
npm/examples/rpc_payment.tsPython —
python/examples/rpc_payment.py— same shape viaPaymentConfig(...)keyword args,except PaymentIndeterminateError/PaymentRejectedError.Ruby —
ruby/examples/rpc_payment.rb— same shape via a payment hash onrpc:,rescue QuicknodeSdk::Error::PaymentIndeterminateError.Notes
keyfield is readable (likeethers .privateKey). The SDK never prints the key in its own errors/Debug.