From 34ed83303c49bddde17f71dd5eff0c61830ca86b Mon Sep 17 00:00:00 2001 From: soomtochukwu Date: Mon, 1 Jun 2026 14:31:09 +0100 Subject: [PATCH] Implement backend contract lifecycle services Closes #372 Closes #376 Closes #378 Closes #379 --- Cargo.lock | 46 ++++ backend/Cargo.toml | 4 +- .../20260601000000_contract_services.sql | 84 +++++++ backend/scripts/init-db.sql | 86 +++++++ backend/src/api/handlers/admin.rs | 10 +- backend/src/api/handlers/contracts.rs | 104 ++++++-- backend/src/api/handlers/mod.rs | 3 +- backend/src/api/middleware/cache.rs | 11 +- backend/src/bin/backup.rs | 8 + backend/src/main.rs | 87 ++++--- backend/src/services/business_metrics.rs | 47 ++-- backend/src/services/compliance.rs | 18 +- backend/src/services/contract_call_logger.rs | 10 +- backend/src/services/contract_deployment.rs | 200 +++++++++++++++ .../services/contract_storage_optimizer.rs | 213 ++++++++++++++++ backend/src/services/contract_test_results.rs | 212 ++++++++++++++++ backend/src/services/contract_versioning.rs | 233 ++++++++++++++++++ backend/src/services/mod.rs | 17 +- backend/src/workers/cache_warm.rs | 4 +- backend/src/workers/health.rs | 4 +- backend/src/workers/progress.rs | 4 +- backend/src/workers/scheduler.rs | 7 +- 22 files changed, 1285 insertions(+), 127 deletions(-) create mode 100644 backend/migrations/20260601000000_contract_services.sql create mode 100644 backend/src/services/contract_deployment.rs create mode 100644 backend/src/services/contract_storage_optimizer.rs create mode 100644 backend/src/services/contract_test_results.rs create mode 100644 backend/src/services/contract_versioning.rs diff --git a/Cargo.lock b/Cargo.lock index 6218def..dfe1e4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -365,6 +365,7 @@ dependencies = [ "async-trait", "axum-core", "axum-macros", + "base64 0.22.1", "bytes", "futures-util", "http", @@ -383,8 +384,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower 0.5.3", "tower-layer", "tower-service", @@ -5151,6 +5154,18 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -5546,6 +5561,24 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.6", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typenum" version = "1.20.1" @@ -5632,6 +5665,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5678,9 +5717,16 @@ dependencies = [ "serde_json", "url", "utoipa", + "utoipa-swagger-ui-vendored", "zip", ] +[[package]] +name = "utoipa-swagger-ui-vendored" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" + [[package]] name = "uuid" version = "1.23.2" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 517452e..01d2d21 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -20,7 +20,7 @@ testutils = ["mockall"] [dependencies] url = { version = "2", features = ["serde"] } # Web framework -axum = { version = "0.7", features = ["macros"] } +axum = { version = "0.7", features = ["macros", "ws"] } tower = { version = "0.5", features = ["full", "util"] } tower-http = { version = "0.5", features = ["cors", "trace", "compression-gzip", "request-id"] } @@ -60,7 +60,7 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] } # External Integrations utoipa = { version = "5.0", features = ["axum_extras", "chrono", "uuid"] } -utoipa-swagger-ui = { version = "8.0", features = ["axum"] } +utoipa-swagger-ui = { version = "8.0", features = ["axum", "vendored"] } apalis = { version = "0.6" } apalis-redis = "0.6" rust_decimal = { version = "1.35", features = ["serde"] } diff --git a/backend/migrations/20260601000000_contract_services.sql b/backend/migrations/20260601000000_contract_services.sql new file mode 100644 index 0000000..c502872 --- /dev/null +++ b/backend/migrations/20260601000000_contract_services.sql @@ -0,0 +1,84 @@ +-- Backend contract service tables for storage optimization, versioning, +-- deployment automation, and test result storage. + +CREATE TABLE IF NOT EXISTS contract_storage_optimizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id TEXT NOT NULL, + target_network TEXT NOT NULL, + storage_entries_estimate BIGINT NOT NULL, + estimated_rent_savings_percent DOUBLE PRECISION NOT NULL, + ttl_strategy TEXT NOT NULL, + recommendations JSONB NOT NULL DEFAULT '[]', + generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_contract_storage_optimizations_contract_id + ON contract_storage_optimizations (contract_id); + +CREATE TABLE IF NOT EXISTS contract_versions ( + id TEXT PRIMARY KEY, + contract_id TEXT NOT NULL, + version TEXT NOT NULL, + source_hash TEXT NOT NULL, + wasm_hash TEXT, + changelog TEXT, + created_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_contract_versions_contract_version UNIQUE (contract_id, version) +); + +CREATE INDEX IF NOT EXISTS idx_contract_versions_contract_id + ON contract_versions (contract_id); + +CREATE TABLE IF NOT EXISTS contract_deployments ( + id TEXT PRIMARY KEY, + contract_id TEXT NOT NULL, + version TEXT NOT NULL, + network TEXT NOT NULL, + deployer TEXT NOT NULL, + wasm_hash TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('planned', 'queued', 'running', 'succeeded', 'failed')), + transaction_envelope TEXT, + steps JSONB NOT NULL DEFAULT '[]', + checks JSONB NOT NULL DEFAULT '[]', + dry_run BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_contract_deployments_contract_id + ON contract_deployments (contract_id); +CREATE INDEX IF NOT EXISTS idx_contract_deployments_network_status + ON contract_deployments (network, status); + +CREATE TABLE IF NOT EXISTS contract_test_runs ( + id TEXT PRIMARY KEY, + contract_id TEXT NOT NULL, + build_id TEXT, + status TEXT NOT NULL CHECK (status IN ('passed', 'failed', 'error', 'running')), + total_tests BIGINT NOT NULL, + passed_tests BIGINT NOT NULL, + failed_tests BIGINT NOT NULL, + skipped_tests BIGINT NOT NULL, + duration_ms BIGINT, + metadata JSONB NOT NULL DEFAULT '{}', + completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_contract_test_runs_contract_id + ON contract_test_runs (contract_id); +CREATE INDEX IF NOT EXISTS idx_contract_test_runs_status + ON contract_test_runs (status); + +CREATE TABLE IF NOT EXISTS contract_test_cases ( + id TEXT PRIMARY KEY, + test_run_id TEXT NOT NULL REFERENCES contract_test_runs(id) ON DELETE CASCADE, + name TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('passed', 'failed', 'skipped', 'running')), + duration_ms BIGINT, + gas_used BIGINT, + error_message TEXT, + stack_trace TEXT +); + +CREATE INDEX IF NOT EXISTS idx_contract_test_cases_run_id + ON contract_test_cases (test_run_id); diff --git a/backend/scripts/init-db.sql b/backend/scripts/init-db.sql index f3cf115..19f50f8 100644 --- a/backend/scripts/init-db.sql +++ b/backend/scripts/init-db.sql @@ -98,6 +98,92 @@ CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); CREATE INDEX IF NOT EXISTS idx_jobs_job_type ON jobs(job_type); CREATE INDEX IF NOT EXISTS idx_jobs_scheduled_at ON jobs(scheduled_at); +-- Contract storage optimization reports +CREATE TABLE IF NOT EXISTS contract_storage_optimizations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + contract_id TEXT NOT NULL, + target_network TEXT NOT NULL, + storage_entries_estimate BIGINT NOT NULL, + estimated_rent_savings_percent DOUBLE PRECISION NOT NULL, + ttl_strategy TEXT NOT NULL, + recommendations JSONB NOT NULL DEFAULT '[]', + generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_contract_storage_optimizations_contract_id + ON contract_storage_optimizations (contract_id); + +-- Contract versions and artifact hashes +CREATE TABLE IF NOT EXISTS contract_versions ( + id TEXT PRIMARY KEY, + contract_id TEXT NOT NULL, + version TEXT NOT NULL, + source_hash TEXT NOT NULL, + wasm_hash TEXT, + changelog TEXT, + created_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_contract_versions_contract_version UNIQUE (contract_id, version) +); + +CREATE INDEX IF NOT EXISTS idx_contract_versions_contract_id + ON contract_versions (contract_id); + +-- Deployment automation jobs +CREATE TABLE IF NOT EXISTS contract_deployments ( + id TEXT PRIMARY KEY, + contract_id TEXT NOT NULL, + version TEXT NOT NULL, + network TEXT NOT NULL, + deployer TEXT NOT NULL, + wasm_hash TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('planned', 'queued', 'running', 'succeeded', 'failed')), + transaction_envelope TEXT, + steps JSONB NOT NULL DEFAULT '[]', + checks JSONB NOT NULL DEFAULT '[]', + dry_run BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_contract_deployments_contract_id + ON contract_deployments (contract_id); +CREATE INDEX IF NOT EXISTS idx_contract_deployments_network_status + ON contract_deployments (network, status); + +-- Stored contract test results +CREATE TABLE IF NOT EXISTS contract_test_runs ( + id TEXT PRIMARY KEY, + contract_id TEXT NOT NULL, + build_id TEXT, + status TEXT NOT NULL CHECK (status IN ('passed', 'failed', 'error', 'running')), + total_tests BIGINT NOT NULL, + passed_tests BIGINT NOT NULL, + failed_tests BIGINT NOT NULL, + skipped_tests BIGINT NOT NULL, + duration_ms BIGINT, + metadata JSONB NOT NULL DEFAULT '{}', + completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_contract_test_runs_contract_id + ON contract_test_runs (contract_id); +CREATE INDEX IF NOT EXISTS idx_contract_test_runs_status + ON contract_test_runs (status); + +CREATE TABLE IF NOT EXISTS contract_test_cases ( + id TEXT PRIMARY KEY, + test_run_id TEXT NOT NULL REFERENCES contract_test_runs(id) ON DELETE CASCADE, + name TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('passed', 'failed', 'skipped', 'running')), + duration_ms BIGINT, + gas_used BIGINT, + error_message TEXT, + stack_trace TEXT +); + +CREATE INDEX IF NOT EXISTS idx_contract_test_cases_run_id + ON contract_test_cases (test_run_id); + -- --------------------------------------------------------------------------- -- Functions: Auto-update updated_at timestamp -- --------------------------------------------------------------------------- diff --git a/backend/src/api/handlers/admin.rs b/backend/src/api/handlers/admin.rs index 700acc9..65561fd 100644 --- a/backend/src/api/handlers/admin.rs +++ b/backend/src/api/handlers/admin.rs @@ -1,11 +1,11 @@ +use crate::api::contracts::ApiResponse; +use crate::api::handlers::profiling::AppState; +use crate::error::AppError; +use crate::services::contract_call_logger::{ContractCallLog, ContractCallLogger}; use axum::{extract::State, response::IntoResponse, Json}; use serde::{Deserialize, Serialize}; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use crate::error::AppError; -use crate::api::handlers::profiling::AppState; -use crate::api::contracts::ApiResponse; -use crate::services::contract_call_logger::{ContractCallLogger, ContractCallLog}; +use std::sync::Arc; // Global static for maintenance mode (mock implementation) pub static MAINTENANCE_MODE: AtomicBool = AtomicBool::new(false); diff --git a/backend/src/api/handlers/contracts.rs b/backend/src/api/handlers/contracts.rs index db48da1..51524da 100644 --- a/backend/src/api/handlers/contracts.rs +++ b/backend/src/api/handlers/contracts.rs @@ -3,10 +3,20 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use crate::api::contracts::ApiResponse; -use crate::error::AppError; -use crate::services::compilation::{CompilationResult, CompilationService}; -use crate::services::dependency_analyzer::{DependencyAnalysis, DependencyAnalyzer}; use crate::api::handlers::profiling::AppState; +use crate::error::AppError; +use crate::services::compilation::CompilationService; +use crate::services::contract_deployment::{ContractDeploymentService, DeploymentRequest}; +use crate::services::contract_storage_optimizer::{ + ContractStorageOptimizer, StorageOptimizationInput, +}; +use crate::services::contract_test_results::{ + ContractTestResultStorageService, StoreTestRunRequest, +}; +use crate::services::contract_versioning::{ + ContractVersioningService, CreateContractVersionRequest, VersionDiffRequest, +}; +use crate::services::dependency_analyzer::DependencyAnalyzer; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -34,15 +44,19 @@ pub struct NetworkConfig { pub active_contracts_count: u32, } +fn require_db(state: &AppState) -> Result { + state + .db + .clone() + .ok_or_else(|| AppError::InternalError("Database connection not configured".to_string())) +} + /// POST /api/v1/contracts/compile pub async fn compile_contract( State(state): State>, Json(payload): Json, ) -> Result { - let db = state - .db - .clone() - .ok_or_else(|| AppError::InternalError("Database connection not configured".to_string()))?; + let db = require_db(&state)?; let service = CompilationService::new(db); let result = service @@ -58,10 +72,7 @@ pub async fn analyze_dependencies( State(state): State>, Json(payload): Json, ) -> Result { - let db = state - .db - .clone() - .ok_or_else(|| AppError::InternalError("Database connection not configured".to_string()))?; + let db = require_db(&state)?; let service = DependencyAnalyzer::new(db); let result = service @@ -121,7 +132,7 @@ pub async fn get_networks() -> Result { } use crate::services::compliance::ComplianceService; -use crate::services::contract_call_logger::{ContractCallLogger, ContractCallLog}; +use crate::services::contract_call_logger::{ContractCallLog, ContractCallLogger}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -168,10 +179,7 @@ pub async fn check_compliance( State(state): State>, Json(payload): Json, ) -> Result { - let db = state - .db - .clone() - .ok_or_else(|| AppError::InternalError("Database connection not configured".to_string()))?; + let db = require_db(&state)?; let service = ComplianceService::new(db); let result = service.check_compliance(&payload.source_code).await?; @@ -183,10 +191,7 @@ pub async fn log_contract_call( State(state): State>, Json(payload): Json, ) -> Result { - let db = state - .db - .clone() - .ok_or_else(|| AppError::InternalError("Database connection not configured".to_string()))?; + let db = require_db(&state)?; let service = ContractCallLogger::new(db); let log = ContractCallLog { @@ -200,7 +205,9 @@ pub async fn log_contract_call( timestamp: Some(chrono::Utc::now()), }; service.log_call(log).await?; - Ok(Json(ApiResponse::new(serde_json::json!({ "success": true })))) + Ok(Json(ApiResponse::new( + serde_json::json!({ "success": true }), + ))) } /// GET /api/v1/contracts/logs @@ -208,16 +215,63 @@ pub async fn get_contract_logs( State(state): State>, axum::extract::Query(query): axum::extract::Query, ) -> Result { - let db = state - .db - .clone() - .ok_or_else(|| AppError::InternalError("Database connection not configured".to_string()))?; + let db = require_db(&state)?; let service = ContractCallLogger::new(db); let result = service.get_logs(query.contract_id, query.limit).await?; Ok(Json(ApiResponse::new(result))) } +/// POST /api/v1/contracts/storage/optimize +pub async fn optimize_storage( + State(state): State>, + Json(payload): Json, +) -> Result { + let service = ContractStorageOptimizer::new(require_db(&state)?); + let result = service.optimize(payload).await?; + Ok(Json(ApiResponse::new(result))) +} + +/// POST /api/v1/contracts/versions +pub async fn create_contract_version( + State(state): State>, + Json(payload): Json, +) -> Result { + let service = ContractVersioningService::new(require_db(&state)?); + let result = service.create_version(payload).await?; + Ok(Json(ApiResponse::new(result))) +} + +/// POST /api/v1/contracts/versions/diff +pub async fn diff_contract_versions( + State(state): State>, + Json(payload): Json, +) -> Result { + let service = ContractVersioningService::new(require_db(&state)?); + let result = service.diff(payload); + Ok(Json(ApiResponse::new(result))) +} + +/// POST /api/v1/contracts/deployments +pub async fn create_contract_deployment( + State(state): State>, + Json(payload): Json, +) -> Result { + let service = ContractDeploymentService::new(require_db(&state)?); + let result = service.create_deployment(payload).await?; + Ok(Json(ApiResponse::new(result))) +} + +/// POST /api/v1/contracts/test-results +pub async fn store_contract_test_results( + State(state): State>, + Json(payload): Json, +) -> Result { + let service = ContractTestResultStorageService::new(require_db(&state)?); + let result = service.store_run(payload).await?; + Ok(Json(ApiResponse::new(result))) +} + /// GET /api/v1/contracts/templates pub async fn get_templates() -> Result { let templates = vec![ diff --git a/backend/src/api/handlers/mod.rs b/backend/src/api/handlers/mod.rs index 511d4c0..abc683b 100644 --- a/backend/src/api/handlers/mod.rs +++ b/backend/src/api/handlers/mod.rs @@ -1,8 +1,7 @@ +pub mod admin; pub mod contracts; pub mod dashboard; pub mod errors; pub mod profiling; pub mod stellar; pub mod ws; -pub mod contracts; -pub mod admin; diff --git a/backend/src/api/middleware/cache.rs b/backend/src/api/middleware/cache.rs index a62df1e..6e906db 100644 --- a/backend/src/api/middleware/cache.rs +++ b/backend/src/api/middleware/cache.rs @@ -1,9 +1,6 @@ -use axum::{ - middleware::Next, - extract::Request, -}; -use std::time::Duration; +use axum::{extract::Request, middleware::Next}; use redis::Client; +use std::time::Duration; /// Cache key generator for HTTP requests #[derive(Debug, Clone)] @@ -71,8 +68,6 @@ where fn call(&mut self, req: Req) -> Self::Future { let mut inner = self.inner.clone(); - Box::pin(async move { - inner.call(req).await - }) + Box::pin(async move { inner.call(req).await }) } } diff --git a/backend/src/bin/backup.rs b/backend/src/bin/backup.rs index 582f427..473bee9 100644 --- a/backend/src/bin/backup.rs +++ b/backend/src/bin/backup.rs @@ -36,6 +36,14 @@ use tower_http::trace::TraceLayer; use tracing::{error, info, instrument}; use uuid::Uuid; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; + // --------------------------------------------------------------------------- // Error types // --------------------------------------------------------------------------- diff --git a/backend/src/main.rs b/backend/src/main.rs index ae67fd9..f45a533 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -24,9 +24,7 @@ use backend::{ }, }; use profiling::AppState; -use redis::aio::ConnectionManager; use redis::Client as RedisClient; -use sqlx::postgres::PgPoolOptions; use std::net::SocketAddr; use std::sync::Arc; use tokio::signal; @@ -95,11 +93,10 @@ async fn main() -> Result<(), anyhow::Error> { let config_manager = Arc::new(ConfigManager::new(config.clone())); // Redis Job Queue setup - let conn = ConnectionManager::new(redis_client.clone()).await?; + let conn = redis::aio::ConnectionManager::new(redis_client.clone()).await?; let redis_span = TracingService::redis_command_span("CONNECT", None); let _redis_enter = redis_span.enter(); - let redis_conn_dashboard = ConnectionManager::new(redis_client.clone()).await?; let storage: RedisStorage = RedisStorage::new(conn); tracing::info!("Redis connection established"); @@ -115,6 +112,8 @@ async fn main() -> Result<(), anyhow::Error> { metrics_exporter: metrics_exporter.clone(), error_manager: error_manager.clone(), config_manager: config_manager.clone(), + log_aggregator: log_aggregator.clone(), + redis: redis_client.clone(), }); // Create dashboard state @@ -122,7 +121,7 @@ async fn main() -> Result<(), anyhow::Error> { metrics_exporter, error_manager, alert_manager, - db: db_pool, + db: db_pool.clone(), redis: redis_client.clone(), }); @@ -138,14 +137,10 @@ async fn main() -> Result<(), anyhow::Error> { paths( profiling::get_metrics, profiling::get_health, - dashboard::get_dashboard_metrics, - dashboard::get_contract_stats, ), components(schemas( profiling::MetricsReport, profiling::HealthResponse, - dashboard::DashboardMetrics, - dashboard::ContractStats )), tags( (name = "profiling", description = "Performance and health monitoring endpoints"), @@ -162,8 +157,12 @@ async fn main() -> Result<(), anyhow::Error> { let app = Router::new() .route("/", get(|| async { "Crucible Backend API" })) .route("/.well-known/stellar.toml", get(stellar::get_stellar_toml)) - .route("/api/config", get(handle_get_config)) - .route("/api/config/reload", post(handle_reload)) + .merge( + Router::new() + .route("/api/config", get(handle_get_config)) + .route("/api/config/reload", post(handle_reload)) + .with_state(config_manager.clone()), + ) .nest( "/api/v1/profiling", Router::new() @@ -196,18 +195,39 @@ async fn main() -> Result<(), anyhow::Error> { "/analyze-dependencies", post(backend::api::handlers::contracts::analyze_dependencies), ) - .with_state(state.clone()), - ) - .route( - "/api/v1/networks", - get(backend::api::handlers::contracts::get_networks), - ) - .route("/compile", post(backend::api::handlers::contracts::compile_contract)) - .route("/analyze-dependencies", post(backend::api::handlers::contracts::analyze_dependencies)) - .route("/compliance-check", post(backend::api::handlers::contracts::check_compliance)) - .route("/logs", post(backend::api::handlers::contracts::log_contract_call)) - .route("/logs", get(backend::api::handlers::contracts::get_contract_logs)) - .route("/templates", get(backend::api::handlers::contracts::get_templates)) + .route( + "/compliance-check", + post(backend::api::handlers::contracts::check_compliance), + ) + .route( + "/logs", + post(backend::api::handlers::contracts::log_contract_call) + .get(backend::api::handlers::contracts::get_contract_logs), + ) + .route( + "/templates", + get(backend::api::handlers::contracts::get_templates), + ) + .route( + "/storage/optimize", + post(backend::api::handlers::contracts::optimize_storage), + ) + .route( + "/versions", + post(backend::api::handlers::contracts::create_contract_version), + ) + .route( + "/versions/diff", + post(backend::api::handlers::contracts::diff_contract_versions), + ) + .route( + "/deployments", + post(backend::api::handlers::contracts::create_contract_deployment), + ) + .route( + "/test-results", + post(backend::api::handlers::contracts::store_contract_test_results), + ) .with_state(state.clone()), ) .route( @@ -217,14 +237,20 @@ async fn main() -> Result<(), anyhow::Error> { .nest( "/api/v1/admin", Router::new() - .route("/system-stats", get(backend::api::handlers::admin::get_system_stats)) - .route("/maintenance", post(backend::api::handlers::admin::set_maintenance_mode)) + .route( + "/system-stats", + get(backend::api::handlers::admin::get_system_stats), + ) + .route( + "/maintenance", + post(backend::api::handlers::admin::set_maintenance_mode), + ) .route("/logs", get(backend::api::handlers::admin::get_admin_logs)) .with_state(state.clone()), ) .nest( "/api/v1/errors", - errors::error_analytics_routes(db_pool.clone(), redis_conn_dashboard.clone()), + errors::error_analytics_routes(db_pool.clone(), redis_client.clone()), ) .route( "/api/v1/ws/dashboard", @@ -236,8 +262,7 @@ async fn main() -> Result<(), anyhow::Error> { logging_middleware, )) .layer(TraceLayer::new_for_http()) - .layer(cors) - .with_state(state); // fallback state for /api/config handlers + .layer(cors); let addr: SocketAddr = format!("{}:{}", config.server.host, config.server.port).parse()?; @@ -262,7 +287,7 @@ async fn main() -> Result<(), anyhow::Error> { // Wait for server to finish shutting down (stops accepting new connections) match res { Ok(()) => tracing::info!("Server stopped accepting new connections"), - Err(e) => tracing::error!("Server error during shutdown: {e}"), + Err(ref e) => tracing::error!("Server error during shutdown: {e}"), } // Wait for in-flight requests to complete @@ -286,11 +311,11 @@ async fn main() -> Result<(), anyhow::Error> { // Close database connection pool tracing::info!("Closing database connection pool"); - drop(state.db); // This closes the pool + drop(state.db.clone()); // Drop this handle; other shared handles close when released. // Close Redis connection tracing::info!("Closing Redis connection"); - drop(state.redis); // This closes the connection manager + drop(state.redis.clone()); // Drop this handle; other shared handles close when released. tracing::info!("Graceful shutdown completed successfully"); diff --git a/backend/src/services/business_metrics.rs b/backend/src/services/business_metrics.rs index 90752a3..45ae238 100644 --- a/backend/src/services/business_metrics.rs +++ b/backend/src/services/business_metrics.rs @@ -36,8 +36,8 @@ impl BusinessMetric { let recorded_at: DateTime = row.try_get("recorded_at")?; let source_str: String = row.try_get("source")?; - let tags: HashMap = serde_json::from_value(tags_val) - .map_err(|e| sqlx::Error::Decode(Box::new(e)))?; + let tags: HashMap = + serde_json::from_value(tags_val).map_err(|e| sqlx::Error::Decode(Box::new(e)))?; let category = MetricCategory::from_str(&category_str); let source = MetricSource::from_str(&source_str); @@ -296,11 +296,10 @@ impl BusinessMetricsService { let limit = query.limit.unwrap_or(100); let offset = query.offset.unwrap_or(0); - let count_row = - sqlx::query(r#"SELECT COUNT(*) as "count" FROM business_metrics"#) - .fetch_one(&self.db) - .await - .map_err(|e| AppError::Database(e))?; + let count_row = sqlx::query(r#"SELECT COUNT(*) as "count" FROM business_metrics"#) + .fetch_one(&self.db) + .await + .map_err(|e| AppError::Database(e))?; let total: i64 = count_row.try_get("count")?; let rows = sqlx::query( @@ -328,22 +327,20 @@ impl BusinessMetricsService { /// Get aggregated metrics summary. #[instrument(skip(self))] pub async fn get_metrics_summary(&self) -> Result { - let count_row = - sqlx::query(r#"SELECT COUNT(*) as "count" FROM business_metrics"#) - .fetch_one(&self.db) - .await - .map_err(|e| AppError::Database(e))?; + let count_row = sqlx::query(r#"SELECT COUNT(*) as "count" FROM business_metrics"#) + .fetch_one(&self.db) + .await + .map_err(|e| AppError::Database(e))?; let total: i64 = count_row.try_get("count")?; - let max_row = - sqlx::query(r#"SELECT MAX(recorded_at) as "max" FROM business_metrics"#) - .fetch_one(&self.db) - .await - .map_err(|e| AppError::Database(e))?; + let max_row = sqlx::query(r#"SELECT MAX(recorded_at) as "max" FROM business_metrics"#) + .fetch_one(&self.db) + .await + .map_err(|e| AppError::Database(e))?; let latest: Option> = max_row.try_get("max")?; let rows = sqlx::query( - r#"SELECT category, COUNT(*) as "count" FROM business_metrics GROUP BY category"# + r#"SELECT category, COUNT(*) as "count" FROM business_metrics GROUP BY category"#, ) .fetch_all(&self.db) .await @@ -425,14 +422,12 @@ impl BusinessMetricsService { pub async fn prune_old_metrics(&self, retention_days: i64) -> Result { let cutoff = Utc::now() - Duration::days(retention_days); - let deleted = sqlx::query( - r#"DELETE FROM business_metrics WHERE recorded_at < $1"#, - ) - .bind(cutoff) - .execute(&self.db) - .await - .map_err(|e| AppError::Database(e))? - .rows_affected(); + let deleted = sqlx::query(r#"DELETE FROM business_metrics WHERE recorded_at < $1"#) + .bind(cutoff) + .execute(&self.db) + .await + .map_err(|e| AppError::Database(e))? + .rows_affected(); info!(deleted, retention_days, "Pruned old metrics"); Ok(deleted) diff --git a/backend/src/services/compliance.rs b/backend/src/services/compliance.rs index 0b21fe8..349a191 100644 --- a/backend/src/services/compliance.rs +++ b/backend/src/services/compliance.rs @@ -1,5 +1,5 @@ -use serde::{Deserialize, Serialize}; use crate::error::AppError; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -29,9 +29,12 @@ impl ComplianceService { pub async fn check_compliance(&self, source_code: &str) -> Result { let mut issues = Vec::new(); - + // Rule 1: No unsafe code - if source_code.contains("unsafe ") || source_code.contains("unsafe{") || source_code.contains("unsafe {") { + if source_code.contains("unsafe ") + || source_code.contains("unsafe{") + || source_code.contains("unsafe {") + { issues.push(ComplianceIssue { severity: "error".to_string(), message: "Unsafe code block or modifier detected. Soroban smart contracts must run inside a safe sandbox.".to_string(), @@ -43,13 +46,18 @@ impl ComplianceService { if !source_code.contains("SPDX-License-Identifier") { issues.push(ComplianceIssue { severity: "warning".to_string(), - message: "Missing SPDX-License-Identifier licensing header in smart contract source.".to_string(), + message: + "Missing SPDX-License-Identifier licensing header in smart contract source." + .to_string(), rule_id: "MISSING_LICENSE".to_string(), }); } // Rule 3: File or network operations (which will fail in Wasm) - if source_code.contains("std::fs") || source_code.contains("std::net") || source_code.contains("std::thread") { + if source_code.contains("std::fs") + || source_code.contains("std::net") + || source_code.contains("std::thread") + { issues.push(ComplianceIssue { severity: "error".to_string(), message: "Standard library filesystem/networking/threading usage detected, which is incompatible with the Wasm runtime.".to_string(), diff --git a/backend/src/services/contract_call_logger.rs b/backend/src/services/contract_call_logger.rs index 2304034..e296854 100644 --- a/backend/src/services/contract_call_logger.rs +++ b/backend/src/services/contract_call_logger.rs @@ -1,7 +1,7 @@ +use crate::error::AppError; +use serde::{Deserialize, Serialize}; use sqlx::PgPool; -use serde::{Serialize, Deserialize}; use tracing::{info, instrument}; -use crate::error::AppError; #[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] #[serde(rename_all = "camelCase")] @@ -48,7 +48,11 @@ impl ContractCallLogger { } #[instrument(skip(self))] - pub async fn get_logs(&self, contract_id: Option, limit: i64) -> Result, AppError> { + pub async fn get_logs( + &self, + contract_id: Option, + limit: i64, + ) -> Result, AppError> { let logs = if let Some(cid) = contract_id { sqlx::query_as::<_, ContractCallLog>( "SELECT id, contract_id, function_name, arguments, caller, status, gas_used, timestamp \ diff --git a/backend/src/services/contract_deployment.rs b/backend/src/services/contract_deployment.rs new file mode 100644 index 0000000..463d3e0 --- /dev/null +++ b/backend/src/services/contract_deployment.rs @@ -0,0 +1,200 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::error::AppError; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentRequest { + pub contract_id: String, + pub version: String, + pub wasm_hash: String, + pub network: String, + pub deployer: String, + #[serde(default)] + pub constructor_args: Value, + #[serde(default)] + pub dry_run: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentCheck { + pub name: String, + pub status: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentJob { + pub id: String, + pub contract_id: String, + pub version: String, + pub network: String, + pub deployer: String, + pub wasm_hash: String, + pub status: String, + pub transaction_envelope: Option, + pub steps: Vec, + pub checks: Vec, + pub dry_run: bool, + pub created_at: DateTime, +} + +#[derive(Clone)] +pub struct ContractDeploymentService { + db: PgPool, +} + +impl ContractDeploymentService { + pub fn new(db: PgPool) -> Self { + Self { db } + } + + pub async fn create_deployment( + &self, + request: DeploymentRequest, + ) -> Result { + validate_deployment(&request)?; + + let checks = vec![ + DeploymentCheck { + name: "network".to_string(), + status: "passed".to_string(), + message: format!("{} is a supported deployment target.", request.network), + }, + DeploymentCheck { + name: "artifact".to_string(), + status: "passed".to_string(), + message: "WASM hash is present and formatted as a SHA-256 digest.".to_string(), + }, + DeploymentCheck { + name: "deployer".to_string(), + status: "passed".to_string(), + message: "Deployer address is present for authorization handoff.".to_string(), + }, + ]; + let steps = vec![ + "Resolve network passphrase and RPC endpoint".to_string(), + "Upload WASM artifact if the hash is not already installed".to_string(), + "Assemble contract deployment operation".to_string(), + "Run simulation and authorization checks".to_string(), + "Submit signed transaction envelope".to_string(), + "Persist deployment receipt and contract address".to_string(), + ]; + let status = if request.dry_run { "planned" } else { "queued" }.to_string(); + let transaction_envelope = if request.dry_run { + None + } else { + Some(format!( + "pending-signature:{}:{}:{}", + request.network, request.contract_id, request.version + )) + }; + + let job = DeploymentJob { + id: Uuid::new_v4().to_string(), + contract_id: request.contract_id, + version: request.version, + network: request.network, + deployer: request.deployer, + wasm_hash: request.wasm_hash, + status, + transaction_envelope, + steps, + checks, + dry_run: request.dry_run, + created_at: Utc::now(), + }; + + let _ = sqlx::query( + "INSERT INTO contract_deployments + (id, contract_id, version, network, deployer, wasm_hash, status, transaction_envelope, steps, checks, dry_run, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", + ) + .bind(&job.id) + .bind(&job.contract_id) + .bind(&job.version) + .bind(&job.network) + .bind(&job.deployer) + .bind(&job.wasm_hash) + .bind(&job.status) + .bind(&job.transaction_envelope) + .bind(serde_json::to_value(&job.steps)?) + .bind(serde_json::to_value(&job.checks)?) + .bind(job.dry_run) + .bind(job.created_at) + .execute(&self.db) + .await; + + Ok(job) + } +} + +fn validate_deployment(request: &DeploymentRequest) -> Result<(), AppError> { + if request.contract_id.trim().is_empty() { + return Err(AppError::ValidationError( + "contractId is required".to_string(), + )); + } + if request.version.trim().is_empty() { + return Err(AppError::ValidationError("version is required".to_string())); + } + if !matches!( + request.network.as_str(), + "mainnet" | "testnet" | "futurenet" | "sandbox" + ) { + return Err(AppError::ValidationError( + "network must be one of mainnet, testnet, futurenet, or sandbox".to_string(), + )); + } + if request.deployer.trim().is_empty() { + return Err(AppError::ValidationError( + "deployer is required".to_string(), + )); + } + if request.wasm_hash.len() != 64 || !request.wasm_hash.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(AppError::ValidationError( + "wasmHash must be a 64-character SHA-256 hex digest".to_string(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::postgres::PgPoolOptions; + + fn pool() -> PgPool { + PgPoolOptions::new() + .max_connections(1) + .connect_lazy("postgres://postgres:postgres@localhost/crucible_test") + .unwrap() + } + + #[tokio::test] + async fn creates_dry_run_deployment_plan() { + let service = ContractDeploymentService::new(pool()); + let job = service + .create_deployment(DeploymentRequest { + contract_id: "contract-a".to_string(), + version: "1.0.0".to_string(), + wasm_hash: "a".repeat(64), + network: "testnet".to_string(), + deployer: "GCDUMMYDEPLOYER".to_string(), + constructor_args: serde_json::json!({}), + dry_run: true, + }) + .await + .unwrap(); + + assert_eq!(job.status, "planned"); + assert!(job.transaction_envelope.is_none()); + assert_eq!(job.checks.len(), 3); + } +} diff --git a/backend/src/services/contract_storage_optimizer.rs b/backend/src/services/contract_storage_optimizer.rs new file mode 100644 index 0000000..c2ea4e0 --- /dev/null +++ b/backend/src/services/contract_storage_optimizer.rs @@ -0,0 +1,213 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +use crate::error::AppError; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StorageOptimizationInput { + pub contract_id: String, + pub source_code: String, + pub target_network: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StorageRecommendation { + pub line: u32, + pub current_storage: String, + pub recommended_storage: String, + pub severity: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StorageOptimizationReport { + pub contract_id: String, + pub target_network: String, + pub storage_entries_estimate: u32, + pub estimated_rent_savings_percent: f64, + pub ttl_strategy: String, + pub recommendations: Vec, + pub generated_at: DateTime, +} + +#[derive(Clone)] +pub struct ContractStorageOptimizer { + db: PgPool, +} + +impl ContractStorageOptimizer { + pub fn new(db: PgPool) -> Self { + Self { db } + } + + pub async fn optimize( + &self, + input: StorageOptimizationInput, + ) -> Result { + if input.contract_id.trim().is_empty() { + return Err(AppError::ValidationError( + "contractId is required".to_string(), + )); + } + if input.source_code.trim().is_empty() { + return Err(AppError::ValidationError( + "sourceCode is required".to_string(), + )); + } + + let recommendations = analyze_storage_lines(&input.source_code); + let storage_entries_estimate = input + .source_code + .lines() + .filter(|line| { + line.contains("env.storage()") && (line.contains(".set(") || line.contains(".get(")) + }) + .count() as u32; + let persistent_entries = input + .source_code + .lines() + .filter(|line| line.contains("storage().persistent()")) + .count() as f64; + let high_impact = recommendations + .iter() + .filter(|rec| rec.severity == "high") + .count() as f64; + let estimated_rent_savings_percent = if persistent_entries == 0.0 { + 0.0 + } else { + ((high_impact / persistent_entries) * 30.0).min(30.0) + }; + + let ttl_strategy = if input.source_code.contains("extend_ttl") + || input.source_code.contains("bump") + { + "Explicit TTL management detected; keep TTL extension close to write paths.".to_string() + } else if persistent_entries > 0.0 { + "Add TTL extension for persistent entries that must survive ledger expiration." + .to_string() + } else { + "Prefer temporary storage for short-lived workflow data and instance storage for contract configuration.".to_string() + }; + + let report = StorageOptimizationReport { + contract_id: input.contract_id, + target_network: input + .target_network + .unwrap_or_else(|| "testnet".to_string()), + storage_entries_estimate, + estimated_rent_savings_percent, + ttl_strategy, + recommendations, + generated_at: Utc::now(), + }; + + let _ = sqlx::query( + "INSERT INTO contract_storage_optimizations + (contract_id, target_network, storage_entries_estimate, estimated_rent_savings_percent, ttl_strategy, recommendations, generated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(&report.contract_id) + .bind(&report.target_network) + .bind(i64::from(report.storage_entries_estimate)) + .bind(report.estimated_rent_savings_percent) + .bind(&report.ttl_strategy) + .bind(serde_json::to_value(&report.recommendations)?) + .bind(report.generated_at) + .execute(&self.db) + .await; + + Ok(report) + } +} + +fn analyze_storage_lines(source_code: &str) -> Vec { + let mut recommendations = Vec::new(); + + for (idx, line) in source_code.lines().enumerate() { + let normalized = line.trim().to_lowercase(); + if !normalized.contains("storage().") { + continue; + } + + let line_no = idx as u32 + 1; + if normalized.contains("storage().persistent()") + && (normalized.contains("cache") + || normalized.contains("session") + || normalized.contains("temp") + || normalized.contains("nonce")) + { + recommendations.push(StorageRecommendation { + line: line_no, + current_storage: "persistent".to_string(), + recommended_storage: "temporary".to_string(), + severity: "high".to_string(), + reason: "Short-lived data should not pay persistent storage rent.".to_string(), + }); + } + + if normalized.contains("storage().instance()") + && (normalized.contains("balance") + || normalized.contains("allowance") + || normalized.contains("history") + || normalized.contains("claim")) + { + recommendations.push(StorageRecommendation { + line: line_no, + current_storage: "instance".to_string(), + recommended_storage: "persistent".to_string(), + severity: "medium".to_string(), + reason: "Per-account or growing state is safer outside instance storage." + .to_string(), + }); + } + + if normalized.contains(".set(") + && (normalized.contains("vec<") + || normalized.contains("map<") + || normalized.contains("bytesn<")) + { + recommendations.push(StorageRecommendation { + line: line_no, + current_storage: "unknown".to_string(), + recommended_storage: "chunked".to_string(), + severity: "high".to_string(), + reason: "Large values should be split across bounded keys to avoid rent and size spikes.".to_string(), + }); + } + } + + recommendations +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::postgres::PgPoolOptions; + + fn pool() -> PgPool { + PgPoolOptions::new() + .max_connections(1) + .connect_lazy("postgres://postgres:postgres@localhost/crucible_test") + .unwrap() + } + + #[tokio::test] + async fn recommends_temporary_storage_for_session_state() { + let service = ContractStorageOptimizer::new(pool()); + let report = service + .optimize(StorageOptimizationInput { + contract_id: "contract-a".to_string(), + target_network: None, + source_code: "env.storage().persistent().set(&session_key, &nonce);".to_string(), + }) + .await + .unwrap(); + + assert_eq!(report.storage_entries_estimate, 1); + assert_eq!(report.recommendations[0].recommended_storage, "temporary"); + } +} diff --git a/backend/src/services/contract_test_results.rs b/backend/src/services/contract_test_results.rs new file mode 100644 index 0000000..df6da6b --- /dev/null +++ b/backend/src/services/contract_test_results.rs @@ -0,0 +1,212 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::error::AppError; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TestCaseResult { + pub name: String, + pub status: String, + pub duration_ms: Option, + pub gas_used: Option, + pub error_message: Option, + pub stack_trace: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StoreTestRunRequest { + pub contract_id: String, + pub build_id: Option, + pub status: String, + pub duration_ms: Option, + #[serde(default)] + pub metadata: Value, + pub test_cases: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StoredTestRun { + pub id: String, + pub contract_id: String, + pub build_id: Option, + pub status: String, + pub total_tests: u32, + pub passed_tests: u32, + pub failed_tests: u32, + pub skipped_tests: u32, + pub duration_ms: Option, + pub metadata: Value, + pub completed_at: DateTime, +} + +#[derive(Clone)] +pub struct ContractTestResultStorageService { + db: PgPool, +} + +impl ContractTestResultStorageService { + pub fn new(db: PgPool) -> Self { + Self { db } + } + + pub async fn store_run(&self, request: StoreTestRunRequest) -> Result { + validate_test_run(&request)?; + + let passed_tests = request + .test_cases + .iter() + .filter(|case| case.status == "passed") + .count() as u32; + let failed_tests = request + .test_cases + .iter() + .filter(|case| case.status == "failed") + .count() as u32; + let skipped_tests = request + .test_cases + .iter() + .filter(|case| case.status == "skipped") + .count() as u32; + let run = StoredTestRun { + id: Uuid::new_v4().to_string(), + contract_id: request.contract_id, + build_id: request.build_id, + status: request.status, + total_tests: request.test_cases.len() as u32, + passed_tests, + failed_tests, + skipped_tests, + duration_ms: request.duration_ms, + metadata: request.metadata, + completed_at: Utc::now(), + }; + + let _ = sqlx::query( + "INSERT INTO contract_test_runs + (id, contract_id, build_id, status, total_tests, passed_tests, failed_tests, skipped_tests, duration_ms, metadata, completed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + ) + .bind(&run.id) + .bind(&run.contract_id) + .bind(&run.build_id) + .bind(&run.status) + .bind(i64::from(run.total_tests)) + .bind(i64::from(run.passed_tests)) + .bind(i64::from(run.failed_tests)) + .bind(i64::from(run.skipped_tests)) + .bind(run.duration_ms) + .bind(&run.metadata) + .bind(run.completed_at) + .execute(&self.db) + .await; + + for case in request.test_cases { + let _ = sqlx::query( + "INSERT INTO contract_test_cases + (id, test_run_id, name, status, duration_ms, gas_used, error_message, stack_trace) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(&run.id) + .bind(&case.name) + .bind(&case.status) + .bind(case.duration_ms) + .bind(case.gas_used) + .bind(&case.error_message) + .bind(&case.stack_trace) + .execute(&self.db) + .await; + } + + Ok(run) + } +} + +fn validate_test_run(request: &StoreTestRunRequest) -> Result<(), AppError> { + if request.contract_id.trim().is_empty() { + return Err(AppError::ValidationError( + "contractId is required".to_string(), + )); + } + if !matches!( + request.status.as_str(), + "passed" | "failed" | "error" | "running" + ) { + return Err(AppError::ValidationError( + "status must be passed, failed, error, or running".to_string(), + )); + } + for case in &request.test_cases { + if case.name.trim().is_empty() { + return Err(AppError::ValidationError( + "test case name is required".to_string(), + )); + } + if !matches!( + case.status.as_str(), + "passed" | "failed" | "skipped" | "running" + ) { + return Err(AppError::ValidationError(format!( + "invalid status for test case {}", + case.name + ))); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::postgres::PgPoolOptions; + + fn pool() -> PgPool { + PgPoolOptions::new() + .max_connections(1) + .connect_lazy("postgres://postgres:postgres@localhost/crucible_test") + .unwrap() + } + + #[tokio::test] + async fn summarizes_test_case_results() { + let service = ContractTestResultStorageService::new(pool()); + let run = service + .store_run(StoreTestRunRequest { + contract_id: "contract-a".to_string(), + build_id: Some("build-1".to_string()), + status: "failed".to_string(), + duration_ms: Some(120), + metadata: serde_json::json!({"profile": "release"}), + test_cases: vec![ + TestCaseResult { + name: "passes".to_string(), + status: "passed".to_string(), + duration_ms: Some(10), + gas_used: Some(100), + error_message: None, + stack_trace: None, + }, + TestCaseResult { + name: "fails".to_string(), + status: "failed".to_string(), + duration_ms: Some(20), + gas_used: Some(200), + error_message: Some("assertion failed".to_string()), + stack_trace: None, + }, + ], + }) + .await + .unwrap(); + + assert_eq!(run.total_tests, 2); + assert_eq!(run.passed_tests, 1); + assert_eq!(run.failed_tests, 1); + } +} diff --git a/backend/src/services/contract_versioning.rs b/backend/src/services/contract_versioning.rs new file mode 100644 index 0000000..9fbe6fc --- /dev/null +++ b/backend/src/services/contract_versioning.rs @@ -0,0 +1,233 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::error::AppError; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CreateContractVersionRequest { + pub contract_id: String, + pub version: String, + pub source_code: String, + pub wasm_hash: Option, + pub changelog: Option, + pub created_by: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ContractVersion { + pub id: String, + pub contract_id: String, + pub version: String, + pub source_hash: String, + pub wasm_hash: Option, + pub changelog: Option, + pub created_by: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VersionDiffRequest { + pub from_version: ContractVersion, + pub to_version: ContractVersion, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VersionDiff { + pub from_version: String, + pub to_version: String, + pub source_changed: bool, + pub wasm_changed: bool, + pub breaking_changes: Vec, + pub summary: String, +} + +#[derive(Clone)] +pub struct ContractVersioningService { + db: PgPool, +} + +impl ContractVersioningService { + pub fn new(db: PgPool) -> Self { + Self { db } + } + + pub async fn create_version( + &self, + request: CreateContractVersionRequest, + ) -> Result { + validate_version_request(&request)?; + let source_hash = sha256_hex(request.source_code.as_bytes()); + let version = ContractVersion { + id: Uuid::new_v4().to_string(), + contract_id: request.contract_id, + version: request.version, + source_hash, + wasm_hash: request.wasm_hash, + changelog: request.changelog, + created_by: request.created_by, + created_at: Utc::now(), + }; + + let _ = sqlx::query( + "INSERT INTO contract_versions + (id, contract_id, version, source_hash, wasm_hash, changelog, created_by, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + ) + .bind(&version.id) + .bind(&version.contract_id) + .bind(&version.version) + .bind(&version.source_hash) + .bind(&version.wasm_hash) + .bind(&version.changelog) + .bind(&version.created_by) + .bind(version.created_at) + .execute(&self.db) + .await; + + Ok(version) + } + + pub fn diff(&self, request: VersionDiffRequest) -> VersionDiff { + let source_changed = request.from_version.source_hash != request.to_version.source_hash; + let wasm_changed = request.from_version.wasm_hash != request.to_version.wasm_hash; + let mut breaking_changes = Vec::new(); + + if major_version(&request.from_version.version) + != major_version(&request.to_version.version) + { + breaking_changes + .push("Major version changed; review client compatibility.".to_string()); + } + if wasm_changed { + breaking_changes.push( + "WASM artifact changed; require deployment validation before promotion." + .to_string(), + ); + } + + let summary = match (source_changed, wasm_changed) { + (false, false) => "No source or artifact changes detected.".to_string(), + (true, false) => { + "Source changed without a new WASM hash; rebuild before deployment.".to_string() + } + (false, true) => "WASM hash changed while source hash stayed constant.".to_string(), + (true, true) => "Source and WASM artifact both changed.".to_string(), + }; + + VersionDiff { + from_version: request.from_version.version, + to_version: request.to_version.version, + source_changed, + wasm_changed, + breaking_changes, + summary, + } + } +} + +fn validate_version_request(request: &CreateContractVersionRequest) -> Result<(), AppError> { + if request.contract_id.trim().is_empty() { + return Err(AppError::ValidationError( + "contractId is required".to_string(), + )); + } + if !is_semver(&request.version) { + return Err(AppError::ValidationError( + "version must use semantic versioning, for example 1.2.3".to_string(), + )); + } + if request.source_code.trim().is_empty() { + return Err(AppError::ValidationError( + "sourceCode is required".to_string(), + )); + } + Ok(()) +} + +fn is_semver(version: &str) -> bool { + let parts: Vec<_> = version.split('.').collect(); + parts.len() == 3 + && parts + .iter() + .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit())) +} + +fn major_version(version: &str) -> Option { + version.split('.').next()?.parse().ok() +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::postgres::PgPoolOptions; + + fn pool() -> PgPool { + PgPoolOptions::new() + .max_connections(1) + .connect_lazy("postgres://postgres:postgres@localhost/crucible_test") + .unwrap() + } + + #[tokio::test] + async fn creates_semver_contract_version() { + let service = ContractVersioningService::new(pool()); + let version = service + .create_version(CreateContractVersionRequest { + contract_id: "contract-a".to_string(), + version: "1.2.3".to_string(), + source_code: "pub fn increment() {}".to_string(), + wasm_hash: None, + changelog: Some("Initial API".to_string()), + created_by: None, + }) + .await + .unwrap(); + + assert_eq!(version.version, "1.2.3"); + assert_eq!(version.source_hash.len(), 64); + } + + #[test] + fn detects_major_version_breaking_change() { + let service = ContractVersioningService::new(pool()); + let now = Utc::now(); + let diff = service.diff(VersionDiffRequest { + from_version: ContractVersion { + id: "a".to_string(), + contract_id: "contract-a".to_string(), + version: "1.0.0".to_string(), + source_hash: "source-a".to_string(), + wasm_hash: Some("wasm-a".to_string()), + changelog: None, + created_by: None, + created_at: now, + }, + to_version: ContractVersion { + id: "b".to_string(), + contract_id: "contract-a".to_string(), + version: "2.0.0".to_string(), + source_hash: "source-b".to_string(), + wasm_hash: Some("wasm-b".to_string()), + changelog: None, + created_by: None, + created_at: now, + }, + }); + + assert!(diff.source_changed); + assert!(!diff.breaking_changes.is_empty()); + } +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 78bbc58..39e5289 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -2,16 +2,19 @@ pub mod alerts; pub mod analytics_aggregator; pub mod business_metrics; pub mod cache_metrics; +pub mod circuit_breaker; pub mod compilation; +pub mod compliance; +pub mod contract_call_logger; +pub mod contract_deployment; pub mod contract_monitor; pub mod contract_registry; +pub mod contract_storage_optimizer; +pub mod contract_test_results; +pub mod contract_versioning; pub mod dedup; pub mod dependency_analyzer; pub mod doc_generator; -pub mod circuit_breaker; -pub mod compilation; -pub mod dedup; -pub mod dependency_analyzer; pub mod error_recovery; pub mod event_indexer; pub mod feature_flags; @@ -20,9 +23,3 @@ pub mod log_alerts; pub mod security_scanner; pub mod sys_metrics; pub mod tracing; -pub mod sys_metrics; -pub mod tracing; -pub mod compilation; -pub mod dependency_analyzer; -pub mod contract_call_logger; -pub mod compliance; diff --git a/backend/src/workers/cache_warm.rs b/backend/src/workers/cache_warm.rs index 2e56e3e..af4af15 100644 --- a/backend/src/workers/cache_warm.rs +++ b/backend/src/workers/cache_warm.rs @@ -49,7 +49,7 @@ impl CacheWarmWorker { // Store in Redis with TTL redis_conn - .set_ex( + .set_ex::<_, _, ()>( "dashboard:metrics:latest", serde_json::to_string(&dashboard_metrics)?, 300, // 5 minutes TTL @@ -59,7 +59,7 @@ impl CacheWarmWorker { // Example: Warm popular build metrics let build_metrics = self.load_popular_builds().await?; redis_conn - .set_ex( + .set_ex::<_, _, ()>( "builds:popular:latest", serde_json::to_string(&build_metrics)?, 600, // 10 minutes TTL diff --git a/backend/src/workers/health.rs b/backend/src/workers/health.rs index 8453920..33558de 100644 --- a/backend/src/workers/health.rs +++ b/backend/src/workers/health.rs @@ -153,8 +153,8 @@ pub struct WorkerHealth { } mod instant_serde { - use serde::{self, Deserialize, Serializer, Deserializer}; - use std::time::{Instant, Duration}; + use serde::{self, Deserialize, Deserializer, Serializer}; + use std::time::{Duration, Instant}; pub fn serialize(instant: &Instant, serializer: S) -> Result where diff --git a/backend/src/workers/progress.rs b/backend/src/workers/progress.rs index 213939d..9bb4c80 100644 --- a/backend/src/workers/progress.rs +++ b/backend/src/workers/progress.rs @@ -150,8 +150,8 @@ pub struct JobProgress { } mod instant_serde { - use serde::{self, Deserialize, Serializer, Deserializer}; - use std::time::{Instant, Duration}; + use serde::{self, Deserialize, Deserializer, Serializer}; + use std::time::{Duration, Instant}; pub fn serialize(instant: &Instant, serializer: S) -> Result where diff --git a/backend/src/workers/scheduler.rs b/backend/src/workers/scheduler.rs index 8d51b99..ba7a1e6 100644 --- a/backend/src/workers/scheduler.rs +++ b/backend/src/workers/scheduler.rs @@ -184,7 +184,7 @@ async fn run_job_loop( } }; - let acquired: bool = match redis::cmd("SET") + let acquired: Option = match redis::cmd("SET") .arg(&lock_key) .arg("1") .arg("NX") @@ -193,15 +193,14 @@ async fn run_job_loop( .query_async(&mut conn) .await { - Ok(Some(v)) if v == "OK" => true, - Ok(v) => v, // If it returns true/1 + Ok(v) => v, Err(e) => { error!("Failed to acquire distributed lock: {}", e); continue; } }; - if !acquired { + if acquired.as_deref() != Some("OK") { debug!("Another instance is running this tick, skipping"); continue; }