From 2a246a0322b7998eaee50a92ef14ad4e2faa8228 Mon Sep 17 00:00:00 2001 From: Justice Date: Sun, 31 May 2026 19:20:41 +0100 Subject: [PATCH 1/2] feat(backend): add Stellar contract benchmarking service --- Cargo.lock | 1 + backend/Cargo.toml | 6 - backend/src/api/handlers/profiling.rs | 64 +- backend/src/api/middleware/logging.rs | 11 +- backend/src/main.rs | 17 +- backend/src/services/contract_benchmark.rs | 671 ++++++++++++++++++++ backend/src/services/mod.rs | 6 +- backend/tests/api_tests.rs | 27 +- backend/tests/config_tests.rs | 32 +- backend/tests/contract_integration_tests.rs | 127 +++- backend/tests/integration/mod.rs | 13 +- backend/tests/load/profile_load.rs | 10 +- backend/tests/load/status_load.rs | 10 +- 13 files changed, 923 insertions(+), 72 deletions(-) create mode 100644 backend/src/services/contract_benchmark.rs diff --git a/Cargo.lock b/Cargo.lock index 487e9ff..4a9eb20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -454,6 +454,7 @@ dependencies = [ "rust_decimal_macros", "serde", "serde_json", + "sha2", "sqlx", "stellar-xdr 21.2.0", "temp-env", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 3c9e3c1..074ff4e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -96,9 +96,3 @@ opt-level = 3 lto = true codegen-units = 1 strip = true - -[[bin]] -name = "backup" -path = "src/bin/backup.rs" -[features] -testutils = ["mockall"] diff --git a/backend/src/api/handlers/profiling.rs b/backend/src/api/handlers/profiling.rs index 63448a0..08a2191 100644 --- a/backend/src/api/handlers/profiling.rs +++ b/backend/src/api/handlers/profiling.rs @@ -3,14 +3,20 @@ use crate::api::contracts::{ }; use crate::config::reload::ConfigManager; use crate::services::{ - error_recovery::ErrorManager, log_aggregator::LogAggregator, sys_metrics::MetricsExporter, + contract_benchmark::{ + ContractBenchmarkError, ContractBenchmarkReport, ContractBenchmarkRequest, + ContractBenchmarkService, + }, + error_recovery::ErrorManager, + log_aggregator::LogAggregator, + sys_metrics::MetricsExporter, tracing::TracingService, }; +use crate::AppError; use axum::{extract::State, response::IntoResponse, Json}; use chrono::{DateTime, Utc}; use redis::Client as RedisClient; use serde::{Deserialize, Serialize}; -use sqlx::PgPool; use std::sync::Arc; use tracing::{info, info_span, instrument}; use utoipa::ToSchema; @@ -21,6 +27,7 @@ pub struct AppState { pub error_manager: Arc, pub config_manager: Arc, pub log_aggregator: Arc, + pub contract_benchmark_service: Arc, pub redis: RedisClient, } @@ -72,7 +79,6 @@ pub async fn get_metrics( info!("Collecting performance metrics"); - // Instrument the metrics exporter call let metrics_span = TracingService::service_method_span("MetricsExporter", "get_metrics"); let _metrics_enter = metrics_span.enter(); @@ -120,14 +126,18 @@ pub async fn get_health(State(state): State>) -> Result>) -> ApiRespons /// Handler to trigger profile collection (CPU, memory profiling) #[instrument(skip_all, fields(http.method = "POST", http.route = "/api/profile"))] pub async fn trigger_profile_collection( - State(_state): State>, + State(_state): State>, ValidatedJson(payload): ValidatedJson, ) -> Result, AppError> { // In a real implementation, this would trigger a CPU/Memory profile @@ -201,7 +211,10 @@ pub async fn trigger_profile_collection( // Validate duration doesn't cause overflow in chrono::Duration (Issue #208) // chrono::Duration::seconds() accepts i64, so we need to ensure payload.duration_secs <= i64::MAX if payload.duration_secs > i64::MAX as u32 { - return Err(AppError::BadRequest(format!("Invalid duration_secs (Issue #208): too large for time calculation, maximum {}", i64::MAX))); + return Err(AppError::BadRequest(format!( + "Invalid duration_secs (Issue #208): too large for time calculation, maximum {}", + i64::MAX + ))); } // Additional safety check for chrono::Duration::seconds() bounds if payload.duration_secs > 2_147_483_647 { @@ -213,8 +226,8 @@ pub async fn trigger_profile_collection( "Profiling collection triggered for label: {}", payload.label ); - let estimated_completion = chrono::Utc::now() - + chrono::Duration::seconds(payload.duration_secs as i64); + let estimated_completion = + chrono::Utc::now() + chrono::Duration::seconds(payload.duration_secs as i64); Ok(ApiResponse::new(ProfileTriggerResponse { profile_id, @@ -222,3 +235,24 @@ pub async fn trigger_profile_collection( estimated_completion, })) } + +/// Handler for contract performance benchmark aggregation. +#[instrument(skip_all, fields(http.method = "POST", http.route = "/api/v1/profiling/contracts/benchmark"))] +pub async fn run_contract_benchmark( + State(state): State>, + Json(payload): Json, +) -> Result, AppError> { + let report = state + .contract_benchmark_service + .run_benchmark(payload) + .await + .map_err(map_contract_benchmark_error)?; + + Ok(ApiResponse::new(report)) +} + +fn map_contract_benchmark_error(error: ContractBenchmarkError) -> AppError { + match error { + ContractBenchmarkError::Validation(message) => AppError::BadRequest(message), + } +} diff --git a/backend/src/api/middleware/logging.rs b/backend/src/api/middleware/logging.rs index 5bf7bc1..a3138f1 100644 --- a/backend/src/api/middleware/logging.rs +++ b/backend/src/api/middleware/logging.rs @@ -75,13 +75,14 @@ pub async fn logging_middleware( #[cfg(test)] mod tests { use super::*; + use crate::config::{reload::ConfigManager, AppConfig}; use crate::services::{ - error_recovery::ErrorManager, log_aggregator::LogAggregator, sys_metrics::MetricsExporter, + contract_benchmark::ContractBenchmarkService, error_recovery::ErrorManager, + log_aggregator::LogAggregator, sys_metrics::MetricsExporter, }; use axum::{routing::get, Router}; use hyper::StatusCode; use redis::Client as RedisClient; - use sqlx::PgPool; use tower::ServiceExt; #[tokio::test] @@ -92,15 +93,15 @@ mod tests { let (log_aggregator, _rx) = LogAggregator::new(); let log_aggregator = Arc::new(log_aggregator); - // Use connect_lazy for testing to avoid needing a real DB - let db = PgPool::connect_lazy("postgres://localhost/test").unwrap(); let redis = RedisClient::open("redis://localhost").unwrap(); let state = Arc::new(AppState { + db: None, metrics_exporter, error_manager, + config_manager: Arc::new(ConfigManager::new(AppConfig::default())), log_aggregator, - db, + contract_benchmark_service: Arc::new(ContractBenchmarkService::new()), redis, }); diff --git a/backend/src/main.rs b/backend/src/main.rs index ec56541..206a75c 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -15,6 +15,7 @@ use backend::{ }, jobs::{monitor_transaction, TransactionMonitorJob}, services::{ + contract_benchmark::ContractBenchmarkService, error_recovery::ErrorManager, log_aggregator::LogAggregator, log_alerts::AlertManager, @@ -86,6 +87,7 @@ async fn main() -> Result<(), anyhow::Error> { let alert_manager = Arc::new(AlertManager::new()); let (log_aggregator, log_receiver) = LogAggregator::new(); let log_aggregator = Arc::new(log_aggregator); + let contract_benchmark_service = Arc::new(ContractBenchmarkService::new()); tokio::spawn(MetricsExporter::run_collector(metrics_exporter.clone())); tokio::spawn(LogAggregator::run_worker(log_receiver)); @@ -114,18 +116,17 @@ 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(), + contract_benchmark_service: contract_benchmark_service.clone(), + redis: redis_client.clone(), }); // Create dashboard state let dashboard_state = Arc::new(DashboardState { metrics_exporter, error_manager, - config_manager: config_manager.clone(), alert_manager, - log_aggregator, redis: redis_client, - db: db_pool, - redis_conn: redis_conn_dashboard, // Depending on what DashboardState actually expects }); // OpenAPI docs @@ -168,6 +169,10 @@ async fn main() -> Result<(), anyhow::Error> { .route("/prometheus", get(profiling::get_prometheus_metrics)) .route("/status", get(profiling::get_system_status)) .route("/profile", post(profiling::trigger_profile_collection)) + .route( + "/contracts/benchmark", + post(profiling::run_contract_benchmark), + ) .with_state(state.clone()), ) .nest( @@ -241,11 +246,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(db_pool); // This closes the pool when all clones are released // Close Redis connection tracing::info!("Closing Redis connection"); - drop(state.redis); // This closes the connection manager + drop(redis_conn_dashboard); // This closes the dashboard connection manager tracing::info!("Graceful shutdown completed successfully"); diff --git a/backend/src/services/contract_benchmark.rs b/backend/src/services/contract_benchmark.rs new file mode 100644 index 0000000..67794fd --- /dev/null +++ b/backend/src/services/contract_benchmark.rs @@ -0,0 +1,671 @@ +//! Contract performance benchmarking service. +//! +//! The service accepts already-measured Soroban contract invocation samples, +//! validates a bounded workload, computes deterministic aggregate statistics, +//! and stores a small in-memory history for operational inspection. It is +//! intentionally independent from HTTP and persistence concerns so API handlers +//! can remain thin and tests can exercise the domain logic directly. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; +use thiserror::Error; +use tokio::sync::RwLock; +use uuid::Uuid; + +const MAX_ID_LEN: usize = 128; +const MAX_SAMPLES: usize = 10_000; +const HISTORY_LIMIT_PER_CONTRACT: usize = 50; + +/// Errors returned by [`ContractBenchmarkService`]. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ContractBenchmarkError { + /// Request payload failed domain validation. + #[error("validation error: {0}")] + Validation(String), +} + +/// One measured contract operation invocation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContractBenchmarkSample { + pub operation: String, + pub duration_us: u64, + #[serde(alias = "instructions")] + pub cpu_instructions: u64, + pub memory_bytes: u64, + #[serde(default)] + pub ledger_read_entries: u64, + #[serde(default)] + pub ledger_write_entries: u64, + #[serde(default)] + pub ledger_read_bytes: u64, + #[serde(default)] + pub ledger_write_bytes: u64, + #[serde(default)] + pub transaction_size_bytes: u64, + #[serde(default)] + pub events_return_bytes: u64, + #[serde(default)] + pub ledger_space_rent_stroops: u64, + #[serde(default)] + pub resource_fee_stroops: u64, + pub success: bool, +} + +/// Optional baseline used to flag regressions. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ContractBenchmarkBaseline { + pub p95_duration_us: u64, + #[serde(alias = "avg_instructions")] + pub avg_cpu_instructions: f64, + pub peak_memory_bytes: u64, + #[serde(default)] + pub avg_resource_fee_stroops: f64, +} + +/// Optional percentage thresholds applied to a baseline comparison. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +pub struct ContractBenchmarkThresholds { + pub max_p95_latency_regression_pct: f64, + #[serde(alias = "max_instruction_regression_pct")] + pub max_cpu_instruction_regression_pct: f64, + pub max_memory_regression_pct: f64, + #[serde(default = "default_resource_fee_regression_threshold_pct")] + pub max_resource_fee_regression_pct: f64, +} + +impl Default for ContractBenchmarkThresholds { + fn default() -> Self { + Self { + max_p95_latency_regression_pct: 10.0, + max_cpu_instruction_regression_pct: 10.0, + max_memory_regression_pct: 10.0, + max_resource_fee_regression_pct: default_resource_fee_regression_threshold_pct(), + } + } +} + +/// Benchmark request consumed by the service and API. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ContractBenchmarkRequest { + pub contract_id: String, + pub benchmark_name: String, + pub samples: Vec, + #[serde(default)] + pub baseline: Option, + #[serde(default)] + pub thresholds: Option, +} + +/// Health classification for a benchmark run. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BenchmarkStatus { + Passed, + Warning, + Failed, +} + +/// Aggregate statistics for one contract operation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct OperationBenchmarkSummary { + pub operation: String, + pub sample_count: u64, + pub success_rate: f64, + pub min_duration_us: u64, + pub avg_duration_us: f64, + pub p95_duration_us: u64, + pub max_duration_us: u64, + pub avg_cpu_instructions: f64, + pub total_cpu_instructions: u64, + pub avg_memory_bytes: f64, + pub peak_memory_bytes: u64, + pub total_ledger_read_entries: u64, + pub total_ledger_write_entries: u64, + pub total_ledger_read_bytes: u64, + pub total_ledger_write_bytes: u64, + pub total_transaction_size_bytes: u64, + pub total_events_return_bytes: u64, + pub total_ledger_space_rent_stroops: u64, + pub total_resource_fee_stroops: u64, + pub avg_resource_fee_stroops: f64, +} + +/// Regression detail emitted when a metric exceeds its threshold. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct BenchmarkRegression { + pub metric: String, + pub baseline: f64, + pub current: f64, + pub change_pct: f64, + pub threshold_pct: f64, +} + +/// Complete benchmark report. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ContractBenchmarkReport { + pub benchmark_id: Uuid, + pub contract_id: String, + pub benchmark_name: String, + pub status: BenchmarkStatus, + pub sample_count: u64, + pub generated_at: DateTime, + pub operations: Vec, + pub overall: OperationBenchmarkSummary, + pub regressions: Vec, +} + +#[derive(Default)] +struct OperationAccumulator { + sample_count: u64, + success_count: u64, + total_duration_us: u128, + min_duration_us: u64, + max_duration_us: u64, + total_cpu_instructions: u128, + total_memory_bytes: u128, + peak_memory_bytes: u64, + total_ledger_read_entries: u128, + total_ledger_write_entries: u128, + total_ledger_read_bytes: u128, + total_ledger_write_bytes: u128, + total_transaction_size_bytes: u128, + total_events_return_bytes: u128, + total_ledger_space_rent_stroops: u128, + total_resource_fee_stroops: u128, + durations: Vec, +} + +impl OperationAccumulator { + fn push(&mut self, sample: &ContractBenchmarkSample) { + if self.sample_count == 0 { + self.min_duration_us = sample.duration_us; + } + + self.sample_count += 1; + self.success_count += u64::from(sample.success); + self.total_duration_us += u128::from(sample.duration_us); + self.min_duration_us = self.min_duration_us.min(sample.duration_us); + self.max_duration_us = self.max_duration_us.max(sample.duration_us); + self.total_cpu_instructions += u128::from(sample.cpu_instructions); + self.total_memory_bytes += u128::from(sample.memory_bytes); + self.peak_memory_bytes = self.peak_memory_bytes.max(sample.memory_bytes); + self.total_ledger_read_entries += u128::from(sample.ledger_read_entries); + self.total_ledger_write_entries += u128::from(sample.ledger_write_entries); + self.total_ledger_read_bytes += u128::from(sample.ledger_read_bytes); + self.total_ledger_write_bytes += u128::from(sample.ledger_write_bytes); + self.total_transaction_size_bytes += u128::from(sample.transaction_size_bytes); + self.total_events_return_bytes += u128::from(sample.events_return_bytes); + self.total_ledger_space_rent_stroops += u128::from(sample.ledger_space_rent_stroops); + self.total_resource_fee_stroops += u128::from(sample.resource_fee_stroops); + self.durations.push(sample.duration_us); + } + + fn into_summary(mut self, operation: String) -> OperationBenchmarkSummary { + self.durations.sort_unstable(); + let p95_duration_us = percentile_nearest_rank(&self.durations, 95); + let sample_count = self.sample_count as f64; + + OperationBenchmarkSummary { + operation, + sample_count: self.sample_count, + success_rate: self.success_count as f64 / sample_count, + min_duration_us: self.min_duration_us, + avg_duration_us: self.total_duration_us as f64 / sample_count, + p95_duration_us, + max_duration_us: self.max_duration_us, + avg_cpu_instructions: self.total_cpu_instructions as f64 / sample_count, + total_cpu_instructions: saturating_u128_to_u64(self.total_cpu_instructions), + avg_memory_bytes: self.total_memory_bytes as f64 / sample_count, + peak_memory_bytes: self.peak_memory_bytes, + total_ledger_read_entries: saturating_u128_to_u64(self.total_ledger_read_entries), + total_ledger_write_entries: saturating_u128_to_u64(self.total_ledger_write_entries), + total_ledger_read_bytes: saturating_u128_to_u64(self.total_ledger_read_bytes), + total_ledger_write_bytes: saturating_u128_to_u64(self.total_ledger_write_bytes), + total_transaction_size_bytes: saturating_u128_to_u64(self.total_transaction_size_bytes), + total_events_return_bytes: saturating_u128_to_u64(self.total_events_return_bytes), + total_ledger_space_rent_stroops: saturating_u128_to_u64( + self.total_ledger_space_rent_stroops, + ), + total_resource_fee_stroops: saturating_u128_to_u64(self.total_resource_fee_stroops), + avg_resource_fee_stroops: self.total_resource_fee_stroops as f64 / sample_count, + } + } +} + +/// Stateless benchmark calculator with bounded in-memory report history. +#[derive(Clone, Default)] +pub struct ContractBenchmarkService { + history: Arc>>>, +} + +impl ContractBenchmarkService { + /// Creates a benchmark service. + pub fn new() -> Self { + Self::default() + } + + /// Validates, aggregates, and records a contract benchmark report. + pub async fn run_benchmark( + &self, + request: ContractBenchmarkRequest, + ) -> Result { + validate_request(&request)?; + + let mut by_operation: HashMap = HashMap::new(); + let mut overall = OperationAccumulator::default(); + + for sample in &request.samples { + by_operation + .entry(sample.operation.clone()) + .or_default() + .push(sample); + overall.push(sample); + } + + let mut operations = by_operation + .into_iter() + .map(|(operation, accumulator)| accumulator.into_summary(operation)) + .collect::>(); + operations.sort_by(|left, right| left.operation.cmp(&right.operation)); + + let overall = overall.into_summary("overall".to_string()); + let thresholds = request.thresholds.unwrap_or_default(); + let regressions = request + .baseline + .as_ref() + .map(|baseline| compare_baseline(&overall, baseline, thresholds)) + .unwrap_or_default(); + + let status = classify_status(overall.success_rate, !regressions.is_empty()); + let report = ContractBenchmarkReport { + benchmark_id: Uuid::new_v4(), + contract_id: request.contract_id, + benchmark_name: request.benchmark_name, + status, + sample_count: overall.sample_count, + generated_at: Utc::now(), + operations, + overall, + regressions, + }; + + self.record_report(report.clone()).await; + Ok(report) + } + + /// Returns recent reports for one contract, newest first. + pub async fn recent_reports(&self, contract_id: &str) -> Vec { + self.history + .read() + .await + .get(contract_id) + .map(|reports| reports.iter().rev().cloned().collect()) + .unwrap_or_default() + } + + async fn record_report(&self, report: ContractBenchmarkReport) { + let mut history = self.history.write().await; + let reports = history.entry(report.contract_id.clone()).or_default(); + reports.push_back(report); + while reports.len() > HISTORY_LIMIT_PER_CONTRACT { + reports.pop_front(); + } + } +} + +fn validate_request(request: &ContractBenchmarkRequest) -> Result<(), ContractBenchmarkError> { + validate_identifier("contract_id", &request.contract_id)?; + validate_identifier("benchmark_name", &request.benchmark_name)?; + + if let Some(baseline) = &request.baseline { + validate_non_negative_finite( + "baseline.avg_cpu_instructions", + baseline.avg_cpu_instructions, + )?; + validate_non_negative_finite( + "baseline.avg_resource_fee_stroops", + baseline.avg_resource_fee_stroops, + )?; + } + + if request.samples.is_empty() || request.samples.len() > MAX_SAMPLES { + return Err(ContractBenchmarkError::Validation(format!( + "samples must contain between 1 and {MAX_SAMPLES} entries" + ))); + } + + if let Some(thresholds) = request.thresholds { + validate_percentage( + "max_p95_latency_regression_pct", + thresholds.max_p95_latency_regression_pct, + )?; + validate_percentage( + "max_cpu_instruction_regression_pct", + thresholds.max_cpu_instruction_regression_pct, + )?; + validate_percentage( + "max_memory_regression_pct", + thresholds.max_memory_regression_pct, + )?; + validate_percentage( + "max_resource_fee_regression_pct", + thresholds.max_resource_fee_regression_pct, + )?; + } + + for sample in &request.samples { + validate_identifier("operation", &sample.operation)?; + } + + Ok(()) +} + +fn default_resource_fee_regression_threshold_pct() -> f64 { + 10.0 +} + +fn validate_identifier(field: &str, value: &str) -> Result<(), ContractBenchmarkError> { + if value.is_empty() || value.len() > MAX_ID_LEN { + return Err(ContractBenchmarkError::Validation(format!( + "{field} must be between 1 and {MAX_ID_LEN} characters" + ))); + } + + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b':' | b'.')) + { + return Err(ContractBenchmarkError::Validation(format!( + "{field} may contain only letters, numbers, '_', '-', ':' or '.'" + ))); + } + + Ok(()) +} + +fn validate_percentage(field: &str, value: f64) -> Result<(), ContractBenchmarkError> { + validate_non_negative_finite(field, value).map_err(|_| { + ContractBenchmarkError::Validation(format!( + "{field} must be a finite percentage greater than or equal to 0" + )) + }) +} + +fn validate_non_negative_finite(field: &str, value: f64) -> Result<(), ContractBenchmarkError> { + if value.is_finite() && value >= 0.0 { + return Ok(()); + } + + Err(ContractBenchmarkError::Validation(format!( + "{field} must be finite and greater than or equal to 0" + ))) +} + +fn percentile_nearest_rank(sorted_values: &[u64], percentile: usize) -> u64 { + let rank = (percentile * sorted_values.len()).div_ceil(100); + sorted_values[rank.saturating_sub(1)] +} + +fn saturating_u128_to_u64(value: u128) -> u64 { + value.min(u128::from(u64::MAX)) as u64 +} + +fn compare_baseline( + overall: &OperationBenchmarkSummary, + baseline: &ContractBenchmarkBaseline, + thresholds: ContractBenchmarkThresholds, +) -> Vec { + let mut regressions = Vec::with_capacity(3); + + push_regression( + &mut regressions, + "p95_duration_us", + baseline.p95_duration_us as f64, + overall.p95_duration_us as f64, + thresholds.max_p95_latency_regression_pct, + ); + push_regression( + &mut regressions, + "avg_cpu_instructions", + baseline.avg_cpu_instructions, + overall.avg_cpu_instructions, + thresholds.max_cpu_instruction_regression_pct, + ); + push_regression( + &mut regressions, + "peak_memory_bytes", + baseline.peak_memory_bytes as f64, + overall.peak_memory_bytes as f64, + thresholds.max_memory_regression_pct, + ); + push_regression( + &mut regressions, + "avg_resource_fee_stroops", + baseline.avg_resource_fee_stroops, + overall.avg_resource_fee_stroops, + thresholds.max_resource_fee_regression_pct, + ); + + regressions +} + +fn push_regression( + regressions: &mut Vec, + metric: &str, + baseline: f64, + current: f64, + threshold_pct: f64, +) { + if baseline <= 0.0 { + return; + } + + let change_pct = ((current - baseline) / baseline) * 100.0; + if change_pct > threshold_pct { + regressions.push(BenchmarkRegression { + metric: metric.to_string(), + baseline, + current, + change_pct, + threshold_pct, + }); + } +} + +fn classify_status(success_rate: f64, has_regressions: bool) -> BenchmarkStatus { + if success_rate < 1.0 { + BenchmarkStatus::Failed + } else if has_regressions { + BenchmarkStatus::Warning + } else { + BenchmarkStatus::Passed + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(operation: &str, duration_us: u64, instructions: u64) -> ContractBenchmarkSample { + ContractBenchmarkSample { + operation: operation.to_string(), + duration_us, + cpu_instructions: instructions, + memory_bytes: duration_us * 10, + ledger_read_entries: 1, + ledger_write_entries: 0, + ledger_read_bytes: 256, + ledger_write_bytes: 0, + transaction_size_bytes: 512, + events_return_bytes: 64, + ledger_space_rent_stroops: 0, + resource_fee_stroops: duration_us * 2, + success: true, + } + } + + fn request(samples: Vec) -> ContractBenchmarkRequest { + ContractBenchmarkRequest { + contract_id: "counter".to_string(), + benchmark_name: "increment".to_string(), + samples, + baseline: None, + thresholds: None, + } + } + + #[tokio::test] + async fn benchmark_aggregates_overall_and_operation_summaries() { + let service = ContractBenchmarkService::new(); + let report = service + .run_benchmark(request(vec![ + sample("inc", 10, 100), + sample("inc", 30, 300), + sample("get", 20, 200), + ])) + .await + .unwrap(); + + assert_eq!(report.status, BenchmarkStatus::Passed); + assert_eq!(report.sample_count, 3); + assert_eq!(report.overall.min_duration_us, 10); + assert_eq!(report.overall.max_duration_us, 30); + assert_eq!(report.overall.p95_duration_us, 30); + assert_eq!(report.overall.avg_duration_us, 20.0); + assert_eq!(report.operations.len(), 2); + assert_eq!(report.operations[0].operation, "get"); + assert_eq!(report.operations[1].operation, "inc"); + } + + #[tokio::test] + async fn benchmark_aggregates_soroban_resource_fields() { + let service = ContractBenchmarkService::new(); + let mut first = sample("inc", 10, 100); + first.ledger_write_entries = 1; + first.ledger_write_bytes = 128; + first.ledger_space_rent_stroops = 25; + first.resource_fee_stroops = 200; + + let mut second = sample("inc", 20, 200); + second.ledger_read_entries = 2; + second.ledger_read_bytes = 512; + second.transaction_size_bytes = 700; + second.events_return_bytes = 100; + second.resource_fee_stroops = 300; + + let report = service + .run_benchmark(request(vec![first, second])) + .await + .unwrap(); + + assert_eq!(report.overall.total_ledger_read_entries, 3); + assert_eq!(report.overall.total_ledger_write_entries, 1); + assert_eq!(report.overall.total_ledger_read_bytes, 768); + assert_eq!(report.overall.total_ledger_write_bytes, 128); + assert_eq!(report.overall.total_transaction_size_bytes, 1212); + assert_eq!(report.overall.total_events_return_bytes, 164); + assert_eq!(report.overall.total_ledger_space_rent_stroops, 25); + assert_eq!(report.overall.total_resource_fee_stroops, 500); + assert_eq!(report.overall.avg_resource_fee_stroops, 250.0); + } + + #[tokio::test] + async fn benchmark_flags_baseline_regression() { + let service = ContractBenchmarkService::new(); + let mut req = request(vec![sample("inc", 120, 150), sample("inc", 140, 170)]); + req.baseline = Some(ContractBenchmarkBaseline { + p95_duration_us: 100, + avg_cpu_instructions: 100.0, + peak_memory_bytes: 1_000, + avg_resource_fee_stroops: 1_000.0, + }); + req.thresholds = Some(ContractBenchmarkThresholds { + max_p95_latency_regression_pct: 10.0, + max_cpu_instruction_regression_pct: 20.0, + max_memory_regression_pct: 100.0, + max_resource_fee_regression_pct: 100.0, + }); + + let report = service.run_benchmark(req).await.unwrap(); + + assert_eq!(report.status, BenchmarkStatus::Warning); + assert_eq!(report.regressions.len(), 2); + assert_eq!(report.regressions[0].metric, "p95_duration_us"); + assert_eq!(report.regressions[1].metric, "avg_cpu_instructions"); + } + + #[tokio::test] + async fn benchmark_rejects_non_finite_baseline_values() { + let service = ContractBenchmarkService::new(); + let mut req = request(vec![sample("inc", 120, 150)]); + req.baseline = Some(ContractBenchmarkBaseline { + p95_duration_us: 100, + avg_cpu_instructions: f64::NAN, + peak_memory_bytes: 1_000, + avg_resource_fee_stroops: 1_000.0, + }); + + let err = service.run_benchmark(req).await.unwrap_err(); + + assert!(err + .to_string() + .contains("baseline.avg_cpu_instructions must be finite")); + } + + #[tokio::test] + async fn benchmark_fails_when_any_sample_failed() { + let service = ContractBenchmarkService::new(); + let mut failed = sample("inc", 10, 100); + failed.success = false; + + let report = service + .run_benchmark(request(vec![sample("inc", 8, 80), failed])) + .await + .unwrap(); + + assert_eq!(report.status, BenchmarkStatus::Failed); + assert_eq!(report.overall.success_rate, 0.5); + } + + #[tokio::test] + async fn benchmark_rejects_empty_samples() { + let service = ContractBenchmarkService::new(); + let err = service.run_benchmark(request(vec![])).await.unwrap_err(); + + assert_eq!( + err, + ContractBenchmarkError::Validation( + "samples must contain between 1 and 10000 entries".to_string() + ) + ); + } + + #[tokio::test] + async fn benchmark_rejects_unsafe_identifiers() { + let service = ContractBenchmarkService::new(); + let mut req = request(vec![sample("bad op", 10, 100)]); + req.contract_id = "counter".to_string(); + + let err = service.run_benchmark(req).await.unwrap_err(); + + assert!(err.to_string().contains("operation may contain only")); + } + + #[tokio::test] + async fn benchmark_keeps_bounded_recent_history() { + let service = ContractBenchmarkService::new(); + + for index in 0..60 { + let mut req = request(vec![sample("inc", 10 + index, 100)]); + req.benchmark_name = format!("run_{index}"); + service.run_benchmark(req).await.unwrap(); + } + + let reports = service.recent_reports("counter").await; + assert_eq!(reports.len(), HISTORY_LIMIT_PER_CONTRACT); + assert_eq!(reports[0].benchmark_name, "run_59"); + assert_eq!(reports[49].benchmark_name, "run_10"); + } +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 4f74909..f27d750 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,13 +1,11 @@ pub mod alerts; pub mod business_metrics; -pub mod log_alerts; -pub mod dedup; -pub mod alerts; pub mod cache_metrics; +pub mod contract_benchmark; +pub mod dedup; pub mod error_recovery; pub mod feature_flags; pub mod log_aggregator; pub mod log_alerts; -pub mod log_alerts; pub mod sys_metrics; pub mod tracing; diff --git a/backend/tests/api_tests.rs b/backend/tests/api_tests.rs index 132c0f6..cb6e7bb 100644 --- a/backend/tests/api_tests.rs +++ b/backend/tests/api_tests.rs @@ -6,10 +6,29 @@ use axum::{ Router, }; use backend::api::handlers::profiling::{get_system_status, AppState}; -use backend::services::{error_recovery::ErrorManager, sys_metrics::MetricsExporter}; +use backend::config::{reload::ConfigManager, AppConfig}; +use backend::services::{ + contract_benchmark::ContractBenchmarkService, error_recovery::ErrorManager, + log_aggregator::LogAggregator, sys_metrics::MetricsExporter, +}; +use redis::Client as RedisClient; use std::sync::Arc; use tower::ServiceExt; +fn test_state() -> Arc { + let (log_aggregator, _receiver) = LogAggregator::new(); + + Arc::new(AppState { + db: None, + metrics_exporter: Arc::new(MetricsExporter::new()), + error_manager: Arc::new(ErrorManager::new()), + config_manager: Arc::new(ConfigManager::new(AppConfig::default())), + log_aggregator: Arc::new(log_aggregator), + contract_benchmark_service: Arc::new(ContractBenchmarkService::new()), + redis: RedisClient::open("redis://127.0.0.1:1/").unwrap(), + }) +} + #[tokio::test] async fn test_health_check_integration() { // Placeholder — full integration test requires a live DB. @@ -30,11 +49,7 @@ async fn test_stellar_toml_headers() { #[tokio::test] async fn test_get_status_endpoint() { - let state = Arc::new(AppState { - db: None, - metrics_exporter: Arc::new(MetricsExporter::new()), - error_manager: Arc::new(ErrorManager::new()), - }); + let state = test_state(); let app = Router::new() .route("/api/status", get(get_system_status)) diff --git a/backend/tests/config_tests.rs b/backend/tests/config_tests.rs index 67e1dc5..e8a49bf 100644 --- a/backend/tests/config_tests.rs +++ b/backend/tests/config_tests.rs @@ -7,20 +7,34 @@ use backend::config::{ reload::{handle_get_config, handle_reload, ConfigManager}, AppConfig, }; -use backend::services::{error_recovery::ErrorManager, sys_metrics::MetricsExporter}; +use backend::services::{ + contract_benchmark::ContractBenchmarkService, error_recovery::ErrorManager, + log_aggregator::LogAggregator, sys_metrics::MetricsExporter, +}; use hyper::{Request, StatusCode}; +use redis::Client as RedisClient; use std::sync::Arc; use tower::ServiceExt; +fn test_state(config_manager: Arc) -> Arc { + let (log_aggregator, _receiver) = LogAggregator::new(); + + Arc::new(AppState { + db: None, + metrics_exporter: Arc::new(MetricsExporter::new()), + error_manager: Arc::new(ErrorManager::new()), + config_manager, + log_aggregator: Arc::new(log_aggregator), + contract_benchmark_service: Arc::new(ContractBenchmarkService::new()), + redis: RedisClient::open("redis://127.0.0.1:1/").unwrap(), + }) +} + #[tokio::test] async fn test_config_get_endpoint() { let config = AppConfig::default(); let config_manager = Arc::new(ConfigManager::new(config)); - let state = Arc::new(AppState { - metrics_exporter: Arc::new(MetricsExporter::new()), - error_manager: Arc::new(ErrorManager::new()), - config_manager: config_manager.clone(), - }); + let state = test_state(config_manager.clone()); let app = Router::new() .route("/api/config", get(handle_get_config)) @@ -43,11 +57,7 @@ async fn test_config_get_endpoint() { async fn test_config_reload_endpoint_no_file() { let config = AppConfig::default(); let config_manager = Arc::new(ConfigManager::new(config)); - let state = Arc::new(AppState { - metrics_exporter: Arc::new(MetricsExporter::new()), - error_manager: Arc::new(ErrorManager::new()), - config_manager: config_manager.clone(), - }); + let state = test_state(config_manager.clone()); let app = Router::new() .route("/api/config/reload", post(handle_reload)) diff --git a/backend/tests/contract_integration_tests.rs b/backend/tests/contract_integration_tests.rs index dac978e..c198000 100644 --- a/backend/tests/contract_integration_tests.rs +++ b/backend/tests/contract_integration_tests.rs @@ -2,22 +2,38 @@ use axum::{ routing::{get, post}, Router, }; -use backend::api::handlers::profiling::{get_system_status, trigger_profile_collection, AppState}; +use backend::api::handlers::profiling::{ + get_system_status, run_contract_benchmark, trigger_profile_collection, AppState, +}; use backend::config::reload::ConfigManager; use backend::config::AppConfig; -use backend::services::{error_recovery::ErrorManager, sys_metrics::MetricsExporter}; +use backend::services::{ + contract_benchmark::ContractBenchmarkService, error_recovery::ErrorManager, + log_aggregator::LogAggregator, sys_metrics::MetricsExporter, +}; use hyper::{Request, StatusCode}; +use redis::Client as RedisClient; use serde_json::json; use std::sync::Arc; use tower::ServiceExt; -#[tokio::test] -async fn test_system_status_contract() { - let state = Arc::new(AppState { +fn test_state() -> Arc { + let (log_aggregator, _receiver) = LogAggregator::new(); + + Arc::new(AppState { + db: None, metrics_exporter: Arc::new(MetricsExporter::new()), error_manager: Arc::new(ErrorManager::new()), config_manager: Arc::new(ConfigManager::new(AppConfig::default())), - }); + log_aggregator: Arc::new(log_aggregator), + contract_benchmark_service: Arc::new(ContractBenchmarkService::new()), + redis: RedisClient::open("redis://127.0.0.1:1/").unwrap(), + }) +} + +#[tokio::test] +async fn test_system_status_contract() { + let state = test_state(); let app = Router::new() .route("/api/status", get(get_system_status)) @@ -47,11 +63,7 @@ async fn test_system_status_contract() { #[tokio::test] async fn test_profile_trigger_validation_success() { - let state = Arc::new(AppState { - metrics_exporter: Arc::new(MetricsExporter::new()), - error_manager: Arc::new(ErrorManager::new()), - config_manager: Arc::new(ConfigManager::new(AppConfig::default())), - }); + let state = test_state(); let app = Router::new() .route("/api/profile", post(trigger_profile_collection)) @@ -82,11 +94,7 @@ async fn test_profile_trigger_validation_success() { #[tokio::test] async fn test_profile_trigger_validation_failure() { - let state = Arc::new(AppState { - metrics_exporter: Arc::new(MetricsExporter::new()), - error_manager: Arc::new(ErrorManager::new()), - config_manager: Arc::new(ConfigManager::new(AppConfig::default())), - }); + let state = test_state(); let app = Router::new() .route("/api/profile", post(trigger_profile_collection)) @@ -125,3 +133,90 @@ async fn test_profile_trigger_validation_failure() { .unwrap() .contains("Validation failed")); } + +#[tokio::test] +async fn test_contract_benchmark_endpoint_success() { + let state = test_state(); + + let app = Router::new() + .route( + "/api/v1/profiling/contracts/benchmark", + post(run_contract_benchmark), + ) + .with_state(state); + + let payload = json!({ + "contract_id": "counter", + "benchmark_name": "increment_hot_path", + "samples": [ + { + "operation": "increment", + "duration_us": 100, + "cpu_instructions": 1200, + "memory_bytes": 4096, + "ledger_read_entries": 1, + "ledger_write_entries": 1, + "ledger_read_bytes": 256, + "ledger_write_bytes": 128, + "transaction_size_bytes": 512, + "events_return_bytes": 64, + "ledger_space_rent_stroops": 100, + "resource_fee_stroops": 700, + "success": true + }, + { + "operation": "increment", + "duration_us": 150, + "cpu_instructions": 1300, + "memory_bytes": 4096, + "ledger_read_entries": 1, + "ledger_write_entries": 1, + "ledger_read_bytes": 256, + "ledger_write_bytes": 128, + "transaction_size_bytes": 512, + "events_return_bytes": 64, + "ledger_space_rent_stroops": 100, + "resource_fee_stroops": 750, + "success": true + } + ], + "baseline": { + "p95_duration_us": 200, + "avg_cpu_instructions": 1500.0, + "peak_memory_bytes": 8192, + "avg_resource_fee_stroops": 1000.0 + } + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/profiling/contracts/benchmark") + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::to_vec(&payload).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(json["status"], "success"); + assert_eq!(json["data"]["contract_id"], "counter"); + assert_eq!(json["data"]["status"], "passed"); + assert_eq!(json["data"]["overall"]["p95_duration_us"], 150); + assert_eq!(json["data"]["overall"]["total_cpu_instructions"], 2500); + assert_eq!( + json["data"]["overall"]["total_ledger_space_rent_stroops"], + 200 + ); + assert_eq!(json["data"]["overall"]["total_resource_fee_stroops"], 1450); +} diff --git a/backend/tests/integration/mod.rs b/backend/tests/integration/mod.rs index 7c280f3..a83d621 100644 --- a/backend/tests/integration/mod.rs +++ b/backend/tests/integration/mod.rs @@ -14,15 +14,26 @@ use axum::{ }; use backend::{ api::handlers::profiling::{get_system_status, trigger_profile_collection, AppState}, - services::{error_recovery::ErrorManager, sys_metrics::MetricsExporter}, + config::{reload::ConfigManager, AppConfig}, + services::{ + contract_benchmark::ContractBenchmarkService, error_recovery::ErrorManager, + log_aggregator::LogAggregator, sys_metrics::MetricsExporter, + }, }; +use redis::Client as RedisClient; use std::sync::Arc; /// Build a test [`Router`] backed by fresh service instances. pub fn test_app() -> Router { + let (log_aggregator, _receiver) = LogAggregator::new(); let state = Arc::new(AppState { + db: None, metrics_exporter: Arc::new(MetricsExporter::new()), error_manager: Arc::new(ErrorManager::new()), + config_manager: Arc::new(ConfigManager::new(AppConfig::default())), + log_aggregator: Arc::new(log_aggregator), + contract_benchmark_service: Arc::new(ContractBenchmarkService::new()), + redis: RedisClient::open("redis://127.0.0.1:1/").unwrap(), }); Router::new() diff --git a/backend/tests/load/profile_load.rs b/backend/tests/load/profile_load.rs index ebcb132..8ed9978 100644 --- a/backend/tests/load/profile_load.rs +++ b/backend/tests/load/profile_load.rs @@ -7,14 +7,22 @@ use tower::ServiceExt; use backend::api::handlers::profiling::{trigger_profile_collection, AppState}; use backend::config::{reload::ConfigManager, AppConfig}; -use backend::services::{error_recovery::ErrorManager, sys_metrics::MetricsExporter}; +use backend::services::{ + contract_benchmark::ContractBenchmarkService, error_recovery::ErrorManager, + log_aggregator::LogAggregator, sys_metrics::MetricsExporter, +}; +use redis::Client as RedisClient; fn build_app() -> Router { + let (log_aggregator, _receiver) = LogAggregator::new(); let state = Arc::new(AppState { db: None, metrics_exporter: Arc::new(MetricsExporter::new()), error_manager: Arc::new(ErrorManager::new()), config_manager: Arc::new(ConfigManager::new(AppConfig::default())), + log_aggregator: Arc::new(log_aggregator), + contract_benchmark_service: Arc::new(ContractBenchmarkService::new()), + redis: RedisClient::open("redis://127.0.0.1:1/").unwrap(), }); Router::new() .route("/api/profile", post(trigger_profile_collection)) diff --git a/backend/tests/load/status_load.rs b/backend/tests/load/status_load.rs index 7508b01..fe82252 100644 --- a/backend/tests/load/status_load.rs +++ b/backend/tests/load/status_load.rs @@ -7,15 +7,23 @@ use tower::ServiceExt; use backend::api::handlers::profiling::{get_system_status, AppState}; use backend::config::{reload::ConfigManager, AppConfig}; -use backend::services::{error_recovery::ErrorManager, sys_metrics::MetricsExporter}; +use backend::services::{ + contract_benchmark::ContractBenchmarkService, error_recovery::ErrorManager, + log_aggregator::LogAggregator, sys_metrics::MetricsExporter, +}; +use redis::Client as RedisClient; /// Build a test router with the status endpoint. fn build_app() -> Router { + let (log_aggregator, _receiver) = LogAggregator::new(); let state = Arc::new(AppState { db: None, metrics_exporter: Arc::new(MetricsExporter::new()), error_manager: Arc::new(ErrorManager::new()), config_manager: Arc::new(ConfigManager::new(AppConfig::default())), + log_aggregator: Arc::new(log_aggregator), + contract_benchmark_service: Arc::new(ContractBenchmarkService::new()), + redis: RedisClient::open("redis://127.0.0.1:1/").unwrap(), }); Router::new() .route("/api/status", get(get_system_status)) From 6e870f163980a4af86093524d841ebe7de53ea73 Mon Sep 17 00:00:00 2001 From: Justice Date: Mon, 1 Jun 2026 14:50:04 +0100 Subject: [PATCH 2/2] feat(contracts): add lending borrowing contract with interest rates --- Cargo.lock | 8 + Cargo.toml | 1 + examples/lending/Cargo.toml | 13 ++ examples/lending/src/lib.rs | 417 +++++++++++++++++++++++++++++++++++ examples/lending/src/test.rs | 266 ++++++++++++++++++++++ 5 files changed, 705 insertions(+) create mode 100644 examples/lending/Cargo.toml create mode 100644 examples/lending/src/lib.rs create mode 100644 examples/lending/src/test.rs diff --git a/Cargo.lock b/Cargo.lock index 4a9eb20..33b42de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,6 +1059,14 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "crucible-example-lending" +version = "0.1.0" +dependencies = [ + "crucible", + "soroban-sdk", +] + [[package]] name = "crucible-example-token" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 522e8b4..8658c93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "examples/token", "examples/escrow", "examples/vesting", + "examples/lending", ] resolver = "2" diff --git a/examples/lending/Cargo.toml b/examples/lending/Cargo.toml new file mode 100644 index 0000000..3a30aa0 --- /dev/null +++ b/examples/lending/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "crucible-example-lending" +version.workspace = true +edition.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +crucible = { path = "../../contracts/crucible" } diff --git a/examples/lending/src/lib.rs b/examples/lending/src/lib.rs new file mode 100644 index 0000000..b4acf61 --- /dev/null +++ b/examples/lending/src/lib.rs @@ -0,0 +1,417 @@ +#![no_std] +#![allow(deprecated)] + +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, token, Address, Env}; + +const BPS: i128 = 10_000; +const INDEX_SCALE: i128 = 1_000_000_000_000; +const SECONDS_PER_YEAR: i128 = 31_536_000; + +#[contracttype] +#[derive(Clone)] +pub struct ReserveConfig { + pub admin: Address, + pub asset: Address, + pub collateral_asset: Address, + pub base_rate_bps: i128, + pub utilization_rate_bps: i128, + pub collateral_factor_bps: i128, +} + +#[contracttype] +#[derive(Clone)] +pub struct ReserveState { + pub total_supplied: i128, + pub total_borrowed: i128, + pub total_collateral: i128, + pub supply_index: i128, + pub borrow_index: i128, + pub last_accrual_time: u64, +} + +#[contracttype] +#[derive(Clone)] +pub struct AccountPosition { + pub supplied_scaled: i128, + pub borrowed_scaled: i128, + pub collateral: i128, +} + +#[contracttype] +#[derive(Clone)] +pub struct AccountSnapshot { + pub supplied: i128, + pub borrowed: i128, + pub collateral: i128, +} + +#[contracttype] +enum DataKey { + Config, + State, + Position(Address), +} + +/// Single-reserve lending pool with linear utilization-based interest. +/// +/// The contract keeps lender and borrower balances in scaled units. Interest +/// accrues globally by updating two indexes once per mutating call, so the pool +/// never iterates through accounts. +#[contract] +#[derive(Default)] +pub struct Lending; + +#[contractimpl] +impl Lending { + /// Initialize a lending reserve. + /// + /// `base_rate_bps` is always charged to borrowers. `utilization_rate_bps` + /// is multiplied by current pool utilization and added to the base rate. + /// `collateral_factor_bps` caps borrow value against deposited collateral. + pub fn initialize( + env: Env, + admin: Address, + asset: Address, + collateral_asset: Address, + base_rate_bps: i128, + utilization_rate_bps: i128, + collateral_factor_bps: i128, + ) { + if env.storage().instance().has(&DataKey::Config) { + panic!("lending pool already initialized"); + } + Self::require_bps("base rate", base_rate_bps); + Self::require_bps("utilization rate", utilization_rate_bps); + Self::require_bps("collateral factor", collateral_factor_bps); + + admin.require_auth(); + env.storage().instance().set( + &DataKey::Config, + &ReserveConfig { + admin, + asset, + collateral_asset, + base_rate_bps, + utilization_rate_bps, + collateral_factor_bps, + }, + ); + env.storage().instance().set( + &DataKey::State, + &ReserveState { + total_supplied: 0, + total_borrowed: 0, + total_collateral: 0, + supply_index: INDEX_SCALE, + borrow_index: INDEX_SCALE, + last_accrual_time: env.ledger().timestamp(), + }, + ); + } + + /// Deposit pool liquidity and begin earning borrower-paid interest. + pub fn deposit(env: Env, lender: Address, amount: i128) { + Self::require_positive("deposit amount", amount); + lender.require_auth(); + let config = Self::config(&env); + let mut state = Self::accrue(&env, &config); + let mut position = Self::load_position(&env, lender.clone()); + let scaled = Self::scale_down(amount, state.supply_index); + + position.supplied_scaled = Self::checked_add(position.supplied_scaled, scaled); + state.total_supplied = Self::checked_add(state.total_supplied, amount); + + token::Client::new(&env, &config.asset).transfer( + &lender, + &env.current_contract_address(), + &amount, + ); + Self::save_position(&env, lender.clone(), &position); + Self::save_state(&env, &state); + env.events() + .publish((symbol_short!("deposit"), lender), amount); + } + + /// Withdraw available supplied liquidity. + pub fn withdraw(env: Env, lender: Address, amount: i128) { + Self::require_positive("withdraw amount", amount); + lender.require_auth(); + let config = Self::config(&env); + let mut state = Self::accrue(&env, &config); + let mut position = Self::load_position(&env, lender.clone()); + let supplied = Self::scale_up(position.supplied_scaled, state.supply_index); + + if supplied < amount { + panic!("insufficient supplied balance"); + } + if Self::available_liquidity(&state) < amount { + panic!("insufficient pool liquidity"); + } + + position.supplied_scaled = Self::scale_down(supplied - amount, state.supply_index); + state.total_supplied = Self::checked_sub(state.total_supplied, amount); + + Self::save_position(&env, lender.clone(), &position); + Self::save_state(&env, &state); + token::Client::new(&env, &config.asset).transfer( + &env.current_contract_address(), + &lender, + &amount, + ); + env.events() + .publish((symbol_short!("withdraw"), lender), amount); + } + + /// Deposit collateral for future borrows. + pub fn deposit_collateral(env: Env, borrower: Address, amount: i128) { + Self::require_positive("collateral amount", amount); + borrower.require_auth(); + let config = Self::config(&env); + let mut state = Self::accrue(&env, &config); + let mut position = Self::load_position(&env, borrower.clone()); + + position.collateral = Self::checked_add(position.collateral, amount); + state.total_collateral = Self::checked_add(state.total_collateral, amount); + + token::Client::new(&env, &config.collateral_asset).transfer( + &borrower, + &env.current_contract_address(), + &amount, + ); + Self::save_position(&env, borrower.clone(), &position); + Self::save_state(&env, &state); + env.events() + .publish((symbol_short!("collat"), borrower), amount); + } + + /// Withdraw collateral if the remaining position stays healthy. + pub fn withdraw_collateral(env: Env, borrower: Address, amount: i128) { + Self::require_positive("collateral amount", amount); + borrower.require_auth(); + let config = Self::config(&env); + let mut state = Self::accrue(&env, &config); + let mut position = Self::load_position(&env, borrower.clone()); + + if position.collateral < amount { + panic!("insufficient collateral"); + } + position.collateral = Self::checked_sub(position.collateral, amount); + Self::require_healthy(&position, &state, &config); + state.total_collateral = Self::checked_sub(state.total_collateral, amount); + + Self::save_position(&env, borrower.clone(), &position); + Self::save_state(&env, &state); + token::Client::new(&env, &config.collateral_asset).transfer( + &env.current_contract_address(), + &borrower, + &amount, + ); + env.events() + .publish((symbol_short!("uncollat"), borrower), amount); + } + + /// Borrow pool liquidity against deposited collateral. + pub fn borrow(env: Env, borrower: Address, amount: i128) { + Self::require_positive("borrow amount", amount); + borrower.require_auth(); + let config = Self::config(&env); + let mut state = Self::accrue(&env, &config); + let mut position = Self::load_position(&env, borrower.clone()); + + if Self::available_liquidity(&state) < amount { + panic!("insufficient pool liquidity"); + } + + let borrowed = Self::scale_up(position.borrowed_scaled, state.borrow_index); + let next_borrowed = Self::checked_add(borrowed, amount); + position.borrowed_scaled = Self::scale_down(next_borrowed, state.borrow_index); + Self::require_healthy(&position, &state, &config); + state.total_borrowed = Self::checked_add(state.total_borrowed, amount); + + Self::save_position(&env, borrower.clone(), &position); + Self::save_state(&env, &state); + token::Client::new(&env, &config.asset).transfer( + &env.current_contract_address(), + &borrower, + &amount, + ); + env.events() + .publish((symbol_short!("borrow"), borrower), amount); + } + + /// Repay borrowed principal plus accrued interest. + /// + /// Overpayments are capped to the current debt and only the needed amount + /// is pulled from `borrower`. + pub fn repay(env: Env, borrower: Address, amount: i128) { + Self::require_positive("repay amount", amount); + borrower.require_auth(); + let config = Self::config(&env); + let mut state = Self::accrue(&env, &config); + let mut position = Self::load_position(&env, borrower.clone()); + let borrowed = Self::scale_up(position.borrowed_scaled, state.borrow_index); + + if borrowed == 0 { + panic!("nothing to repay"); + } + let paid = if amount > borrowed { borrowed } else { amount }; + let remaining = borrowed - paid; + position.borrowed_scaled = Self::scale_down(remaining, state.borrow_index); + state.total_borrowed = Self::checked_sub(state.total_borrowed, paid); + + token::Client::new(&env, &config.asset).transfer( + &borrower, + &env.current_contract_address(), + &paid, + ); + Self::save_position(&env, borrower.clone(), &position); + Self::save_state(&env, &state); + env.events() + .publish((symbol_short!("repay"), borrower), paid); + } + + /// Return the current reserve state after applying pending interest. + pub fn reserve(env: Env) -> ReserveState { + let config = Self::config(&env); + Self::accrue(&env, &config) + } + + /// Return a user's balances after applying pending interest. + pub fn position(env: Env, account: Address) -> AccountSnapshot { + let config = Self::config(&env); + let state = Self::accrue(&env, &config); + let position = Self::load_position(&env, account); + AccountSnapshot { + supplied: Self::scale_up(position.supplied_scaled, state.supply_index), + borrowed: Self::scale_up(position.borrowed_scaled, state.borrow_index), + collateral: position.collateral, + } + } + + /// Return available, unborrowed asset liquidity. + pub fn liquidity(env: Env) -> i128 { + let config = Self::config(&env); + let state = Self::accrue(&env, &config); + Self::available_liquidity(&state) + } + + fn accrue(env: &Env, config: &ReserveConfig) -> ReserveState { + let mut state: ReserveState = env.storage().instance().get(&DataKey::State).unwrap(); + let now = env.ledger().timestamp(); + let elapsed = now - state.last_accrual_time; + if elapsed == 0 || state.total_borrowed == 0 { + state.last_accrual_time = now; + env.storage().instance().set(&DataKey::State, &state); + return state; + } + + let rate_bps = Self::borrow_rate_bps(config, &state); + let interest = Self::checked_mul( + Self::checked_mul(state.total_borrowed, rate_bps), + elapsed as i128, + ) / BPS + / SECONDS_PER_YEAR; + if interest > 0 { + let prior_supplied = state.total_supplied; + let prior_borrowed = state.total_borrowed; + state.total_supplied = Self::checked_add(state.total_supplied, interest); + state.total_borrowed = Self::checked_add(state.total_borrowed, interest); + if prior_supplied > 0 { + state.supply_index = + Self::checked_mul(state.supply_index, state.total_supplied) / prior_supplied; + } + state.borrow_index = + Self::checked_mul(state.borrow_index, state.total_borrowed) / prior_borrowed; + } + state.last_accrual_time = now; + env.storage().instance().set(&DataKey::State, &state); + state + } + + fn borrow_rate_bps(config: &ReserveConfig, state: &ReserveState) -> i128 { + if state.total_supplied == 0 { + return config.base_rate_bps; + } + let utilization_bps = Self::checked_mul(state.total_borrowed, BPS) / state.total_supplied; + Self::checked_add( + config.base_rate_bps, + Self::checked_mul(config.utilization_rate_bps, utilization_bps) / BPS, + ) + } + + fn require_healthy(position: &AccountPosition, state: &ReserveState, config: &ReserveConfig) { + let borrowed = Self::scale_up(position.borrowed_scaled, state.borrow_index); + let borrow_limit = + Self::checked_mul(position.collateral, config.collateral_factor_bps) / BPS; + if borrowed > borrow_limit { + panic!("insufficient collateral"); + } + } + + fn available_liquidity(state: &ReserveState) -> i128 { + Self::checked_sub(state.total_supplied, state.total_borrowed) + } + + fn config(env: &Env) -> ReserveConfig { + env.storage().instance().get(&DataKey::Config).unwrap() + } + + fn load_position(env: &Env, account: Address) -> AccountPosition { + env.storage() + .instance() + .get(&DataKey::Position(account)) + .unwrap_or(AccountPosition { + supplied_scaled: 0, + borrowed_scaled: 0, + collateral: 0, + }) + } + + fn save_state(env: &Env, state: &ReserveState) { + env.storage().instance().set(&DataKey::State, state); + } + + fn save_position(env: &Env, account: Address, position: &AccountPosition) { + env.storage() + .instance() + .set(&DataKey::Position(account), position); + } + + fn scale_down(amount: i128, index: i128) -> i128 { + Self::checked_mul(amount, INDEX_SCALE) / index + } + + fn scale_up(scaled: i128, index: i128) -> i128 { + Self::checked_mul(scaled, index) / INDEX_SCALE + } + + fn checked_add(left: i128, right: i128) -> i128 { + left.checked_add(right) + .unwrap_or_else(|| panic!("math overflow")) + } + + fn checked_sub(left: i128, right: i128) -> i128 { + left.checked_sub(right) + .unwrap_or_else(|| panic!("math underflow")) + } + + fn checked_mul(left: i128, right: i128) -> i128 { + left.checked_mul(right) + .unwrap_or_else(|| panic!("math overflow")) + } + + fn require_positive(label: &str, amount: i128) { + if amount <= 0 { + panic!("{label} must be positive"); + } + } + + fn require_bps(label: &str, value: i128) { + if !(0..=BPS).contains(&value) { + panic!("{label} must be between 0 and 10000 bps"); + } + } +} + +#[cfg(test)] +mod test; diff --git a/examples/lending/src/test.rs b/examples/lending/src/test.rs new file mode 100644 index 0000000..7233904 --- /dev/null +++ b/examples/lending/src/test.rs @@ -0,0 +1,266 @@ +#![cfg(test)] +extern crate std; + +use crucible::assert_reverts; +use crucible::prelude::*; +use soroban_sdk::Address; + +use crate::{Lending, LendingClient}; + +const BASE_TIME: u64 = 1_000_000; +const LIQUIDITY: i128 = 1_000_000; +const COLLATERAL: i128 = 2_000_000; + +struct Ctx { + env: MockEnv, + id: Address, + admin: AccountHandle, + lender: AccountHandle, + borrower: AccountHandle, + asset: MockToken, + collateral: MockToken, +} + +impl Ctx { + fn setup() -> Self { + let env = MockEnv::builder() + .at_timestamp(BASE_TIME) + .with_contract::() + .with_account("admin", Stroops::xlm(100)) + .with_account("lender", Stroops::xlm(100)) + .with_account("borrower", Stroops::xlm(100)) + .build(); + + let id = env.contract_id::(); + let admin = env.account("admin"); + let lender = env.account("lender"); + let borrower = env.account("borrower"); + let asset = MockToken::new(&env, "USDC", 6); + let collateral = MockToken::new(&env, "XLM", 7); + + asset.mint(&lender, LIQUIDITY * 2); + asset.mint(&borrower, LIQUIDITY); + collateral.mint(&borrower, COLLATERAL); + + env.mock_all_auths(); + LendingClient::new(env.inner(), &id).initialize( + &admin, + &asset.address(), + &collateral.address(), + &500_i128, + &1_500_i128, + &7_500_i128, + ); + + Self { + env, + id, + admin, + lender, + borrower, + asset, + collateral, + } + } + + fn client(&self) -> LendingClient<'_> { + LendingClient::new(self.env.inner(), &self.id) + } + + fn fund_pool_and_collateral(&self) { + self.env.mock_all_auths(); + self.client().deposit(&self.lender, &LIQUIDITY); + self.client() + .deposit_collateral(&self.borrower, &COLLATERAL); + } +} + +#[test] +fn test_deposit_supplies_liquidity() { + let ctx = Ctx::setup(); + ctx.env.mock_all_auths(); + ctx.client().deposit(&ctx.lender, &LIQUIDITY); + + assert_eq!(ctx.asset.balance(&ctx.id), LIQUIDITY); + assert_eq!(ctx.client().liquidity(), LIQUIDITY); + assert_eq!(ctx.client().position(&ctx.lender).supplied, LIQUIDITY); +} + +#[test] +fn test_deposit_emits_event() { + let ctx = Ctx::setup(); + ctx.env.mock_all_auths(); + ctx.client().deposit(&ctx.lender, &LIQUIDITY); + + let emitted = ctx.env.events_all().events().iter().any(|event| { + let soroban_sdk::xdr::ContractEventBody::V0(body) = &event.body; + body.topics.first().is_some_and(|topic| { + topic == &soroban_sdk::xdr::ScVal::Symbol("deposit".try_into().unwrap()) + }) + }); + assert!(emitted, "deposit event should be emitted"); +} + +#[test] +fn test_borrow_transfers_liquidity_and_records_debt() { + let ctx = Ctx::setup(); + ctx.fund_pool_and_collateral(); + + ctx.env.mock_all_auths(); + ctx.client().borrow(&ctx.borrower, &500_000_i128); + + let position = ctx.client().position(&ctx.borrower); + assert_eq!(position.borrowed, 500_000); + assert_eq!(position.collateral, COLLATERAL); + assert_eq!(ctx.asset.balance(&ctx.borrower), LIQUIDITY + 500_000); + assert_eq!(ctx.client().liquidity(), 500_000); +} + +#[test] +fn test_borrow_above_collateral_factor_reverts() { + let ctx = Ctx::setup(); + ctx.fund_pool_and_collateral(); + + ctx.env.mock_all_auths(); + assert_reverts!( + ctx.client().borrow(&ctx.borrower, &1_600_000_i128), + "insufficient pool liquidity" + ); +} + +#[test] +fn test_borrow_without_enough_collateral_reverts() { + let ctx = Ctx::setup(); + ctx.env.mock_all_auths(); + ctx.client().deposit(&ctx.lender, &(LIQUIDITY * 2)); + ctx.client() + .deposit_collateral(&ctx.borrower, &100_000_i128); + + assert_reverts!( + ctx.client().borrow(&ctx.borrower, &100_000_i128), + "insufficient collateral" + ); +} + +#[test] +fn test_repay_caps_overpayment_to_current_debt() { + let ctx = Ctx::setup(); + ctx.fund_pool_and_collateral(); + ctx.env.mock_all_auths(); + ctx.client().borrow(&ctx.borrower, &400_000_i128); + ctx.client().repay(&ctx.borrower, &900_000_i128); + + assert_eq!(ctx.client().position(&ctx.borrower).borrowed, 0); + assert_eq!(ctx.client().liquidity(), LIQUIDITY); +} + +#[test] +fn test_interest_accrues_to_debt_and_supply() { + let ctx = Ctx::setup(); + ctx.fund_pool_and_collateral(); + ctx.env.mock_all_auths(); + ctx.client().borrow(&ctx.borrower, &500_000_i128); + + ctx.env.advance_time(Duration::days(365)); + + let borrower = ctx.client().position(&ctx.borrower); + let lender = ctx.client().position(&ctx.lender); + + assert_eq!(borrower.borrowed, 562_500); + assert_eq!(lender.supplied, 1_062_500); + assert_eq!(ctx.client().reserve().total_borrowed, 562_500); +} + +#[test] +fn test_withdraw_respects_available_liquidity() { + let ctx = Ctx::setup(); + ctx.fund_pool_and_collateral(); + ctx.env.mock_all_auths(); + ctx.client().borrow(&ctx.borrower, &750_000_i128); + + assert_reverts!( + ctx.client().withdraw(&ctx.lender, &500_000_i128), + "insufficient pool liquidity" + ); +} + +#[test] +fn test_withdraw_reduces_supply_balance() { + let ctx = Ctx::setup(); + ctx.fund_pool_and_collateral(); + ctx.env.mock_all_auths(); + ctx.client().withdraw(&ctx.lender, &250_000_i128); + + assert_eq!(ctx.client().position(&ctx.lender).supplied, 750_000); + assert_eq!(ctx.asset.balance(&ctx.lender), LIQUIDITY + 250_000); +} + +#[test] +fn test_collateral_withdrawal_requires_healthy_position() { + let ctx = Ctx::setup(); + ctx.fund_pool_and_collateral(); + ctx.env.mock_all_auths(); + ctx.client().borrow(&ctx.borrower, &1_000_000_i128); + + assert_reverts!( + ctx.client() + .withdraw_collateral(&ctx.borrower, &1_000_000_i128), + "insufficient collateral" + ); +} + +#[test] +fn test_collateral_can_be_withdrawn_after_repay() { + let ctx = Ctx::setup(); + ctx.fund_pool_and_collateral(); + ctx.env.mock_all_auths(); + ctx.client().borrow(&ctx.borrower, &500_000_i128); + ctx.client().repay(&ctx.borrower, &500_000_i128); + ctx.client().withdraw_collateral(&ctx.borrower, &COLLATERAL); + + assert_eq!(ctx.client().position(&ctx.borrower).collateral, 0); + assert_eq!(ctx.collateral.balance(&ctx.borrower), COLLATERAL); +} + +#[test] +fn test_invalid_initialization_reverts() { + let ctx = Ctx::setup(); + let env = MockEnv::builder() + .at_timestamp(BASE_TIME) + .with_contract::() + .with_account("admin", Stroops::xlm(100)) + .build(); + let id = env.contract_id::(); + let admin = env.account("admin"); + env.mock_all_auths(); + + assert_reverts!( + LendingClient::new(env.inner(), &id).initialize( + &admin, + &ctx.asset.address(), + &ctx.collateral.address(), + &10_001_i128, + &0_i128, + &7_500_i128, + ), + "base rate" + ); +} + +#[test] +fn test_double_initialize_reverts() { + let ctx = Ctx::setup(); + ctx.env.mock_all_auths(); + + assert_reverts!( + ctx.client().initialize( + &ctx.admin, + &ctx.asset.address(), + &ctx.collateral.address(), + &500_i128, + &1_500_i128, + &7_500_i128, + ), + "already initialized" + ); +}