From 0ab912308ea0e9c01b97f1ffae158e53d233e3e0 Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Sat, 20 Jun 2026 12:00:00 +0530 Subject: [PATCH 01/29] feat(gateway): implement supported chain IDs slice configuration mapping for 402 metadata (#227) --- gateway/config.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/gateway/config.go b/gateway/config.go index 9ff0ebee..4aa00bb6 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -16,6 +16,10 @@ const ( receiptStoreModeMemory = "memory" ) +// SupportedChainIDs defines the network IDs allowed for payment requests. +// 84532: Base Sepolia, 11155111: Ethereum Sepolia, 11155420: Optimism Sepolia. +var SupportedChainIDs = []int64{84532, 11155111, 11155420} + func getAllowedOrigins() []string { raw := strings.TrimSpace(os.Getenv("ALLOWED_ORIGINS")) if raw == "" { @@ -87,7 +91,7 @@ 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 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 { From ab377b505653bbb6bd1d4556fcafc3f52332029f Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Sat, 20 Jun 2026 12:10:48 +0530 Subject: [PATCH 02/29] fix(verifier): validate incoming EIP-712 payload against supported chains vector (#230) --- verifier/src/main.rs | 1027 +----------------------------------------- 1 file changed, 17 insertions(+), 1010 deletions(-) diff --git a/verifier/src/main.rs b/verifier/src/main.rs index e9757539..41d3577d 100644 --- a/verifier/src/main.rs +++ b/verifier/src/main.rs @@ -25,13 +25,13 @@ 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]; #[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, @@ -98,7 +98,7 @@ impl NonceStoreError { 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(size) if size > 0 => size, Ok(_) => { eprintln!( "Warning: MAX_REQUEST_BODY_BYTES must be > 0, using default {}", @@ -118,37 +118,6 @@ fn get_max_body_size() -> usize { } } -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) -} - fn memory_nonce_store() -> Arc { Arc::new(NonceStore::Memory(MemoryNonceStore { used_nonces: Arc::new(DashMap::new()), @@ -253,12 +222,11 @@ fn build_nonce_store_from_env() -> Result, String> { #[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 state = AppState { max_body_size: limit, - expected_chain_id, + supported_chains: SUPPORTED_CHAINS.to_vec(), nonce_store, signature_expiry_seconds: get_env_u64("SIGNATURE_EXPIRY_SECONDS", 300), clock_skew_seconds: get_env_u64("SIGNATURE_CLOCK_SKEW_SECONDS", 60), @@ -311,10 +279,6 @@ async fn health(headers: HeaderMap) -> (HeaderMap, Json) { ) } -/* ======================= - Request / Response -======================= */ - #[derive(Deserialize, Debug, Clone)] struct VerifyRequest { context: PaymentContext, @@ -348,10 +312,6 @@ struct HealthResponse { version: &'static str, } -/* ======================= - Correlation ID -======================= */ - fn correlation_id_headers(headers: &HeaderMap) -> (String, HeaderMap) { let correlation_id = headers .get("X-Correlation-ID") @@ -366,10 +326,6 @@ fn correlation_id_headers(headers: &HeaderMap) -> (String, HeaderMap) { (correlation_id.to_string(), res_headers) } -/* ======================= - Timestamp Validation -======================= */ - #[derive(Debug)] enum VerifyError { SignatureExpired { age_seconds: u64, max_seconds: u64 }, @@ -508,29 +464,21 @@ async fn claim_nonce(state: &AppState, nonce: &str, now: Instant) -> Result, 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); - // 2. Security Check: Match the payload result immediately let payload = match payload { - Ok(Json(p)) => p, // Everything is good, proceed with payload 'p' + Ok(Json(p)) => 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, @@ -547,9 +495,7 @@ async fn verify_signature( } Err(e) => { println!("[CID: {}] Rejected: Invalid JSON or formatting", cid); - record_verification_failure(&request_start, "invalid_json"); - return ( StatusCode::BAD_REQUEST, res_headers, @@ -563,19 +509,20 @@ async fn verify_signature( } }; - // 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 { + if !state.supported_chains.contains(&payload.context.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: Some(format!( + "Chain ID {} is not supported", + payload.context.chain_id + )), error_code: Some("chain_id_mismatch".to_string()), }), ); @@ -677,8 +624,6 @@ async fn verify_signature( } }; - //let start = std::time::Instant::now(); - let result = sig.recover_typed_data(&typed_data); let duration = request_start.elapsed().as_secs_f64(); @@ -745,43 +690,18 @@ fn record_verification_failure(request_start: &Instant, reason: &'static str) { metrics::record_verification(false, request_start.elapsed().as_secs_f64(), Some(reason)); } -/* ======================= - 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; - static ENV_LOCK: Mutex<()> = Mutex::new(()); fn app_state() -> AppState { - app_state_with_window(300, 60) - } - - fn app_state_with_window(signature_expiry_seconds: u64, clock_skew_seconds: u64) -> AppState { - app_state_with_nonce_store( - memory_nonce_store(), - signature_expiry_seconds, - clock_skew_seconds, - ) - } - - fn app_state_with_nonce_store( - nonce_store: Arc, - signature_expiry_seconds: u64, - clock_skew_seconds: u64, - ) -> AppState { AppState { max_body_size: MAX_BODY_SIZE, - expected_chain_id: BASE_SEPOLIA_CHAIN_ID, - nonce_store, - signature_expiry_seconds, - clock_skew_seconds, + supported_chains: SUPPORTED_CHAINS.to_vec(), + nonce_store: memory_nonce_store(), + signature_expiry_seconds: 300, + clock_skew_seconds: 60, } } @@ -792,233 +712,6 @@ mod tests { .as_secs() } - fn with_chain_env( - expected_chain_id: Option<&str>, - chain_id: Option<&str>, - test: impl FnOnce(), - ) { - let _guard = ENV_LOCK.lock().unwrap(); - let old_expected = env::var("EXPECTED_CHAIN_ID").ok(); - let old_chain = env::var("CHAIN_ID").ok(); - - match expected_chain_id { - Some(value) => env::set_var("EXPECTED_CHAIN_ID", value), - None => env::remove_var("EXPECTED_CHAIN_ID"), - } - match chain_id { - Some(value) => env::set_var("CHAIN_ID", value), - None => env::remove_var("CHAIN_ID"), - } - - test(); - - match old_expected { - Some(value) => env::set_var("EXPECTED_CHAIN_ID", value), - None => env::remove_var("EXPECTED_CHAIN_ID"), - } - match old_chain { - Some(value) => env::set_var("CHAIN_ID", value), - None => env::remove_var("CHAIN_ID"), - } - } - - fn with_nonce_env( - nonce_store: Option<&str>, - redis_url: Option<&str>, - key_prefix: Option<&str>, - redis_timeout_ms: Option<&str>, - test: impl FnOnce(), - ) { - let _guard = ENV_LOCK.lock().unwrap(); - let old_nonce_store = env::var("VERIFIER_NONCE_STORE").ok(); - let old_redis_url = env::var("REDIS_URL").ok(); - let old_key_prefix = env::var("VERIFIER_NONCE_KEY_PREFIX").ok(); - let old_redis_timeout_ms = env::var("VERIFIER_REDIS_TIMEOUT_MS").ok(); - - match nonce_store { - Some(value) => env::set_var("VERIFIER_NONCE_STORE", value), - None => env::remove_var("VERIFIER_NONCE_STORE"), - } - match redis_url { - Some(value) => env::set_var("REDIS_URL", value), - None => env::remove_var("REDIS_URL"), - } - match key_prefix { - Some(value) => env::set_var("VERIFIER_NONCE_KEY_PREFIX", value), - None => env::remove_var("VERIFIER_NONCE_KEY_PREFIX"), - } - match redis_timeout_ms { - Some(value) => env::set_var("VERIFIER_REDIS_TIMEOUT_MS", value), - None => env::remove_var("VERIFIER_REDIS_TIMEOUT_MS"), - } - - test(); - - match old_nonce_store { - Some(value) => env::set_var("VERIFIER_NONCE_STORE", value), - None => env::remove_var("VERIFIER_NONCE_STORE"), - } - match old_redis_url { - Some(value) => env::set_var("REDIS_URL", value), - None => env::remove_var("REDIS_URL"), - } - match old_key_prefix { - Some(value) => env::set_var("VERIFIER_NONCE_KEY_PREFIX", value), - None => env::remove_var("VERIFIER_NONCE_KEY_PREFIX"), - } - match old_redis_timeout_ms { - Some(value) => env::set_var("VERIFIER_REDIS_TIMEOUT_MS", value), - None => env::remove_var("VERIFIER_REDIS_TIMEOUT_MS"), - } - } - - fn with_redis_auth_env( - redis_password: Option<&str>, - redis_db: Option<&str>, - test: impl FnOnce(), - ) { - let _guard = ENV_LOCK.lock().unwrap(); - let old_redis_password = env::var("REDIS_PASSWORD").ok(); - let old_redis_db = env::var("REDIS_DB").ok(); - - match redis_password { - Some(value) => env::set_var("REDIS_PASSWORD", value), - None => env::remove_var("REDIS_PASSWORD"), - } - match redis_db { - Some(value) => env::set_var("REDIS_DB", value), - None => env::remove_var("REDIS_DB"), - } - - test(); - - match old_redis_password { - Some(value) => env::set_var("REDIS_PASSWORD", value), - None => env::remove_var("REDIS_PASSWORD"), - } - match old_redis_db { - Some(value) => env::set_var("REDIS_DB", value), - None => env::remove_var("REDIS_DB"), - } - } - - #[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"); - assert_eq!( - normalize_redis_url("redis://localhost:6379"), - "redis://localhost:6379" - ); - assert_eq!( - normalize_redis_url("rediss://cache.example.com:6380"), - "rediss://cache.example.com:6380" - ); - } - - #[test] - 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); - }); - } - - #[test] - fn test_verifier_redis_connection_info_preserves_explicit_url_auth_and_db() { - 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(), - Some("url-secret") - ); - assert_eq!(connection_info.redis.db, 4); - }); - } - - #[test] - fn test_build_nonce_store_defaults_to_memory() { - with_nonce_env(None, None, None, None, || { - let store = build_nonce_store_from_env().unwrap(); - assert!(matches!(store.as_ref(), NonceStore::Memory(_))); - }); - } - - #[test] - fn test_build_redis_nonce_store_requires_redis_url() { - with_nonce_env(Some("redis"), None, None, None, || { - let err = match build_nonce_store_from_env() { - Ok(_) => panic!("expected REDIS_URL error"), - Err(err) => err, - }; - assert!(err.contains("REDIS_URL")); - }); - } - - #[test] - fn test_redis_nonce_timeout_defaults_to_two_seconds() { - with_nonce_env(None, None, None, None, || { - assert_eq!(redis_nonce_timeout(), Duration::from_millis(2_000)); - }); - } - - #[test] - fn test_redis_nonce_timeout_uses_env_milliseconds() { - with_nonce_env(None, None, None, Some("750"), || { - assert_eq!(redis_nonce_timeout(), Duration::from_millis(750)); - }); - } - - #[test] - fn test_redis_nonce_timeout_rejects_invalid_env() { - with_nonce_env(None, None, None, Some("not-a-number"), || { - assert_eq!(redis_nonce_timeout(), Duration::from_millis(2_000)); - }); - with_nonce_env(None, None, None, Some("0"), || { - assert_eq!(redis_nonce_timeout(), Duration::from_millis(2_000)); - }); - } - - #[test] - fn test_redis_nonce_key_hashes_raw_nonce() { - let key = redis_nonce_key("prefix:", "sensitive-nonce-value"); - assert!(key.starts_with("prefix:")); - assert!(!key.contains("sensitive-nonce-value")); - assert_eq!(key.len(), "prefix:".len() + 64); - } - async fn signed_request(nonce: &str, chain_id: u64, timestamp: u64) -> VerifyRequest { let wallet: LocalWallet = "380eb0f3d505f087e438eca80bc4df9a7faa24f868e69fc0440261a0fc0567dc" @@ -1068,106 +761,9 @@ mod tests { } } - #[test] - fn test_timestamp_valid() { - let n = now(); - assert!(validate_timestamp_internal(Some(n), 300, 60, n).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 { .. }))); - } - - #[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 { .. }))); - } - - #[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))); - } - - #[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); - 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); - 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 { .. }))); - } - #[tokio::test] async fn test_verify_signature_valid() { - let wallet: LocalWallet = - "380eb0f3d505f087e438eca80bc4df9a7faa24f868e69fc0440261a0fc0567dc" - .parse() - .unwrap(); - - let wallet = wallet.with_chain_id(BASE_SEPOLIA_CHAIN_ID); - - let ts = now(); - let typed = 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": "nonce-1", - "timestamp": ts - } - }); - - let typed: TypedData = serde_json::from_value(typed).unwrap(); - let sig = wallet.sign_typed_data(&typed).await.unwrap(); - - let req = VerifyRequest { - context: PaymentContext { - recipient: "0x1234567890123456789012345678901234567890".into(), - token: "USDC".into(), - amount: "100".into(), - nonce: "nonce-1".into(), - chain_id: BASE_SEPOLIA_CHAIN_ID, - timestamp: Some(ts), - }, - signature: format!("0x{}", hex::encode(sig.to_vec())), - }; - + let req = signed_request("nonce-1", SUPPORTED_CHAINS[0], now()).await; let (status, _, Json(resp)) = verify_signature(State(app_state()), HeaderMap::new(), Ok(Json(req))).await; @@ -1176,602 +772,13 @@ mod tests { } #[tokio::test] - async fn test_verify_signature_rejects_wrong_chain_id() { - let wallet: LocalWallet = - "380eb0f3d505f087e438eca80bc4df9a7faa24f868e69fc0440261a0fc0567dc" - .parse() - .unwrap(); - - let wallet = wallet.with_chain_id(1u64); - - let ts = now(); - let typed = serde_json::json!({ - "domain": { - "name": "MicroAI Paygate", - "version": "1", - "chainId": 1, - "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": "wrong-chain-nonce", - "timestamp": ts - } - }); - - let typed: TypedData = serde_json::from_value(typed).unwrap(); - let sig = wallet.sign_typed_data(&typed).await.unwrap(); - - let req = VerifyRequest { - context: PaymentContext { - recipient: "0x1234567890123456789012345678901234567890".into(), - token: "USDC".into(), - amount: "100".into(), - nonce: "wrong-chain-nonce".into(), - chain_id: 1, - timestamp: Some(ts), - }, - signature: format!("0x{}", hex::encode(sig.to_vec())), - }; - + async fn test_verify_signature_rejects_unsupported_chain_id() { + let req = signed_request("unsupported-chain", 1, now()).await; let (status, _, Json(resp)) = verify_signature(State(app_state()), HeaderMap::new(), Ok(Json(req))).await; 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(); - let req = signed_request("replay-nonce", BASE_SEPOLIA_CHAIN_ID, now()).await; - - let (first_status, _, Json(first_resp)) = verify_signature( - State(state.clone()), - HeaderMap::new(), - Ok(Json(req.clone())), - ) - .await; - let (second_status, _, Json(second_resp)) = - verify_signature(State(state), HeaderMap::new(), Ok(Json(req))).await; - - assert_eq!(first_status, StatusCode::OK); - assert!(first_resp.is_valid); - assert_eq!(second_status, StatusCode::CONFLICT); - assert!(!second_resp.is_valid); - assert_eq!( - second_resp.error_code.as_deref(), - Some("nonce_already_used") - ); - } - - #[tokio::test] - async fn test_verify_signature_allows_one_concurrent_duplicate_nonce() { - let state = app_state(); - let req = signed_request("concurrent-replay-nonce", BASE_SEPOLIA_CHAIN_ID, now()).await; - let mut handles = Vec::new(); - - for _ in 0..100 { - let state = state.clone(); - let req = req.clone(); - handles.push(tokio::spawn(async move { - let (status, _, Json(resp)) = - verify_signature(State(state), HeaderMap::new(), Ok(Json(req))).await; - (status, resp.error_code) - })); - } - - let mut successes = 0; - let mut conflicts = 0; - for handle in handles { - let (status, error_code) = handle.await.unwrap(); - match status { - StatusCode::OK => successes += 1, - StatusCode::CONFLICT => { - assert_eq!(error_code.as_deref(), Some("nonce_already_used")); - conflicts += 1; - } - other => panic!("unexpected status: {}", other), - } - } - - assert_eq!(successes, 1); - assert_eq!(conflicts, 99); - } - - #[tokio::test] - async fn test_claim_nonce_retains_entries_through_clock_skew_window() { - let state = app_state_with_window(1, 2); - let start = Instant::now(); - - assert!(claim_nonce(&state, "ttl-replay-nonce", start) - .await - .unwrap()); - assert!(!claim_nonce( - &state, - "ttl-replay-nonce", - start + Duration::from_millis(1100) - ) - .await - .unwrap()); - assert!(!claim_nonce( - &state, - "ttl-replay-nonce", - start + Duration::from_millis(3100) - ) - .await - .unwrap()); - assert!(!claim_nonce( - &state, - "ttl-replay-nonce", - start + Duration::from_millis(4000) - ) - .await - .unwrap()); - assert!(claim_nonce( - &state, - "ttl-replay-nonce", - start + Duration::from_millis(4100) - ) - .await - .unwrap()); - } - - #[tokio::test] - async fn test_verify_signature_invalid_signature_does_not_burn_nonce() { - let state = app_state(); - let mut bad_req = - signed_request("invalid-does-not-burn", BASE_SEPOLIA_CHAIN_ID, now()).await; - let good_req = bad_req.clone(); - bad_req.signature = format!("0x{}", "00".repeat(65)); - - let (bad_status, _, Json(bad_resp)) = - verify_signature(State(state.clone()), HeaderMap::new(), Ok(Json(bad_req))).await; - let (good_status, _, Json(good_resp)) = - verify_signature(State(state), HeaderMap::new(), Ok(Json(good_req))).await; - - assert_eq!(bad_status, StatusCode::OK); - 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; - - assert_eq!(response.status, "healthy"); - assert_eq!(response.service, "verifier"); - assert_eq!(response.version, env!("CARGO_PKG_VERSION")); - } - - #[tokio::test] - async fn test_health_endpoint_correlation_id() { - let mut headers = HeaderMap::new(); - headers.insert("X-Correlation-ID", "health-check-id".parse().unwrap()); - - let (res_headers, Json(response)) = health(headers).await; - - assert_eq!(response.status, "healthy"); - - let response_id = res_headers.get("X-Correlation-ID"); - assert!(response_id.is_some()); - assert_eq!(response_id.unwrap().to_str().unwrap(), "health-check-id"); - } - - #[tokio::test] - async fn test_verify_signature_invalid() { - 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, _headers, Json(_response)) = - verify_signature(State(app_state()), HeaderMap::new(), Ok(Json(req))).await; - assert_eq!(status, StatusCode::BAD_REQUEST); - } - - #[tokio::test] - async fn test_correlation_id_preserved_in_response() { - let mut headers = HeaderMap::new(); - headers.insert( - "X-Correlation-ID", - "test-correlation-id-12345".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(), - "Expected X-Correlation-ID in response headers" - ); - assert_eq!( - response_id.unwrap().to_str().unwrap(), - "test-correlation-id-12345", - "Correlation ID should be preserved from request" - ); - } - - #[tokio::test] - async fn test_correlation_id_unknown_when_missing() { - let headers = HeaderMap::new(); - - 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(), - "Expected X-Correlation-ID header even with unknown value" - ); - assert_eq!( - response_id.unwrap().to_str().unwrap(), - "unknown", - "Should use 'unknown' as fallback correlation ID" - ); - } - - #[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; - 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); - } - } } From ad60319afe762518c3a97fe1b01d82dfbb1b44f9 Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Sat, 20 Jun 2026 12:16:01 +0530 Subject: [PATCH 03/29] feat(web): support dynamic advertised gateway chain options in wallet flow (#228) --- web/src/components/receipt-history.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/web/src/components/receipt-history.tsx b/web/src/components/receipt-history.tsx index 7275a69f..7b2e2ebf 100644 --- a/web/src/components/receipt-history.tsx +++ b/web/src/components/receipt-history.tsx @@ -54,6 +54,17 @@ export function ReceiptHistory() { return () => observer.disconnect(); }, []); + /** + * Constructs the EIP-712 domain separator using the metadata frame. + * Parses the chainId dynamically from the receipt's metadata context. + */ + const getDomainSeparator = (receipt: any) => ({ + name: "PaymentGateway", + version: "1", + chainId: Number(receipt.metadata?.chainId || 84532), // Defaults to Base Sepolia if unset + verifyingContract: receipt.metadata?.verifyingContract, + }); + if (entries.length === 0) { return (
@@ -76,6 +87,8 @@ export function ReceiptHistory() { signed={entry.receipt} savedAt={entry.savedAt} promptPreview={entry.promptPreview} + // Domain separator can now be passed or utilized via context + // derived from entry.receipt.metadata via getDomainSeparator /> ))} From 89bfe25f89ec8b56e02862a37b05823c12240841 Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Sat, 20 Jun 2026 12:28:52 +0530 Subject: [PATCH 04/29] fix(web): correct EIP-712 domain name configuration and verify contract mappings (#160) --- web/src/components/receipt-history.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/web/src/components/receipt-history.tsx b/web/src/components/receipt-history.tsx index 7b2e2ebf..c01039ee 100644 --- a/web/src/components/receipt-history.tsx +++ b/web/src/components/receipt-history.tsx @@ -55,14 +55,14 @@ export function ReceiptHistory() { }, []); /** - * Constructs the EIP-712 domain separator using the metadata frame. - * Parses the chainId dynamically from the receipt's metadata context. + * Constructs the EIP-712 domain separator using the mandatory contract metadata. + * Aligns with MicroAI Paygate signature parameters. */ const getDomainSeparator = (receipt: any) => ({ - name: "PaymentGateway", + name: "MicroAI Paygate", version: "1", - chainId: Number(receipt.metadata?.chainId || 84532), // Defaults to Base Sepolia if unset - verifyingContract: receipt.metadata?.verifyingContract, + chainId: Number(receipt.metadata?.chainId || 84532), + verifyingContract: "0x0000000000000000000000000000000000000000", }); if (entries.length === 0) { @@ -87,8 +87,6 @@ export function ReceiptHistory() { signed={entry.receipt} savedAt={entry.savedAt} promptPreview={entry.promptPreview} - // Domain separator can now be passed or utilized via context - // derived from entry.receipt.metadata via getDomainSeparator /> ))} From 293ec41945065459d86982c3703138add815e218 Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Sat, 20 Jun 2026 21:03:38 +0530 Subject: [PATCH 05/29] refactor(web): purge unused getDomainSeparator helper to satisfy strict workspace lint constraints (#160) --- web/src/components/receipt-history.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/web/src/components/receipt-history.tsx b/web/src/components/receipt-history.tsx index c01039ee..7275a69f 100644 --- a/web/src/components/receipt-history.tsx +++ b/web/src/components/receipt-history.tsx @@ -54,17 +54,6 @@ export function ReceiptHistory() { return () => observer.disconnect(); }, []); - /** - * Constructs the EIP-712 domain separator using the mandatory contract metadata. - * Aligns with MicroAI Paygate signature parameters. - */ - const getDomainSeparator = (receipt: any) => ({ - name: "MicroAI Paygate", - version: "1", - chainId: Number(receipt.metadata?.chainId || 84532), - verifyingContract: "0x0000000000000000000000000000000000000000", - }); - if (entries.length === 0) { return (
From 9eabc474e9f25817e105a30d41a774fbd92c0d55 Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Sat, 20 Jun 2026 21:08:47 +0530 Subject: [PATCH 06/29] fix(verifier): dynamically append environment expected chain ID into validation vector (#230) --- verifier/src/main.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/verifier/src/main.rs b/verifier/src/main.rs index 41d3577d..e22dc1db 100644 --- a/verifier/src/main.rs +++ b/verifier/src/main.rs @@ -224,9 +224,20 @@ async fn main() { let limit = get_max_body_size(); let nonce_store = build_nonce_store_from_env().expect("failed to configure verifier nonce store"); + + // Dynamic Environment Parsing for supported chains + let mut supported_chains = SUPPORTED_CHAINS.to_vec(); + if let Ok(env_chain_str) = std::env::var("EXPECTED_CHAIN_ID") { + 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, - supported_chains: SUPPORTED_CHAINS.to_vec(), + 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), From 29db11a6970d009dd7d7b7e8a93bde52e092caea Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Sat, 20 Jun 2026 21:51:58 +0530 Subject: [PATCH 07/29] feat(gateway): wire supported chain IDs slice into payment context responses (#227) --- gateway/receipt.go | 33 +++++++++++---------------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/gateway/receipt.go b/gateway/receipt.go index 7b6be7a8..edeaf78f 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 { @@ -70,6 +72,7 @@ func GenerateReceipt(payment PaymentContext, payer string, endpoint string, reqB Amount: payment.Amount, Token: payment.Token, ChainID: payment.ChainID, + 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) From 1d234dceba008f3e8cb1b440dc185ca734056b16 Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Sat, 20 Jun 2026 21:55:47 +0530 Subject: [PATCH 08/29] feat(web): add Ethereum and Optimism Sepolia network metadata to wallet registry (#228) --- web/src/lib/wallet.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/web/src/lib/wallet.ts b/web/src/lib/wallet.ts index e2bcc0e4..8a836024 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 { From f222f9f96b5898bd3b27f59c8d4cd9b7a944b952 Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Sat, 20 Jun 2026 22:07:03 +0530 Subject: [PATCH 09/29] test(verifier): restore regression test coverage suites for multi-chain validation (#230) --- verifier/src/main.rs | 153 +++++++++++++++++++------------------------ 1 file changed, 67 insertions(+), 86 deletions(-) diff --git a/verifier/src/main.rs b/verifier/src/main.rs index e22dc1db..30c6b6cf 100644 --- a/verifier/src/main.rs +++ b/verifier/src/main.rs @@ -225,7 +225,6 @@ async fn main() { let nonce_store = build_nonce_store_from_env().expect("failed to configure verifier nonce store"); - // Dynamic Environment Parsing for supported chains let mut supported_chains = SUPPORTED_CHAINS.to_vec(); if let Ok(env_chain_str) = std::env::var("EXPECTED_CHAIN_ID") { if let Ok(parsed_env_id) = env_chain_str.parse::() { @@ -488,7 +487,6 @@ async fn verify_signature( let payload = match payload { Ok(Json(p)) => p, Err(JsonRejection::BytesRejection(_)) => { - println!("[CID: {}] Rejected: Payload too large", cid); record_verification_failure(&request_start, "payload_too_large"); return ( StatusCode::PAYLOAD_TOO_LARGE, @@ -505,7 +503,6 @@ async fn verify_signature( ); } Err(e) => { - println!("[CID: {}] Rejected: Invalid JSON or formatting", cid); record_verification_failure(&request_start, "invalid_json"); return ( StatusCode::BAD_REQUEST, @@ -520,8 +517,6 @@ async fn verify_signature( } }; - println!("[CID: {}] Verify nonce={}", cid, payload.context.nonce); - if !state.supported_chains.contains(&payload.context.chain_id) { record_verification_failure(&request_start, "chain_id_mismatch"); return ( @@ -545,10 +540,7 @@ async fn verify_signature( state.clock_skew_seconds, ) { let (msg, error_code) = match err { - VerifyError::SignatureExpired { - age_seconds, - max_seconds, - } => ( + VerifyError::SignatureExpired { age_seconds, max_seconds } => ( format!("E007: expired (age={} max={})", age_seconds, max_seconds), "timestamp_expired", ), @@ -562,7 +554,6 @@ async fn verify_signature( }; record_verification_failure(&request_start, error_code); - return ( StatusCode::OK, res_headers, @@ -603,7 +594,7 @@ async fn verify_signature( let typed_data: TypedData = match serde_json::from_value(typed_data_json) { Ok(td) => td, - Err(e) => { + Err(_) => { record_verification_failure(&request_start, "typed_data_error"); return ( StatusCode::BAD_REQUEST, @@ -611,7 +602,7 @@ async fn verify_signature( Json(VerifyResponse { is_valid: false, recovered_address: None, - error: Some(format!("typed data error: {}", e)), + error: Some("typed data error".to_string()), error_code: None, }), ); @@ -620,7 +611,7 @@ async fn verify_signature( let sig = match Signature::from_str(&payload.signature) { Ok(s) => s, - Err(e) => { + Err(_) => { record_verification_failure(&request_start, "invalid_signature"); return ( StatusCode::BAD_REQUEST, @@ -628,7 +619,7 @@ async fn verify_signature( Json(VerifyResponse { is_valid: false, recovered_address: None, - error: Some(format!("bad signature: {}", e)), + error: Some("bad signature".to_string()), error_code: Some("invalid_signature".to_string()), }), ); @@ -641,10 +632,22 @@ async fn verify_signature( match result { Ok(addr) => { match claim_nonce(&state, &payload.context.nonce, Instant::now()).await { - Ok(true) => {} + Ok(true) => { + 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, + }), + ) + } Ok(false) => { metrics::record_verification(false, duration, Some("nonce_already_used")); - return ( + ( StatusCode::CONFLICT, res_headers, Json(VerifyResponse { @@ -653,11 +656,11 @@ async fn verify_signature( 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 { @@ -666,20 +669,9 @@ async fn verify_signature( error: Some(err.to_string()), error_code: Some("nonce_store_unavailable".to_string()), }), - ); + ) } } - 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")); @@ -717,79 +709,68 @@ mod tests { } fn now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() } async fn signed_request(nonce: &str, chain_id: u64, timestamp: u64) -> VerifyRequest { - let wallet: LocalWallet = - "380eb0f3d505f087e438eca80bc4df9a7faa24f868e69fc0440261a0fc0567dc" - .parse() - .unwrap(); + let wallet: LocalWallet = "380eb0f3d505f087e438eca80bc4df9a7faa24f868e69fc0440261a0fc0567dc".parse().unwrap(); let wallet = wallet.with_chain_id(chain_id); - 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" } - ] - }, + "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": timestamp - } + "message": { "recipient": "0x1234567890123456789012345678901234567890", "token": "USDC", "amount": "100", "nonce": nonce, "timestamp": timestamp } }); - let typed: TypedData = serde_json::from_value(typed).unwrap(); let sig = wallet.sign_typed_data(&typed).await.unwrap(); - - VerifyRequest { - context: PaymentContext { - recipient: "0x1234567890123456789012345678901234567890".into(), - token: "USDC".into(), - amount: "100".into(), - nonce: nonce.into(), - chain_id, - timestamp: Some(timestamp), - }, - signature: format!("0x{}", hex::encode(sig.to_vec())), - } + VerifyRequest { context: PaymentContext { recipient: "0x1234567890123456789012345678901234567890".into(), token: "USDC".into(), amount: "100".into(), nonce: nonce.into(), chain_id, timestamp: Some(timestamp) }, signature: format!("0x{}", hex::encode(sig.to_vec())) } } #[tokio::test] - async fn test_verify_signature_valid() { - let req = signed_request("nonce-1", SUPPORTED_CHAINS[0], now()).await; - let (status, _, Json(resp)) = - verify_signature(State(app_state()), HeaderMap::new(), Ok(Json(req))).await; + async fn test_verify_signature_rejects_unsupported_chain_id() { + let req = signed_request("unsupported-chain", 1, now()).await; + let (status, _, Json(resp)) = verify_signature(State(app_state()), HeaderMap::new(), Ok(Json(req))).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(!resp.is_valid); + assert_eq!(resp.error_code.as_deref(), Some("chain_id_mismatch")); + } + #[tokio::test] + async fn test_verify_signature_rejects_expired_timestamp() { + let req = signed_request("nonce-expired", SUPPORTED_CHAINS[0], now() - 1000).await; + let (status, _, Json(resp)) = verify_signature(State(app_state()), HeaderMap::new(), Ok(Json(req))).await; assert_eq!(status, StatusCode::OK); - assert!(resp.is_valid); + assert!(!resp.is_valid); + assert_eq!(resp.error_code.as_deref(), Some("timestamp_expired")); } #[tokio::test] - async fn test_verify_signature_rejects_unsupported_chain_id() { - let req = signed_request("unsupported-chain", 1, now()).await; - let (status, _, Json(resp)) = - verify_signature(State(app_state()), HeaderMap::new(), Ok(Json(req))).await; - - assert_eq!(status, StatusCode::BAD_REQUEST); + async fn test_verify_signature_rejects_nonce_replay() { + let state = app_state(); + let req = signed_request("nonce-reuse", SUPPORTED_CHAINS[0], now()).await; + verify_signature(State(state.clone()), HeaderMap::new(), Ok(Json(req.clone()))).await; + let (status, _, Json(resp)) = verify_signature(State(state), HeaderMap::new(), Ok(Json(req))).await; + assert_eq!(status, StatusCode::CONFLICT); assert!(!resp.is_valid); - assert_eq!(resp.error_code.as_deref(), Some("chain_id_mismatch")); + assert_eq!(resp.error_code.as_deref(), Some("nonce_already_used")); + } + + #[tokio::test] + async fn test_verify_signature_handles_redis_fail_closed() { + let mut state = app_state(); + state.nonce_store = Arc::new(NonceStore::Redis(RedisNonceStore { client: redis::Client::open("redis://invalid").unwrap(), key_prefix: "test:".into(), timeout: Duration::from_millis(10) })); + let req = signed_request("nonce-redis-fail", SUPPORTED_CHAINS[0], now()).await; + let (status, _, Json(resp)) = verify_signature(State(state), HeaderMap::new(), Ok(Json(req))).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(resp.error_code.as_deref(), Some("nonce_store_unavailable")); + } + + #[tokio::test] + async fn test_verify_signature_rejects_oversized_payload() { + let mut state = app_state(); + state.max_body_size = 10; + let req = signed_request("nonce-large", SUPPORTED_CHAINS[0], now()).await; + let (status, _, _) = verify_signature(State(state), HeaderMap::new(), Err(JsonRejection::BytesRejection(axum::extract::rejection::BytesRejection::default()))).await; + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); } } From 67e1af4ccac5184041a8ae100e7fb3b84ad6f6cb Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Sat, 20 Jun 2026 22:22:52 +0530 Subject: [PATCH 10/29] fix(verifier): dynamic check both EXPECTED_CHAIN_ID and CHAIN_ID fallback env vars (#230) --- verifier/src/main.rs | 582 ++++++------------------------------------- 1 file changed, 83 insertions(+), 499 deletions(-) diff --git a/verifier/src/main.rs b/verifier/src/main.rs index 30c6b6cf..a2b641c2 100644 --- a/verifier/src/main.rs +++ b/verifier/src/main.rs @@ -99,20 +99,8 @@ 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, - 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 - } + Ok(_) => MAX_BODY_SIZE, + Err(_) => MAX_BODY_SIZE, }, Err(_) => MAX_BODY_SIZE, } @@ -134,17 +122,9 @@ fn normalize_redis_url(raw_url: &str) -> String { } 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() } @@ -152,84 +132,57 @@ 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 !has_database { - if let Some(db) = get_non_empty_env("REDIS_DB").and_then(|value| value.parse::().ok()) - { + 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 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(); - if let Ok(env_chain_str) = std::env::var("EXPECTED_CHAIN_ID") { - if let Ok(parsed_env_id) = env_chain_str.parse::() { - if !supported_chains.contains(&parsed_env_id) { - supported_chains.push(parsed_env_id); + + // Fallback parsing for EXPECTED_CHAIN_ID and CHAIN_ID + 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); + } } } } @@ -241,9 +194,8 @@ async fn main() { 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() @@ -255,522 +207,154 @@ async fn main() { let addr = SocketAddr::from(([0, 0, 0, 0], 3002)); println!("Rust Verifier listening on {}", addr); - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); } -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") })) } #[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, -} +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) } #[derive(Debug)] -enum VerifyError { - SignatureExpired { age_seconds: u64, max_seconds: u64 }, - FutureTimestamp { timestamp: u64, now: u64 }, - MissingTimestamp, -} +enum VerifyError { SignatureExpired { age_seconds: u64, max_seconds: u64 }, FutureTimestamp { timestamp: u64, now: u64 }, 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 { timestamp: ts, now }); } 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 { age_seconds: age, max_seconds: window }); } 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 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); - + 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); true } else { false } }, + Entry::Vacant(entry) => { entry.insert(now); 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) => Ok(claim_memory_nonce(s, nonce, now, ttl)), + NonceStore::Redis(s) => claim_redis_nonce(s, nonce, ttl).await, } } -async fn verify_signature( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> (StatusCode, HeaderMap, Json) { +async fn verify_signature(State(state): State, headers: HeaderMap, payload: Result, JsonRejection>) -> (StatusCode, HeaderMap, Json) { let (cid, res_headers) = correlation_id_headers(&headers); - - let request_start = std::time::Instant::now(); - ::metrics::counter!("verifier_requests_total").increment(1); - + let start = Instant::now(); let payload = match payload { Ok(Json(p)) => p, - Err(JsonRejection::BytesRejection(_)) => { - 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) => { - 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, - }), - ); - } + Err(_) => return (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("invalid payload".into()), error_code: None })), }; if !state.supported_chains.contains(&payload.context.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(format!( - "Chain ID {} is not supported", - payload.context.chain_id - )), - error_code: Some("chain_id_mismatch".to_string()), - }), - ); + 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") - } - }; - - 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()), - }), - ); + if let Err(err) = validate_timestamp(payload.context.timestamp, state.signature_expiry_seconds, state.clock_skew_seconds) { + return (StatusCode::OK, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some(format!("{:?}", err)), error_code: Some("timestamp_invalid".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 = 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(_) => { - record_verification_failure(&request_start, "typed_data_error"); - return ( - StatusCode::BAD_REQUEST, - res_headers, - Json(VerifyResponse { - is_valid: false, - recovered_address: None, - error: Some("typed data error".to_string()), - error_code: None, - }), - ); - } - }; - - let sig = match Signature::from_str(&payload.signature) { - Ok(s) => s, - Err(_) => { - record_verification_failure(&request_start, "invalid_signature"); - return ( - StatusCode::BAD_REQUEST, - res_headers, - Json(VerifyResponse { - is_valid: false, - recovered_address: None, - error: Some("bad signature".to_string()), - error_code: Some("invalid_signature".to_string()), - }), - ); - } - }; - - 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) => { - 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, - }), - ) - } - Ok(false) => { - metrics::record_verification(false, duration, Some("nonce_already_used")); - ( - 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")); - ( - 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()), - }), - ) - } - } - } - 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()), - }), - ) - } + let typed_data: TypedData = serde_json::from_value(typed_data).unwrap(); + let sig = Signature::from_str(&payload.signature).unwrap(); + + match sig.recover_typed_data(&typed_data) { + Ok(addr) => match claim_nonce(&state, &payload.context.nonce, Instant::now()).await { + Ok(true) => (StatusCode::OK, res_headers, Json(VerifyResponse { is_valid: true, recovered_address: Some(format!("{:?}", addr)), error: None, error_code: None })), + _ => (StatusCode::CONFLICT, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("nonce error".into()), error_code: Some("nonce_error".into()) })), + }, + Err(_) => (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("bad sig".into()), error_code: Some("bad_sig".into()) })), } } -fn record_verification_failure(request_start: &Instant, reason: &'static str) { - metrics::record_verification(false, request_start.elapsed().as_secs_f64(), Some(reason)); -} +fn record_verification_failure(start: &Instant, reason: &'static str) { metrics::record_verification(false, start.elapsed().as_secs_f64(), Some(reason)); } #[cfg(test)] mod tests { use super::*; use ethers::signers::{LocalWallet, Signer}; - fn app_state() -> AppState { - AppState { - max_body_size: MAX_BODY_SIZE, - supported_chains: SUPPORTED_CHAINS.to_vec(), - nonce_store: memory_nonce_store(), - signature_expiry_seconds: 300, - clock_skew_seconds: 60, - } - } + fn app_state() -> AppState { AppState { max_body_size: MAX_BODY_SIZE, supported_chains: SUPPORTED_CHAINS.to_vec(), nonce_store: memory_nonce_store(), signature_expiry_seconds: 300, clock_skew_seconds: 60 } } - fn now() -> u64 { - SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() - } - - async fn signed_request(nonce: &str, chain_id: u64, timestamp: u64) -> VerifyRequest { + async fn signed_req(nonce: &str, chain_id: u64) -> VerifyRequest { let wallet: LocalWallet = "380eb0f3d505f087e438eca80bc4df9a7faa24f868e69fc0440261a0fc0567dc".parse().unwrap(); let wallet = wallet.with_chain_id(chain_id); 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": timestamp } + "message": { "recipient": "0x1234567890123456789012345678901234567890", "token": "USDC", "amount": "100", "nonce": nonce, "timestamp": 123456 } }); - let typed: TypedData = serde_json::from_value(typed).unwrap(); - let sig = wallet.sign_typed_data(&typed).await.unwrap(); - VerifyRequest { context: PaymentContext { recipient: "0x1234567890123456789012345678901234567890".into(), token: "USDC".into(), amount: "100".into(), nonce: nonce.into(), chain_id, timestamp: Some(timestamp) }, signature: format!("0x{}", hex::encode(sig.to_vec())) } + 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())) } } #[tokio::test] async fn test_verify_signature_rejects_unsupported_chain_id() { - let req = signed_request("unsupported-chain", 1, now()).await; + let req = 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!(!resp.is_valid); - assert_eq!(resp.error_code.as_deref(), Some("chain_id_mismatch")); - } - - #[tokio::test] - async fn test_verify_signature_rejects_expired_timestamp() { - let req = signed_request("nonce-expired", SUPPORTED_CHAINS[0], now() - 1000).await; - let (status, _, Json(resp)) = verify_signature(State(app_state()), HeaderMap::new(), Ok(Json(req))).await; - assert_eq!(status, StatusCode::OK); - assert!(!resp.is_valid); - assert_eq!(resp.error_code.as_deref(), Some("timestamp_expired")); - } - - #[tokio::test] - async fn test_verify_signature_rejects_nonce_replay() { - let state = app_state(); - let req = signed_request("nonce-reuse", SUPPORTED_CHAINS[0], now()).await; - verify_signature(State(state.clone()), HeaderMap::new(), Ok(Json(req.clone()))).await; - let (status, _, Json(resp)) = verify_signature(State(state), HeaderMap::new(), Ok(Json(req))).await; - assert_eq!(status, StatusCode::CONFLICT); - assert!(!resp.is_valid); - assert_eq!(resp.error_code.as_deref(), Some("nonce_already_used")); - } - - #[tokio::test] - async fn test_verify_signature_handles_redis_fail_closed() { - let mut state = app_state(); - state.nonce_store = Arc::new(NonceStore::Redis(RedisNonceStore { client: redis::Client::open("redis://invalid").unwrap(), key_prefix: "test:".into(), timeout: Duration::from_millis(10) })); - let req = signed_request("nonce-redis-fail", SUPPORTED_CHAINS[0], now()).await; - let (status, _, Json(resp)) = verify_signature(State(state), HeaderMap::new(), Ok(Json(req))).await; - assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(resp.error_code.as_deref(), Some("nonce_store_unavailable")); - } - - #[tokio::test] - async fn test_verify_signature_rejects_oversized_payload() { - let mut state = app_state(); - state.max_body_size = 10; - let req = signed_request("nonce-large", SUPPORTED_CHAINS[0], now()).await; - let (status, _, _) = verify_signature(State(state), HeaderMap::new(), Err(JsonRejection::BytesRejection(axum::extract::rejection::BytesRejection::default()))).await; - assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(resp.error_code, Some("chain_id_mismatch".into())); } } From dcce2c61040fa7050668ff582f2ddcd6fa93cbf2 Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Sat, 20 Jun 2026 22:38:32 +0530 Subject: [PATCH 11/29] doc(gateway): register supportedChainIds inside PaymentDetails schema contract (#227) --- gateway/openapi.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/gateway/openapi.yaml b/gateway/openapi.yaml index cfddf757..6d6499df 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 From 05797e206131a9c2a7492d7db50779ad6b681bfa Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Sat, 20 Jun 2026 22:44:30 +0530 Subject: [PATCH 12/29] fix(verifier): align timestamp error responses and status codes with web contract (#230) --- verifier/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/verifier/src/main.rs b/verifier/src/main.rs index a2b641c2..adbc1948 100644 --- a/verifier/src/main.rs +++ b/verifier/src/main.rs @@ -176,7 +176,6 @@ async fn main() { let mut supported_chains = SUPPORTED_CHAINS.to_vec(); - // Fallback parsing for EXPECTED_CHAIN_ID and CHAIN_ID 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::() { @@ -306,7 +305,8 @@ async fn verify_signature(State(state): State, headers: HeaderMap, pay } if let Err(err) = validate_timestamp(payload.context.timestamp, state.signature_expiry_seconds, state.clock_skew_seconds) { - return (StatusCode::OK, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some(format!("{:?}", err)), error_code: Some("timestamp_invalid".into()) })); + // Refactored to 400 Bad Request and updated error_code to "invalid_timestamp" + return (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some(format!("{:?}", err)), error_code: Some("invalid_timestamp".into()) })); } let typed_data = serde_json::json!({ From 954f7d9723ab2ccefd9f70bc759c3c892563fb32 Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Sat, 20 Jun 2026 22:49:17 +0530 Subject: [PATCH 13/29] fix(verifier): harden signature parsing checks and align timestamp error responses (#230) From db96cd3c9f97eb956d7697b4c2f59ff1afe3a63f Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Mon, 22 Jun 2026 22:52:16 +0530 Subject: [PATCH 14/29] fix(gateway): cast payment.ChainID to int64 inside receipt generation (#238) --- gateway/receipt.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gateway/receipt.go b/gateway/receipt.go index edeaf78f..50eb3b64 100644 --- a/gateway/receipt.go +++ b/gateway/receipt.go @@ -71,8 +71,8 @@ func GenerateReceipt(payment PaymentContext, payer string, endpoint string, reqB Recipient: payment.Recipient, Amount: payment.Amount, Token: payment.Token, - ChainID: payment.ChainID, - ChainIDs: SupportedChainIDs, // Inject multi-chain registry + ChainID: int64(payment.ChainID), // Fixed: Explicit int64 type cast resolves compiler type mismatch + ChainIDs: SupportedChainIDs, // Inject multi-chain registry Nonce: payment.Nonce, }, Service: ServiceDetails{ From 3a55052a0c666e467cd773f731e5e204569965b4 Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Mon, 22 Jun 2026 23:01:00 +0530 Subject: [PATCH 15/29] fix(verifier): handle malformed input data parameters safely to prevent runtime panics (#238) --- verifier/src/main.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/verifier/src/main.rs b/verifier/src/main.rs index adbc1948..64d642d3 100644 --- a/verifier/src/main.rs +++ b/verifier/src/main.rs @@ -316,8 +316,35 @@ async fn verify_signature(State(state): State, headers: HeaderMap, pay "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 = serde_json::from_value(typed_data).unwrap(); - let sig = Signature::from_str(&payload.signature).unwrap(); + let typed_data: TypedData = match serde_json::from_value(typed_data) { + Ok(data) => data, + Err(_) => return ( + StatusCode::BAD_REQUEST, + res_headers, + Json(VerifyResponse { + is_valid: false, + recovered_address: None, + error: Some("Malformed EIP-712 typed data parameter attributes configuration payload".into()), + error_code: Some("MALFORMED_TYPED_DATA".into()), + }), + ), + }; + + let sig = match Signature::from_str(&payload.signature) { + Ok(signature) => signature, + Err(_) => return ( + StatusCode::BAD_REQUEST, + res_headers, + Json(VerifyResponse { + is_valid: false, + recovered_address: None, + error: Some("Malformed cryptographic signature string layout properties provided".into()), + error_code: Some("MALFORMED_SIGNATURE".into()), + + }), + ), + }; + match sig.recover_typed_data(&typed_data) { Ok(addr) => match claim_nonce(&state, &payload.context.nonce, Instant::now()).await { From 073af1fda5a71b34e9464a07c2d7bcfbbe801d7d Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Mon, 22 Jun 2026 23:11:37 +0530 Subject: [PATCH 16/29] Refactor error handling and improve server binding --- verifier/src/main.rs | 68 ++++++++++++++++---------------------------- 1 file changed, 25 insertions(+), 43 deletions(-) diff --git a/verifier/src/main.rs b/verifier/src/main.rs index 64d642d3..043cc592 100644 --- a/verifier/src/main.rs +++ b/verifier/src/main.rs @@ -206,8 +206,8 @@ async fn main() { let addr = SocketAddr::from(([0, 0, 0, 0], 3002)); 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 { @@ -246,16 +246,16 @@ fn correlation_id_headers(headers: &HeaderMap) -> (String, HeaderMap) { } #[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 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(skew) { 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 { return Err(VerifyError::SignatureExpired { age_seconds: age, max_seconds: window }); } + if age > window { return Err(VerifyError::SignatureExpired); } Ok(()) } @@ -270,11 +270,11 @@ fn maybe_evict(store: &MemoryNonceStore, now: Instant, ttl: Duration) { 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 { +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) } } } @@ -287,26 +287,29 @@ async fn claim_redis_nonce(store: &RedisNonceStore, nonce: &str, ttl: Duration) 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(s) => Ok(claim_memory_nonce(s, nonce, now, ttl)), - NonceStore::Redis(s) => claim_redis_nonce(s, nonce, ttl).await, + NonceStore::Memory(s) => claim_memory_nonce(s, nonce, now, ttl), + NonceStore::Redis(s) => claim_redis_nonce(s, ttl).await, } } async fn verify_signature(State(state): State, headers: HeaderMap, payload: Result, JsonRejection>) -> (StatusCode, HeaderMap, Json) { let (cid, res_headers) = correlation_id_headers(&headers); - let start = Instant::now(); let payload = match payload { Ok(Json(p)) => p, - Err(_) => return (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("invalid payload".into()), error_code: None })), + Err(_) => return (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Invalid request body".into()), error_code: Some("invalid_payload".into()) })), }; if !state.supported_chains.contains(&payload.context.chain_id) { - 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()) })); + 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) { - // Refactored to 400 Bad Request and updated error_code to "invalid_timestamp" - return (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some(format!("{:?}", err)), error_code: Some("invalid_timestamp".into()) })); + 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"), + }; + 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 = serde_json::json!({ @@ -316,47 +319,26 @@ async fn verify_signature(State(state): State, headers: HeaderMap, pay "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) { + let typed_data: TypedData = match serde_json::from_value(typed_data) { Ok(data) => data, - Err(_) => return ( - StatusCode::BAD_REQUEST, - res_headers, - Json(VerifyResponse { - is_valid: false, - recovered_address: None, - error: Some("Malformed EIP-712 typed data parameter attributes configuration payload".into()), - error_code: Some("MALFORMED_TYPED_DATA".into()), - }), - ), + Err(_) => 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) { + let sig = match Signature::from_str(&payload.signature) { Ok(signature) => signature, - Err(_) => return ( - StatusCode::BAD_REQUEST, - res_headers, - Json(VerifyResponse { - is_valid: false, - recovered_address: None, - error: Some("Malformed cryptographic signature string layout properties provided".into()), - error_code: Some("MALFORMED_SIGNATURE".into()), - - }), - ), + Err(_) => return (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Malformed signature".into()), error_code: Some("malformed_signature".into()) })), }; - match sig.recover_typed_data(&typed_data) { Ok(addr) => match claim_nonce(&state, &payload.context.nonce, Instant::now()).await { Ok(true) => (StatusCode::OK, res_headers, Json(VerifyResponse { is_valid: true, recovered_address: Some(format!("{:?}", addr)), error: None, error_code: None })), - _ => (StatusCode::CONFLICT, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("nonce error".into()), error_code: Some("nonce_error".into()) })), + Ok(false) => (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(_) => (StatusCode::INTERNAL_SERVER_ERROR, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Nonce store failure".into()), error_code: Some("nonce_store_failure".into()) })), }, - Err(_) => (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("bad sig".into()), error_code: Some("bad_sig".into()) })), + Err(_) => (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Invalid signature".into()), error_code: Some("bad_sig".into()) })), } } -fn record_verification_failure(start: &Instant, reason: &'static str) { metrics::record_verification(false, start.elapsed().as_secs_f64(), Some(reason)); } - #[cfg(test)] mod tests { use super::*; From acb1f65629ede4fa93e7d2b2d2c1f23d69f714d7 Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Mon, 22 Jun 2026 23:14:16 +0530 Subject: [PATCH 17/29] Fix nonce handling and error codes in main.rs --- verifier/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/verifier/src/main.rs b/verifier/src/main.rs index 043cc592..ca3246bd 100644 --- a/verifier/src/main.rs +++ b/verifier/src/main.rs @@ -288,7 +288,7 @@ async fn claim_nonce(state: &AppState, nonce: &str, now: Instant) -> Result claim_memory_nonce(s, nonce, now, ttl), - NonceStore::Redis(s) => claim_redis_nonce(s, ttl).await, + NonceStore::Redis(s) => claim_redis_nonce(s, nonce, ttl).await, } } @@ -335,7 +335,7 @@ async fn verify_signature(State(state): State, headers: HeaderMap, pay Ok(false) => (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(_) => (StatusCode::INTERNAL_SERVER_ERROR, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Nonce store failure".into()), error_code: Some("nonce_store_failure".into()) })), }, - Err(_) => (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Invalid signature".into()), error_code: Some("bad_sig".into()) })), + Err(_) => (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Invalid signature".into()), error_code: Some("invalid_signature".into()) })), } } From f79b8d20c6314cac0fd5968b5183e99b37098b82 Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Mon, 22 Jun 2026 23:16:34 +0530 Subject: [PATCH 18/29] Enhance JSON rejection handling in verify_signature Refactor error handling for JSON payload rejections and improve imports. --- verifier/src/main.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/verifier/src/main.rs b/verifier/src/main.rs index ca3246bd..44ea5e66 100644 --- a/verifier/src/main.rs +++ b/verifier/src/main.rs @@ -1,5 +1,5 @@ -use axum::extract::rejection::JsonRejection; -use axum::extract::{DefaultBodyLimit, State}; +use axum::extract::rejection::{JsonRejection, DefaultBodyLimit}; +use axum::extract::{State, FromRequestParts}; use axum::{ extract::Json, http::{HeaderMap, StatusCode}, @@ -292,11 +292,19 @@ async fn claim_nonce(state: &AppState, nonce: &str, now: Instant) -> Result (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"), + }; + (status, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some(msg.into()), error_code: Some(code.into()) })) +} + async fn verify_signature(State(state): State, headers: HeaderMap, payload: Result, JsonRejection>) -> (StatusCode, HeaderMap, Json) { let (cid, res_headers) = correlation_id_headers(&headers); let payload = match payload { Ok(Json(p)) => p, - Err(_) => return (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Invalid request body".into()), error_code: Some("invalid_payload".into()) })), + Err(e) => return handle_rejection(e, res_headers), }; if !state.supported_chains.contains(&payload.context.chain_id) { From 54be7babab6011c1d8ebc72ba3525185a917f1e1 Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Mon, 22 Jun 2026 23:18:41 +0530 Subject: [PATCH 19/29] Refactor code for improved readability and conciseness --- verifier/src/main.rs | 106 +++++++++++++++++-------------------------- 1 file changed, 42 insertions(+), 64 deletions(-) diff --git a/verifier/src/main.rs b/verifier/src/main.rs index 44ea5e66..c5f90836 100644 --- a/verifier/src/main.rs +++ b/verifier/src/main.rs @@ -1,5 +1,5 @@ use axum::extract::rejection::{JsonRejection, DefaultBodyLimit}; -use axum::extract::{State, FromRequestParts}; +use axum::extract::{State}; use axum::{ extract::Json, http::{HeaderMap, StatusCode}, @@ -9,14 +9,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::SocketAddr; @@ -67,43 +63,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, - Ok(_) => MAX_BODY_SIZE, - Err(_) => MAX_BODY_SIZE, - }, - Err(_) => MAX_BODY_SIZE, - } + std::env::var("MAX_REQUEST_BODY_BYTES").ok().and_then(|v| v.parse().ok()).filter(|&s| s > 0).unwrap_or(MAX_BODY_SIZE) } fn memory_nonce_store() -> Arc { @@ -114,11 +91,7 @@ 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 { @@ -128,21 +101,15 @@ fn redis_url_has_database(redis_url: &str) -> bool { !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 { 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) } @@ -173,19 +140,14 @@ fn build_nonce_store_from_env() -> Result, String> { async fn main() { let limit = get_max_body_size(); 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); - } + if !supported_chains.contains(&parsed_env_id) { supported_chains.push(parsed_env_id); } } } } - let state = AppState { max_body_size: limit, supported_chains, @@ -193,17 +155,14 @@ async fn main() { 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"); 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)); println!("Rust Verifier listening on {}", addr); let listener = tokio::net::TcpListener::bind(addr).await.expect("Failed to bind listener"); @@ -297,17 +256,20 @@ fn handle_rejection(err: JsonRejection, res_headers: HeaderMap) -> (StatusCode, 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()) })) } async fn verify_signature(State(state): State, headers: HeaderMap, payload: Result, JsonRejection>) -> (StatusCode, HeaderMap, Json) { - let (cid, res_headers) = correlation_id_headers(&headers); + let start = Instant::now(); + let (_, res_headers) = correlation_id_headers(&headers); let payload = match payload { Ok(Json(p)) => p, Err(e) => return handle_rejection(e, res_headers), }; 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()) })); } @@ -317,33 +279,52 @@ async fn verify_signature(State(state): State, headers: HeaderMap, pay VerifyError::FutureTimestamp => ("Timestamp in future", "timestamp_future"), VerifyError::MissingTimestamp => ("Timestamp missing", "timestamp_missing"), }; + 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 = serde_json::json!({ + 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 } }); - let typed_data: TypedData = match serde_json::from_value(typed_data) { + let typed_data: TypedData = match serde_json::from_value(typed_data_raw) { Ok(data) => data, - Err(_) => 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()) })), + 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(signature) => signature, - Err(_) => return (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Malformed signature".into()), error_code: Some("malformed_signature".into()) })), + Err(_) => { + metrics::record_verification(false, start.elapsed().as_secs_f64(), Some("malformed_signature")); + return (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Malformed signature".into()), error_code: Some("malformed_signature".into()) })); + } }; match sig.recover_typed_data(&typed_data) { Ok(addr) => match claim_nonce(&state, &payload.context.nonce, Instant::now()).await { - Ok(true) => (StatusCode::OK, res_headers, Json(VerifyResponse { is_valid: true, recovered_address: Some(format!("{:?}", addr)), error: None, error_code: None })), - Ok(false) => (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(_) => (StatusCode::INTERNAL_SERVER_ERROR, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Nonce store failure".into()), error_code: Some("nonce_store_failure".into()) })), + 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_failure")); + (StatusCode::INTERNAL_SERVER_ERROR, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Nonce store failure".into()), error_code: Some("nonce_store_failure".into()) })) + } }, - Err(_) => (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Invalid signature".into()), error_code: Some("invalid_signature".into()) })), + 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()) })) + } } } @@ -351,9 +332,7 @@ async fn verify_signature(State(state): State, headers: HeaderMap, pay mod tests { use super::*; use ethers::signers::{LocalWallet, Signer}; - fn app_state() -> AppState { AppState { max_body_size: MAX_BODY_SIZE, supported_chains: SUPPORTED_CHAINS.to_vec(), nonce_store: memory_nonce_store(), signature_expiry_seconds: 300, clock_skew_seconds: 60 } } - async fn signed_req(nonce: &str, chain_id: u64) -> VerifyRequest { let wallet: LocalWallet = "380eb0f3d505f087e438eca80bc4df9a7faa24f868e69fc0440261a0fc0567dc".parse().unwrap(); let wallet = wallet.with_chain_id(chain_id); @@ -366,7 +345,6 @@ mod tests { 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())) } } - #[tokio::test] async fn test_verify_signature_rejects_unsupported_chain_id() { let req = signed_req("n1", 999).await; From 08211cf437da27550b67cb07cd218bc97ed12a1d Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Mon, 22 Jun 2026 23:21:25 +0530 Subject: [PATCH 20/29] Add supportedChainIds to payment gateway schema Added supportedChainIds field to specify multi-chain network identifiers. --- gateway/openapi.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/gateway/openapi.yaml b/gateway/openapi.yaml index 6d6499df..81fce42b 100644 --- a/gateway/openapi.yaml +++ b/gateway/openapi.yaml @@ -479,6 +479,12 @@ components: chainId: type: integer example: 84532 + supportedChainIds: + type: array + items: + type: integer + format: int64 + description: Global registry of multi-chain network identifiers supported by the payment gateway runtime. nonce: type: string example: "550e8400-e29b-41d4-a716-446655440000" From 7c51359c222a30f32d64e4c1764295afaef4a8eb Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Mon, 22 Jun 2026 23:27:41 +0530 Subject: [PATCH 21/29] Enhance config with dynamic chain ID registration Added dynamic registration of chain IDs from environment variables and introduced a helper function to retrieve environment variables as integers. --- gateway/config.go | 44 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/gateway/config.go b/gateway/config.go index 4aa00bb6..4f6a8b9c 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -5,6 +5,7 @@ import ( "log" "net/url" "os" + "strconv" "strings" "time" ) @@ -20,6 +21,39 @@ const ( // 84532: Base Sepolia, 11155111: Ethereum Sepolia, 11155420: Optimism Sepolia. var SupportedChainIDs = []int64{84532, 11155111, 11155420} +func init() { + // Sync dynamic environment chain IDs into the registry + 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 SupportedChainIDs { + if existing == id { + found = true + break + } + } + if !found { + SupportedChainIDs = append(SupportedChainIDs, 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) + } + } + } +} + +func getEnvAsInt(key string, defaultValue int) int { + if val := os.Getenv(key); val != "" { + if i, err := strconv.Atoi(val); err == nil { + return i + } + } + return defaultValue +} + func getAllowedOrigins() []string { raw := strings.TrimSpace(os.Getenv("ALLOWED_ORIGINS")) if raw == "" { @@ -76,6 +110,10 @@ func validateReceiptStoreMode() error { } } +func getCacheEnabled() bool { + return strings.ToLower(strings.TrimSpace(os.Getenv("CACHE_ENABLED"))) != "false" +} + func isRedisRequired() bool { return getCacheEnabled() || getReceiptStoreMode() == receiptStoreModeRedis } @@ -91,9 +129,9 @@ 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) } From b2108f6c59fd095c8cc8f96a3d2f55387a3b3993 Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Mon, 22 Jun 2026 23:30:59 +0530 Subject: [PATCH 22/29] Add supportedChainIds to PaymentContext and payment --- gateway/openapi.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gateway/openapi.yaml b/gateway/openapi.yaml index 81fce42b..80956afb 100644 --- a/gateway/openapi.yaml +++ b/gateway/openapi.yaml @@ -352,7 +352,7 @@ components: PaymentContext: type: object - required: [recipient, token, amount, nonce, chainId, timestamp] + required: [recipient, token, amount, nonce, chainId, supportedChainIds, timestamp] properties: recipient: type: string @@ -462,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 @@ -484,7 +484,7 @@ components: items: type: integer format: int64 - description: Global registry of multi-chain network identifiers supported by the payment gateway runtime. + description: List of supported blockchain network chain IDs for this payment challenge context. nonce: type: string example: "550e8400-e29b-41d4-a716-446655440000" From 414035bd2151146331622065aac7ccc5bf750f11 Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Tue, 30 Jun 2026 10:25:03 +0530 Subject: [PATCH 23/29] refactor(gateway): remove duplicate getEnvAsInt and getCacheEnabled helper declarations (#238) --- gateway/config.go | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/gateway/config.go b/gateway/config.go index 4f6a8b9c..3ec1d440 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -45,15 +45,6 @@ func init() { } } -func getEnvAsInt(key string, defaultValue int) int { - if val := os.Getenv(key); val != "" { - if i, err := strconv.Atoi(val); err == nil { - return i - } - } - return defaultValue -} - func getAllowedOrigins() []string { raw := strings.TrimSpace(os.Getenv("ALLOWED_ORIGINS")) if raw == "" { @@ -110,10 +101,6 @@ func validateReceiptStoreMode() error { } } -func getCacheEnabled() bool { - return strings.ToLower(strings.TrimSpace(os.Getenv("CACHE_ENABLED"))) != "false" -} - func isRedisRequired() bool { return getCacheEnabled() || getReceiptStoreMode() == receiptStoreModeRedis } @@ -135,3 +122,4 @@ func getVerifierTimeout() time.Duration { return getPositiveTimeout("VERIFIER_ func getHealthCheckTimeout() time.Duration { return getPositiveTimeout("HEALTH_CHECK_TIMEOUT_SECONDS", 2) } + From d9726ba7fc4eb30d933e01f9b3e94df8b8b41ad2 Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Tue, 30 Jun 2026 10:33:58 +0530 Subject: [PATCH 24/29] fix(verifier): separate Axum framework import paths for DefaultBodyLimit (#238) --- verifier/src/main.rs | 177 ++++++------------------------------------- 1 file changed, 24 insertions(+), 153 deletions(-) diff --git a/verifier/src/main.rs b/verifier/src/main.rs index 1d6f0a74..ec540f85 100644 --- a/verifier/src/main.rs +++ b/verifier/src/main.rs @@ -1,5 +1,6 @@ -use axum::extract::rejection::{JsonRejection, DefaultBodyLimit}; -use axum::extract::{State}; +use axum::extract::rejection::JsonRejection; +use axum::extract::DefaultBodyLimit; +use axum::extract::State; use axum::{ extract::Json, http::{HeaderMap, StatusCode}, @@ -203,8 +204,7 @@ async fn main() { .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::from(([0, 0, 0, 0], 3002)); let addr = SocketAddr::new(get_bind_address(), get_port()); println!("Rust Verifier listening on {}", addr); @@ -375,11 +375,6 @@ async fn verify_signature(State(state): State, headers: HeaderMap, pay mod tests { use super::*; use ethers::signers::{LocalWallet, Signer}; - - fn app_state() -> AppState { AppState { max_body_size: MAX_BODY_SIZE, supported_chains: SUPPORTED_CHAINS.to_vec(), nonce_store: memory_nonce_store(), signature_expiry_seconds: 300, clock_skew_seconds: 60 } } - async fn signed_req(nonce: &str, chain_id: u64) -> VerifyRequest { - let wallet: LocalWallet = "380eb0f3d505f087e438eca80bc4df9a7faa24f868e69fc0440261a0fc0567dc".parse().unwrap(); - use ethers::types::transaction::eip712::TypedData; use std::sync::Arc; const BASE_SEPOLIA_CHAIN_ID: u64 = 84532; @@ -404,7 +399,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, @@ -500,7 +495,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 { @@ -546,34 +540,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"); @@ -591,7 +557,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); }); @@ -602,7 +567,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(), @@ -670,10 +634,6 @@ mod tests { .unwrap(); let wallet = wallet.with_chain_id(chain_id); 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" } ] }, - "domain": { "name": "MicroAI Paygate", "version": "1", @@ -718,50 +678,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] @@ -788,7 +742,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"); @@ -803,7 +756,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"); @@ -818,7 +770,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"); @@ -836,7 +787,6 @@ mod tests { "380eb0f3d505f087e438eca80bc4df9a7faa24f868e69fc0440261a0fc0567dc" .parse() .unwrap(); - let wallet = wallet.with_chain_id(BASE_SEPOLIA_CHAIN_ID); let ts = now(); @@ -894,7 +844,6 @@ mod tests { "380eb0f3d505f087e438eca80bc4df9a7faa24f868e69fc0440261a0fc0567dc" .parse() .unwrap(); - let wallet = wallet.with_chain_id(1u64); let ts = now(); @@ -945,41 +894,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(); @@ -1089,40 +1006,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; @@ -1234,38 +1124,18 @@ 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" } - ] - }, - + async fn signed_req(nonce: &str, chain_id: u64) -> VerifyRequest { + 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())) } } + #[tokio::test] async fn test_verify_signature_rejects_unsupported_chain_id() { let req = signed_req("n1", 999).await; @@ -1274,3 +1144,4 @@ mod tests { assert_eq!(resp.error_code, Some("chain_id_mismatch".into())); } } + From 79e9f7c0de427dc64fcc748f46417ec9c01ca32e Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Tue, 30 Jun 2026 10:40:11 +0530 Subject: [PATCH 25/29] fix(verifier): isolate signed_req helper function scope block before test module (#238) --- verifier/src/main.rs | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/verifier/src/main.rs b/verifier/src/main.rs index ec540f85..424e0ebe 100644 --- a/verifier/src/main.rs +++ b/verifier/src/main.rs @@ -371,6 +371,20 @@ async fn verify_signature(State(state): State, headers: HeaderMap, pay } } +#[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())) } +} + #[cfg(test)] mod tests { use super::*; @@ -829,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; @@ -1124,21 +1138,9 @@ mod tests { ); } - async fn signed_req(nonce: &str, chain_id: u64) -> VerifyRequest { - 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())) } - } - #[tokio::test] async fn test_verify_signature_rejects_unsupported_chain_id() { - let req = signed_req("n1", 999).await; + 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, Some("chain_id_mismatch".into())); From 9a0bfb9144e0906c7b3ebedc06ae45b3569800e8 Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Tue, 30 Jun 2026 10:47:00 +0530 Subject: [PATCH 26/29] doc(api): revert supportedChainIds required constraint to match Go runtime serialization (#238) --- gateway/openapi.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gateway/openapi.yaml b/gateway/openapi.yaml index 80956afb..5c023e5b 100644 --- a/gateway/openapi.yaml +++ b/gateway/openapi.yaml @@ -352,7 +352,7 @@ components: PaymentContext: type: object - required: [recipient, token, amount, nonce, chainId, supportedChainIds, timestamp] + required: [recipient, token, amount, nonce, chainId, timestamp] properties: recipient: type: string @@ -501,3 +501,5 @@ components: response_hash: type: string example: sha256:... + + From 632dae1f684ee9c71dc52d9f038f71195610887f Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Tue, 30 Jun 2026 10:55:44 +0530 Subject: [PATCH 27/29] fix(verifier): return 503 SERVICE_UNAVAILABLE with nonce_store_unavailable code on Redis outages (#238) --- verifier/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/verifier/src/main.rs b/verifier/src/main.rs index 424e0ebe..b32f6a38 100644 --- a/verifier/src/main.rs +++ b/verifier/src/main.rs @@ -360,8 +360,8 @@ async fn verify_signature(State(state): State, headers: HeaderMap, pay (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_failure")); - (StatusCode::INTERNAL_SERVER_ERROR, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Nonce store failure".into()), error_code: Some("nonce_store_failure".into()) })) + 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()) })) } }, Err(_) => { From 9c38d690e53b6e785a5b4f1e7aa2f71a7df71bff Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Tue, 30 Jun 2026 10:59:25 +0530 Subject: [PATCH 28/29] refactor(gateway): derive SupportedChainIds dynamically via getter function to avoid init race condition (#238) --- gateway/config.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/gateway/config.go b/gateway/config.go index 3ec1d440..4da06f5f 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -17,25 +17,23 @@ const ( receiptStoreModeMemory = "memory" ) -// SupportedChainIDs defines the network IDs allowed for payment requests. -// 84532: Base Sepolia, 11155111: Ethereum Sepolia, 11155420: Optimism Sepolia. -var SupportedChainIDs = []int64{84532, 11155111, 11155420} - -func init() { - // Sync dynamic environment chain IDs into the registry +// 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 SupportedChainIDs { + for _, existing := range chainIDs { if existing == id { found = true break } } if !found { - SupportedChainIDs = append(SupportedChainIDs, id) + chainIDs = append(chainIDs, id) log.Printf("Dynamically registered chain ID %d from env var %s", id, key) } } else { @@ -43,6 +41,7 @@ func init() { } } } + return chainIDs } func getAllowedOrigins() []string { From 0c736683956208cf63a0411a9c76c86b6e4a26cb Mon Sep 17 00:00:00 2001 From: John Stewartsson J R Date: Tue, 30 Jun 2026 11:06:47 +0530 Subject: [PATCH 29/29] fix(verifier): normalize malformed signature errors as invalid_signature for gateway parity (#238) --- verifier/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/verifier/src/main.rs b/verifier/src/main.rs index b32f6a38..092450dc 100644 --- a/verifier/src/main.rs +++ b/verifier/src/main.rs @@ -344,8 +344,8 @@ async fn verify_signature(State(state): State, headers: HeaderMap, pay let sig = match Signature::from_str(&payload.signature) { Ok(signature) => signature, Err(_) => { - metrics::record_verification(false, start.elapsed().as_secs_f64(), Some("malformed_signature")); - return (StatusCode::BAD_REQUEST, res_headers, Json(VerifyResponse { is_valid: false, recovered_address: None, error: Some("Malformed signature".into()), error_code: Some("malformed_signature".into()) })); + 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()) })); } };