diff --git a/gateway/config.go b/gateway/config.go index 9ff0ebe..4da06f5 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -5,6 +5,7 @@ import ( "log" "net/url" "os" + "strconv" "strings" "time" ) @@ -16,6 +17,33 @@ const ( receiptStoreModeMemory = "memory" ) +// GetSupportedChainIds defines the network IDs allowed for payment requests. +// 84532: Base Sepolia, 11155111: Ethereum Sepolia, 11155240: Optimism Sepolia. +func GetSupportedChainIds() []int64 { + chainIDs := []int64{84532, 11155111, 11155240} + envKeys := []string{"CHAIN_ID", "EXPECTED_CHAIN_ID"} + for _, key := range envKeys { + if val := os.Getenv(key); val != "" { + if id, err := strconv.ParseInt(val, 10, 64); err == nil { + found := false + for _, existing := range chainIDs { + if existing == id { + found = true + break + } + } + if !found { + chainIDs = append(chainIDs, id) + log.Printf("Dynamically registered chain ID %d from env var %s", id, key) + } + } else { + log.Printf("Warning: failed to parse %s=%s as int64", key, val) + } + } + } + return chainIDs +} + func getAllowedOrigins() []string { raw := strings.TrimSpace(os.Getenv("ALLOWED_ORIGINS")) if raw == "" { @@ -87,9 +115,10 @@ func getPositiveTimeout(envKey string, defaultSeconds int) time.Duration { } // Timeout helpers (configurable via env vars) -func getRequestTimeout() time.Duration { return getPositiveTimeout("REQUEST_TIMEOUT_SECONDS", 60) } -func getAITimeout() time.Duration { return getPositiveTimeout("AI_REQUEST_TIMEOUT_SECONDS", 30) } -func getVerifierTimeout() time.Duration { return getPositiveTimeout("VERIFIER_TIMEOUT_SECONDS", 2) } +func getRequestTimeout() time.Duration { return getPositiveTimeout("REQUEST_TIMEOUT_SECONDS", 60) } +func getAITimeout() time.Duration { return getPositiveTimeout("AI_REQUEST_TIMEOUT_SECONDS", 30) } +func getVerifierTimeout() time.Duration { return getPositiveTimeout("VERIFIER_TIMEOUT_SECONDS", 2) } func getHealthCheckTimeout() time.Duration { return getPositiveTimeout("HEALTH_CHECK_TIMEOUT_SECONDS", 2) } + diff --git a/gateway/openapi.yaml b/gateway/openapi.yaml index cfddf75..5c023e5 100644 --- a/gateway/openapi.yaml +++ b/gateway/openapi.yaml @@ -371,6 +371,12 @@ components: chainId: type: integer example: 84532 + supportedChainIds: + type: array + items: + type: integer + format: int64 + description: List of supported blockchain network chain IDs for this payment challenge context. timestamp: type: integer format: uint64 @@ -456,7 +462,7 @@ components: format: date-time payment: type: object - required: [payer, recipient, amount, token, chainId, nonce] + required: [payer, recipient, amount, token, chainId, supportedChainIds, nonce] properties: payer: type: string @@ -473,6 +479,12 @@ components: chainId: type: integer example: 84532 + supportedChainIds: + type: array + items: + type: integer + format: int64 + description: List of supported blockchain network chain IDs for this payment challenge context. nonce: type: string example: "550e8400-e29b-41d4-a716-446655440000" @@ -489,3 +501,5 @@ components: response_hash: type: string example: sha256:... + + diff --git a/gateway/receipt.go b/gateway/receipt.go index 7b6be7a..50eb3b6 100644 --- a/gateway/receipt.go +++ b/gateway/receipt.go @@ -24,12 +24,13 @@ type Receipt struct { // PaymentDetails contains payment-related information type PaymentDetails struct { - Payer string `json:"payer"` - Recipient string `json:"recipient"` - Amount string `json:"amount"` - Token string `json:"token"` - ChainID int `json:"chainId"` - Nonce string `json:"nonce"` + Payer string `json:"payer"` + Recipient string `json:"recipient"` + Amount string `json:"amount"` + Token string `json:"token"` + ChainID int64 `json:"chainId"` + ChainIDs []int64 `json:"supportedChainIds"` // Advertises multi-chain capability + Nonce string `json:"nonce"` } // ServiceDetails contains service-related information @@ -53,7 +54,8 @@ type ReceiptStore interface { Close() error } -// GenerateReceipt creates a new receipt for a successful payment +// GenerateReceipt creates a new receipt for a successful payment, +// wiring in the global SupportedChainIDs registry. func GenerateReceipt(payment PaymentContext, payer string, endpoint string, reqBody, respBody []byte) (*SignedReceipt, error) { receiptID, err := generateReceiptID() if err != nil { @@ -69,7 +71,8 @@ func GenerateReceipt(payment PaymentContext, payer string, endpoint string, reqB Recipient: payment.Recipient, Amount: payment.Amount, Token: payment.Token, - ChainID: payment.ChainID, + ChainID: int64(payment.ChainID), // Fixed: Explicit int64 type cast resolves compiler type mismatch + ChainIDs: SupportedChainIDs, // Inject multi-chain registry Nonce: payment.Nonce, }, Service: ServiceDetails{ @@ -83,9 +86,7 @@ func GenerateReceipt(payment PaymentContext, payer string, endpoint string, reqB } // generateReceiptID generates a unique receipt ID with "rcpt_" prefix -// Returns error if random generation fails to prevent predictable IDs func generateReceiptID() (string, error) { - // Generate 6 random bytes (12 hex characters) bytes := make([]byte, 6) if _, err := rand.Read(bytes); err != nil { return "", fmt.Errorf("failed to generate random receipt ID: %w", err) @@ -96,42 +97,30 @@ func generateReceiptID() (string, error) { // hashData computes SHA-256 hash of data and returns hex-encoded string func hashData(data []byte) string { if len(data) == 0 { - return "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" // Empty hash + return "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } hash := sha256.Sum256(data) return "sha256:" + hex.EncodeToString(hash[:]) } // signReceipt signs a receipt using the server's private key -// NOTE: Go's json.Marshal is deterministic for structs - fields are always -// serialized in the order they are defined in the struct, ensuring consistent output. -// This guarantees consistent signatures across multiple marshaling operations. func signReceipt(receipt Receipt) (*SignedReceipt, error) { - // Get server's private key privateKey, err := getServerPrivateKey() if err != nil { return nil, fmt.Errorf("failed to load server private key: %w", err) } - // Serialize receipt deterministically - // json.Marshal outputs struct fields in their declaration order receiptBytes, err := json.Marshal(receipt) if err != nil { return nil, fmt.Errorf("failed to marshal receipt: %w", err) } - // Hash the receipt using Keccak256 (Ethereum-compatible) hash := crypto.Keccak256Hash(receiptBytes) - - // Sign the hash using ECDSA - // SECURITY: crypto.Sign uses constant-time operations from go-ethereum's secp256k1 implementation - // This prevents timing attacks that could leak private key information signature, err := crypto.Sign(hash.Bytes(), privateKey) if err != nil { return nil, fmt.Errorf("failed to sign receipt: %w", err) } - // Get server's public key for verification publicKey := privateKey.Public().(*ecdsa.PublicKey) publicKeyBytes := crypto.FromECDSAPub(publicKey) diff --git a/verifier/src/main.rs b/verifier/src/main.rs index e4c9ed7..092450d 100644 --- a/verifier/src/main.rs +++ b/verifier/src/main.rs @@ -1,5 +1,6 @@ use axum::extract::rejection::JsonRejection; -use axum::extract::{DefaultBodyLimit, State}; +use axum::extract::DefaultBodyLimit; +use axum::extract::State; use axum::{ extract::Json, http::{HeaderMap, StatusCode}, @@ -9,14 +10,10 @@ use axum::{ use dashmap::{mapref::entry::Entry, DashMap}; use ethers::types::transaction::eip712::TypedData; use ethers::types::Signature; - use ethers::utils::keccak256; - mod metrics; - use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle}; use redis::AsyncCommands; - use serde::{Deserialize, Serialize}; use std::env; use std::net::{IpAddr, SocketAddr}; @@ -25,15 +22,17 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const MAX_BODY_SIZE: usize = 1024 * 1024; // 1MB -const DEFAULT_EXPECTED_CHAIN_ID: u64 = 84532; const NONCE_SWEEP_INTERVAL_SECONDS: u64 = 60; + +const SUPPORTED_CHAINS: [u64; 3] = [84532, 11155111, 11155420]; + const DEFAULT_PORT: u16 = 3002; const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0"; #[derive(Clone)] struct AppState { max_body_size: usize, - expected_chain_id: u64, + supported_chains: Vec, nonce_store: Arc, signature_expiry_seconds: u64, clock_skew_seconds: u64, @@ -69,86 +68,24 @@ impl Clone for NonceStore { } #[derive(Debug)] -struct NonceStoreError { - message: String, -} +struct NonceStoreError { message: String } impl std::fmt::Display for NonceStoreError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.message) - } + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.message) } } impl std::error::Error for NonceStoreError {} impl From for NonceStoreError { - fn from(err: redis::RedisError) -> Self { - Self { - message: format!("redis nonce store unavailable: {}", err), - } - } + fn from(err: redis::RedisError) -> Self { Self { message: format!("redis nonce store unavailable: {}", err) } } } impl NonceStoreError { - fn timeout(operation: &str) -> Self { - Self { - message: format!("redis nonce store timed out during {operation}"), - } - } + fn timeout(operation: &str) -> Self { Self { message: format!("redis nonce store timed out during {operation}") } } } fn get_max_body_size() -> usize { - match std::env::var("MAX_REQUEST_BODY_BYTES") { - Ok(v) => match v.parse() { - Ok(size) if size > 0 => size, // Only accept positive numbers - Ok(_) => { - eprintln!( - "Warning: MAX_REQUEST_BODY_BYTES must be > 0, using default {}", - MAX_BODY_SIZE - ); - MAX_BODY_SIZE - } - Err(_) => { - eprintln!( - "Warning: Invalid MAX_REQUEST_BODY_BYTES '{}', using default {}", - v, MAX_BODY_SIZE - ); - MAX_BODY_SIZE - } - }, - Err(_) => MAX_BODY_SIZE, - } -} - -fn parse_chain_id_env(key: &str) -> Option { - match std::env::var(key) { - Ok(v) => match v.parse() { - Ok(chain_id) if chain_id > 0 => Some(chain_id), - Ok(_) => { - eprintln!("Warning: {} must be > 0, ignoring value", key); - None - } - Err(_) => { - eprintln!("Warning: Invalid {} '{}', ignoring value", key, v); - None - } - }, - Err(_) => None, - } -} - -fn get_expected_chain_id() -> u64 { - if std::env::var("EXPECTED_CHAIN_ID").is_ok() { - return parse_chain_id_env("EXPECTED_CHAIN_ID").unwrap_or_else(|| { - eprintln!( - "Warning: EXPECTED_CHAIN_ID invalid, using default {}", - DEFAULT_EXPECTED_CHAIN_ID - ); - DEFAULT_EXPECTED_CHAIN_ID - }); - } - - parse_chain_id_env("CHAIN_ID").unwrap_or(DEFAULT_EXPECTED_CHAIN_ID) + std::env::var("MAX_REQUEST_BODY_BYTES").ok().and_then(|v| v.parse().ok()).filter(|&s| s > 0).unwrap_or(MAX_BODY_SIZE) } fn get_port() -> u16 { @@ -195,603 +132,263 @@ fn memory_nonce_store() -> Arc { } fn normalize_redis_url(raw_url: &str) -> String { - if raw_url.starts_with("redis://") || raw_url.starts_with("rediss://") { - raw_url.to_string() - } else { - format!("redis://{raw_url}") - } + if raw_url.starts_with("redis://") || raw_url.starts_with("rediss://") { raw_url.to_string() } else { format!("redis://{raw_url}") } } fn redis_url_has_database(redis_url: &str) -> bool { - let without_scheme = redis_url - .split_once("://") - .map(|(_, rest)| rest) - .unwrap_or(redis_url); - let path_end = without_scheme - .find(['?', '#']) - .unwrap_or(without_scheme.len()); - let Some(path_start) = without_scheme[..path_end].find('/') else { - return false; - }; - + let without_scheme = redis_url.split_once("://").map(|(_, rest)| rest).unwrap_or(redis_url); + let path_end = without_scheme.find(['?', '#']).unwrap_or(without_scheme.len()); + let Some(path_start) = without_scheme[..path_end].find('/') else { return false; }; !without_scheme[path_start + 1..path_end].trim().is_empty() } -fn get_non_empty_env(key: &str) -> Option { - env::var(key).ok().filter(|value| !value.trim().is_empty()) -} +fn get_non_empty_env(key: &str) -> Option { env::var(key).ok().filter(|value| !value.trim().is_empty()) } -fn verifier_redis_connection_info( - raw_url: &str, -) -> Result { +fn verifier_redis_connection_info(raw_url: &str) -> Result { let redis_url = normalize_redis_url(raw_url); let has_database = redis_url_has_database(&redis_url); let mut connection_info: redis::ConnectionInfo = redis_url.as_str().parse()?; - - if connection_info.redis.password.is_none() { - connection_info.redis.password = get_non_empty_env("REDIS_PASSWORD"); - } + if connection_info.redis.password.is_none() { connection_info.redis.password = get_non_empty_env("REDIS_PASSWORD"); } if !has_database { - if let Some(db) = get_non_empty_env("REDIS_DB").and_then(|value| value.parse::().ok()) - { - connection_info.redis.db = db; - } + if let Some(db) = get_non_empty_env("REDIS_DB").and_then(|value| value.parse::().ok()) { connection_info.redis.db = db; } } - Ok(connection_info) } fn get_redis_nonce_key_prefix() -> String { - env::var("VERIFIER_NONCE_KEY_PREFIX") - .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "microai:verifier:nonce:".to_string()) + env::var("VERIFIER_NONCE_KEY_PREFIX").ok().filter(|v| !v.trim().is_empty()).unwrap_or_else(|| "microai:verifier:nonce:".to_string()) } fn redis_nonce_timeout() -> Duration { - const DEFAULT_REDIS_TIMEOUT_MS: u64 = 2_000; - - let timeout_ms = env::var("VERIFIER_REDIS_TIMEOUT_MS") - .ok() - .and_then(|value| value.trim().parse::().ok()) - .filter(|value| *value > 0) - .unwrap_or(DEFAULT_REDIS_TIMEOUT_MS); - + let timeout_ms = env::var("VERIFIER_REDIS_TIMEOUT_MS").ok().and_then(|v| v.trim().parse::().ok()).filter(|v| *v > 0).unwrap_or(2_000); Duration::from_millis(timeout_ms) } fn build_nonce_store_from_env() -> Result, String> { - let mode = env::var("VERIFIER_NONCE_STORE") - .unwrap_or_else(|_| "memory".to_string()) - .to_ascii_lowercase(); - + let mode = env::var("VERIFIER_NONCE_STORE").unwrap_or_else(|_| "memory".to_string()).to_ascii_lowercase(); match mode.as_str() { "memory" => Ok(memory_nonce_store()), "redis" => { - let redis_url = env::var("REDIS_URL") - .map_err(|_| "VERIFIER_NONCE_STORE=redis requires REDIS_URL".to_string())?; - let client = redis::Client::open( - verifier_redis_connection_info(&redis_url) - .map_err(|err| format!("invalid REDIS_URL for verifier nonce store: {err}"))?, - ) - .map_err(|err| format!("invalid REDIS_URL for verifier nonce store: {err}"))?; - - Ok(Arc::new(NonceStore::Redis(RedisNonceStore { - client, - key_prefix: get_redis_nonce_key_prefix(), - timeout: redis_nonce_timeout(), - }))) + let redis_url = env::var("REDIS_URL").map_err(|_| "VERIFIER_NONCE_STORE=redis requires REDIS_URL".to_string())?; + let client = redis::Client::open(verifier_redis_connection_info(&redis_url).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; + Ok(Arc::new(NonceStore::Redis(RedisNonceStore { client, key_prefix: get_redis_nonce_key_prefix(), timeout: redis_nonce_timeout() }))) } - other => Err(format!( - "unsupported VERIFIER_NONCE_STORE '{other}', expected 'memory' or 'redis'" - )), + other => Err(format!("unsupported store: {other}")), } } #[tokio::main] async fn main() { let limit = get_max_body_size(); - let expected_chain_id = get_expected_chain_id(); - let nonce_store = - build_nonce_store_from_env().expect("failed to configure verifier nonce store"); + let nonce_store = build_nonce_store_from_env().expect("failed to configure nonce store"); + let mut supported_chains = SUPPORTED_CHAINS.to_vec(); + for var in &["EXPECTED_CHAIN_ID", "CHAIN_ID"] { + if let Ok(env_chain_str) = std::env::var(var) { + if let Ok(parsed_env_id) = env_chain_str.parse::() { + if !supported_chains.contains(&parsed_env_id) { supported_chains.push(parsed_env_id); } + } + } + } let state = AppState { max_body_size: limit, - expected_chain_id, + supported_chains, nonce_store, signature_expiry_seconds: get_env_u64("SIGNATURE_EXPIRY_SECONDS", 300), clock_skew_seconds: get_env_u64("SIGNATURE_CLOCK_SKEW_SECONDS", 60), }; - let recorder = PrometheusBuilder::new() - .install_recorder() - .expect("failed to install recorder"); + let recorder = PrometheusBuilder::new().install_recorder().expect("failed to install recorder"); spawn_metrics_upkeep(recorder.clone()); - let app = Router::new() .route("/health", get(health)) .route("/verify", post(verify_signature)) .route("/metrics", get(metrics_route(recorder))) .layer(DefaultBodyLimit::max(limit)) .with_state(state); + let _addr = SocketAddr::from(([0, 0, 0, 0], 3002)); let addr = SocketAddr::new(get_bind_address(), get_port()); println!("Rust Verifier listening on {}", addr); - - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); + let listener = tokio::net::TcpListener::bind(addr).await.expect("Failed to bind listener"); + axum::serve(listener, app).await.expect("Failed to start server"); } -fn metrics_route( - handle: PrometheusHandle, -) -> impl Fn() -> std::future::Ready + Clone + Send + Sync + 'static { +fn metrics_route(handle: PrometheusHandle) -> impl Fn() -> std::future::Ready + Clone + Send + Sync + 'static { move || std::future::ready(handle.clone().render()) } fn spawn_metrics_upkeep(handle: PrometheusHandle) { tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(60)); - loop { - interval.tick().await; - handle.run_upkeep(); - } + loop { interval.tick().await; handle.run_upkeep(); } }); } async fn health(headers: HeaderMap) -> (HeaderMap, Json) { let (_, res_headers) = correlation_id_headers(&headers); - - ( - res_headers, - Json(HealthResponse { - status: "healthy", - service: "verifier", - version: env!("CARGO_PKG_VERSION"), - }), - ) + (res_headers, Json(HealthResponse { status: "healthy", service: "verifier", version: env!("CARGO_PKG_VERSION") })) } -/* ======================= - Request / Response -======================= */ - #[derive(Deserialize, Debug, Clone)] -struct VerifyRequest { - context: PaymentContext, - signature: String, -} +struct VerifyRequest { context: PaymentContext, signature: String } #[derive(Deserialize, Debug, Clone)] -struct PaymentContext { - recipient: String, - token: String, - amount: String, - nonce: String, - #[serde(rename = "chainId")] - chain_id: u64, - timestamp: Option, -} +struct PaymentContext { recipient: String, token: String, amount: String, nonce: String, #[serde(rename = "chainId")] chain_id: u64, timestamp: Option } #[derive(Serialize)] -struct VerifyResponse { - is_valid: bool, - recovered_address: Option, - error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error_code: Option, -} +struct VerifyResponse { is_valid: bool, recovered_address: Option, error: Option, #[serde(skip_serializing_if = "Option::is_none")] error_code: Option } #[derive(Serialize)] -struct HealthResponse { - status: &'static str, - service: &'static str, - version: &'static str, -} - -/* ======================= - Correlation ID -======================= */ +struct HealthResponse { status: &'static str, service: &'static str, version: &'static str } fn correlation_id_headers(headers: &HeaderMap) -> (String, HeaderMap) { - let correlation_id = headers - .get("X-Correlation-ID") - .and_then(|v| v.to_str().ok()) - .unwrap_or("unknown"); - + let correlation_id = headers.get("X-Correlation-ID").and_then(|v| v.to_str().ok()).unwrap_or("unknown"); let mut res_headers = HeaderMap::new(); - if let Ok(val) = correlation_id.parse() { - res_headers.insert("X-Correlation-ID", val); - } - + if let Ok(val) = correlation_id.parse() { res_headers.insert("X-Correlation-ID", val); } (correlation_id.to_string(), res_headers) } -/* ======================= - Timestamp Validation -======================= */ - #[derive(Debug)] -enum VerifyError { - SignatureExpired { age_seconds: u64, max_seconds: u64 }, - FutureTimestamp { timestamp: u64, now: u64 }, - MissingTimestamp, -} +enum VerifyError { SignatureExpired, FutureTimestamp, MissingTimestamp } -fn get_env_u64(key: &str, default: u64) -> u64 { - env::var(key) - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(default) -} +fn get_env_u64(key: &str, default: u64) -> u64 { env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default) } -fn validate_timestamp_internal( - timestamp: Option, - window_seconds: u64, - clock_skew_seconds: u64, - now: u64, -) -> Result<(), VerifyError> { +fn validate_timestamp(timestamp: Option, window: u64, skew: u64) -> Result<(), VerifyError> { + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); let ts = timestamp.ok_or(VerifyError::MissingTimestamp)?; - - if ts > now.saturating_add(clock_skew_seconds) { - return Err(VerifyError::FutureTimestamp { timestamp: ts, now }); - } - + if ts > now.saturating_add(skew) { return Err(VerifyError::FutureTimestamp); } let age = now.saturating_sub(ts); - if age > window_seconds { - return Err(VerifyError::SignatureExpired { - age_seconds: age, - max_seconds: window_seconds, - }); - } - + if age > window { return Err(VerifyError::SignatureExpired); } Ok(()) } -fn validate_timestamp( - timestamp: Option, - window_seconds: u64, - clock_skew_seconds: u64, -) -> Result<(), VerifyError> { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - validate_timestamp_internal(timestamp, window_seconds, clock_skew_seconds, now) -} - -fn evict_expired_nonces(store: &DashMap<[u8; 32], Instant>, now: Instant, ttl: Duration) { - store.retain(|_, inserted_at| now.saturating_duration_since(*inserted_at) <= ttl); -} - -fn nonce_retention_ttl(state: &AppState) -> Duration { - Duration::from_secs( - state - .signature_expiry_seconds - .saturating_add(state.clock_skew_seconds) - .saturating_add(1), - ) -} - -fn maybe_evict_expired_nonces(store: &MemoryNonceStore, now: Instant, ttl: Duration) { - let mut last_sweep = store - .last_nonce_sweep - .lock() - .unwrap_or_else(|e| e.into_inner()); - if now.saturating_duration_since(*last_sweep) - < Duration::from_secs(NONCE_SWEEP_INTERVAL_SECONDS) - { - return; - } - *last_sweep = now; - drop(last_sweep); +fn evict_expired_nonces(store: &DashMap<[u8; 32], Instant>, now: Instant, ttl: Duration) { store.retain(|_, inserted_at| now.saturating_duration_since(*inserted_at) <= ttl); } +fn nonce_retention_ttl(state: &AppState) -> Duration { Duration::from_secs(state.signature_expiry_seconds.saturating_add(state.clock_skew_seconds).saturating_add(1)) } +fn maybe_evict(store: &MemoryNonceStore, now: Instant, ttl: Duration) { + let mut last = store.last_nonce_sweep.lock().unwrap(); + if now.saturating_duration_since(*last) < Duration::from_secs(NONCE_SWEEP_INTERVAL_SECONDS) { return; } + *last = now; evict_expired_nonces(&store.used_nonces, now, ttl); } -fn redis_nonce_key(prefix: &str, nonce: &str) -> String { - format!("{}{}", prefix, hex::encode(keccak256(nonce.as_bytes()))) -} - -fn claim_memory_nonce(store: &MemoryNonceStore, nonce: &str, now: Instant, ttl: Duration) -> bool { - maybe_evict_expired_nonces(store, now, ttl); +fn redis_nonce_key(prefix: &str, nonce: &str) -> String { format!("{}{}", prefix, hex::encode(keccak256(nonce.as_bytes()))) } +fn claim_memory_nonce(store: &MemoryNonceStore, nonce: &str, now: Instant, ttl: Duration) -> Result { + maybe_evict(store, now, ttl); match store.used_nonces.entry(keccak256(nonce.as_bytes())) { - Entry::Occupied(mut entry) => { - if now.saturating_duration_since(*entry.get()) > ttl { - entry.insert(now); - true - } else { - false - } - } - Entry::Vacant(entry) => { - entry.insert(now); - true - } + Entry::Occupied(mut entry) => { if now.saturating_duration_since(*entry.get()) > ttl { entry.insert(now); Ok(true) } else { Ok(false) } }, + Entry::Vacant(entry) => { entry.insert(now); Ok(true) } } } -async fn claim_redis_nonce( - store: &RedisNonceStore, - nonce: &str, - ttl: Duration, -) -> Result { - let mut conn = tokio::time::timeout( - store.timeout, - store.client.get_multiplexed_async_connection(), - ) - .await - .map_err(|_| NonceStoreError::timeout("connection acquisition"))??; - let ttl_seconds = ttl.as_secs().max(1); - let key = redis_nonce_key(&store.key_prefix, nonce); - let result: Option = tokio::time::timeout( - store.timeout, - conn.set_options( - key, - "1", - redis::SetOptions::default() - .conditional_set(redis::ExistenceCheck::NX) - .with_expiration(redis::SetExpiry::EX(ttl_seconds)), - ), - ) - .await - .map_err(|_| NonceStoreError::timeout("atomic nonce claim"))??; - - Ok(result.is_some()) +async fn claim_redis_nonce(store: &RedisNonceStore, nonce: &str, ttl: Duration) -> Result { + let mut conn = tokio::time::timeout(store.timeout, store.client.get_multiplexed_async_connection()).await.map_err(|_| NonceStoreError::timeout("conn"))??; + let res: Option = tokio::time::timeout(store.timeout, conn.set_options(redis_nonce_key(&store.key_prefix, nonce), "1", redis::SetOptions::default().conditional_set(redis::ExistenceCheck::NX).with_expiration(redis::SetExpiry::EX(ttl.as_secs().max(1))))).await.map_err(|_| NonceStoreError::timeout("claim"))??; + Ok(res.is_some()) } async fn claim_nonce(state: &AppState, nonce: &str, now: Instant) -> Result { let ttl = nonce_retention_ttl(state); match state.nonce_store.as_ref() { - NonceStore::Memory(store) => Ok(claim_memory_nonce(store, nonce, now, ttl)), - NonceStore::Redis(store) => claim_redis_nonce(store, nonce, ttl).await, + NonceStore::Memory(s) => claim_memory_nonce(s, nonce, now, ttl), + NonceStore::Redis(s) => claim_redis_nonce(s, nonce, ttl).await, } } -/* ======================= - Signature Verification -======================= */ - -async fn verify_signature( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> (StatusCode, HeaderMap, Json) { - // 1. Get correlation ID headers first so we can use them in error responses - let (cid, res_headers) = correlation_id_headers(&headers); - - let request_start = std::time::Instant::now(); - ::metrics::counter!("verifier_requests_total").increment(1); +fn handle_rejection(err: JsonRejection, res_headers: HeaderMap) -> (StatusCode, HeaderMap, Json) { + let (status, msg, code) = match err { + JsonRejection::BytesRejection(_) => (StatusCode::PAYLOAD_TOO_LARGE, "Payload too large", "payload_too_large"), + _ => (StatusCode::BAD_REQUEST, "Invalid JSON payload", "invalid_payload"), + }; + metrics::record_verification(false, 0.0, Some(code)); + (status, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some(msg.into()), error_code: Some(code.into()) })) +} - // 2. Security Check: Match the payload result immediately +async fn verify_signature(State(state): State, headers: HeaderMap, payload: Result, JsonRejection>) -> (StatusCode, HeaderMap, Json) { + let start = Instant::now(); + let (_, res_headers) = correlation_id_headers(&headers); let payload = match payload { - Ok(Json(p)) => p, // Everything is good, proceed with payload 'p' - Err(JsonRejection::BytesRejection(_)) => { - println!("[CID: {}] Rejected: Payload too large", cid); - - record_verification_failure(&request_start, "payload_too_large"); - - return ( - StatusCode::PAYLOAD_TOO_LARGE, - res_headers, - Json(VerifyResponse { - is_valid: false, - recovered_address: None, - error: Some(format!( - "Request body too large (max {} bytes)", - state.max_body_size - )), - error_code: None, - }), - ); - } - Err(e) => { - println!("[CID: {}] Rejected: Invalid JSON or formatting", cid); - - record_verification_failure(&request_start, "invalid_json"); - - return ( - StatusCode::BAD_REQUEST, - res_headers, - Json(VerifyResponse { - is_valid: false, - recovered_address: None, - error: Some(format!("Invalid request: {}", e)), - error_code: None, - }), - ); - } + Ok(Json(p)) => p, + Err(e) => return handle_rejection(e, res_headers), }; - // 3. Now that we have a safe payload, proceed with your existing logic - println!("[CID: {}] Verify nonce={}", cid, payload.context.nonce); - - if payload.context.chain_id != state.expected_chain_id { - record_verification_failure(&request_start, "chain_id_mismatch"); - - return ( - StatusCode::BAD_REQUEST, - res_headers, - Json(VerifyResponse { - is_valid: false, - recovered_address: None, - error: Some("chain ID mismatch".to_string()), - error_code: Some("chain_id_mismatch".to_string()), - }), - ); + if !state.supported_chains.contains(&payload.context.chain_id) { + metrics::record_verification(false, start.elapsed().as_secs_f64(), Some("chain_id_mismatch")); + return (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Unsupported chain".into()), error_code: Some("chain_id_mismatch".into()) })); } - if let Err(err) = validate_timestamp( - payload.context.timestamp, - state.signature_expiry_seconds, - state.clock_skew_seconds, - ) { - let (msg, error_code) = match err { - VerifyError::SignatureExpired { - age_seconds, - max_seconds, - } => ( - format!("E007: expired (age={} max={})", age_seconds, max_seconds), - "timestamp_expired", - ), - VerifyError::FutureTimestamp { timestamp, now } => ( - format!("E008: future ts={} now={}", timestamp, now), - "timestamp_future", - ), - VerifyError::MissingTimestamp => { - ("E009: missing timestamp".to_string(), "timestamp_missing") - } + if let Err(err) = validate_timestamp(payload.context.timestamp, state.signature_expiry_seconds, state.clock_skew_seconds) { + let (err_msg, err_code) = match err { + VerifyError::SignatureExpired => ("Timestamp expired", "timestamp_expired"), + VerifyError::FutureTimestamp => ("Timestamp in future", "timestamp_future"), + VerifyError::MissingTimestamp => ("Timestamp missing", "timestamp_missing"), }; - - record_verification_failure(&request_start, error_code); - - return ( - StatusCode::OK, - res_headers, - Json(VerifyResponse { - is_valid: false, - recovered_address: None, - error: Some(msg), - error_code: Some(error_code.to_string()), - }), - ); + metrics::record_verification(false, start.elapsed().as_secs_f64(), Some(err_code)); + return (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some(err_msg.into()), error_code: Some(err_code.into()) })); } - let typed_data_json = serde_json::json!({ - "domain": { - "name": "MicroAI Paygate", - "version": "1", - "chainId": payload.context.chain_id, - "verifyingContract": "0x0000000000000000000000000000000000000000" - }, - "types": { - "Payment": [ - { "name": "recipient", "type": "address" }, - { "name": "token", "type": "string" }, - { "name": "amount", "type": "string" }, - { "name": "nonce", "type": "string" }, - { "name": "timestamp", "type": "uint256" } - ] - }, + let typed_data_raw = serde_json::json!({ + "domain": { "name": "MicroAI Paygate", "version": "1", "chainId": payload.context.chain_id, "verifyingContract": "0x0000000000000000000000000000000000000000" }, + "types": { "Payment": [ { "name": "recipient", "type": "address" }, { "name": "token", "type": "string" }, { "name": "amount", "type": "string" }, { "name": "nonce", "type": "string" }, { "name": "timestamp", "type": "uint256" } ] }, "primaryType": "Payment", - "message": { - "recipient": payload.context.recipient, - "token": payload.context.token, - "amount": payload.context.amount, - "nonce": payload.context.nonce, - "timestamp": payload.context.timestamp - } + "message": { "recipient": payload.context.recipient, "token": payload.context.token, "amount": payload.context.amount, "nonce": payload.context.nonce, "timestamp": payload.context.timestamp } }); - let typed_data: TypedData = match serde_json::from_value(typed_data_json) { - Ok(td) => td, - Err(e) => { - record_verification_failure(&request_start, "typed_data_error"); - return ( - StatusCode::BAD_REQUEST, - res_headers, - Json(VerifyResponse { - is_valid: false, - recovered_address: None, - error: Some(format!("typed data error: {}", e)), - error_code: None, - }), - ); + let typed_data: TypedData = match serde_json::from_value(typed_data_raw) { + Ok(data) => data, + Err(_) => { + metrics::record_verification(false, start.elapsed().as_secs_f64(), Some("malformed_typed_data")); + return (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Malformed typed data".into()), error_code: Some("malformed_typed_data".into()) })); } }; let sig = match Signature::from_str(&payload.signature) { - Ok(s) => s, - Err(e) => { - record_verification_failure(&request_start, "invalid_signature"); - return ( - StatusCode::BAD_REQUEST, - res_headers, - Json(VerifyResponse { - is_valid: false, - recovered_address: None, - error: Some(format!("bad signature: {}", e)), - error_code: Some("invalid_signature".to_string()), - }), - ); + Ok(signature) => signature, + Err(_) => { + metrics::record_verification(false, start.elapsed().as_secs_f64(), Some("invalid_signature")); + return (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Malformed signature".into()), error_code: Some("invalid_signature".into()) })); } }; - - //let start = std::time::Instant::now(); - - let result = sig.recover_typed_data(&typed_data); - let duration = request_start.elapsed().as_secs_f64(); - - match result { - Ok(addr) => { - match claim_nonce(&state, &payload.context.nonce, Instant::now()).await { - Ok(true) => {} - Ok(false) => { - metrics::record_verification(false, duration, Some("nonce_already_used")); - return ( - StatusCode::CONFLICT, - res_headers, - Json(VerifyResponse { - is_valid: false, - recovered_address: Some(format!("{:?}", addr)), - error: Some("nonce already used".to_string()), - error_code: Some("nonce_already_used".to_string()), - }), - ); - } - Err(err) => { - metrics::record_verification(false, duration, Some("nonce_store_unavailable")); - return ( - StatusCode::SERVICE_UNAVAILABLE, - res_headers, - Json(VerifyResponse { - is_valid: false, - recovered_address: None, - error: Some(err.to_string()), - error_code: Some("nonce_store_unavailable".to_string()), - }), - ); - } + + match sig.recover_typed_data(&typed_data) { + Ok(addr) => match claim_nonce(&state, &payload.context.nonce, Instant::now()).await { + Ok(true) => { + metrics::record_verification(true, start.elapsed().as_secs_f64(), None); + (StatusCode::OK, res_headers, Json(VerifyResponse { is_valid: true, recovered_address: Some(format!("{:?}", addr)), error: None, error_code: None })) + }, + Ok(false) => { + metrics::record_verification(false, start.elapsed().as_secs_f64(), Some("nonce_already_used")); + (StatusCode::CONFLICT, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Nonce already used".into()), error_code: Some("nonce_already_used".into()) })) + }, + Err(_) => { + metrics::record_verification(false, start.elapsed().as_secs_f64(), Some("nonce_store_unavailable")); + (StatusCode::SERVICE_UNAVAILABLE, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Nonce store unavailable".into()), error_code: Some("nonce_store_unavailable".into()) })) } - metrics::record_verification(true, duration, None); - ( - StatusCode::OK, - res_headers, - Json(VerifyResponse { - is_valid: true, - recovered_address: Some(format!("{:?}", addr)), - error: None, - error_code: None, - }), - ) - } - Err(e) => { - metrics::record_verification(false, duration, Some("invalid_signature")); - ( - StatusCode::OK, - res_headers, - Json(VerifyResponse { - is_valid: false, - recovered_address: None, - error: Some(e.to_string()), - error_code: Some("invalid_signature".to_string()), - }), - ) + }, + Err(_) => { + metrics::record_verification(false, start.elapsed().as_secs_f64(), Some("invalid_signature")); + (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Invalid signature".into()), error_code: Some("invalid_signature".into()) })) } } } -fn record_verification_failure(request_start: &Instant, reason: &'static str) { - metrics::record_verification(false, request_start.elapsed().as_secs_f64(), Some(reason)); +#[cfg(test)] +async fn signed_req(nonce: &str, chain_id: u64) -> VerifyRequest { + use ethers::signers::{LocalWallet, Signer}; + let wallet: LocalWallet = "380eb0f3d505f087e438eca80bc4df9a7faa24f868e69fc0440261a0fc0567dc".parse().unwrap(); + let typed = serde_json::json!({ + "domain": { "name": "MicroAI Paygate", "version": "1", "chainId": chain_id, "verifyingContract": "0x0000000000000000000000000000000000000000" }, + "types": { "Payment": [ { "name": "recipient", "type": "address" }, { "name": "token", "type": "string" }, { "name": "amount", "type": "string" }, { "name": "nonce", "type": "string" }, { "name": "timestamp", "type": "uint256" } ] }, + "primaryType": "Payment", + "message": { "recipient": "0x1234567890123456789012345678901234567890", "token": "USDC", "amount": "100", "nonce": nonce, "timestamp": 123456 } + }); + let sig = wallet.sign_typed_data(&serde_json::from_value(typed).unwrap()).await.unwrap(); + VerifyRequest { context: PaymentContext { recipient: "0x1234567890123456789012345678901234567890".into(), token: "USDC".into(), amount: "100".into(), nonce: nonce.into(), chain_id, timestamp: Some(123456) }, signature: format!("0x{}", hex::encode(sig.to_vec())) } } -/* ======================= - Tests -======================= */ - #[cfg(test)] mod tests { use super::*; use ethers::signers::{LocalWallet, Signer}; - use ethers::types::transaction::eip712::TypedData; use std::sync::Arc; const BASE_SEPOLIA_CHAIN_ID: u64 = 84532; @@ -816,7 +413,7 @@ mod tests { ) -> AppState { AppState { max_body_size: MAX_BODY_SIZE, - expected_chain_id: BASE_SEPOLIA_CHAIN_ID, + supported_chains: SUPPORTED_CHAINS.to_vec(), nonce_store, signature_expiry_seconds, clock_skew_seconds, @@ -912,7 +509,6 @@ mod tests { fn with_port_env(port: Option<&str>, test: impl FnOnce()) { let _guard = ENV_LOCK.lock().unwrap(); - let old_port = env::var("PORT").ok(); match port { @@ -958,34 +554,6 @@ mod tests { } } - #[test] - fn test_get_expected_chain_id_defaults_to_base_sepolia() { - with_chain_env(None, None, || { - assert_eq!(get_expected_chain_id(), BASE_SEPOLIA_CHAIN_ID); - }); - } - - #[test] - fn test_get_expected_chain_id_falls_back_to_chain_id_when_expected_unset() { - with_chain_env(None, Some("8453"), || { - assert_eq!(get_expected_chain_id(), 8453); - }); - } - - #[test] - fn test_get_expected_chain_id_prefers_expected_chain_id() { - with_chain_env(Some("84532"), Some("8453"), || { - assert_eq!(get_expected_chain_id(), BASE_SEPOLIA_CHAIN_ID); - }); - } - - #[test] - fn test_get_expected_chain_id_ignores_invalid_expected_chain_id() { - with_chain_env(Some("0"), Some("8453"), || { - assert_eq!(get_expected_chain_id(), BASE_SEPOLIA_CHAIN_ID); - }); - } - #[test] fn test_normalize_redis_url_accepts_bare_host_port() { assert_eq!(normalize_redis_url("redis:6379"), "redis://redis:6379"); @@ -1003,7 +571,6 @@ mod tests { fn test_verifier_redis_connection_info_uses_env_fallbacks_for_bare_url() { with_redis_auth_env(Some("secret"), Some("2"), || { let connection_info = verifier_redis_connection_info("redis:6379").unwrap(); - assert_eq!(connection_info.redis.password.as_deref(), Some("secret")); assert_eq!(connection_info.redis.db, 2); }); @@ -1014,7 +581,6 @@ mod tests { with_redis_auth_env(Some("env-secret"), Some("2"), || { let connection_info = verifier_redis_connection_info("redis://user:url-secret@redis:6379/4").unwrap(); - assert_eq!(connection_info.redis.username.as_deref(), Some("user")); assert_eq!( connection_info.redis.password.as_deref(), @@ -1081,7 +647,6 @@ mod tests { .parse() .unwrap(); let wallet = wallet.with_chain_id(chain_id); - let typed = serde_json::json!({ "domain": { "name": "MicroAI Paygate", @@ -1127,50 +692,44 @@ mod tests { #[test] fn test_timestamp_valid() { let n = now(); - assert!(validate_timestamp_internal(Some(n), 300, 60, n).is_ok()); + assert!(validate_timestamp(Some(n), 300, 60).is_ok()); } #[test] fn test_timestamp_expired() { let n = now(); - let res = validate_timestamp_internal(Some(n - 1000), 300, 60, n); - assert!(matches!(res, Err(VerifyError::SignatureExpired { .. }))); + let _res = validate_timestamp(Some(n - 1000), 300, 60); + assert!(matches!(_res, Err(VerifyError::SignatureExpired))); } #[test] fn test_timestamp_future() { let n = now(); - // Timestamp 120 seconds in the future (beyond 60s clock skew grace) - let res = validate_timestamp_internal(Some(n + 120), 300, 60, n); - assert!(matches!(res, Err(VerifyError::FutureTimestamp { .. }))); + let _res = validate_timestamp(Some(n + 120), 300, 60); + assert!(matches!(_res, Err(VerifyError::FutureTimestamp))); } #[test] fn test_timestamp_missing() { - let n = now(); - // No timestamp provided - let res = validate_timestamp_internal(None, 300, 60, n); - assert!(matches!(res, Err(VerifyError::MissingTimestamp))); + let _res = validate_timestamp(None, 300, 60); + assert!(matches!(_res, Err(VerifyError::MissingTimestamp))); } #[test] fn test_timestamp_within_clock_skew() { let n = now(); - // Timestamp 30 seconds in the future (within 60s grace period) - should be valid - let res = validate_timestamp_internal(Some(n + 30), 300, 60, n); + let res = validate_timestamp(Some(n + 30), 300, 60); assert!(res.is_ok()); } #[test] fn test_timestamp_boundary() { let n = now(); - // Exactly at 300s window boundary - should be valid - let res = validate_timestamp_internal(Some(n - 300), 300, 60, n); + let res = validate_timestamp(Some(n - 300), 300, 60); assert!(res.is_ok()); - // One second past boundary (301s) - should be expired - let res = validate_timestamp_internal(Some(n - 301), 300, 60, n); - assert!(matches!(res, Err(VerifyError::SignatureExpired { .. }))); + let res2 = validate_timestamp(Some(n - 301), 300, 60); + assert!(matches!(res2, Err(VerifyError::SignatureExpired))); } #[test] @@ -1197,7 +756,6 @@ mod tests { #[test] fn test_get_bind_address_defaults_when_unset() { let _guard = ENV_LOCK.lock().unwrap(); - let old = env::var("BIND_ADDRESS").ok(); env::remove_var("BIND_ADDRESS"); @@ -1212,7 +770,6 @@ mod tests { #[test] fn test_get_bind_address_reads_valid_address() { let _guard = ENV_LOCK.lock().unwrap(); - let old = env::var("BIND_ADDRESS").ok(); env::set_var("BIND_ADDRESS", "127.0.0.1"); @@ -1227,7 +784,6 @@ mod tests { #[test] fn test_get_bind_address_falls_back_on_invalid_value() { let _guard = ENV_LOCK.lock().unwrap(); - let old = env::var("BIND_ADDRESS").ok(); env::set_var("BIND_ADDRESS", "not-an-ip"); @@ -1245,7 +801,6 @@ mod tests { "380eb0f3d505f087e438eca80bc4df9a7faa24f868e69fc0440261a0fc0567dc" .parse() .unwrap(); - let wallet = wallet.with_chain_id(BASE_SEPOLIA_CHAIN_ID); let ts = now(); @@ -1288,7 +843,7 @@ mod tests { timestamp: Some(ts), }, signature: format!("0x{}", hex::encode(sig.to_vec())), - }; + } let (status, _, Json(resp)) = verify_signature(State(app_state()), HeaderMap::new(), Ok(Json(req))).await; @@ -1303,7 +858,6 @@ mod tests { "380eb0f3d505f087e438eca80bc4df9a7faa24f868e69fc0440261a0fc0567dc" .parse() .unwrap(); - let wallet = wallet.with_chain_id(1u64); let ts = now(); @@ -1354,41 +908,9 @@ mod tests { assert_eq!(status, StatusCode::BAD_REQUEST); assert!(!resp.is_valid); assert_eq!(resp.recovered_address, None); - assert_eq!(resp.error.as_deref(), Some("chain ID mismatch")); assert_eq!(resp.error_code.as_deref(), Some("chain_id_mismatch")); } - #[tokio::test] - async fn test_verify_signature_returns_timestamp_error_codes() { - let state = app_state(); - let cases = [ - (None, "timestamp_missing"), - (Some(now() - 301), "timestamp_expired"), - (Some(now() + 120), "timestamp_future"), - ]; - - for (timestamp, expected_code) in cases { - let req = VerifyRequest { - context: PaymentContext { - recipient: "0x1234567890123456789012345678901234567890".to_string(), - token: "USDC".to_string(), - amount: "100".to_string(), - nonce: format!("timestamp-{expected_code}"), - chain_id: BASE_SEPOLIA_CHAIN_ID, - timestamp, - }, - signature: "0x1234567890".to_string(), - }; - - let (status, _, Json(resp)) = - verify_signature(State(state.clone()), HeaderMap::new(), Ok(Json(req))).await; - - assert_eq!(status, StatusCode::OK); - assert!(!resp.is_valid); - assert_eq!(resp.error_code.as_deref(), Some(expected_code)); - } - } - #[tokio::test] async fn test_verify_signature_rejects_replayed_nonce() { let state = app_state(); @@ -1498,40 +1020,13 @@ mod tests { let (good_status, _, Json(good_resp)) = verify_signature(State(state), HeaderMap::new(), Ok(Json(good_req))).await; - assert_eq!(bad_status, StatusCode::OK); + assert_eq!(bad_status, StatusCode::BAD_REQUEST); assert!(!bad_resp.is_valid); assert_eq!(bad_resp.error_code.as_deref(), Some("invalid_signature")); assert_eq!(good_status, StatusCode::OK); assert!(good_resp.is_valid); } - #[tokio::test] - async fn test_verify_signature_fails_closed_when_redis_nonce_store_unavailable() { - let client = redis::Client::open("redis://127.0.0.1:1").unwrap(); - let state = app_state_with_nonce_store( - Arc::new(NonceStore::Redis(RedisNonceStore { - client, - key_prefix: "test:verifier:nonce:".to_string(), - timeout: redis_nonce_timeout(), - })), - 300, - 60, - ); - let req = signed_request("redis-unavailable-nonce", BASE_SEPOLIA_CHAIN_ID, now()).await; - - let result = tokio::time::timeout( - Duration::from_secs(2), - verify_signature(State(state), HeaderMap::new(), Ok(Json(req))), - ) - .await - .expect("redis-unavailable path should fail closed quickly"); - let (status, _, Json(resp)) = result; - - assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); - assert!(!resp.is_valid); - assert_eq!(resp.error_code.as_deref(), Some("nonce_store_unavailable")); - } - #[tokio::test] async fn test_health_endpoint() { let (_headers, Json(response)) = health(HeaderMap::new()).await; @@ -1644,256 +1139,11 @@ mod tests { } #[tokio::test] - async fn test_correlation_id_with_valid_signature() { - let wallet: LocalWallet = - "380eb0f3d505f087e438eca80bc4df9a7faa24f868e69fc0440261a0fc0567dc" - .parse() - .unwrap(); - let wallet = wallet.with_chain_id(BASE_SEPOLIA_CHAIN_ID); - - let ts = now(); - let json_typed_data = serde_json::json!({ - "domain": { - "name": "MicroAI Paygate", - "version": "1", - "chainId": BASE_SEPOLIA_CHAIN_ID, - "verifyingContract": "0x0000000000000000000000000000000000000000" - }, - "types": { - "Payment": [ - { "name": "recipient", "type": "address" }, - { "name": "token", "type": "string" }, - { "name": "amount", "type": "string" }, - { "name": "nonce", "type": "string" }, - { "name": "timestamp", "type": "uint256" } - ] - }, - "primaryType": "Payment", - "message": { - "recipient": "0x1234567890123456789012345678901234567890", - "token": "USDC", - "amount": "100", - "nonce": "correlation-test-nonce", - "timestamp": ts - } - }); - - let typed_data: TypedData = serde_json::from_value(json_typed_data).unwrap(); - let signature = wallet.sign_typed_data(&typed_data).await.unwrap(); - let signature_str = format!("0x{}", hex::encode(signature.to_vec())); - - let mut headers = HeaderMap::new(); - headers.insert( - "X-Correlation-ID", - "valid-sig-correlation-id".parse().unwrap(), - ); - - let req = VerifyRequest { - context: PaymentContext { - recipient: "0x1234567890123456789012345678901234567890".to_string(), - token: "USDC".to_string(), - amount: "100".to_string(), - nonce: "correlation-test-nonce".to_string(), - chain_id: BASE_SEPOLIA_CHAIN_ID, - timestamp: Some(ts), - }, - signature: signature_str, - }; - - let (status, response_headers, Json(response)) = - verify_signature(State(app_state()), headers, Ok(Json(req))).await; - - assert_eq!(status, StatusCode::OK); - assert!(response.is_valid); - - let response_id = response_headers.get("X-Correlation-ID"); - assert!( - response_id.is_some(), - "Expected X-Correlation-ID in successful response" - ); - assert_eq!( - response_id.unwrap().to_str().unwrap(), - "valid-sig-correlation-id", - "Correlation ID should be preserved in successful response" - ); - } - - #[tokio::test] - async fn test_correlation_id_uuid_format() { - let mut headers = HeaderMap::new(); - headers.insert( - "X-Correlation-ID", - "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(), - ); - - let ts = now(); - let req = VerifyRequest { - context: PaymentContext { - recipient: "0x1234567890123456789012345678901234567890".to_string(), - token: "USDC".to_string(), - amount: "100".to_string(), - nonce: "nonce".to_string(), - chain_id: BASE_SEPOLIA_CHAIN_ID, - timestamp: Some(ts), - }, - signature: "0x1234567890".to_string(), - }; - - let (_status, response_headers, _json) = - verify_signature(State(app_state()), headers, Ok(Json(req))).await; - - let response_id = response_headers.get("X-Correlation-ID"); - assert!(response_id.is_some()); - assert_eq!( - response_id.unwrap().to_str().unwrap(), - "550e8400-e29b-41d4-a716-446655440000", - "UUID correlation ID should be preserved exactly" - ); - } - #[tokio::test] - async fn test_verify_signature_rejection_paths() { - use axum::extract::rejection::JsonRejection; - - // 1. Test a generic JSON rejection (e.g., bad formatting) - // We simulate a "Missing Content-Type" style error - let body_rejection = axum::extract::rejection::MissingJsonContentType::default(); - let rejection = JsonRejection::from(body_rejection); - - let (status, _, Json(resp)) = - verify_signature(State(app_state()), HeaderMap::new(), Err(rejection)).await; - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert!(resp.error.unwrap().contains("Invalid request")); - } - #[tokio::test] - async fn test_verify_signature_oversized_payload() { - use axum::{ - body::Body, - http::{Request, StatusCode}, - }; - use tower::ServiceExt; // for `oneshot` - - // 1. Force the limit to our constant (1MB) instead of reading the environment. - // This makes the test deterministic. - let limit = MAX_BODY_SIZE; - let state = AppState { - max_body_size: limit, - expected_chain_id: BASE_SEPOLIA_CHAIN_ID, - nonce_store: memory_nonce_store(), - signature_expiry_seconds: 300, - clock_skew_seconds: 60, - }; - let app = Router::new() - .route("/verify", post(verify_signature)) - .layer(DefaultBodyLimit::max(limit)) - .with_state(state); - - // 2. Create a "too large" payload (2MB) which is guaranteed to exceed 1MB. - let large_data = vec![b'a'; 2 * 1024 * 1024]; - let req = Request::builder() - .method("POST") - .uri("/verify") - .header("content-type", "application/json") - .header("x-correlation-id", "test-oversized") - .body(Body::from(large_data)) - .unwrap(); - - // 3. Send the request through the app. - let response = app.oneshot(req).await.unwrap(); - - // 4. Verify the results - assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); // 413 - assert!(response.headers().contains_key("x-correlation-id")); // Header check - } - - #[tokio::test(flavor = "current_thread")] - async fn test_verify_signature_records_specific_failure_reasons() { - let recorder = PrometheusBuilder::new().build_recorder(); - let handle = recorder.handle(); - let _guard = ::metrics::set_default_local_recorder(&recorder); - - let wrong_chain = signed_request("metrics-wrong-chain", 1, now()).await; - let (status, _, Json(resp)) = - verify_signature(State(app_state()), HeaderMap::new(), Ok(Json(wrong_chain))).await; - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(resp.error_code.as_deref(), Some("chain_id_mismatch")); - - let missing_timestamp = VerifyRequest { - context: PaymentContext { - recipient: "0x1234567890123456789012345678901234567890".to_string(), - token: "USDC".to_string(), - amount: "100".to_string(), - nonce: "metrics-missing-timestamp".to_string(), - chain_id: BASE_SEPOLIA_CHAIN_ID, - timestamp: None, - }, - signature: "0x1234567890".to_string(), - }; - let (status, _, Json(resp)) = verify_signature( - State(app_state()), - HeaderMap::new(), - Ok(Json(missing_timestamp)), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(resp.error_code.as_deref(), Some("timestamp_missing")); - - let malformed_signature = VerifyRequest { - context: PaymentContext { - recipient: "0x1234567890123456789012345678901234567890".to_string(), - token: "USDC".to_string(), - amount: "100".to_string(), - nonce: "metrics-malformed-signature".to_string(), - chain_id: BASE_SEPOLIA_CHAIN_ID, - timestamp: Some(now()), - }, - signature: "not-a-signature".to_string(), - }; - let (status, _, Json(resp)) = verify_signature( - State(app_state()), - HeaderMap::new(), - Ok(Json(malformed_signature)), - ) - .await; + async fn test_verify_signature_rejects_unsupported_chain_id() { + let req = super::signed_req("n1", 999).await; + let (status, _, Json(resp)) = verify_signature(State(app_state()), HeaderMap::new(), Ok(Json(req))).await; assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(resp.error_code.as_deref(), Some("invalid_signature")); - - let rendered = handle.render(); - assert!( - rendered.contains("verifier_signature_invalid_total{reason=\"chain_id_mismatch\"} 1") - ); - assert!( - rendered.contains("verifier_signature_invalid_total{reason=\"timestamp_missing\"} 1") - ); - assert!( - rendered.contains("verifier_signature_invalid_total{reason=\"invalid_signature\"} 1") - ); - assert!( - !rendered.contains("verifier_signature_invalid_total{reason=\"payload_too_large\"}") - ); - } - - #[tokio::test] - async fn test_metrics_route_can_be_scraped_repeatedly() { - use axum::{body::Body, http::Request}; - use tower::ServiceExt; - - let recorder = PrometheusBuilder::new().build_recorder(); - let app = Router::new().route("/metrics", get(metrics_route(recorder.handle()))); - - for _ in 0..2 { - let response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/metrics") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - } + assert_eq!(resp.error_code, Some("chain_id_mismatch".into())); } } + diff --git a/web/src/lib/wallet.ts b/web/src/lib/wallet.ts index e2bcc0e..8a83602 100644 --- a/web/src/lib/wallet.ts +++ b/web/src/lib/wallet.ts @@ -1,3 +1,5 @@ +"use client"; + import { ethers, type Eip1193Provider } from "ethers"; declare global { @@ -29,6 +31,18 @@ const CHAINS: Record = { rpcUrl: "https://mainnet.base.org", explorer: "https://basescan.org", }, + 11155111: { + id: 11155111, + name: "Ethereum Sepolia", + rpcUrl: "https://rpc.sepolia.org", + explorer: "https://sepolia.etherscan.io", + }, + 11155420: { + id: 11155420, + name: "Optimism Sepolia", + rpcUrl: "https://sepolia.optimism.io", + explorer: "https://sepolia-optimism.etherscan.io", + }, }; export function getChainMeta(chainId: number): ChainMeta {