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.
This commit is contained in:
Gulshan Yadav 2026-01-28 15:03:36 +05:30
parent b9f1f013b3
commit 03c1664739
12 changed files with 3207 additions and 0 deletions

View file

@ -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"] }

View file

@ -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<String>,
}
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<String>,
/// User's tier
pub tier: ApiKeyTier,
/// User's permissions
pub permissions: Permissions,
/// Authentication timestamp
pub authenticated_at: DateTime<Utc>,
/// Token expiration (if JWT)
pub expires_at: Option<DateTime<Utc>>,
}
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<String>,
/// User tier
pub tier: ApiKeyTier,
/// Permissions
pub permissions: Permissions,
}
/// Authentication service.
#[derive(Clone)]
pub struct AuthService {
jwt_secret: Arc<String>,
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<String, ApiError> {
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<AuthContext, ApiError> {
let token_data = decode::<JwtClaims>(
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<AuthContext, ApiError> {
// 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<AuthContext, ApiError> {
// 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<S> FromRequestParts<S> for Authenticated
where
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
// Get auth service from extensions
let auth_service = parts
.extensions
.get::<AuthService>()
.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<AuthContext>);
impl<S> FromRequestParts<S> for OptionalAuth
where
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
// Get auth service from extensions
let auth_service = parts
.extensions
.get::<AuthService>()
.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
)))
}
}

View file

@ -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<String>,
/// Allowed methods
pub allowed_methods: Vec<String>,
/// Allowed headers
pub allowed_headers: Vec<String>,
/// Expose headers
pub expose_headers: Vec<String>,
/// 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<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let s = humantime::format_duration(*duration).to_string();
serializer.serialize_str(&s)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
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<Self> {
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<Self> {
let settings = config::Config::builder()
.add_source(config::Environment::with_prefix("SYNOR_GATEWAY"))
.build()?;
Ok(settings.try_deserialize().unwrap_or_default())
}
}

View file

@ -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<T> = Result<T, ApiError>;
/// 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<ErrorMeta>,
}
/// 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<serde_json::Value>,
}
/// 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<anyhow::Error> for ApiError {
fn from(err: anyhow::Error) -> Self {
tracing::error!("Internal error: {:?}", err);
Self::InternalError
}
}
impl From<serde_json::Error> for ApiError {
fn from(err: serde_json::Error) -> Self {
Self::BadRequest(format!("JSON parsing error: {}", err))
}
}
impl From<std::io::Error> for ApiError {
fn from(err: std::io::Error) -> Self {
tracing::error!("IO error: {:?}", err);
Self::InternalError
}
}

View file

@ -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;

View file

@ -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<HeaderValue> = config
.allowed_origins
.iter()
.filter_map(|o| o.parse().ok())
.collect();
cors = cors.allow_origin(origins);
}
// Allowed methods
let methods: Vec<Method> = config
.allowed_methods
.iter()
.filter_map(|m| m.parse().ok())
.collect();
cors = cors.allow_methods(methods);
// Allowed headers
let headers: Vec<HeaderName> = config
.allowed_headers
.iter()
.filter_map(|h| h.parse().ok())
.collect();
cors = cors.allow_headers(headers);
// Expose headers
let expose: Vec<HeaderName> = 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<HashMap<String, Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>>>,
/// Per-API-key rate limiters
key_limiters: RwLock<HashMap<String, Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>>>,
}
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<RateLimiter<NotKeyed, InMemoryState, DefaultClock>> {
{
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<RateLimiter<NotKeyed, InMemoryState, DefaultClock>> {
{
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<Arc<RateLimiterState>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
request: Request,
next: Next,
) -> Result<Response, ApiError> {
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<Arc<AuthService>>,
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<Response, ApiError> {
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
}

View file

@ -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<T> {
/// Whether the request succeeded
pub success: bool,
/// Response data (present on success)
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<T>,
/// Response metadata
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ResponseMeta>,
}
/// Response metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseMeta {
/// Unique request ID for tracing
pub request_id: String,
/// Response timestamp
pub timestamp: DateTime<Utc>,
/// Request processing time in milliseconds
#[serde(skip_serializing_if = "Option::is_none")]
pub latency_ms: Option<u64>,
/// Pagination info (if applicable)
#[serde(skip_serializing_if = "Option::is_none")]
pub pagination: Option<PaginationMeta>,
}
/// 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<T: Serialize> ApiResponse<T> {
/// 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<T: Serialize> IntoResponse for ApiResponse<T> {
fn into_response(self) -> Response {
(StatusCode::OK, Json(self)).into_response()
}
}
/// Response for created resources (201).
pub struct Created<T>(pub ApiResponse<T>);
impl<T: Serialize> IntoResponse for Created<T> {
fn into_response(self) -> Response {
(StatusCode::CREATED, Json(self.0)).into_response()
}
}
/// Response for accepted requests (202).
pub struct Accepted<T>(pub ApiResponse<T>);
impl<T: Serialize> IntoResponse for Accepted<T> {
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<EmptyResponse> {
/// 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<String>,
/// 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<Vec<ServiceHealth>>,
}
/// 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<String>,
/// Response time in milliseconds
#[serde(skip_serializing_if = "Option::is_none")]
pub latency_ms: Option<u64>,
}

View file

@ -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<AppState> {
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<AppState>) -> Json<HealthResponse> {
// 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<AppState>) -> Json<HealthResponse> {
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),
}
}

View file

@ -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<GatewayConfig>,
// 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<AppState> {
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())
}

View file

@ -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<AppState> {
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<String>,
pub transactions: Vec<String>,
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<TxInput>,
pub outputs: Vec<TxOutput>,
pub lock_time: u64,
pub mass: u64,
pub block_hash: Option<String>,
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<u32>,
}
#[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<u64>,
}
#[derive(Debug, Serialize)]
pub struct BlockTemplate {
pub parent_hashes: Vec<String>,
pub transactions: Vec<String>,
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<String>,
}
// ============================================================================
// Handlers
// ============================================================================
async fn get_block(
State(state): State<AppState>,
Authenticated(auth): Authenticated,
Path(id): Path<String>,
) -> ApiResult<Json<ApiResponse<BlockResponse>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<Json<ApiResponse<Vec<BlockResponse>>>> {
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<AppState>,
Authenticated(auth): Authenticated,
) -> ApiResult<Json<ApiResponse<Vec<String>>>> {
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<AppState>,
Authenticated(auth): Authenticated,
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Path(txid): Path<String>,
) -> ApiResult<Json<ApiResponse<TransactionResponse>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Json(req): Json<SendTransactionRequest>,
) -> ApiResult<Json<ApiResponse<SendTransactionResponse>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Json(req): Json<EstimateFeeRequest>,
) -> ApiResult<Json<ApiResponse<FeeEstimate>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<Json<ApiResponse<Vec<String>>>> {
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<AppState>,
Authenticated(auth): Authenticated,
) -> ApiResult<Json<ApiResponse<NetworkInfo>>> {
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<AppState>,
Authenticated(auth): Authenticated,
) -> ApiResult<Json<ApiResponse<Vec<serde_json::Value>>>> {
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<AppState>,
Authenticated(auth): Authenticated,
) -> ApiResult<Json<ApiResponse<SyncStatus>>> {
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<AppState>,
Authenticated(auth): Authenticated,
) -> ApiResult<Json<ApiResponse<BlockTemplate>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Json(req): Json<SubmitBlockRequest>,
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
require_permission(&auth, "write")?;
let result = serde_json::json!({
"accepted": true,
"block_hash": "newblockhash..."
});
Ok(Json(ApiResponse::success(result)))
}

View file

@ -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<AppState> {
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<String>,
}
#[derive(Debug, Deserialize)]
pub struct PinRequest {
pub duration_days: Option<u32>,
}
#[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<FileEntry>,
}
#[derive(Debug, Deserialize)]
pub struct FileEntry {
pub name: String,
pub cid: String,
}
// ============================================================================
// Handlers
// ============================================================================
async fn upload_file(
State(state): State<AppState>,
Authenticated(auth): Authenticated,
multipart: Multipart,
) -> ApiResult<Json<ApiResponse<UploadResponse>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Path(cid): Path<String>,
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
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<AppState>,
Path(cid): Path<String>,
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Path(cid): Path<String>,
Json(req): Json<PinRequest>,
) -> ApiResult<Json<ApiResponse<PinStatus>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Path(cid): Path<String>,
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Path(cid): Path<String>,
) -> ApiResult<Json<ApiResponse<PinStatus>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<Json<ApiResponse<Vec<PinStatus>>>> {
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<AppState>,
Authenticated(auth): Authenticated,
multipart: Multipart,
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
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<AppState>,
Authenticated(auth): Authenticated,
multipart: Multipart,
) -> ApiResult<Json<ApiResponse<Vec<String>>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Json(req): Json<CreateDirectoryRequest>,
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Path(cid): Path<String>,
) -> ApiResult<Json<ApiResponse<Vec<DirectoryEntry>>>> {
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)))
}

View file

@ -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<AppState> {
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<String>,
/// 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<String>,
/// 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<String>,
}
/// 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<u64>,
/// 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<String>,
/// 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<CreateWalletResponse>),
(status = 400, description = "Invalid request"),
(status = 401, description = "Authentication required")
),
security(("api_key" = [])),
tag = "Wallet"
))]
async fn create_wallet(
State(state): State<AppState>,
Authenticated(auth): Authenticated,
Json(req): Json<CreateWalletRequest>,
) -> ApiResult<Json<ApiResponse<CreateWalletResponse>>> {
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<ImportWalletResponse>),
(status = 400, description = "Invalid mnemonic"),
(status = 401, description = "Authentication required")
),
security(("api_key" = [])),
tag = "Wallet"
))]
async fn import_wallet(
State(state): State<AppState>,
Authenticated(auth): Authenticated,
Json(req): Json<ImportWalletRequest>,
) -> ApiResult<Json<ApiResponse<ImportWalletResponse>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Path(address): Path<String>,
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Query(query): Query<AddressQuery>,
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<Json<ApiResponse<Vec<serde_json::Value>>>> {
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<AppState>,
Authenticated(auth): Authenticated,
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
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<BalanceResponse>),
(status = 400, description = "Invalid address"),
(status = 404, description = "Address not found")
),
security(("api_key" = [])),
tag = "Wallet"
))]
async fn get_balance(
State(state): State<AppState>,
Authenticated(auth): Authenticated,
Path(address): Path<String>,
) -> ApiResult<Json<ApiResponse<BalanceResponse>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Json(req): Json<GetBalancesRequest>,
) -> ApiResult<Json<ApiResponse<Vec<BalanceResponse>>>> {
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<BalanceResponse> = 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<AppState>,
Authenticated(auth): Authenticated,
Path(address): Path<String>,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<Json<ApiResponse<Vec<Utxo>>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Json(req): Json<SignTransactionRequest>,
) -> ApiResult<Json<ApiResponse<SignTransactionResponse>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Json(req): Json<SignMessageRequest>,
) -> ApiResult<Json<ApiResponse<SignMessageResponse>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Json(req): Json<SendTransactionRequest>,
) -> ApiResult<Json<ApiResponse<SendTransactionResponse>>> {
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)))
}