use serde::Deserialize;
use serde_json::Value;
use crate::admin::tooling_access::parse_rfc3339_to_unix;
use crate::errors::SdkError;
use super::{now_unix, random_nonce, ResolvedPayment};
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct GatewaySession {
pub token: String,
pub exp_unix: i64,
pub account_id: String,
}
impl std::fmt::Debug for GatewaySession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GatewaySession")
.field("token", &"[redacted]")
.field("exp_unix", &self.exp_unix)
.field("account_id", &self.account_id)
.finish()
}
}
impl GatewaySession {
pub fn is_fresh(&self, margin_secs: i64) -> bool {
now_unix() as i64 + margin_secs < self.exp_unix
}
}
#[derive(Deserialize)]
struct AuthResponse {
token: String,
#[serde(rename = "expiresAt")]
expires_at: String,
#[serde(rename = "accountId")]
account_id: String,
}
#[derive(Deserialize)]
struct CreditsResponse {
#[serde(rename = "accountId")]
account_id: String,
credits: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreditBalance {
pub account_id: String,
pub credits: u64,
}
const SIWX_STATEMENT: &str =
"I accept the Quicknode Terms of Service: https://www.quicknode.com/terms";
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());
let address = to_checksum_address(&payment.signer.address()?);
let chain_id = eip155_chain_id(&payment.pay_network)?;
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 url = format!("{}/auth", base.trim_end_matches('/'));
let resp = client
.post(&url)
.json(&serde_json::json!({
"type": "siwx",
"message": message,
"signature": signature,
}))
.send()
.await
.map_err(SdkError::Http)?;
let status = resp.status();
let body = resp.text().await.map_err(SdkError::Http)?;
if !status.is_success() {
return Err(SdkError::Api { status, body });
}
let parsed: AuthResponse =
serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })?;
let exp_unix = parse_rfc3339_to_unix(&parsed.expires_at)?;
Ok(GatewaySession {
token: parsed.token,
exp_unix,
account_id: parsed.account_id,
})
}
pub async fn drawdown_call(
client: &reqwest::Client,
payment: &ResolvedPayment,
session: &GatewaySession,
query_network: &str,
body: &Value,
) -> Result<String, SdkError> {
let base = super::PaymentScheme::X402.host_base(payment.base_url_override.as_deref());
let url = format!("{}/{}", base.trim_end_matches('/'), query_network);
let resp = client
.post(&url)
.bearer_auth(&session.token)
.json(body)
.send()
.await
.map_err(SdkError::Http)?;
let status = resp.status();
let text = resp.text().await.map_err(SdkError::Http)?;
if !status.is_success() {
return Err(SdkError::Api { status, body: text });
}
Ok(text)
}
pub async fn credits(
client: &reqwest::Client,
payment: &ResolvedPayment,
session: &GatewaySession,
) -> Result<CreditBalance, SdkError> {
let base = super::PaymentScheme::X402.host_base(payment.base_url_override.as_deref());
let url = format!("{}/credits", base.trim_end_matches('/'));
let resp = client
.get(&url)
.bearer_auth(&session.token)
.send()
.await
.map_err(SdkError::Http)?;
let status = resp.status();
let body = resp.text().await.map_err(SdkError::Http)?;
if !status.is_success() {
return Err(SdkError::Api { status, body });
}
let parsed: CreditsResponse =
serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })?;
Ok(CreditBalance {
account_id: parsed.account_id,
credits: parsed.credits,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DripReceipt {
pub account_id: String,
pub transaction_hash: String,
}
pub async fn drip(
client: &reqwest::Client,
payment: &ResolvedPayment,
session: &GatewaySession,
) -> Result<DripReceipt, SdkError> {
let base = super::PaymentScheme::X402.host_base(payment.base_url_override.as_deref());
let url = format!("{}/drip", base.trim_end_matches('/'));
let resp = client
.post(&url)
.bearer_auth(&session.token)
.send()
.await
.map_err(SdkError::Http)?;
let status = resp.status();
let body = resp.text().await.map_err(SdkError::Http)?;
if !status.is_success() {
return Err(SdkError::Api { status, body });
}
#[derive(Deserialize)]
struct DripBody {
#[serde(rename = "accountId")]
account_id: String,
#[serde(rename = "transactionHash")]
transaction_hash: String,
}
let parsed: DripBody =
serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })?;
Ok(DripReceipt {
account_id: parsed.account_id,
transaction_hash: parsed.transaction_hash,
})
}
pub async fn buy_credits(
client: &reqwest::Client,
payment: &ResolvedPayment,
session: &GatewaySession,
query_network: &str,
) -> Result<CreditBalance, SdkError> {
use crate::errors::HttpKind;
let base = super::PaymentScheme::X402.host_base(payment.base_url_override.as_deref());
let url = format!("{}/{}", base.trim_end_matches('/'), query_network);
let rpc_body = serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "eth_chainId", "params": []
});
let first = client
.post(&url)
.bearer_auth(&session.token)
.json(&rpc_body)
.send()
.await
.map_err(SdkError::Http)?;
let status = first.status();
if status.as_u16() != 402 {
if !status.is_success() {
let body = first.text().await.unwrap_or_default();
return Err(SdkError::Api { status, body });
}
return credits(client, payment, session).await;
}
let challenge_body = first.text().await.map_err(SdkError::Http)?;
let authorized = super::authorize_x402_credit(client, payment, &challenge_body).await?;
let header = authorized
.x402_header()
.ok_or_else(|| SdkError::Config("credit purchase produced no x402 credential".into()))?;
let paid = match client
.post(&url)
.bearer_auth(&session.token)
.header("PAYMENT-SIGNATURE", header)
.json(&rpc_body)
.send()
.await
{
Ok(resp) => resp,
Err(e) => {
let err = SdkError::Http(e);
return Err(match err.http_kind() {
Some(HttpKind::Connect) => err,
_ => SdkError::PaymentIndeterminate,
});
}
};
let paid_status = paid.status().as_u16();
if !(200..300).contains(&paid_status) {
let body = paid.text().await.unwrap_or_default();
return Err(SdkError::PaymentRejected {
status: paid_status,
body,
});
}
let _ = paid.text().await;
credits(client, payment, session).await
}
pub(super) fn siwe_message(
host: &str,
address: &str,
chain_id: u64,
nonce: &str,
issued_at: &str,
statement: &str,
) -> String {
format!(
"{host} wants you to sign in with your Ethereum 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}"
)
}
fn to_checksum_address(addr: &str) -> String {
use sha3::{Digest, Keccak256};
let lower = addr.strip_prefix("0x").unwrap_or(addr).to_lowercase();
let hash = Keccak256::digest(lower.as_bytes());
let mut out = String::with_capacity(42);
out.push_str("0x");
for (i, c) in lower.chars().enumerate() {
if c.is_ascii_digit() {
out.push(c);
} else {
let nibble = if i % 2 == 0 {
hash[i / 2] >> 4
} else {
hash[i / 2] & 0x0f
};
if nibble >= 8 {
out.push(c.to_ascii_uppercase());
} else {
out.push(c);
}
}
}
out
}
fn eip155_chain_id(pay_network: &str) -> Result<u64, SdkError> {
pay_network
.strip_prefix("eip155:")
.and_then(|s| s.parse().ok())
.ok_or_else(|| {
SdkError::Config(format!(
"x402 drawdown requires an eip155 pay network (e.g. eip155:84532), got {pay_network:?}"
))
})
}
fn host_only(base: &str) -> String {
base.trim_end_matches('/')
.trim_start_matches("https://")
.trim_start_matches("http://")
.to_string()
}
fn rfc3339_now() -> String {
let secs = now_unix() as i64;
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let (hour, min, sec) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let (year, month, day) = civil_from_days(days);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}.000Z")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use secrecy::SecretString;
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
use wiremock::matchers::{body_partial_json, header, method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
const EVM_KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
const EVM_ADDR: &str = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266";
const USDC: &str = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
fn evm_payment(base: &str) -> ResolvedPayment {
ResolvedPayment {
scheme: super::super::PaymentScheme::X402,
signer: super::super::signer::Signer::Evm(SecretString::new(EVM_KEY.to_string())),
pay_network: "eip155:84532".into(),
asset: USDC.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,
"accepts": [{
"scheme": "exact",
"network": "eip155:84532",
"amount": amount,
"payTo": "0x000000000000000000000000000000000000dEaD",
"maxTimeoutSeconds": 60,
"asset": USDC,
"extra": { "name": "USDC", "version": "2" }
}]
})
}
#[test]
fn siwe_message_is_byte_exact() {
let msg = siwe_message(
"x402.quicknode.com",
EVM_ADDR,
84532,
"abc12345",
"2026-07-17T12:00:00Z",
SIWX_STATEMENT,
);
let expected = "x402.quicknode.com wants you to sign in with your Ethereum account:\n\
0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266\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: 84532\n\
Nonce: abc12345\n\
Issued At: 2026-07-17T12:00:00Z";
assert_eq!(msg, expected);
}
#[test]
fn issued_at_carries_millisecond_precision() {
let iso = rfc3339_now();
assert!(iso.ends_with(".000Z"), "issued_at was {iso}");
assert_eq!(
iso.len(),
24,
"expected YYYY-MM-DDTHH:MM:SS.000Z, got {iso}"
);
let msg = siwe_message(
"x402.quicknode.com",
EVM_ADDR,
84532,
"abc12345",
&iso,
SIWX_STATEMENT,
);
assert!(msg.ends_with(&format!("Issued At: {iso}")));
}
#[test]
fn rfc3339_now_round_trips_through_the_parser() {
let iso = rfc3339_now();
let back = parse_rfc3339_to_unix(&iso).unwrap();
let now = now_unix() as i64;
assert!((now - back).abs() <= 1, "iso={iso} back={back} now={now}");
}
#[test]
fn checksum_address_matches_eip55() {
assert_eq!(
to_checksum_address("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"),
"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
);
assert_eq!(
to_checksum_address("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"),
"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
);
}
#[test]
fn host_only_strips_scheme_and_slash() {
assert_eq!(
host_only("https://x402.quicknode.com/"),
"x402.quicknode.com"
);
assert_eq!(host_only("http://127.0.0.1:8080"), "127.0.0.1:8080");
}
#[test]
fn session_freshness() {
let s = GatewaySession {
token: "t".into(),
exp_unix: now_unix() as i64 + 3600,
account_id: "a".into(),
};
assert!(s.is_fresh(60));
let stale = GatewaySession {
token: "t".into(),
exp_unix: now_unix() as i64 + 10,
account_id: "a".into(),
};
assert!(!stale.is_fresh(60));
}
#[test]
fn session_debug_redacts_the_jwt() {
let s = GatewaySession {
token: "super-secret-jwt".into(),
exp_unix: 0,
account_id: "a".into(),
};
let rendered = format!("{s:?}");
assert!(rendered.contains("[redacted]"));
assert!(!rendered.contains("super-secret-jwt"));
}
#[tokio::test]
async fn authenticate_posts_siwx_and_caches_session() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/auth"))
.and(body_partial_json(json!({ "type": "siwx" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"token": "jwt-abc",
"expiresAt": "2099-01-01T00:00:00Z",
"accountId": "eip155:84532:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"
})))
.expect(1)
.mount(&server)
.await;
let payment = evm_payment(&server.uri());
let client = reqwest::Client::new();
let session = authenticate(&client, &payment).await.unwrap();
assert_eq!(session.token, "jwt-abc");
assert!(session.account_id.contains("0xf39fd6e5"));
assert!(session.is_fresh(60));
}
#[tokio::test]
async fn authenticate_error_surfaces_as_api() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/auth"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": "invalid_signature", "message": "bad SIWX signature"
})))
.mount(&server)
.await;
let payment = evm_payment(&server.uri());
let client = reqwest::Client::new();
let err = authenticate(&client, &payment).await.unwrap_err();
assert!(matches!(err, SdkError::Api { status, .. } if status == 401));
}
#[tokio::test]
async fn drawdown_call_attaches_bearer_and_returns_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/base-sepolia"))
.and(header("authorization", "Bearer jwt-abc"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 1, "result": "0x1335f9a"
})))
.expect(1)
.mount(&server)
.await;
let payment = evm_payment(&server.uri());
let session = GatewaySession {
token: "jwt-abc".into(),
exp_unix: now_unix() as i64 + 3600,
account_id: "a".into(),
};
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": "eth_blockNumber", "params": [] });
let client = reqwest::Client::new();
let text = drawdown_call(&client, &payment, &session, "base-sepolia", &body)
.await
.unwrap();
assert!(text.contains("0x1335f9a"));
}
#[tokio::test]
async fn drawdown_call_403_monthly_limit_is_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/base-sepolia"))
.respond_with(ResponseTemplate::new(403).set_body_json(json!({
"error": "monthly_limit_reached", "message": "monthly credit limit reached"
})))
.mount(&server)
.await;
let payment = evm_payment(&server.uri());
let session = GatewaySession {
token: "jwt-abc".into(),
exp_unix: now_unix() as i64 + 3600,
account_id: "a".into(),
};
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": "eth_blockNumber", "params": [] });
let client = reqwest::Client::new();
let err = drawdown_call(&client, &payment, &session, "base-sepolia", &body)
.await
.unwrap_err();
assert!(
matches!(&err, SdkError::Api { status, body } if *status == 403 && body.contains("monthly_limit_reached"))
);
}
#[tokio::test]
async fn credits_reads_the_balance() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/credits"))
.and(header("authorization", "Bearer jwt-abc"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"accountId": "eip155:84532:0xabc", "credits": 1_000_095u64
})))
.mount(&server)
.await;
let payment = evm_payment(&server.uri());
let session = GatewaySession {
token: "jwt-abc".into(),
exp_unix: now_unix() as i64 + 3600,
account_id: "a".into(),
};
let client = reqwest::Client::new();
let bal = credits(&client, &payment, &session).await.unwrap();
assert_eq!(bal.credits, 1_000_095);
}
#[tokio::test]
async fn drip_returns_the_funding_transaction() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/drip"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"accountId": "eip155:84532:0xabc",
"walletAddress": "0xabc",
"transactionHash": "0xfeed"
})))
.mount(&server)
.await;
let payment = evm_payment(&server.uri());
let session = GatewaySession {
token: "jwt-abc".into(),
exp_unix: now_unix() as i64 + 3600,
account_id: "a".into(),
};
let client = reqwest::Client::new();
let receipt = drip(&client, &payment, &session).await.unwrap();
assert_eq!(receipt.transaction_hash, "0xfeed");
assert_eq!(receipt.account_id, "eip155:84532:0xabc");
}
fn gateway_menu() -> Value {
let mut nanopayment = x402_credit_offer("100")
.pointer("/accepts/0")
.cloned()
.unwrap();
nanopayment["maxTimeoutSeconds"] = json!(604_900);
nanopayment["extra"] = json!({
"name": "GatewayWalletBatched",
"version": "1",
"verifyingContract": "0x0077777d7EBA4688BDeF3E311b846F25870A19B9"
});
json!({
"x402Version": 2,
"accepts": [
x402_credit_offer("1000000").pointer("/accepts/0").cloned().unwrap(),
x402_credit_offer("1000").pointer("/accepts/0").cloned().unwrap(),
nanopayment,
]
})
}
#[tokio::test]
async fn buy_credits_settles_the_regular_credit_tier() {
let server = MockServer::start().await;
struct BuySeq {
calls: AtomicUsize,
}
impl Respond for BuySeq {
fn respond(&self, req: &Request) -> ResponseTemplate {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if n == 0 && !req.headers.contains_key("payment-signature") {
ResponseTemplate::new(402).set_body_json(gateway_menu())
} else {
use base64::Engine;
let header = req
.headers
.get("payment-signature")
.expect("paid resend must include a payment signature")
.to_str()
.unwrap();
let envelope: Value = serde_json::from_slice(
&base64::engine::general_purpose::STANDARD
.decode(header)
.unwrap(),
)
.unwrap();
assert_eq!(envelope["accepted"]["amount"], "1000000");
ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 1, "result": "0x1"
}))
}
}
}
Mock::given(method("POST"))
.and(path("/base-sepolia"))
.respond_with(BuySeq {
calls: AtomicUsize::new(0),
})
.expect(2)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/credits"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"accountId": "eip155:84532:0xabc", "credits": 100_000u64
})))
.expect(1)
.mount(&server)
.await;
let payment = evm_payment(&server.uri());
let session = GatewaySession {
token: "jwt-abc".into(),
exp_unix: now_unix() as i64 + 3600,
account_id: "a".into(),
};
let client = reqwest::Client::new();
let balance = buy_credits(&client, &payment, &session, "base-sepolia")
.await
.unwrap();
assert_eq!(balance.credits, 100_000);
}
#[tokio::test]
async fn buy_credits_does_not_downgrade_when_credit_tier_exceeds_ceiling() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/base-sepolia"))
.respond_with(ResponseTemplate::new(402).set_body_json(gateway_menu()))
.expect(1)
.mount(&server)
.await;
let mut payment = evm_payment(&server.uri());
payment.max_amount = 1_000;
let session = GatewaySession {
token: "jwt-abc".into(),
exp_unix: now_unix() as i64 + 3600,
account_id: "a".into(),
};
let client = reqwest::Client::new();
let err = buy_credits(&client, &payment, &session, "base-sepolia")
.await
.unwrap_err();
assert!(
matches!(&err, SdkError::PaymentUnsupported { offered }
if offered.contains("above max_amount 1000")),
"unexpected error: {err:?}"
);
}
#[tokio::test]
async fn buy_credits_without_a_credit_offer_settles_nothing() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/base-sepolia"))
.respond_with(ResponseTemplate::new(402).set_body_json(x402_credit_offer("1000")))
.expect(1)
.mount(&server)
.await;
let payment = evm_payment(&server.uri());
let session = GatewaySession {
token: "jwt-abc".into(),
exp_unix: now_unix() as i64 + 3600,
account_id: "a".into(),
};
let client = reqwest::Client::new();
let err = buy_credits(&client, &payment, &session, "base-sepolia")
.await
.unwrap_err();
assert!(
matches!(&err, SdkError::PaymentUnsupported { offered }
if offered.contains("no credit-drawdown offer")),
"unexpected error: {err:?}"
);
}
#[tokio::test]
async fn buy_credits_with_existing_credits_reads_the_balance() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/base-sepolia"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 1, "result": "0x1"
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/credits"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"accountId": "eip155:84532:0xabc", "credits": 42u64
})))
.mount(&server)
.await;
let payment = evm_payment(&server.uri());
let session = GatewaySession {
token: "jwt-abc".into(),
exp_unix: now_unix() as i64 + 3600,
account_id: "a".into(),
};
let client = reqwest::Client::new();
let bal = buy_credits(&client, &payment, &session, "base-sepolia")
.await
.unwrap();
assert_eq!(bal.credits, 42);
}
}