diff --git a/crates/core/README.md b/crates/core/README.md index 613b3ab..450a767 100644 --- a/crates/core/README.md +++ b/crates/core/README.md @@ -1945,7 +1945,7 @@ 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. @@ -1953,7 +1953,9 @@ 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 | |---|---|---| diff --git a/crates/core/examples/rpc_payment.rs b/crates/core/examples/rpc_payment.rs index 4f271c5..1eceead 100644 --- a/crates/core/examples/rpc_payment.rs +++ b/crates/core/examples/rpc_payment.rs @@ -9,6 +9,9 @@ //! QN_PAYMENT_KEY=0x \ //! 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:` +//! pay network, and the Solana USDC mint; authentication uses SIWS. use quicknode_sdk::{PaymentConfig, QuicknodeSdk, RpcConfig, SdkFullConfig}; diff --git a/crates/core/src/rpc/payment/drawdown.rs b/crates/core/src/rpc/payment/drawdown.rs index 4ec3006..42742c4 100644 --- a/crates/core/src/rpc/payment/drawdown.rs +++ b/crates/core/src/rpc/payment/drawdown.rs @@ -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 @@ -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 { 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 @@ -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, @@ -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}; @@ -403,6 +447,18 @@ fn eip155_chain_id(pay_network: &str) -> Result { 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:?}" + )) }) } @@ -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, @@ -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() { @@ -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; diff --git a/crates/core/src/rpc/payment/signer/mod.rs b/crates/core/src/rpc/payment/signer/mod.rs index 0dcf706..d694691 100644 --- a/crates/core/src/rpc/payment/signer/mod.rs +++ b/crates/core/src/rpc/payment/signer/mod.rs @@ -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 { + 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` diff --git a/crates/core/src/rpc/payment/signer/svm.rs b/crates/core/src/rpc/payment/signer/svm.rs index a6aaa86..85fd528 100644 --- a/crates/core/src/rpc/payment/signer/svm.rs +++ b/crates/core/src/rpc/payment/signer/svm.rs @@ -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 { + 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, diff --git a/npm/README.md b/npm/README.md index 72612fa..411c8ec 100644 --- a/npm/README.md +++ b/npm/README.md @@ -1829,7 +1829,7 @@ 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. @@ -1837,7 +1837,9 @@ 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 | |---|---|---| diff --git a/npm/examples/rpc_payment.ts b/npm/examples/rpc_payment.ts index a65c07a..cf37a0b 100644 --- a/npm/examples/rpc_payment.ts +++ b/npm/examples/rpc_payment.ts @@ -9,6 +9,8 @@ // // Run the x402 drawdown lane (authenticate once, then 1 credit per call): // QN_PAYMENT_KEY=0x QN_PAYMENT_LANE=drawdown npx tsx examples/rpc_payment.ts +// Solana drawdown uses a base58 key and a solana: pay network; +// the SDK authenticates with SIWS and signs the credit offer with x402/Solana. import { QuicknodeSdk, diff --git a/python/README.md b/python/README.md index 880d552..d61880f 100644 --- a/python/README.md +++ b/python/README.md @@ -1822,7 +1822,7 @@ 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. @@ -1830,7 +1830,9 @@ 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 | |---|---|---| diff --git a/python/examples/rpc_payment.py b/python/examples/rpc_payment.py index 5c51cee..014e80c 100644 --- a/python/examples/rpc_payment.py +++ b/python/examples/rpc_payment.py @@ -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. """ diff --git a/ruby/README.md b/ruby/README.md index ec55492..33bffa5 100644 --- a/ruby/README.md +++ b/ruby/README.md @@ -1830,7 +1830,7 @@ File.write("payment.key", 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. @@ -1838,7 +1838,9 @@ 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 | |---|---|---| diff --git a/ruby/examples/rpc_payment.rb b/ruby/examples/rpc_payment.rb index dd64ef6..54ff51d 100644 --- a/ruby/examples/rpc_payment.rb +++ b/ruby/examples/rpc_payment.rb @@ -11,6 +11,8 @@ # # Run the x402 drawdown lane (authenticate once, then 1 credit per call): # QN_PAYMENT_KEY=0x QN_PAYMENT_LANE=drawdown ruby -Ilib examples/rpc_payment.rb +# Solana drawdown uses a base58 key and a solana: pay network; +# the SDK authenticates with SIWS and signs the credit offer with x402/Solana. require "quicknode_sdk"