Skip to content
Merged
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
6 changes: 4 additions & 2 deletions crates/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1945,15 +1945,17 @@ std::fs::write("payment.key", wallet.into_key())?; // consuming: a deliberate,

### x402 credit drawdown (authenticate once, then draw one credit per call)

Cheaper per call than paying per request: one SIWE signature mints a session JWT, then
Cheaper per call than paying per request: one SIWE or SIWS signature mints a session JWT, then
each call draws a single credit from the account balance instead of signing a fresh
settlement. Minting the JWT is free and moves no funds, so a host can re-authenticate
transparently. Persist the session between processes.

Fund the payment wallet out of band — the testnet faucet below, or by sending funds to
`payment_address()` directly. Credits are provisioned against the account gateway-side.

EVM signers only: SIWE is an EIP-4361 construction, so an x402/Solana key errors here.
EVM payment networks use SIWE. Solana payment networks use SIWS with an Ed25519
signature encoded as Base58. Solana wallets must be funded out of band; the faucet
is available for Base Sepolia only.

| Method | Cost | Returns |
|---|---|---|
Expand Down
3 changes: 3 additions & 0 deletions crates/core/examples/rpc_payment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
//! QN_PAYMENT_KEY=0x<throwaway-key> \
//! cargo run --example rpc_payment -p quicknode-sdk \
//! --features rust,payments,payments-svm,payments-tempo
//!
//! For Solana drawdown, use a base58 Solana key, a `solana:<genesis-hash>`
//! pay network, and the Solana USDC mint; authentication uses SIWS.

use quicknode_sdk::{PaymentConfig, QuicknodeSdk, RpcConfig, SdkFullConfig};

Expand Down
188 changes: 167 additions & 21 deletions crates/core/src/rpc/payment/drawdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
//! response — no per-call signing.
//!
//! The flow:
//! 1. [`authenticate`] — build a SIWE (EIP-4361) message, sign it with the
//! 1. [`authenticate`] — build a SIWE or SIWS message, sign it with the
//! payment key, POST `/auth`, and cache the returned [`GatewaySession`].
//! 2. [`drawdown_call`] — POST `/:network` with the Bearer JWT; returns the raw
//! JSON-RPC envelope text.
//! 3. [`credits`] — GET `/credits` with the Bearer JWT → the current balance.
//! 4. [`drip`] — POST `/drip` (testnet faucet, once per account) — funds the
//! wallet, not the credit ledger.
//! 4. [`drip`] — POST `/drip` (Base Sepolia faucet, once per account) — funds
//! the wallet, not the credit ledger.
//!
//! [`buy_credits`] settles a credit block by signing the gateway's credit-tier
//! offer. It is reachable only where that offer's construction is signable; see
Expand Down Expand Up @@ -100,31 +100,52 @@ const SIWX_STATEMENT: &str =
/// returns a cached [`GatewaySession`]. Free — no funds move — so a caller may
/// (re)auth transparently on a missing/expired session without user consent.
///
/// EVM signers only (SIWE). An SVM signer errors — SIWS is a separate
/// construction.
/// Selects SIWE for EVM payment networks and SIWS for Solana payment networks.
pub async fn authenticate(
client: &reqwest::Client,
payment: &ResolvedPayment,
) -> Result<GatewaySession, SdkError> {
let base = super::PaymentScheme::X402.host_base(payment.base_url_override.as_deref());
// SIWE compares the recovered address case-sensitively.
let address = to_checksum_address(&payment.signer.address()?);
// SIWE requires the decimal EIP-155 id, not the CAIP-2 string.
let chain_id = eip155_chain_id(&payment.pay_network)?;

// Build the gateway's fixed SIWE message with a fresh nonce and timestamp.
let address = payment.signer.address()?;
let host = host_only(base);
let nonce = hex::encode(&random_nonce()[..8]);
let issued_at = rfc3339_now();
let message = siwe_message(
&host,
&address,
chain_id,
&nonce,
&issued_at,
SIWX_STATEMENT,
);
let signature = payment.signer.sign_siwe(&message)?;
let (message, signature) = match payment.signer.kind() {
super::signer::ChainKind::Svm => {
let chain_id = solana_chain_id(&payment.pay_network)?;
let message = siws_message(
&host,
&address,
chain_id,
&nonce,
&issued_at,
SIWX_STATEMENT,
);
let signature = payment.signer.sign_siws(&message)?;
(message, signature)
}
super::signer::ChainKind::Evm => {
// SIWE compares the recovered address case-sensitively and uses
// the decimal EIP-155 id rather than the CAIP-2 string.
let address = to_checksum_address(&address);
let chain_id = eip155_chain_id(&payment.pay_network)?;
let message = siwe_message(
&host,
&address,
chain_id,
&nonce,
&issued_at,
SIWX_STATEMENT,
);
let signature = payment.signer.sign_siwe(&message)?;
(message, signature)
}
super::signer::ChainKind::Tempo => {
return Err(SdkError::Config(
"x402 drawdown requires an EVM or Solana signer".into(),
));
}
};

let url = format!("{}/auth", base.trim_end_matches('/'));
let resp = client
Expand Down Expand Up @@ -224,7 +245,7 @@ pub struct DripReceipt {

/// Requests testnet tokens from the faucet (POST `/drip`, Bearer JWT). The
/// gateway allows this once per account on Base Sepolia and returns the funding
/// transaction (NOT a balance).
/// transaction (NOT a balance). Solana wallets must be funded out of band.
pub async fn drip(
client: &reqwest::Client,
payment: &ResolvedPayment,
Expand Down Expand Up @@ -367,6 +388,29 @@ pub(super) fn siwe_message(
)
}

/// Build the CAIP-122 Sign-In-With-Solana message expected by the gateway.
pub(super) fn siws_message(
host: &str,
address: &str,
chain_id: &str,
nonce: &str,
issued_at: &str,
statement: &str,
) -> String {
format!(
"{host} wants you to sign in with your Solana account:\n\
{address}\n\
\n\
{statement}\n\
\n\
URI: https://{host}\n\
Version: 1\n\
Chain ID: {chain_id}\n\
Nonce: {nonce}\n\
Issued At: {issued_at}"
)
}

// Apply the EIP-55 checksum required by SIWE.
fn to_checksum_address(addr: &str) -> String {
use sha3::{Digest, Keccak256};
Expand Down Expand Up @@ -403,6 +447,18 @@ fn eip155_chain_id(pay_network: &str) -> Result<u64, SdkError> {
SdkError::Config(format!(
"x402 drawdown requires an eip155 pay network (e.g. eip155:84532), got {pay_network:?}"
))
})
}

// SIWS displays the Solana genesis hash without the CAIP-2 namespace prefix.
fn solana_chain_id(pay_network: &str) -> Result<&str, SdkError> {
pay_network
.strip_prefix("solana:")
.filter(|chain_id| !chain_id.is_empty())
.ok_or_else(|| {
SdkError::Config(format!(
"x402 Solana drawdown requires a solana pay network, got {pay_network:?}"
))
})
}

Expand Down Expand Up @@ -466,6 +522,28 @@ mod tests {
}
}

#[cfg(feature = "payments-svm")]
fn svm_payment(base: &str) -> ResolvedPayment {
use ed25519_dalek::SigningKey;

let seed = [7u8; 32];
let signing_key = SigningKey::from_bytes(&seed);
let mut secret = Vec::with_capacity(64);
secret.extend_from_slice(&seed);
secret.extend_from_slice(&signing_key.verifying_key().to_bytes());
ResolvedPayment {
scheme: super::super::PaymentScheme::X402,
signer: super::super::signer::Signer::Svm(SecretString::new(
bs58::encode(secret).into_string(),
)),
pay_network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1".into(),
asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU".into(),
max_amount: 10_000_000,
base_url_override: Some(base.to_string()),
svm_rpc_url: None,
}
}

fn x402_credit_offer(amount: &str) -> Value {
json!({
"x402Version": 2,
Expand Down Expand Up @@ -504,6 +582,30 @@ mod tests {
assert_eq!(msg, expected);
}

#[cfg(feature = "payments-svm")]
#[test]
fn siws_message_is_byte_exact() {
let msg = siws_message(
"x402.quicknode.com",
"11111111111111111111111111111111",
"EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
"abc12345",
"2026-07-17T12:00:00Z",
SIWX_STATEMENT,
);
let expected = "x402.quicknode.com wants you to sign in with your Solana account:\n\
11111111111111111111111111111111\n\
\n\
I accept the Quicknode Terms of Service: https://www.quicknode.com/terms\n\
\n\
URI: https://x402.quicknode.com\n\
Version: 1\n\
Chain ID: EtWTRABZaYq6iMfeYKouRu166VU2xqa1\n\
Nonce: abc12345\n\
Issued At: 2026-07-17T12:00:00Z";
assert_eq!(msg, expected);
}

// Verify the generated timestamp uses the gateway's millisecond format.
#[test]
fn issued_at_carries_millisecond_precision() {
Expand Down Expand Up @@ -609,6 +711,50 @@ mod tests {
assert!(session.is_fresh(60));
}

#[cfg(feature = "payments-svm")]
#[tokio::test]
async fn authenticate_posts_solana_siwx_with_base58_signature() {
use ed25519_dalek::{Signature, SigningKey, Verifier};

struct AuthResponder;
impl Respond for AuthResponder {
fn respond(&self, req: &Request) -> ResponseTemplate {
let body: Value = serde_json::from_slice(&req.body).unwrap();
assert_eq!(body["type"], "siwx");
let message = body["message"].as_str().unwrap();
assert!(message.contains("sign in with your Solana account"));
assert!(message.contains("Chain ID: EtWTRABZaYq6iMfeYKouRu166VU2xqa1"));

let signature = bs58::decode(body["signature"].as_str().unwrap())
.into_vec()
.unwrap();
let signature: [u8; 64] = signature.try_into().unwrap();
let public_key = SigningKey::from_bytes(&[7u8; 32]).verifying_key();
public_key
.verify(message.as_bytes(), &Signature::from_bytes(&signature))
.unwrap();

ResponseTemplate::new(200).set_body_json(json!({
"token": "jwt-solana",
"expiresAt": "2099-01-01T00:00:00Z",
"accountId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1:11111111111111111111111111111111"
}))
}
}

let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/auth"))
.respond_with(AuthResponder)
.mount(&server)
.await;

let payment = svm_payment(&server.uri());
let client = reqwest::Client::new();
let session = authenticate(&client, &payment).await.unwrap();
assert_eq!(session.token, "jwt-solana");
}

#[tokio::test]
async fn authenticate_error_surfaces_as_api() {
let server = MockServer::start().await;
Expand Down
16 changes: 16 additions & 0 deletions crates/core/src/rpc/payment/signer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,22 @@ impl Signer {
}
}

/// Sign a Sign-In-With-Solana (CAIP-122) message with Ed25519 and return
/// the Base58 signature. Solana only; EVM signers use [`Self::sign_siwe`].
pub fn sign_siws(&self, _message: &str) -> Result<String, SdkError> {
match self {
#[cfg(feature = "payments-svm")]
Signer::Svm(_) => svm::sign_siws(self, _message),
Signer::Evm(_) | Signer::Tempo(_) => Err(SdkError::Config(
"SIWS signing requires an SVM signer".into(),
)),
#[cfg(not(feature = "payments-svm"))]
Signer::Svm(_) => Err(SdkError::Config(
"SIWS signing requires the `payments-svm` feature".into(),
)),
}
}

/// Sign an MPP session voucher (`Voucher(bytes32 channelId,uint128
/// cumulativeAmount)`) against the legacy escrow contract's EIP-712 domain
/// ("Tempo Stream Channel"), returning the `0x`-prefixed 65-byte `r||s||v`
Expand Down
7 changes: 7 additions & 0 deletions crates/core/src/rpc/payment/signer/svm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,13 @@ impl Signer {
}
}

/// Sign a CAIP-122 SIWS message and return the Base58 Ed25519 signature.
pub(super) fn sign_siws(signer: &Signer, message: &str) -> Result<String, SdkError> {
let key = svm_signing_key(signer)?;
let signature = key.sign(message.as_bytes());
Ok(bs58::encode(signature.to_bytes()).into_string())
}

/// A v0 message's account-permission counts.
struct MessageHeader {
num_required_signatures: u8,
Expand Down
6 changes: 4 additions & 2 deletions npm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1829,15 +1829,17 @@ console.log("fund this address:", wallet.address);

### x402 credit drawdown (authenticate once, then draw one credit per call)

Cheaper per call than paying per request: one SIWE signature mints a session JWT, then
Cheaper per call than paying per request: one SIWE or SIWS signature mints a session JWT, then
each call draws a single credit from the account balance instead of signing a fresh
settlement. Minting the session is free and moves no funds, so a host can re-authenticate
transparently. Persist it between processes.

Fund the payment wallet out of band — the testnet faucet below, or by sending funds to
`paymentAddress()` directly. Credits are provisioned against the account gateway-side.

EVM signers only: SIWE is an EIP-4361 construction, so an x402/Solana key errors here.
EVM payment networks use SIWE. Solana payment networks use SIWS with an Ed25519
signature encoded as Base58. Solana wallets must be funded out of band; the faucet
is available for Base Sepolia only.

| Method | Cost | Returns |
|---|---|---|
Expand Down
2 changes: 2 additions & 0 deletions npm/examples/rpc_payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
//
// Run the x402 drawdown lane (authenticate once, then 1 credit per call):
// QN_PAYMENT_KEY=0x<key> QN_PAYMENT_LANE=drawdown npx tsx examples/rpc_payment.ts
// Solana drawdown uses a base58 key and a solana:<genesis-hash> pay network;
// the SDK authenticates with SIWS and signs the credit offer with x402/Solana.

import {
QuicknodeSdk,
Expand Down
6 changes: 4 additions & 2 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1822,15 +1822,17 @@ open("payment.key", "w").write(wallet["key"]) # returned exactly once

### x402 credit drawdown (authenticate once, then draw one credit per call)

Cheaper per call than paying per request: one SIWE signature mints a session JWT, then
Cheaper per call than paying per request: one SIWE or SIWS signature mints a session JWT, then
each call draws a single credit from the account balance instead of signing a fresh
settlement. Minting the session is free and moves no funds, so a host can re-authenticate
transparently. Persist it between processes.

Fund the payment wallet out of band — the testnet faucet below, or by sending funds to
`payment_address()` directly. Credits are provisioned against the account gateway-side.

EVM signers only: SIWE is an EIP-4361 construction, so an x402/Solana key errors here.
EVM payment networks use SIWE. Solana payment networks use SIWS with an Ed25519
signature encoded as Base58. Solana wallets must be funded out of band; the faucet
is available for Base Sepolia only.

| Method | Cost | Returns |
|---|---|---|
Expand Down
3 changes: 2 additions & 1 deletion python/examples/rpc_payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ async def selfcheck() -> None:
async def drawdown_demo(key: str) -> None:
"""The x402 drawdown lane: authenticate once, then draw 1 credit per call.

Cheaper per call than the per-request lane (one signature buys a block of
Cheaper per call than the per-request lane (one SIWE or SIWS signature buys
a block of
credits), and the session JWT is free to mint — so a host can re-auth
transparently. Persist the session dict between runs.
"""
Expand Down
Loading
Loading