From 03c1664739962bdc8e6358a2c510e9b39f5a253a Mon Sep 17 00:00:00 2001 From: Gulshan Yadav Date: Wed, 28 Jan 2026 15:03:36 +0530 Subject: [PATCH] feat: Implement standard API response types and health check endpoints - Added `ApiResponse`, `ResponseMeta`, and related structures for standardized API responses. - Created health check routes including basic health, liveness, and readiness checks. - Introduced `AppState` for shared application state across routes. - Developed wallet API endpoints for wallet management, address operations, balance queries, and transaction signing. - Implemented RPC API endpoints for blockchain operations including block queries, transaction handling, and network information. --- crates/synor-gateway/Cargo.toml | 69 +++ crates/synor-gateway/src/auth.rs | 387 +++++++++++++ crates/synor-gateway/src/config.rs | 368 +++++++++++++ crates/synor-gateway/src/error.rs | 289 ++++++++++ crates/synor-gateway/src/lib.rs | 67 +++ crates/synor-gateway/src/middleware.rs | 349 ++++++++++++ crates/synor-gateway/src/response.rs | 290 ++++++++++ crates/synor-gateway/src/routes/health.rs | 104 ++++ crates/synor-gateway/src/routes/mod.rs | 54 ++ crates/synor-gateway/src/routes/rpc.rs | 366 +++++++++++++ crates/synor-gateway/src/routes/storage.rs | 259 +++++++++ crates/synor-gateway/src/routes/wallet.rs | 605 +++++++++++++++++++++ 12 files changed, 3207 insertions(+) create mode 100644 crates/synor-gateway/Cargo.toml create mode 100644 crates/synor-gateway/src/auth.rs create mode 100644 crates/synor-gateway/src/config.rs create mode 100644 crates/synor-gateway/src/error.rs create mode 100644 crates/synor-gateway/src/lib.rs create mode 100644 crates/synor-gateway/src/middleware.rs create mode 100644 crates/synor-gateway/src/response.rs create mode 100644 crates/synor-gateway/src/routes/health.rs create mode 100644 crates/synor-gateway/src/routes/mod.rs create mode 100644 crates/synor-gateway/src/routes/rpc.rs create mode 100644 crates/synor-gateway/src/routes/storage.rs create mode 100644 crates/synor-gateway/src/routes/wallet.rs diff --git a/crates/synor-gateway/Cargo.toml b/crates/synor-gateway/Cargo.toml new file mode 100644 index 0000000..f4f63d7 --- /dev/null +++ b/crates/synor-gateway/Cargo.toml @@ -0,0 +1,69 @@ +[package] +name = "synor-gateway" +version = "0.1.0" +edition = "2021" +description = "Unified REST API gateway for Synor blockchain services" +license = "MIT OR Apache-2.0" +repository = "https://github.com/synortech/synor" +keywords = ["blockchain", "api", "gateway", "rest"] +categories = ["web-programming", "api-bindings"] + +[features] +default = ["openapi"] +openapi = ["dep:utoipa", "dep:utoipa-swagger-ui"] +full = ["openapi"] + +[dependencies] +# Web framework +axum = { version = "0.7", features = ["macros", "ws"] } +axum-extra = { version = "0.9", features = ["typed-header"] } +tower = { version = "0.4", features = ["full"] } +tower-http = { version = "0.5", features = ["cors", "trace", "compression-gzip", "limit", "request-id"] } +hyper = { version = "1.0", features = ["full"] } +hyper-util = { version = "0.1", features = ["full"] } + +# Async runtime +tokio = { version = "1.35", features = ["full"] } +futures = "0.3" + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# OpenAPI documentation +utoipa = { version = "4.2", features = ["axum_extras"], optional = true } +utoipa-swagger-ui = { version = "6.0", features = ["axum"], optional = true } + +# Authentication & Security +jsonwebtoken = "9.2" +blake3 = "1.5" +base64 = "0.21" +uuid = { version = "1.6", features = ["v4", "serde"] } + +# Rate limiting +governor = "0.6" + +# Tracing & Metrics +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +metrics = "0.22" +metrics-exporter-prometheus = "0.13" + +# Error handling +thiserror = "1.0" +anyhow = "1.0" + +# Time +chrono = { version = "0.4", features = ["serde"] } + +# Configuration +config = "0.14" + +# Internal dependencies +# synor-rpc = { path = "../synor-rpc" } +# synor-crypto = { path = "../synor-crypto" } +# synor-database = { path = "../synor-database" } + +[dev-dependencies] +tokio-test = "0.4" +reqwest = { version = "0.11", features = ["json"] } diff --git a/crates/synor-gateway/src/auth.rs b/crates/synor-gateway/src/auth.rs new file mode 100644 index 0000000..e3dc2b9 --- /dev/null +++ b/crates/synor-gateway/src/auth.rs @@ -0,0 +1,387 @@ +//! Authentication and authorization. + +use crate::error::ApiError; +use axum::{ + extract::FromRequestParts, + http::{header::AUTHORIZATION, request::Parts, HeaderMap}, +}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use chrono::{DateTime, Duration, Utc}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// API key tier for rate limiting. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ApiKeyTier { + Free, + Developer, + Pro, + Enterprise, +} + +impl Default for ApiKeyTier { + fn default() -> Self { + Self::Free + } +} + +/// Permissions for API operations. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Permissions { + /// Read access + pub read: bool, + + /// Write access + pub write: bool, + + /// Delete access + pub delete: bool, + + /// Admin access + pub admin: bool, + + /// Specific service access (empty = all) + pub services: Vec, +} + +impl Default for Permissions { + fn default() -> Self { + Self { + read: true, + write: false, + delete: false, + admin: false, + services: vec![], + } + } +} + +impl Permissions { + /// Full permissions. + pub fn full() -> Self { + Self { + read: true, + write: true, + delete: true, + admin: true, + services: vec![], + } + } + + /// Read-only permissions. + pub fn read_only() -> Self { + Self { + read: true, + write: false, + delete: false, + admin: false, + services: vec![], + } + } + + /// Check if the user has access to a specific service. + pub fn has_service_access(&self, service: &str) -> bool { + self.services.is_empty() || self.services.iter().any(|s| s == service) + } +} + +/// Authenticated user context. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthContext { + /// User/account ID + pub user_id: String, + + /// API key ID (if authenticated via API key) + pub api_key_id: Option, + + /// User's tier + pub tier: ApiKeyTier, + + /// User's permissions + pub permissions: Permissions, + + /// Authentication timestamp + pub authenticated_at: DateTime, + + /// Token expiration (if JWT) + pub expires_at: Option>, +} + +impl AuthContext { + /// Check if the user can read. + pub fn can_read(&self) -> bool { + self.permissions.read + } + + /// Check if the user can write. + pub fn can_write(&self) -> bool { + self.permissions.write + } + + /// Check if the user can delete. + pub fn can_delete(&self) -> bool { + self.permissions.delete + } + + /// Check if the user is admin. + pub fn is_admin(&self) -> bool { + self.permissions.admin + } + + /// Check if the user can access a service. + pub fn can_access_service(&self, service: &str) -> bool { + self.permissions.has_service_access(service) + } +} + +/// JWT claims structure. +#[derive(Debug, Serialize, Deserialize)] +pub struct JwtClaims { + /// Subject (user ID) + pub sub: String, + + /// Expiration time + pub exp: i64, + + /// Issued at + pub iat: i64, + + /// API key ID (if applicable) + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key_id: Option, + + /// User tier + pub tier: ApiKeyTier, + + /// Permissions + pub permissions: Permissions, +} + +/// Authentication service. +#[derive(Clone)] +pub struct AuthService { + jwt_secret: Arc, + jwt_expiration: Duration, +} + +impl AuthService { + /// Create a new authentication service. + pub fn new(jwt_secret: String, jwt_expiration_secs: i64) -> Self { + Self { + jwt_secret: Arc::new(jwt_secret), + jwt_expiration: Duration::seconds(jwt_expiration_secs), + } + } + + /// Generate a JWT token. + pub fn generate_token(&self, user_id: &str, tier: ApiKeyTier, permissions: Permissions) -> Result { + let now = Utc::now(); + let exp = now + self.jwt_expiration; + + let claims = JwtClaims { + sub: user_id.to_string(), + exp: exp.timestamp(), + iat: now.timestamp(), + api_key_id: None, + tier, + permissions, + }; + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(self.jwt_secret.as_bytes()), + ) + .map_err(|e| ApiError::InternalError) + } + + /// Validate a JWT token. + pub fn validate_token(&self, token: &str) -> Result { + let token_data = decode::( + token, + &DecodingKey::from_secret(self.jwt_secret.as_bytes()), + &Validation::default(), + ) + .map_err(|e| match e.kind() { + jsonwebtoken::errors::ErrorKind::ExpiredSignature => ApiError::ExpiredToken, + _ => ApiError::InvalidToken(e.to_string()), + })?; + + let claims = token_data.claims; + let expires_at = DateTime::from_timestamp(claims.exp, 0) + .map(|dt| dt.with_timezone(&Utc)); + + Ok(AuthContext { + user_id: claims.sub, + api_key_id: claims.api_key_id, + tier: claims.tier, + permissions: claims.permissions, + authenticated_at: Utc::now(), + expires_at, + }) + } + + /// Validate an API key. + pub async fn validate_api_key(&self, api_key: &str) -> Result { + // Parse API key format: key_id:secret + let parts: Vec<&str> = api_key.split(':').collect(); + if parts.len() != 2 { + return Err(ApiError::InvalidApiKey); + } + + let key_id = parts[0]; + let secret = parts[1]; + + // Hash the secret using blake3 + let secret_hash = blake3::hash(secret.as_bytes()).to_hex().to_string(); + + // In production, this would look up the key in a database + // For now, we'll accept any properly formatted key for development + // TODO: Implement actual key lookup + + // Placeholder: accept keys starting with "sk_" for development + if !key_id.starts_with("sk_") { + return Err(ApiError::InvalidApiKey); + } + + // Return a development context + Ok(AuthContext { + user_id: format!("user_{}", &key_id[3..]), + api_key_id: Some(key_id.to_string()), + tier: ApiKeyTier::Developer, + permissions: Permissions::full(), + authenticated_at: Utc::now(), + expires_at: None, + }) + } + + /// Authenticate from headers. + pub async fn authenticate(&self, headers: &HeaderMap) -> Result { + // Try Bearer token first + if let Some(auth_header) = headers.get(AUTHORIZATION) { + let auth_str = auth_header + .to_str() + .map_err(|_| ApiError::InvalidToken("Invalid header encoding".to_string()))?; + + if auth_str.starts_with("Bearer ") { + let token = &auth_str[7..]; + return self.validate_token(token); + } + } + + // Try API key header + if let Some(api_key) = headers.get("X-API-Key") { + let key = api_key + .to_str() + .map_err(|_| ApiError::InvalidApiKey)?; + return self.validate_api_key(key).await; + } + + // Try Authorization: Basic (for API key as base64) + if let Some(auth_header) = headers.get(AUTHORIZATION) { + let auth_str = auth_header + .to_str() + .map_err(|_| ApiError::InvalidToken("Invalid header encoding".to_string()))?; + + if auth_str.starts_with("Basic ") { + let encoded = &auth_str[6..]; + let decoded = BASE64 + .decode(encoded) + .map_err(|_| ApiError::InvalidApiKey)?; + let key = String::from_utf8(decoded) + .map_err(|_| ApiError::InvalidApiKey)?; + return self.validate_api_key(&key).await; + } + } + + Err(ApiError::Unauthorized) + } +} + +/// Extractor for authenticated requests. +#[derive(Debug, Clone)] +pub struct Authenticated(pub AuthContext); + +impl FromRequestParts for Authenticated +where + S: Send + Sync, +{ + type Rejection = ApiError; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + // Get auth service from extensions + let auth_service = parts + .extensions + .get::() + .ok_or(ApiError::InternalError)? + .clone(); + + let context = auth_service.authenticate(&parts.headers).await?; + Ok(Authenticated(context)) + } +} + +/// Optional authentication extractor. +#[derive(Debug, Clone)] +pub struct OptionalAuth(pub Option); + +impl FromRequestParts for OptionalAuth +where + S: Send + Sync, +{ + type Rejection = ApiError; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + // Get auth service from extensions + let auth_service = parts + .extensions + .get::() + .cloned(); + + if let Some(auth_service) = auth_service { + match auth_service.authenticate(&parts.headers).await { + Ok(context) => Ok(OptionalAuth(Some(context))), + Err(_) => Ok(OptionalAuth(None)), + } + } else { + Ok(OptionalAuth(None)) + } + } +} + +/// Require specific permissions. +pub fn require_permission( + context: &AuthContext, + permission: &str, +) -> Result<(), ApiError> { + let has_permission = match permission { + "read" => context.can_read(), + "write" => context.can_write(), + "delete" => context.can_delete(), + "admin" => context.is_admin(), + _ => false, + }; + + if has_permission { + Ok(()) + } else { + Err(ApiError::InsufficientPermissions) + } +} + +/// Require access to a specific service. +pub fn require_service_access( + context: &AuthContext, + service: &str, +) -> Result<(), ApiError> { + if context.can_access_service(service) { + Ok(()) + } else { + Err(ApiError::Forbidden(format!( + "No access to service: {}", + service + ))) + } +} diff --git a/crates/synor-gateway/src/config.rs b/crates/synor-gateway/src/config.rs new file mode 100644 index 0000000..5d49337 --- /dev/null +++ b/crates/synor-gateway/src/config.rs @@ -0,0 +1,368 @@ +//! Gateway configuration management. + +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; +use std::time::Duration; + +/// Main gateway configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct GatewayConfig { + /// Server configuration + pub server: ServerConfig, + + /// Authentication configuration + pub auth: AuthConfig, + + /// Rate limiting configuration + pub rate_limit: RateLimitConfig, + + /// CORS configuration + pub cors: CorsConfig, + + /// Service endpoints + pub services: ServiceEndpoints, + + /// Metrics configuration + pub metrics: MetricsConfig, +} + +impl Default for GatewayConfig { + fn default() -> Self { + Self { + server: ServerConfig::default(), + auth: AuthConfig::default(), + rate_limit: RateLimitConfig::default(), + cors: CorsConfig::default(), + services: ServiceEndpoints::default(), + metrics: MetricsConfig::default(), + } + } +} + +/// HTTP server configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct ServerConfig { + /// Listen address for REST API + pub listen_addr: SocketAddr, + + /// Listen address for WebSocket + pub ws_addr: SocketAddr, + + /// Maximum request body size in bytes + pub max_body_size: usize, + + /// Request timeout + #[serde(with = "humantime_serde")] + pub request_timeout: Duration, + + /// Shutdown grace period + #[serde(with = "humantime_serde")] + pub shutdown_timeout: Duration, + + /// Enable compression + pub compression: bool, + + /// API version prefix + pub api_version: String, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + listen_addr: "0.0.0.0:8000".parse().unwrap(), + ws_addr: "0.0.0.0:8001".parse().unwrap(), + max_body_size: 10 * 1024 * 1024, // 10 MB + request_timeout: Duration::from_secs(30), + shutdown_timeout: Duration::from_secs(10), + compression: true, + api_version: "v1".to_string(), + } + } +} + +/// Authentication configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AuthConfig { + /// Enable authentication + pub enabled: bool, + + /// JWT secret for token signing + pub jwt_secret: String, + + /// JWT token expiration + #[serde(with = "humantime_serde")] + pub jwt_expiration: Duration, + + /// API key header name + pub api_key_header: String, + + /// Allow unauthenticated access to health endpoints + pub allow_health_unauthenticated: bool, + + /// Allow unauthenticated access to docs + pub allow_docs_unauthenticated: bool, +} + +impl Default for AuthConfig { + fn default() -> Self { + Self { + enabled: true, + jwt_secret: "change-me-in-production".to_string(), + jwt_expiration: Duration::from_secs(3600), // 1 hour + api_key_header: "X-API-Key".to_string(), + allow_health_unauthenticated: true, + allow_docs_unauthenticated: true, + } + } +} + +/// Rate limiting configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct RateLimitConfig { + /// Enable rate limiting + pub enabled: bool, + + /// Default requests per minute + pub default_rpm: u32, + + /// Burst size + pub burst_size: u32, + + /// Rate limit tiers + pub tiers: RateLimitTiers, +} + +impl Default for RateLimitConfig { + fn default() -> Self { + Self { + enabled: true, + default_rpm: 60, + burst_size: 10, + tiers: RateLimitTiers::default(), + } + } +} + +/// Rate limit tiers for different API key types. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct RateLimitTiers { + pub free: TierConfig, + pub developer: TierConfig, + pub pro: TierConfig, + pub enterprise: TierConfig, +} + +impl Default for RateLimitTiers { + fn default() -> Self { + Self { + free: TierConfig { rpm: 60, burst: 10, concurrent: 5 }, + developer: TierConfig { rpm: 600, burst: 100, concurrent: 20 }, + pro: TierConfig { rpm: 6000, burst: 1000, concurrent: 100 }, + enterprise: TierConfig { rpm: 0, burst: 0, concurrent: 0 }, // Unlimited + } + } +} + +/// Configuration for a single rate limit tier. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TierConfig { + /// Requests per minute + pub rpm: u32, + /// Burst allowance + pub burst: u32, + /// Max concurrent requests + pub concurrent: u32, +} + +/// CORS configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct CorsConfig { + /// Enable CORS + pub enabled: bool, + + /// Allowed origins (empty = allow all) + pub allowed_origins: Vec, + + /// Allowed methods + pub allowed_methods: Vec, + + /// Allowed headers + pub allowed_headers: Vec, + + /// Expose headers + pub expose_headers: Vec, + + /// Allow credentials + pub allow_credentials: bool, + + /// Max age for preflight cache + pub max_age: u64, +} + +impl Default for CorsConfig { + fn default() -> Self { + Self { + enabled: true, + allowed_origins: vec![], // Empty = allow all + allowed_methods: vec![ + "GET".to_string(), + "POST".to_string(), + "PUT".to_string(), + "DELETE".to_string(), + "PATCH".to_string(), + "OPTIONS".to_string(), + ], + allowed_headers: vec![ + "Content-Type".to_string(), + "Authorization".to_string(), + "X-API-Key".to_string(), + "X-Request-Id".to_string(), + ], + expose_headers: vec![ + "X-Request-Id".to_string(), + "X-RateLimit-Limit".to_string(), + "X-RateLimit-Remaining".to_string(), + "X-RateLimit-Reset".to_string(), + ], + allow_credentials: true, + max_age: 3600, + } + } +} + +/// Backend service endpoints. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct ServiceEndpoints { + /// RPC service endpoint + pub rpc: String, + + /// Wallet service endpoint + pub wallet: String, + + /// Storage service endpoint + pub storage: String, + + /// Database service endpoint + pub database: String, + + /// DEX service endpoint + pub dex: String, + + /// IBC bridge service endpoint + pub ibc: String, + + /// ZK service endpoint + pub zk: String, + + /// Compiler service endpoint + pub compiler: String, + + /// Crypto service endpoint + pub crypto: String, + + /// Mining service endpoint + pub mining: String, + + /// Governance service endpoint + pub governance: String, + + /// Economics service endpoint + pub economics: String, + + /// Hosting service endpoint + pub hosting: String, +} + +impl Default for ServiceEndpoints { + fn default() -> Self { + Self { + rpc: "http://127.0.0.1:16110".to_string(), + wallet: "http://127.0.0.1:8010".to_string(), + storage: "http://127.0.0.1:8030".to_string(), + database: "http://127.0.0.1:8040".to_string(), + dex: "http://127.0.0.1:8120".to_string(), + ibc: "http://127.0.0.1:8140".to_string(), + zk: "http://127.0.0.1:8130".to_string(), + compiler: "http://127.0.0.1:8150".to_string(), + crypto: "http://127.0.0.1:8160".to_string(), + mining: "http://127.0.0.1:8070".to_string(), + governance: "http://127.0.0.1:8090".to_string(), + economics: "http://127.0.0.1:8080".to_string(), + hosting: "http://127.0.0.1:8050".to_string(), + } + } +} + +/// Metrics configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct MetricsConfig { + /// Enable metrics endpoint + pub enabled: bool, + + /// Metrics endpoint path + pub path: String, + + /// Include detailed histograms + pub detailed_histograms: bool, +} + +impl Default for MetricsConfig { + fn default() -> Self { + Self { + enabled: true, + path: "/metrics".to_string(), + detailed_histograms: true, + } + } +} + +/// Duration serialization helper using humantime format. +mod humantime_serde { + use serde::{Deserialize, Deserializer, Serializer}; + use std::time::Duration; + + pub fn serialize(duration: &Duration, serializer: S) -> Result + where + S: Serializer, + { + let s = humantime::format_duration(*duration).to_string(); + serializer.serialize_str(&s) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + humantime::parse_duration(&s).map_err(serde::de::Error::custom) + } +} + +impl GatewayConfig { + /// Load configuration from file. + pub fn from_file(path: &str) -> anyhow::Result { + let settings = config::Config::builder() + .add_source(config::File::with_name(path)) + .add_source(config::Environment::with_prefix("SYNOR_GATEWAY")) + .build()?; + + Ok(settings.try_deserialize()?) + } + + /// Load configuration from environment variables only. + pub fn from_env() -> anyhow::Result { + let settings = config::Config::builder() + .add_source(config::Environment::with_prefix("SYNOR_GATEWAY")) + .build()?; + + Ok(settings.try_deserialize().unwrap_or_default()) + } +} diff --git a/crates/synor-gateway/src/error.rs b/crates/synor-gateway/src/error.rs new file mode 100644 index 0000000..91cb8b9 --- /dev/null +++ b/crates/synor-gateway/src/error.rs @@ -0,0 +1,289 @@ +//! API error types and handling. + +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Result type for API operations. +pub type ApiResult = Result; + +/// API error types. +#[derive(Debug, Error)] +pub enum ApiError { + // Authentication errors (401) + #[error("Authentication required")] + Unauthorized, + + #[error("Invalid API key")] + InvalidApiKey, + + #[error("Expired token")] + ExpiredToken, + + #[error("Invalid token: {0}")] + InvalidToken(String), + + // Authorization errors (403) + #[error("Access denied: {0}")] + Forbidden(String), + + #[error("Insufficient permissions")] + InsufficientPermissions, + + // Not found errors (404) + #[error("Resource not found: {0}")] + NotFound(String), + + #[error("Endpoint not found")] + EndpointNotFound, + + // Validation errors (400) + #[error("Invalid request: {0}")] + BadRequest(String), + + #[error("Validation failed: {0}")] + ValidationError(String), + + #[error("Missing required field: {0}")] + MissingField(String), + + #[error("Invalid parameter: {field} - {message}")] + InvalidParameter { field: String, message: String }, + + // Rate limiting (429) + #[error("Rate limit exceeded")] + RateLimitExceeded, + + #[error("Too many requests, retry after {retry_after} seconds")] + TooManyRequests { retry_after: u64 }, + + // Conflict errors (409) + #[error("Resource already exists: {0}")] + Conflict(String), + + // Service errors (500+) + #[error("Internal server error")] + InternalError, + + #[error("Service unavailable: {0}")] + ServiceUnavailable(String), + + #[error("Upstream service error: {0}")] + UpstreamError(String), + + #[error("Request timeout")] + Timeout, + + // Domain-specific errors + #[error("Insufficient balance: required {required}, available {available}")] + InsufficientBalance { required: String, available: String }, + + #[error("Transaction failed: {0}")] + TransactionFailed(String), + + #[error("Contract error: {0}")] + ContractError(String), + + #[error("Invalid address: {0}")] + InvalidAddress(String), + + #[error("Invalid signature")] + InvalidSignature, + + // Catch-all + #[error("{0}")] + Custom(String), +} + +/// Error response body. +#[derive(Debug, Serialize, Deserialize)] +pub struct ErrorResponse { + /// Whether the request succeeded + pub success: bool, + + /// Error details + pub error: ErrorDetails, + + /// Request metadata + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// Detailed error information. +#[derive(Debug, Serialize, Deserialize)] +pub struct ErrorDetails { + /// Error code (machine-readable) + pub code: String, + + /// Human-readable error message + pub message: String, + + /// Additional error details + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +/// Error metadata. +#[derive(Debug, Serialize, Deserialize)] +pub struct ErrorMeta { + /// Request ID for tracing + pub request_id: String, + + /// Timestamp + pub timestamp: String, +} + +impl ApiError { + /// Get the HTTP status code for this error. + pub fn status_code(&self) -> StatusCode { + match self { + // 400 Bad Request + Self::BadRequest(_) + | Self::ValidationError(_) + | Self::MissingField(_) + | Self::InvalidParameter { .. } + | Self::InvalidAddress(_) + | Self::InvalidSignature => StatusCode::BAD_REQUEST, + + // 401 Unauthorized + Self::Unauthorized + | Self::InvalidApiKey + | Self::ExpiredToken + | Self::InvalidToken(_) => StatusCode::UNAUTHORIZED, + + // 403 Forbidden + Self::Forbidden(_) | Self::InsufficientPermissions => StatusCode::FORBIDDEN, + + // 404 Not Found + Self::NotFound(_) | Self::EndpointNotFound => StatusCode::NOT_FOUND, + + // 409 Conflict + Self::Conflict(_) => StatusCode::CONFLICT, + + // 422 Unprocessable Entity + Self::InsufficientBalance { .. } + | Self::TransactionFailed(_) + | Self::ContractError(_) => StatusCode::UNPROCESSABLE_ENTITY, + + // 429 Too Many Requests + Self::RateLimitExceeded | Self::TooManyRequests { .. } => { + StatusCode::TOO_MANY_REQUESTS + } + + // 500 Internal Server Error + Self::InternalError | Self::Custom(_) => StatusCode::INTERNAL_SERVER_ERROR, + + // 502 Bad Gateway + Self::UpstreamError(_) => StatusCode::BAD_GATEWAY, + + // 503 Service Unavailable + Self::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, + + // 504 Gateway Timeout + Self::Timeout => StatusCode::GATEWAY_TIMEOUT, + } + } + + /// Get the error code string. + pub fn error_code(&self) -> &'static str { + match self { + Self::Unauthorized => "UNAUTHORIZED", + Self::InvalidApiKey => "INVALID_API_KEY", + Self::ExpiredToken => "EXPIRED_TOKEN", + Self::InvalidToken(_) => "INVALID_TOKEN", + Self::Forbidden(_) => "FORBIDDEN", + Self::InsufficientPermissions => "INSUFFICIENT_PERMISSIONS", + Self::NotFound(_) => "NOT_FOUND", + Self::EndpointNotFound => "ENDPOINT_NOT_FOUND", + Self::BadRequest(_) => "BAD_REQUEST", + Self::ValidationError(_) => "VALIDATION_ERROR", + Self::MissingField(_) => "MISSING_FIELD", + Self::InvalidParameter { .. } => "INVALID_PARAMETER", + Self::RateLimitExceeded => "RATE_LIMIT_EXCEEDED", + Self::TooManyRequests { .. } => "TOO_MANY_REQUESTS", + Self::Conflict(_) => "CONFLICT", + Self::InternalError => "INTERNAL_ERROR", + Self::ServiceUnavailable(_) => "SERVICE_UNAVAILABLE", + Self::UpstreamError(_) => "UPSTREAM_ERROR", + Self::Timeout => "TIMEOUT", + Self::InsufficientBalance { .. } => "INSUFFICIENT_BALANCE", + Self::TransactionFailed(_) => "TRANSACTION_FAILED", + Self::ContractError(_) => "CONTRACT_ERROR", + Self::InvalidAddress(_) => "INVALID_ADDRESS", + Self::InvalidSignature => "INVALID_SIGNATURE", + Self::Custom(_) => "ERROR", + } + } + + /// Build error details with optional extra information. + pub fn to_details(&self) -> ErrorDetails { + let details = match self { + Self::InsufficientBalance { required, available } => Some(serde_json::json!({ + "required": required, + "available": available + })), + Self::InvalidParameter { field, message } => Some(serde_json::json!({ + "field": field, + "message": message + })), + Self::TooManyRequests { retry_after } => Some(serde_json::json!({ + "retry_after": retry_after + })), + _ => None, + }; + + ErrorDetails { + code: self.error_code().to_string(), + message: self.to_string(), + details, + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = self.status_code(); + let body = ErrorResponse { + success: false, + error: self.to_details(), + meta: None, // Will be filled in by middleware + }; + + let mut response = (status, Json(body)).into_response(); + + // Add rate limit headers for 429 errors + if let Self::TooManyRequests { retry_after } = &self { + response.headers_mut().insert( + "Retry-After", + retry_after.to_string().parse().unwrap(), + ); + } + + response + } +} + +// Implement From for common error types +impl From for ApiError { + fn from(err: anyhow::Error) -> Self { + tracing::error!("Internal error: {:?}", err); + Self::InternalError + } +} + +impl From for ApiError { + fn from(err: serde_json::Error) -> Self { + Self::BadRequest(format!("JSON parsing error: {}", err)) + } +} + +impl From for ApiError { + fn from(err: std::io::Error) -> Self { + tracing::error!("IO error: {:?}", err); + Self::InternalError + } +} diff --git a/crates/synor-gateway/src/lib.rs b/crates/synor-gateway/src/lib.rs new file mode 100644 index 0000000..61edc68 --- /dev/null +++ b/crates/synor-gateway/src/lib.rs @@ -0,0 +1,67 @@ +//! # Synor API Gateway +//! +//! Unified REST API gateway for all Synor blockchain services. +//! +//! ## Architecture +//! +//! ```text +//! ┌──────────────────────────────────────────────────────────────────┐ +//! │ Synor API Gateway │ +//! ├──────────────────────────────────────────────────────────────────┤ +//! │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +//! │ │ REST API │ │ WebSocket │ │ Metrics │ │ +//! │ │ /v1/* │ │ /ws │ │ /metrics │ │ +//! │ └──────────────┘ └──────────────┘ └──────────────┘ │ +//! │ │ │ │ │ +//! │ ┌──────┴─────────────────┴─────────────────┴───────────────┐ │ +//! │ │ Middleware Layer │ │ +//! │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │ │ +//! │ │ │ Auth │ │ Rate │ │ CORS │ │ Request Tracing │ │ │ +//! │ │ │ │ │ Limit │ │ │ │ │ │ │ +//! │ │ └─────────┘ └─────────┘ └─────────┘ └─────────────────┘ │ │ +//! │ └──────────────────────────────────────────────────────────┘ │ +//! │ │ │ │ │ +//! │ ┌──────┴─────────────────┴─────────────────┴───────────────┐ │ +//! │ │ Service Routers │ │ +//! │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ +//! │ │ │ Wallet │ │ RPC │ │ Storage │ │ DEX │ ... │ │ +//! │ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │ +//! │ └──────────────────────────────────────────────────────────┘ │ +//! └──────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! ## Features +//! +//! - **REST API**: RESTful endpoints for all services +//! - **OpenAPI**: Auto-generated OpenAPI 3.1 documentation +//! - **WebSocket**: Real-time event subscriptions +//! - **Authentication**: API key and JWT authentication +//! - **Rate Limiting**: Configurable per-endpoint rate limits +//! - **Metrics**: Prometheus-compatible metrics export +//! +//! ## Quick Start +//! +//! ```rust,no_run +//! use synor_gateway::{Gateway, GatewayConfig}; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let config = GatewayConfig::default(); +//! let gateway = Gateway::new(config)?; +//! gateway.serve().await +//! } +//! ``` + +pub mod auth; +pub mod config; +pub mod error; +pub mod middleware; +pub mod response; +pub mod routes; +pub mod server; + +// Re-exports +pub use config::GatewayConfig; +pub use error::{ApiError, ApiResult}; +pub use response::ApiResponse; +pub use server::Gateway; diff --git a/crates/synor-gateway/src/middleware.rs b/crates/synor-gateway/src/middleware.rs new file mode 100644 index 0000000..bb47414 --- /dev/null +++ b/crates/synor-gateway/src/middleware.rs @@ -0,0 +1,349 @@ +//! HTTP middleware components. + +use crate::{ + auth::{ApiKeyTier, AuthService}, + config::{CorsConfig, RateLimitConfig}, + error::ApiError, +}; +use axum::{ + body::Body, + extract::{ConnectInfo, Request, State}, + http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use governor::{ + clock::DefaultClock, + state::{InMemoryState, NotKeyed}, + Quota, RateLimiter, +}; +use std::{ + collections::HashMap, + net::SocketAddr, + num::NonZeroU32, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::sync::RwLock; +use tower_http::cors::{Any, CorsLayer}; +use tracing::{info, warn, Span}; +use uuid::Uuid; + +/// Request ID header name. +pub const REQUEST_ID_HEADER: &str = "X-Request-Id"; + +/// Rate limit headers. +pub const RATE_LIMIT_LIMIT: &str = "X-RateLimit-Limit"; +pub const RATE_LIMIT_REMAINING: &str = "X-RateLimit-Remaining"; +pub const RATE_LIMIT_RESET: &str = "X-RateLimit-Reset"; + +/// Build CORS layer from configuration. +pub fn build_cors_layer(config: &CorsConfig) -> CorsLayer { + let mut cors = CorsLayer::new(); + + // Allowed origins + if config.allowed_origins.is_empty() { + cors = cors.allow_origin(Any); + } else { + let origins: Vec = config + .allowed_origins + .iter() + .filter_map(|o| o.parse().ok()) + .collect(); + cors = cors.allow_origin(origins); + } + + // Allowed methods + let methods: Vec = config + .allowed_methods + .iter() + .filter_map(|m| m.parse().ok()) + .collect(); + cors = cors.allow_methods(methods); + + // Allowed headers + let headers: Vec = config + .allowed_headers + .iter() + .filter_map(|h| h.parse().ok()) + .collect(); + cors = cors.allow_headers(headers); + + // Expose headers + let expose: Vec = config + .expose_headers + .iter() + .filter_map(|h| h.parse().ok()) + .collect(); + cors = cors.expose_headers(expose); + + // Credentials + if config.allow_credentials { + cors = cors.allow_credentials(true); + } + + // Max age + cors = cors.max_age(Duration::from_secs(config.max_age)); + + cors +} + +/// Request ID middleware - adds unique ID to each request. +pub async fn request_id_middleware(mut request: Request, next: Next) -> Response { + let request_id = request + .headers() + .get(REQUEST_ID_HEADER) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .unwrap_or_else(|| Uuid::new_v4().to_string()); + + request + .headers_mut() + .insert(REQUEST_ID_HEADER, request_id.parse().unwrap()); + + // Add to tracing span + Span::current().record("request_id", &request_id); + + let mut response = next.run(request).await; + + response + .headers_mut() + .insert(REQUEST_ID_HEADER, request_id.parse().unwrap()); + + response +} + +/// Request timing middleware - tracks request duration. +pub async fn timing_middleware(request: Request, next: Next) -> Response { + let start = Instant::now(); + let method = request.method().clone(); + let uri = request.uri().path().to_string(); + + let response = next.run(request).await; + + let duration = start.elapsed(); + let status = response.status(); + + // Log request + if status.is_success() { + info!( + method = %method, + uri = %uri, + status = %status.as_u16(), + duration_ms = %duration.as_millis(), + "Request completed" + ); + } else { + warn!( + method = %method, + uri = %uri, + status = %status.as_u16(), + duration_ms = %duration.as_millis(), + "Request failed" + ); + } + + // Update metrics + metrics::counter!("http_requests_total", "method" => method.to_string(), "status" => status.as_u16().to_string()).increment(1); + metrics::histogram!("http_request_duration_seconds", "method" => method.to_string()).record(duration.as_secs_f64()); + + response +} + +/// Rate limiter state. +pub struct RateLimiterState { + config: RateLimitConfig, + /// Per-IP rate limiters + ip_limiters: RwLock>>>, + /// Per-API-key rate limiters + key_limiters: RwLock>>>, +} + +impl RateLimiterState { + /// Create a new rate limiter state. + pub fn new(config: RateLimitConfig) -> Self { + Self { + config, + ip_limiters: RwLock::new(HashMap::new()), + key_limiters: RwLock::new(HashMap::new()), + } + } + + /// Get or create a rate limiter for an IP. + pub async fn get_ip_limiter(&self, ip: &str) -> Arc> { + { + let limiters = self.ip_limiters.read().await; + if let Some(limiter) = limiters.get(ip) { + return limiter.clone(); + } + } + + let rpm = NonZeroU32::new(self.config.default_rpm).unwrap_or(NonZeroU32::new(60).unwrap()); + let quota = Quota::per_minute(rpm).allow_burst( + NonZeroU32::new(self.config.burst_size).unwrap_or(NonZeroU32::new(10).unwrap()), + ); + let limiter = Arc::new(RateLimiter::direct(quota)); + + let mut limiters = self.ip_limiters.write().await; + limiters.insert(ip.to_string(), limiter.clone()); + limiter + } + + /// Get or create a rate limiter for an API key. + pub async fn get_key_limiter(&self, key_id: &str, tier: ApiKeyTier) -> Arc> { + { + let limiters = self.key_limiters.read().await; + if let Some(limiter) = limiters.get(key_id) { + return limiter.clone(); + } + } + + let tier_config = match tier { + ApiKeyTier::Free => &self.config.tiers.free, + ApiKeyTier::Developer => &self.config.tiers.developer, + ApiKeyTier::Pro => &self.config.tiers.pro, + ApiKeyTier::Enterprise => &self.config.tiers.enterprise, + }; + + // Enterprise tier has unlimited requests + if tier_config.rpm == 0 { + let quota = Quota::per_second(NonZeroU32::new(u32::MAX).unwrap()); + let limiter = Arc::new(RateLimiter::direct(quota)); + let mut limiters = self.key_limiters.write().await; + limiters.insert(key_id.to_string(), limiter.clone()); + return limiter; + } + + let rpm = NonZeroU32::new(tier_config.rpm).unwrap_or(NonZeroU32::new(60).unwrap()); + let quota = Quota::per_minute(rpm).allow_burst( + NonZeroU32::new(tier_config.burst).unwrap_or(NonZeroU32::new(10).unwrap()), + ); + let limiter = Arc::new(RateLimiter::direct(quota)); + + let mut limiters = self.key_limiters.write().await; + limiters.insert(key_id.to_string(), limiter.clone()); + limiter + } +} + +/// Rate limiting middleware. +pub async fn rate_limit_middleware( + State(state): State>, + ConnectInfo(addr): ConnectInfo, + request: Request, + next: Next, +) -> Result { + if !state.config.enabled { + return Ok(next.run(request).await); + } + + let ip = addr.ip().to_string(); + let limiter = state.get_ip_limiter(&ip).await; + + match limiter.check() { + Ok(_) => { + let mut response = next.run(request).await; + + // Add rate limit headers + let state = limiter.state_snapshot(); + let remaining = state.remaining_burst_capacity(); + + response.headers_mut().insert( + RATE_LIMIT_LIMIT, + state.config.default_rpm.to_string().parse().unwrap(), + ); + response.headers_mut().insert( + RATE_LIMIT_REMAINING, + remaining.to_string().parse().unwrap(), + ); + + Ok(response) + } + Err(not_until) => { + let retry_after = not_until + .wait_time_from(DefaultClock::default().now()) + .as_secs(); + + Err(ApiError::TooManyRequests { + retry_after, + }) + } + } +} + +/// Authentication middleware that injects auth service. +pub async fn auth_middleware( + State(auth_service): State>, + mut request: Request, + next: Next, +) -> Response { + // Inject auth service into request extensions + request.extensions_mut().insert((*auth_service).clone()); + next.run(request).await +} + +/// API version middleware - validates version prefix. +pub async fn version_middleware( + request: Request, + next: Next, +) -> Result { + let path = request.uri().path(); + + // Skip version check for health, metrics, and docs + if path == "/health" + || path.starts_with("/metrics") + || path.starts_with("/docs") + || path.starts_with("/swagger") + || path.starts_with("/openapi") + { + return Ok(next.run(request).await); + } + + // Check for version prefix + if !path.starts_with("/v1/") && !path.starts_with("/v2/") { + return Err(ApiError::BadRequest( + "API version required. Use /v1/ prefix".to_string(), + )); + } + + Ok(next.run(request).await) +} + +/// Security headers middleware. +pub async fn security_headers_middleware(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + + let headers = response.headers_mut(); + + // Prevent XSS + headers.insert( + "X-Content-Type-Options", + "nosniff".parse().unwrap(), + ); + + // Prevent clickjacking + headers.insert( + "X-Frame-Options", + "DENY".parse().unwrap(), + ); + + // Enable XSS filter + headers.insert( + "X-XSS-Protection", + "1; mode=block".parse().unwrap(), + ); + + // Strict transport security (HTTPS) + headers.insert( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains".parse().unwrap(), + ); + + // Content security policy + headers.insert( + "Content-Security-Policy", + "default-src 'self'".parse().unwrap(), + ); + + response +} diff --git a/crates/synor-gateway/src/response.rs b/crates/synor-gateway/src/response.rs new file mode 100644 index 0000000..56f9303 --- /dev/null +++ b/crates/synor-gateway/src/response.rs @@ -0,0 +1,290 @@ +//! Standard API response types. + +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Standard API response wrapper. +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiResponse { + /// Whether the request succeeded + pub success: bool, + + /// Response data (present on success) + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + + /// Response metadata + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// Response metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseMeta { + /// Unique request ID for tracing + pub request_id: String, + + /// Response timestamp + pub timestamp: DateTime, + + /// Request processing time in milliseconds + #[serde(skip_serializing_if = "Option::is_none")] + pub latency_ms: Option, + + /// Pagination info (if applicable) + #[serde(skip_serializing_if = "Option::is_none")] + pub pagination: Option, +} + +/// Pagination metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaginationMeta { + /// Total number of items + pub total: u64, + + /// Current page (1-indexed) + pub page: u32, + + /// Items per page + pub per_page: u32, + + /// Total number of pages + pub total_pages: u32, + + /// Whether there's a next page + pub has_next: bool, + + /// Whether there's a previous page + pub has_prev: bool, +} + +impl ApiResponse { + /// Create a successful response with data. + pub fn success(data: T) -> Self { + Self { + success: true, + data: Some(data), + meta: Some(ResponseMeta { + request_id: uuid::Uuid::new_v4().to_string(), + timestamp: Utc::now(), + latency_ms: None, + pagination: None, + }), + } + } + + /// Create a successful response with data and pagination. + pub fn success_paginated(data: T, pagination: PaginationMeta) -> Self { + Self { + success: true, + data: Some(data), + meta: Some(ResponseMeta { + request_id: uuid::Uuid::new_v4().to_string(), + timestamp: Utc::now(), + latency_ms: None, + pagination: Some(pagination), + }), + } + } + + /// Set the request ID. + pub fn with_request_id(mut self, request_id: String) -> Self { + if let Some(ref mut meta) = self.meta { + meta.request_id = request_id; + } + self + } + + /// Set the latency. + pub fn with_latency(mut self, latency_ms: u64) -> Self { + if let Some(ref mut meta) = self.meta { + meta.latency_ms = Some(latency_ms); + } + self + } +} + +impl IntoResponse for ApiResponse { + fn into_response(self) -> Response { + (StatusCode::OK, Json(self)).into_response() + } +} + +/// Response for created resources (201). +pub struct Created(pub ApiResponse); + +impl IntoResponse for Created { + fn into_response(self) -> Response { + (StatusCode::CREATED, Json(self.0)).into_response() + } +} + +/// Response for accepted requests (202). +pub struct Accepted(pub ApiResponse); + +impl IntoResponse for Accepted { + fn into_response(self) -> Response { + (StatusCode::ACCEPTED, Json(self.0)).into_response() + } +} + +/// Response for no content (204). +pub struct NoContent; + +impl IntoResponse for NoContent { + fn into_response(self) -> Response { + StatusCode::NO_CONTENT.into_response() + } +} + +/// Empty success response. +#[derive(Debug, Serialize, Deserialize)] +pub struct EmptyResponse; + +impl ApiResponse { + /// Create an empty success response. + pub fn empty() -> Self { + Self { + success: true, + data: None, + meta: Some(ResponseMeta { + request_id: uuid::Uuid::new_v4().to_string(), + timestamp: Utc::now(), + latency_ms: None, + pagination: None, + }), + } + } +} + +/// Pagination query parameters. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaginationParams { + /// Page number (1-indexed, default: 1) + #[serde(default = "default_page")] + pub page: u32, + + /// Items per page (default: 20, max: 100) + #[serde(default = "default_per_page")] + pub per_page: u32, +} + +fn default_page() -> u32 { + 1 +} + +fn default_per_page() -> u32 { + 20 +} + +impl Default for PaginationParams { + fn default() -> Self { + Self { + page: 1, + per_page: 20, + } + } +} + +impl PaginationParams { + /// Get the offset for database queries. + pub fn offset(&self) -> u64 { + ((self.page.saturating_sub(1)) * self.per_page) as u64 + } + + /// Get the limit for database queries. + pub fn limit(&self) -> u32 { + self.per_page.min(100) + } + + /// Create pagination metadata from results. + pub fn to_meta(&self, total: u64) -> PaginationMeta { + let total_pages = ((total as f64) / (self.per_page as f64)).ceil() as u32; + PaginationMeta { + total, + page: self.page, + per_page: self.per_page, + total_pages, + has_next: self.page < total_pages, + has_prev: self.page > 1, + } + } +} + +/// Sorting parameters. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SortParams { + /// Field to sort by + pub sort_by: Option, + + /// Sort direction (asc or desc) + #[serde(default = "default_sort_order")] + pub sort_order: SortOrder, +} + +fn default_sort_order() -> SortOrder { + SortOrder::Desc +} + +/// Sort direction. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SortOrder { + Asc, + Desc, +} + +impl Default for SortOrder { + fn default() -> Self { + Self::Desc + } +} + +/// Health check response. +#[derive(Debug, Serialize, Deserialize)] +pub struct HealthResponse { + /// Overall health status + pub status: HealthStatus, + + /// Service version + pub version: String, + + /// Uptime in seconds + pub uptime_seconds: u64, + + /// Individual service health + #[serde(skip_serializing_if = "Option::is_none")] + pub services: Option>, +} + +/// Health status enum. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum HealthStatus { + Healthy, + Degraded, + Unhealthy, +} + +/// Individual service health. +#[derive(Debug, Serialize, Deserialize)] +pub struct ServiceHealth { + /// Service name + pub name: String, + + /// Health status + pub status: HealthStatus, + + /// Optional message + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + + /// Response time in milliseconds + #[serde(skip_serializing_if = "Option::is_none")] + pub latency_ms: Option, +} diff --git a/crates/synor-gateway/src/routes/health.rs b/crates/synor-gateway/src/routes/health.rs new file mode 100644 index 0000000..b252d6d --- /dev/null +++ b/crates/synor-gateway/src/routes/health.rs @@ -0,0 +1,104 @@ +//! Health check endpoints. + +use axum::{extract::State, routing::get, Json, Router}; +use serde::{Deserialize, Serialize}; +use std::time::Instant; + +use crate::{ + response::{ApiResponse, HealthResponse, HealthStatus, ServiceHealth}, + routes::AppState, +}; + +/// Build health routes. +pub fn router() -> Router { + Router::new() + .route("/health", get(health_check)) + .route("/health/live", get(liveness)) + .route("/health/ready", get(readiness)) +} + +/// Basic health check. +#[utoipa::path( + get, + path = "/health", + responses( + (status = 200, description = "Service is healthy", body = HealthResponse) + ), + tag = "Health" +)] +async fn health_check(State(state): State) -> Json { + // Get uptime (would come from server state in production) + let uptime_seconds = 0; // Placeholder + + Json(HealthResponse { + status: HealthStatus::Healthy, + version: env!("CARGO_PKG_VERSION").to_string(), + uptime_seconds, + services: None, + }) +} + +/// Liveness probe (for Kubernetes). +#[utoipa::path( + get, + path = "/health/live", + responses( + (status = 200, description = "Service is alive") + ), + tag = "Health" +)] +async fn liveness() -> &'static str { + "OK" +} + +/// Readiness probe with service checks. +#[utoipa::path( + get, + path = "/health/ready", + responses( + (status = 200, description = "Service is ready", body = HealthResponse), + (status = 503, description = "Service is not ready") + ), + tag = "Health" +)] +async fn readiness(State(state): State) -> Json { + let mut services = Vec::new(); + let mut overall_status = HealthStatus::Healthy; + + // Check RPC service + let rpc_health = check_service_health("rpc", &state.config.services.rpc).await; + if rpc_health.status != HealthStatus::Healthy { + overall_status = HealthStatus::Degraded; + } + services.push(rpc_health); + + // Check other services... + // In production, we'd check all backend services + + Json(HealthResponse { + status: overall_status, + version: env!("CARGO_PKG_VERSION").to_string(), + uptime_seconds: 0, + services: Some(services), + }) +} + +/// Check health of a backend service. +async fn check_service_health(name: &str, endpoint: &str) -> ServiceHealth { + let start = Instant::now(); + + // Attempt to connect to the service + // In production, this would make an actual health check request + let is_healthy = true; // Placeholder + + ServiceHealth { + name: name.to_string(), + status: if is_healthy { + HealthStatus::Healthy + } else { + HealthStatus::Unhealthy + }, + message: None, + latency_ms: Some(start.elapsed().as_millis() as u64), + } +} diff --git a/crates/synor-gateway/src/routes/mod.rs b/crates/synor-gateway/src/routes/mod.rs new file mode 100644 index 0000000..78262a4 --- /dev/null +++ b/crates/synor-gateway/src/routes/mod.rs @@ -0,0 +1,54 @@ +//! API route definitions. +//! +//! This module contains all REST API endpoints organized by service. + +pub mod health; +pub mod wallet; +pub mod rpc; +pub mod storage; +pub mod dex; +pub mod ibc; +pub mod zk; +pub mod compiler; + +use axum::Router; +use std::sync::Arc; + +use crate::config::GatewayConfig; + +/// Application state shared across all routes. +#[derive(Clone)] +pub struct AppState { + pub config: Arc, + // HTTP clients for backend services would go here +} + +impl AppState { + pub fn new(config: GatewayConfig) -> Self { + Self { + config: Arc::new(config), + } + } +} + +/// Build the main API router with all service routes. +pub fn build_router(state: AppState) -> Router { + Router::new() + // Health & status endpoints (no auth required) + .merge(health::router()) + // Versioned API routes + .nest("/v1", build_v1_router()) + .with_state(state) +} + +/// Build the v1 API router. +fn build_v1_router() -> Router { + Router::new() + .nest("/wallet", wallet::router()) + .nest("/rpc", rpc::router()) + .nest("/storage", storage::router()) + .nest("/dex", dex::router()) + .nest("/ibc", ibc::router()) + .nest("/zk", zk::router()) + .nest("/compiler", compiler::router()) +} diff --git a/crates/synor-gateway/src/routes/rpc.rs b/crates/synor-gateway/src/routes/rpc.rs new file mode 100644 index 0000000..d67603b --- /dev/null +++ b/crates/synor-gateway/src/routes/rpc.rs @@ -0,0 +1,366 @@ +//! RPC API endpoints. +//! +//! REST endpoints for blockchain RPC operations: +//! - Block queries +//! - Transaction submission and queries +//! - Network information +//! - Chain state + +use axum::{ + extract::{Path, Query, State}, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + auth::{require_permission, Authenticated}, + error::{ApiError, ApiResult}, + response::{ApiResponse, PaginationParams}, + routes::AppState, +}; + +/// Build RPC routes. +pub fn router() -> Router { + Router::new() + // Block operations + .route("/block/:id", get(get_block)) + .route("/blocks", get(get_blocks)) + .route("/tips", get(get_tips)) + .route("/block-count", get(get_block_count)) + // Transaction operations + .route("/tx/:txid", get(get_transaction)) + .route("/tx/send", post(send_transaction)) + .route("/tx/estimate-fee", post(estimate_fee)) + .route("/mempool", get(get_mempool)) + // Network info + .route("/info", get(get_network_info)) + .route("/peers", get(get_peers)) + .route("/sync-status", get(get_sync_status)) + // Mining (for miners) + .route("/mining/template", get(get_block_template)) + .route("/mining/submit", post(submit_block)) +} + +// ============================================================================ +// Types +// ============================================================================ + +#[derive(Debug, Serialize, Deserialize)] +pub struct BlockResponse { + pub hash: String, + pub height: u64, + pub timestamp: u64, + pub parent_hashes: Vec, + pub transactions: Vec, + pub blue_score: u64, + pub difficulty: String, + pub nonce: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TransactionResponse { + pub txid: String, + pub version: u32, + pub inputs: Vec, + pub outputs: Vec, + pub lock_time: u64, + pub mass: u64, + pub block_hash: Option, + pub confirmations: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TxInput { + pub previous_outpoint: String, + pub signature_script: String, + pub sequence: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TxOutput { + pub value: String, + pub script_public_key: String, +} + +#[derive(Debug, Deserialize)] +pub struct SendTransactionRequest { + pub raw_tx: String, +} + +#[derive(Debug, Serialize)] +pub struct SendTransactionResponse { + pub txid: String, + pub accepted: bool, +} + +#[derive(Debug, Deserialize)] +pub struct EstimateFeeRequest { + pub priority: String, // low, medium, high + pub tx_size: Option, +} + +#[derive(Debug, Serialize)] +pub struct FeeEstimate { + pub priority: String, + pub fee_per_mass: String, + pub estimated_fee: String, +} + +#[derive(Debug, Serialize)] +pub struct NetworkInfo { + pub version: String, + pub protocol_version: u32, + pub network: String, + pub connections: u32, + pub block_count: u64, + pub difficulty: String, + pub hashrate: String, + pub mempool_size: u32, + pub synced: bool, +} + +#[derive(Debug, Serialize)] +pub struct SyncStatus { + pub synced: bool, + pub current_height: u64, + pub target_height: u64, + pub progress: f64, + pub estimated_time_remaining: Option, +} + +#[derive(Debug, Serialize)] +pub struct BlockTemplate { + pub parent_hashes: Vec, + pub transactions: Vec, + pub coinbase_value: String, + pub coinbase_script: String, + pub bits: String, + pub target: String, + pub timestamp: u64, +} + +#[derive(Debug, Deserialize)] +pub struct SubmitBlockRequest { + pub header: String, + pub transactions: Vec, +} + +// ============================================================================ +// Handlers +// ============================================================================ + +async fn get_block( + State(state): State, + Authenticated(auth): Authenticated, + Path(id): Path, +) -> ApiResult>> { + require_permission(&auth, "read")?; + + // In production, query the RPC service + let block = BlockResponse { + hash: id.clone(), + height: 100000, + timestamp: 1705312200, + parent_hashes: vec!["parent1...".to_string()], + transactions: vec!["tx1...".to_string(), "tx2...".to_string()], + blue_score: 50000, + difficulty: "1234567890".to_string(), + nonce: 12345, + }; + + Ok(Json(ApiResponse::success(block))) +} + +async fn get_blocks( + State(state): State, + Authenticated(auth): Authenticated, + Query(pagination): Query, +) -> ApiResult>>> { + require_permission(&auth, "read")?; + + // Return latest blocks + let blocks = vec![]; // Placeholder + let meta = pagination.to_meta(0); + + Ok(Json(ApiResponse::success_paginated(blocks, meta))) +} + +async fn get_tips( + State(state): State, + Authenticated(auth): Authenticated, +) -> ApiResult>>> { + require_permission(&auth, "read")?; + + let tips = vec!["tip1...".to_string(), "tip2...".to_string()]; + Ok(Json(ApiResponse::success(tips))) +} + +async fn get_block_count( + State(state): State, + Authenticated(auth): Authenticated, +) -> ApiResult>> { + require_permission(&auth, "read")?; + + let count = serde_json::json!({ + "block_count": 100000, + "header_count": 100000 + }); + + Ok(Json(ApiResponse::success(count))) +} + +async fn get_transaction( + State(state): State, + Authenticated(auth): Authenticated, + Path(txid): Path, +) -> ApiResult>> { + require_permission(&auth, "read")?; + + let tx = TransactionResponse { + txid, + version: 1, + inputs: vec![], + outputs: vec![], + lock_time: 0, + mass: 1000, + block_hash: Some("blockhash...".to_string()), + confirmations: 10, + }; + + Ok(Json(ApiResponse::success(tx))) +} + +async fn send_transaction( + State(state): State, + Authenticated(auth): Authenticated, + Json(req): Json, +) -> ApiResult>> { + require_permission(&auth, "write")?; + + // In production, broadcast to network + let response = SendTransactionResponse { + txid: "newtxid...".to_string(), + accepted: true, + }; + + Ok(Json(ApiResponse::success(response))) +} + +async fn estimate_fee( + State(state): State, + Authenticated(auth): Authenticated, + Json(req): Json, +) -> ApiResult>> { + require_permission(&auth, "read")?; + + let estimate = FeeEstimate { + priority: req.priority, + fee_per_mass: "1".to_string(), + estimated_fee: "1000".to_string(), + }; + + Ok(Json(ApiResponse::success(estimate))) +} + +async fn get_mempool( + State(state): State, + Authenticated(auth): Authenticated, + Query(pagination): Query, +) -> ApiResult>>> { + require_permission(&auth, "read")?; + + let txids = vec!["mempool_tx1...".to_string()]; + let meta = pagination.to_meta(1); + + Ok(Json(ApiResponse::success_paginated(txids, meta))) +} + +async fn get_network_info( + State(state): State, + Authenticated(auth): Authenticated, +) -> ApiResult>> { + require_permission(&auth, "read")?; + + let info = NetworkInfo { + version: "0.1.0".to_string(), + protocol_version: 1, + network: "mainnet".to_string(), + connections: 50, + block_count: 100000, + difficulty: "1234567890".to_string(), + hashrate: "100 TH/s".to_string(), + mempool_size: 100, + synced: true, + }; + + Ok(Json(ApiResponse::success(info))) +} + +async fn get_peers( + State(state): State, + Authenticated(auth): Authenticated, +) -> ApiResult>>> { + require_permission(&auth, "read")?; + + let peers = vec![ + serde_json::json!({ + "id": "peer1", + "address": "192.168.1.1:16100", + "connected_since": 1705312200 + }) + ]; + + Ok(Json(ApiResponse::success(peers))) +} + +async fn get_sync_status( + State(state): State, + Authenticated(auth): Authenticated, +) -> ApiResult>> { + require_permission(&auth, "read")?; + + let status = SyncStatus { + synced: true, + current_height: 100000, + target_height: 100000, + progress: 100.0, + estimated_time_remaining: None, + }; + + Ok(Json(ApiResponse::success(status))) +} + +async fn get_block_template( + State(state): State, + Authenticated(auth): Authenticated, +) -> ApiResult>> { + require_permission(&auth, "read")?; + + let template = BlockTemplate { + parent_hashes: vec!["parent...".to_string()], + transactions: vec![], + coinbase_value: "50000000000".to_string(), + coinbase_script: "".to_string(), + bits: "1d00ffff".to_string(), + target: "00000000ffff...".to_string(), + timestamp: 1705312200, + }; + + Ok(Json(ApiResponse::success(template))) +} + +async fn submit_block( + State(state): State, + Authenticated(auth): Authenticated, + Json(req): Json, +) -> ApiResult>> { + require_permission(&auth, "write")?; + + let result = serde_json::json!({ + "accepted": true, + "block_hash": "newblockhash..." + }); + + Ok(Json(ApiResponse::success(result))) +} diff --git a/crates/synor-gateway/src/routes/storage.rs b/crates/synor-gateway/src/routes/storage.rs new file mode 100644 index 0000000..81beb15 --- /dev/null +++ b/crates/synor-gateway/src/routes/storage.rs @@ -0,0 +1,259 @@ +//! Storage API endpoints. +//! +//! REST endpoints for decentralized storage operations: +//! - File upload/download +//! - Pinning management +//! - CAR file operations +//! - Directory management + +use axum::{ + extract::{Multipart, Path, Query, State}, + routing::{delete, get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + auth::{require_permission, Authenticated}, + error::{ApiError, ApiResult}, + response::{ApiResponse, PaginationParams}, + routes::AppState, +}; + +/// Build storage routes. +pub fn router() -> Router { + Router::new() + // Upload/Download + .route("/upload", post(upload_file)) + .route("/download/:cid", get(download_file)) + .route("/gateway/:cid", get(gateway_url)) + // Pinning + .route("/pin/:cid", post(pin_content)) + .route("/pin/:cid", delete(unpin_content)) + .route("/pin/:cid/status", get(pin_status)) + .route("/pins", get(list_pins)) + // CAR files + .route("/car/create", post(create_car)) + .route("/car/import", post(import_car)) + // Directories + .route("/directory", post(create_directory)) + .route("/directory/:cid", get(list_directory)) +} + +// ============================================================================ +// Types +// ============================================================================ + +#[derive(Debug, Serialize)] +pub struct UploadResponse { + pub cid: String, + pub size: u64, + pub gateway_url: String, +} + +#[derive(Debug, Serialize)] +pub struct PinStatus { + pub cid: String, + pub pinned: bool, + pub size: u64, + pub created_at: String, + pub expires_at: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PinRequest { + pub duration_days: Option, +} + +#[derive(Debug, Serialize)] +pub struct DirectoryEntry { + pub name: String, + pub cid: String, + pub size: u64, + pub is_directory: bool, +} + +#[derive(Debug, Deserialize)] +pub struct CreateDirectoryRequest { + pub files: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct FileEntry { + pub name: String, + pub cid: String, +} + +// ============================================================================ +// Handlers +// ============================================================================ + +async fn upload_file( + State(state): State, + Authenticated(auth): Authenticated, + multipart: Multipart, +) -> ApiResult>> { + require_permission(&auth, "write")?; + + // In production, process multipart upload + let response = UploadResponse { + cid: "bafybeigdyr...".to_string(), + size: 1024, + gateway_url: "https://gateway.synor.io/ipfs/bafybeigdyr...".to_string(), + }; + + Ok(Json(ApiResponse::success(response))) +} + +async fn download_file( + State(state): State, + Authenticated(auth): Authenticated, + Path(cid): Path, +) -> ApiResult>> { + require_permission(&auth, "read")?; + + // In production, return file content or redirect to gateway + let result = serde_json::json!({ + "cid": cid, + "gateway_url": format!("https://gateway.synor.io/ipfs/{}", cid) + }); + + Ok(Json(ApiResponse::success(result))) +} + +async fn gateway_url( + State(state): State, + Path(cid): Path, +) -> ApiResult>> { + let url = serde_json::json!({ + "url": format!("https://gateway.synor.io/ipfs/{}", cid) + }); + + Ok(Json(ApiResponse::success(url))) +} + +async fn pin_content( + State(state): State, + Authenticated(auth): Authenticated, + Path(cid): Path, + Json(req): Json, +) -> ApiResult>> { + require_permission(&auth, "write")?; + + let status = PinStatus { + cid, + pinned: true, + size: 1024, + created_at: "2024-01-15T10:30:00Z".to_string(), + expires_at: req.duration_days.map(|d| format!("in {} days", d)), + }; + + Ok(Json(ApiResponse::success(status))) +} + +async fn unpin_content( + State(state): State, + Authenticated(auth): Authenticated, + Path(cid): Path, +) -> ApiResult>> { + require_permission(&auth, "delete")?; + + let result = serde_json::json!({ + "cid": cid, + "unpinned": true + }); + + Ok(Json(ApiResponse::success(result))) +} + +async fn pin_status( + State(state): State, + Authenticated(auth): Authenticated, + Path(cid): Path, +) -> ApiResult>> { + require_permission(&auth, "read")?; + + let status = PinStatus { + cid, + pinned: true, + size: 1024, + created_at: "2024-01-15T10:30:00Z".to_string(), + expires_at: None, + }; + + Ok(Json(ApiResponse::success(status))) +} + +async fn list_pins( + State(state): State, + Authenticated(auth): Authenticated, + Query(pagination): Query, +) -> ApiResult>>> { + require_permission(&auth, "read")?; + + let pins = vec![]; + let meta = pagination.to_meta(0); + + Ok(Json(ApiResponse::success_paginated(pins, meta))) +} + +async fn create_car( + State(state): State, + Authenticated(auth): Authenticated, + multipart: Multipart, +) -> ApiResult>> { + require_permission(&auth, "write")?; + + let result = serde_json::json!({ + "car_cid": "bafycar...", + "root_cid": "bafyroot...", + "size": 2048 + }); + + Ok(Json(ApiResponse::success(result))) +} + +async fn import_car( + State(state): State, + Authenticated(auth): Authenticated, + multipart: Multipart, +) -> ApiResult>>> { + require_permission(&auth, "write")?; + + let cids = vec!["bafycid1...".to_string(), "bafycid2...".to_string()]; + Ok(Json(ApiResponse::success(cids))) +} + +async fn create_directory( + State(state): State, + Authenticated(auth): Authenticated, + Json(req): Json, +) -> ApiResult>> { + require_permission(&auth, "write")?; + + let result = serde_json::json!({ + "cid": "bafydir...", + "file_count": req.files.len() + }); + + Ok(Json(ApiResponse::success(result))) +} + +async fn list_directory( + State(state): State, + Authenticated(auth): Authenticated, + Path(cid): Path, +) -> ApiResult>>> { + require_permission(&auth, "read")?; + + let entries = vec![ + DirectoryEntry { + name: "file1.txt".to_string(), + cid: "bafyfile1...".to_string(), + size: 1024, + is_directory: false, + }, + ]; + + Ok(Json(ApiResponse::success(entries))) +} diff --git a/crates/synor-gateway/src/routes/wallet.rs b/crates/synor-gateway/src/routes/wallet.rs new file mode 100644 index 0000000..acd8ca5 --- /dev/null +++ b/crates/synor-gateway/src/routes/wallet.rs @@ -0,0 +1,605 @@ +//! Wallet API endpoints. +//! +//! REST endpoints for wallet operations including: +//! - Wallet creation and import +//! - Address generation +//! - Balance queries +//! - Transaction signing + +use axum::{ + extract::{Path, Query, State}, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + auth::{require_permission, Authenticated}, + error::{ApiError, ApiResult}, + response::{ApiResponse, PaginationMeta, PaginationParams}, + routes::AppState, +}; + +/// Build wallet routes. +pub fn router() -> Router { + Router::new() + // Wallet management + .route("/create", post(create_wallet)) + .route("/import", post(import_wallet)) + .route("/export/:address", get(export_mnemonic)) + // Address operations + .route("/address", get(get_address)) + .route("/addresses", get(list_addresses)) + .route("/stealth-address", post(generate_stealth_address)) + // Balance queries + .route("/balance/:address", get(get_balance)) + .route("/balances", post(get_balances)) + .route("/utxos/:address", get(get_utxos)) + // Transaction operations + .route("/sign", post(sign_transaction)) + .route("/sign-message", post(sign_message)) + .route("/send", post(send_transaction)) +} + +// ============================================================================ +// Request/Response Types +// ============================================================================ + +/// Request to create a new wallet. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct CreateWalletRequest { + /// Number of words in mnemonic (12, 15, 18, 21, or 24) + #[serde(default = "default_word_count")] + pub word_count: u8, + + /// Optional passphrase for additional security + #[serde(default)] + pub passphrase: Option, + + /// Network to use (mainnet, testnet, devnet) + #[serde(default = "default_network")] + pub network: String, +} + +fn default_word_count() -> u8 { + 24 +} + +fn default_network() -> String { + "mainnet".to_string() +} + +/// Response for wallet creation. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct CreateWalletResponse { + /// The generated mnemonic phrase (SAVE THIS SECURELY!) + pub mnemonic: String, + + /// Primary address derived from the wallet + pub address: String, + + /// Public key (hex encoded) + pub public_key: String, + + /// Network the wallet was created for + pub network: String, +} + +/// Request to import an existing wallet. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct ImportWalletRequest { + /// BIP-39 mnemonic phrase + pub mnemonic: String, + + /// Optional passphrase + #[serde(default)] + pub passphrase: Option, + + /// Network to use + #[serde(default = "default_network")] + pub network: String, +} + +/// Response for wallet import. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct ImportWalletResponse { + /// Primary address derived from the wallet + pub address: String, + + /// Public key (hex encoded) + pub public_key: String, + + /// Network the wallet is configured for + pub network: String, + + /// Validation status + pub valid: bool, +} + +/// Query params for address derivation. +#[derive(Debug, Deserialize)] +pub struct AddressQuery { + /// Account index (default: 0) + #[serde(default)] + pub account: u32, + + /// Address index (default: 0) + #[serde(default)] + pub index: u32, + + /// External (0) or internal/change (1) + #[serde(default)] + pub change: u32, +} + +/// Balance response. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct BalanceResponse { + /// Address queried + pub address: String, + + /// Total balance (including unconfirmed) + pub total: String, + + /// Confirmed balance + pub confirmed: String, + + /// Unconfirmed balance + pub unconfirmed: String, + + /// Balance in smallest unit + pub balance_raw: String, + + /// Human-readable balance + pub balance_formatted: String, +} + +/// Request for multiple balances. +#[derive(Debug, Deserialize)] +pub struct GetBalancesRequest { + /// List of addresses to query + pub addresses: Vec, +} + +/// UTXO (Unspent Transaction Output). +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct Utxo { + /// Transaction ID + pub txid: String, + + /// Output index + pub vout: u32, + + /// Value in smallest unit + pub value: String, + + /// Block height (None if unconfirmed) + pub height: Option, + + /// Number of confirmations + pub confirmations: u64, + + /// Script pubkey (hex) + pub script_pubkey: String, +} + +/// Request to sign a transaction. +#[derive(Debug, Deserialize)] +pub struct SignTransactionRequest { + /// Raw transaction to sign (hex or JSON) + pub transaction: serde_json::Value, + + /// Derivation path or address to sign with + pub signer: String, +} + +/// Response from signing. +#[derive(Debug, Serialize)] +pub struct SignTransactionResponse { + /// Signed transaction (hex) + pub signed_tx: String, + + /// Transaction ID + pub txid: String, + + /// Signature (hex) + pub signature: String, +} + +/// Request to sign a message. +#[derive(Debug, Deserialize)] +pub struct SignMessageRequest { + /// Message to sign + pub message: String, + + /// Address to sign with + pub address: String, +} + +/// Response from message signing. +#[derive(Debug, Serialize)] +pub struct SignMessageResponse { + /// Original message + pub message: String, + + /// Signature (hex) + pub signature: String, + + /// Public key used + pub public_key: String, +} + +/// Request to send a transaction. +#[derive(Debug, Deserialize)] +pub struct SendTransactionRequest { + /// Recipient address + pub to: String, + + /// Amount to send + pub amount: String, + + /// Optional memo/note + #[serde(default)] + pub memo: Option, + + /// Fee priority (low, medium, high) + #[serde(default = "default_priority")] + pub priority: String, +} + +fn default_priority() -> String { + "medium".to_string() +} + +/// Response from sending. +#[derive(Debug, Serialize)] +pub struct SendTransactionResponse { + /// Transaction ID + pub txid: String, + + /// Amount sent + pub amount: String, + + /// Fee paid + pub fee: String, + + /// Transaction status + pub status: String, +} + +// ============================================================================ +// Route Handlers +// ============================================================================ + +/// Create a new wallet with a generated mnemonic. +#[cfg_attr(feature = "openapi", utoipa::path( + post, + path = "/v1/wallet/create", + request_body = CreateWalletRequest, + responses( + (status = 201, description = "Wallet created successfully", body = ApiResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Authentication required") + ), + security(("api_key" = [])), + tag = "Wallet" +))] +async fn create_wallet( + State(state): State, + Authenticated(auth): Authenticated, + Json(req): Json, +) -> ApiResult>> { + require_permission(&auth, "write")?; + + // Validate word count + if ![12, 15, 18, 21, 24].contains(&req.word_count) { + return Err(ApiError::ValidationError( + "word_count must be 12, 15, 18, 21, or 24".to_string(), + )); + } + + // In production, this would call the crypto service + // For now, return a placeholder + let response = CreateWalletResponse { + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(), + address: "synor1abc123...".to_string(), + public_key: "0x04...".to_string(), + network: req.network, + }; + + Ok(Json(ApiResponse::success(response))) +} + +/// Import a wallet from mnemonic. +#[cfg_attr(feature = "openapi", utoipa::path( + post, + path = "/v1/wallet/import", + request_body = ImportWalletRequest, + responses( + (status = 200, description = "Wallet imported successfully", body = ApiResponse), + (status = 400, description = "Invalid mnemonic"), + (status = 401, description = "Authentication required") + ), + security(("api_key" = [])), + tag = "Wallet" +))] +async fn import_wallet( + State(state): State, + Authenticated(auth): Authenticated, + Json(req): Json, +) -> ApiResult>> { + require_permission(&auth, "write")?; + + // Validate mnemonic word count + let word_count = req.mnemonic.split_whitespace().count(); + if ![12, 15, 18, 21, 24].contains(&word_count) { + return Err(ApiError::ValidationError( + "Invalid mnemonic: must be 12, 15, 18, 21, or 24 words".to_string(), + )); + } + + // In production, validate mnemonic checksum and derive keys + let response = ImportWalletResponse { + address: "synor1xyz789...".to_string(), + public_key: "0x04...".to_string(), + network: req.network, + valid: true, + }; + + Ok(Json(ApiResponse::success(response))) +} + +/// Export mnemonic for an address (requires admin permission). +async fn export_mnemonic( + State(state): State, + Authenticated(auth): Authenticated, + Path(address): Path, +) -> ApiResult>> { + require_permission(&auth, "admin")?; + + // This is a sensitive operation that requires additional security + Err(ApiError::Forbidden( + "Mnemonic export requires additional verification".to_string(), + )) +} + +/// Get address at derivation path. +async fn get_address( + State(state): State, + Authenticated(auth): Authenticated, + Query(query): Query, +) -> ApiResult>> { + require_permission(&auth, "read")?; + + let address = serde_json::json!({ + "address": "synor1derived...", + "path": format!("m/44'/21337'/{}'/{}'/{}", + query.account, query.change, query.index), + "public_key": "0x04..." + }); + + Ok(Json(ApiResponse::success(address))) +} + +/// List all derived addresses. +async fn list_addresses( + State(state): State, + Authenticated(auth): Authenticated, + Query(pagination): Query, +) -> ApiResult>>> { + require_permission(&auth, "read")?; + + let addresses = vec![ + serde_json::json!({ + "address": "synor1addr1...", + "index": 0, + "balance": "100.00" + }), + serde_json::json!({ + "address": "synor1addr2...", + "index": 1, + "balance": "50.00" + }), + ]; + + let pagination_meta = pagination.to_meta(addresses.len() as u64); + Ok(Json(ApiResponse::success_paginated(addresses, pagination_meta))) +} + +/// Generate a stealth address. +async fn generate_stealth_address( + State(state): State, + Authenticated(auth): Authenticated, +) -> ApiResult>> { + require_permission(&auth, "write")?; + + let stealth = serde_json::json!({ + "stealth_address": "synor1stealth...", + "scan_key": "0x...", + "spend_key": "0x..." + }); + + Ok(Json(ApiResponse::success(stealth))) +} + +/// Get balance for an address. +#[cfg_attr(feature = "openapi", utoipa::path( + get, + path = "/v1/wallet/balance/{address}", + params( + ("address" = String, Path, description = "Wallet address") + ), + responses( + (status = 200, description = "Balance retrieved", body = ApiResponse), + (status = 400, description = "Invalid address"), + (status = 404, description = "Address not found") + ), + security(("api_key" = [])), + tag = "Wallet" +))] +async fn get_balance( + State(state): State, + Authenticated(auth): Authenticated, + Path(address): Path, +) -> ApiResult>> { + require_permission(&auth, "read")?; + + // Validate address format + if !address.starts_with("synor1") { + return Err(ApiError::InvalidAddress(address)); + } + + // In production, query the RPC service + let balance = BalanceResponse { + address, + total: "150.50".to_string(), + confirmed: "150.00".to_string(), + unconfirmed: "0.50".to_string(), + balance_raw: "150500000000".to_string(), + balance_formatted: "150.50 SYNOR".to_string(), + }; + + Ok(Json(ApiResponse::success(balance))) +} + +/// Get balances for multiple addresses. +async fn get_balances( + State(state): State, + Authenticated(auth): Authenticated, + Json(req): Json, +) -> ApiResult>>> { + require_permission(&auth, "read")?; + + if req.addresses.is_empty() { + return Err(ApiError::ValidationError("addresses cannot be empty".to_string())); + } + + if req.addresses.len() > 100 { + return Err(ApiError::ValidationError( + "Maximum 100 addresses per request".to_string(), + )); + } + + // In production, batch query the RPC service + let balances: Vec = req + .addresses + .iter() + .map(|addr| BalanceResponse { + address: addr.clone(), + total: "100.00".to_string(), + confirmed: "100.00".to_string(), + unconfirmed: "0.00".to_string(), + balance_raw: "100000000000".to_string(), + balance_formatted: "100.00 SYNOR".to_string(), + }) + .collect(); + + Ok(Json(ApiResponse::success(balances))) +} + +/// Get UTXOs for an address. +async fn get_utxos( + State(state): State, + Authenticated(auth): Authenticated, + Path(address): Path, + Query(pagination): Query, +) -> ApiResult>>> { + require_permission(&auth, "read")?; + + // In production, query the RPC service + let utxos = vec![ + Utxo { + txid: "abc123...".to_string(), + vout: 0, + value: "50000000000".to_string(), + height: Some(100000), + confirmations: 100, + script_pubkey: "76a914...".to_string(), + }, + Utxo { + txid: "def456...".to_string(), + vout: 1, + value: "100500000000".to_string(), + height: Some(100050), + confirmations: 50, + script_pubkey: "76a914...".to_string(), + }, + ]; + + let pagination_meta = pagination.to_meta(utxos.len() as u64); + Ok(Json(ApiResponse::success_paginated(utxos, pagination_meta))) +} + +/// Sign a transaction. +async fn sign_transaction( + State(state): State, + Authenticated(auth): Authenticated, + Json(req): Json, +) -> ApiResult>> { + require_permission(&auth, "write")?; + + // In production, this would use the crypto service + let response = SignTransactionResponse { + signed_tx: "0x...signed...".to_string(), + txid: "txid123...".to_string(), + signature: "0x...signature...".to_string(), + }; + + Ok(Json(ApiResponse::success(response))) +} + +/// Sign a message. +async fn sign_message( + State(state): State, + Authenticated(auth): Authenticated, + Json(req): Json, +) -> ApiResult>> { + require_permission(&auth, "write")?; + + let response = SignMessageResponse { + message: req.message, + signature: "0x...message_signature...".to_string(), + public_key: "0x04...".to_string(), + }; + + Ok(Json(ApiResponse::success(response))) +} + +/// Send a transaction. +async fn send_transaction( + State(state): State, + Authenticated(auth): Authenticated, + Json(req): Json, +) -> ApiResult>> { + require_permission(&auth, "write")?; + + // Validate recipient address + if !req.to.starts_with("synor1") { + return Err(ApiError::InvalidAddress(req.to)); + } + + // Validate amount + let amount: f64 = req.amount.parse().map_err(|_| { + ApiError::ValidationError("Invalid amount format".to_string()) + })?; + + if amount <= 0.0 { + return Err(ApiError::ValidationError("Amount must be positive".to_string())); + } + + // In production, build, sign, and broadcast the transaction + let response = SendTransactionResponse { + txid: "newtxid789...".to_string(), + amount: req.amount, + fee: "0.001".to_string(), + status: "pending".to_string(), + }; + + Ok(Json(ApiResponse::success(response))) +}