diff --git a/migrations/20260401000000_mint_authorization_audit.sql b/migrations/20260401000000_mint_authorization_audit.sql new file mode 100644 index 0000000..5ecf42e --- /dev/null +++ b/migrations/20260401000000_mint_authorization_audit.sql @@ -0,0 +1,41 @@ +-- Mint Authorization Audit Trail (cNGN minting) +-- This table is append-only and chain-verified for tamper-evidence. + +CREATE TYPE mint_action_type AS ENUM ( + 'mint_requested', + 'mint_approved', + 'mint_submitted', + 'mint_completed', + 'mint_failed' +); + +CREATE TABLE mint_authorization_logs ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + actor_id TEXT NOT NULL, + public_key TEXT NOT NULL, + action_type mint_action_type NOT NULL, + request_payload JSONB NOT NULL, + previous_hash TEXT NOT NULL, + current_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (id) +); + +CREATE INDEX idx_mint_authorization_logs_created_at ON mint_authorization_logs (created_at); +CREATE INDEX idx_mint_authorization_logs_actor_id ON mint_authorization_logs (actor_id, created_at); +CREATE INDEX idx_mint_authorization_logs_action_type ON mint_authorization_logs (action_type, created_at); + +CREATE OR REPLACE FUNCTION mint_authorization_log_immutable() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'mint_authorization_logs is append-only: % on mint_authorization_logs is forbidden', TG_OP; +END; +$$; + +CREATE TRIGGER trg_mint_authorization_log_no_update + BEFORE UPDATE ON mint_authorization_logs + FOR EACH ROW EXECUTE FUNCTION mint_authorization_log_immutable(); + +CREATE TRIGGER trg_mint_authorization_log_no_delete + BEFORE DELETE ON mint_authorization_logs + FOR EACH ROW EXECUTE FUNCTION mint_authorization_log_immutable(); diff --git a/src/audit/mint_authorization.rs b/src/audit/mint_authorization.rs new file mode 100644 index 0000000..e0c32ef --- /dev/null +++ b/src/audit/mint_authorization.rs @@ -0,0 +1,257 @@ +use crate::audit::redaction::{compute_entry_hash, sha256_hex}; +use crate::database::error::DatabaseError; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "mint_action_type", rename_all = "snake_case")] +pub enum MintActionType { + MintRequested, + MintApproved, + MintSubmitted, + MintCompleted, + MintFailed, +} + +impl MintActionType { + pub fn as_str(&self) -> &'static str { + match self { + Self::MintRequested => "mint_requested", + Self::MintApproved => "mint_approved", + Self::MintSubmitted => "mint_submitted", + Self::MintCompleted => "mint_completed", + Self::MintFailed => "mint_failed", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MintAuthorizationLogEntry { + pub id: Uuid, + pub actor_id: String, + pub public_key: String, + pub action_type: MintActionType, + pub request_payload: JsonValue, + pub previous_hash: String, + pub current_hash: String, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MintAuthorizationChainVerificationResult { + pub valid: bool, + pub total_checked: i64, + pub first_sequence_id: Option, + pub last_sequence_id: Option, + pub tampered_entries: Vec, + pub gaps_detected: Vec, + pub verified_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TamperedMintAuthorizationEntry { + pub entry_id: Uuid, + pub expected_hash: String, + pub actual_hash: String, + pub created_at: DateTime, +} + +pub struct MintAuthorizationRepository { + pool: PgPool, +} + +impl MintAuthorizationRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + pub async fn last_entry_hash(&self) -> Result, DatabaseError> { + let row = sqlx::query_scalar!( + "SELECT current_hash FROM mint_authorization_logs ORDER BY created_at DESC LIMIT 1" + ) + .fetch_optional(&self.pool) + .await + .map_err(DatabaseError::from_sqlx)?; + Ok(row) + } + + pub async fn insert(&self, entry: &MintAuthorizationLogEntry) -> Result<(), DatabaseError> { + sqlx::query!( + "INSERT INTO mint_authorization_logs + (id, actor_id, public_key, action_type, request_payload, previous_hash, current_hash, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + entry.id, + entry.actor_id, + entry.public_key, + entry.action_type.as_str(), + entry.request_payload, + entry.previous_hash, + entry.current_hash, + entry.created_at, + ) + .execute(&self.pool) + .await + .map_err(DatabaseError::from_sqlx)?; + + Ok(()) + } + + pub async fn verify_hash_chain( + &self, + date_from: DateTime, + date_to: DateTime, + ) -> Result { + let rows = sqlx::query!( + "SELECT id, actor_id, public_key, action_type as \"action_type: MintActionType\", request_payload, previous_hash, current_hash, created_at + FROM mint_authorization_logs + WHERE created_at >= $1 AND created_at <= $2 + ORDER BY created_at ASC", + date_from, + date_to, + ) + .fetch_all(&self.pool) + .await + .map_err(DatabaseError::from_sqlx)?; + + let total = rows.len() as i64; + let mut tampered = Vec::new(); + let mut gaps = Vec::new(); + let mut prev_hash = None; + + const GENESIS_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + + let first_id = rows.first().map(|r| r.id); + let last_id = rows.last().map(|r| r.id); + + for row in rows { + let action_type = row.action_type; + let content = format!( + "{}|{}|{}|{}|{}|{}", + row.id, + row.actor_id, + row.public_key, + action_type.as_str(), + row.request_payload.to_string(), + row.created_at.timestamp_millis() + ); + + let previous = prev_hash.as_deref().unwrap_or(GENESIS_HASH); + let expected = compute_entry_hash(previous, &content); + + if expected != row.current_hash { + tampered.push(TamperedMintAuthorizationEntry { + entry_id: row.id, + expected_hash: expected.clone(), + actual_hash: row.current_hash.clone(), + created_at: row.created_at, + }); + } + + if let Some(stored_prev) = row.previous_hash.as_ref() { + if let Some(actual_prev) = prev_hash.as_ref() { + if stored_prev != actual_prev { + gaps.push(format!( + "Hash chain gap at entry {} (created_at: {})", + row.id, row.created_at + )); + } + } + } + + prev_hash = Some(row.current_hash); + } + + Ok(MintAuthorizationChainVerificationResult { + valid: tampered.is_empty() && gaps.is_empty(), + total_checked: total, + first_sequence_id: first_id, + last_sequence_id: last_id, + tampered_entries: tampered, + gaps_detected: gaps, + verified_at: Utc::now(), + }) + } +} + +pub struct MintAuthorizationService { + repo: MintAuthorizationRepository, +} + +impl MintAuthorizationService { + pub fn new(repo: MintAuthorizationRepository) -> Self { + Self { repo } + } + + pub async fn record_event( + &self, + actor_id: &str, + public_key: &str, + action_type: MintActionType, + request_payload: JsonValue, + ) -> Result<(), String> { + let previous_hash = self + .repo + .last_entry_hash() + .await + .map_err(|e| e.to_string())? + .unwrap_or_else(|| "0".repeat(64)); + + let timestamp = Utc::now(); + let content = format!( + "{}|{}|{}|{}|{}|{}|{}", + actor_id, + public_key, + action_type.as_str(), + request_payload.to_string(), + previous_hash, + timestamp.timestamp_millis(), + "mint_audit" + ); + + let current_hash = compute_entry_hash(&previous_hash, &content); + + let entry = MintAuthorizationLogEntry { + id: Uuid::new_v4(), + actor_id: actor_id.to_string(), + public_key: public_key.to_string(), + action_type, + request_payload, + previous_hash, + current_hash, + created_at: timestamp, + }; + + self.repo + .insert(&entry) + .await + .map_err(|e| e.to_string())?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mint_action_type_as_str() { + assert_eq!(MintActionType::MintRequested.as_str(), "mint_requested"); + assert_eq!(MintActionType::MintCompleted.as_str(), "mint_completed"); + } + + #[test] + fn compute_hash_chain_deterministic() { + let base = "0".repeat(64); + let c1 = "content1"; + let h1 = compute_entry_hash(&base, c1); + let h2 = compute_entry_hash(&h1, "content2"); + assert_ne!(h1, h2); + assert_eq!(h2, compute_entry_hash(&h1, "content2")); + assert_eq!(h1.len(), 64); + assert_eq!(h2.len(), 64); + } +} diff --git a/src/audit/mod.rs b/src/audit/mod.rs index 2a84571..76aae7c 100644 --- a/src/audit/mod.rs +++ b/src/audit/mod.rs @@ -6,7 +6,9 @@ pub mod handlers; pub mod metrics; pub mod redaction; pub mod streaming; +pub mod mint_authorization; pub use models::*; pub use writer::AuditWriter; pub use middleware::audit_middleware; +pub use mint_authorization::MintAuthorizationService; diff --git a/src/main.rs b/src/main.rs index 4e0bef6..f90ae9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -412,6 +412,15 @@ async fn main() -> anyhow::Result<()> { None }; + // Mint authorization audit service (chain-verified log trail) + let mint_audit_service: Option> = + if let Some(pool) = db_pool.clone() { + let repo = audit::mint_authorization::MintAuthorizationRepository::new(pool.clone()); + Some(std::sync::Arc::new(audit::mint_authorization::MintAuthorizationService::new(repo))) + } else { + None + }; + // --- Cache warming (must complete before traffic is accepted) --- if let (Some(ref pool), Some(ref redis)) = (&db_pool, &redis_cache) { let registry = prometheus::default_registry(); @@ -463,6 +472,7 @@ async fn main() -> anyhow::Result<()> { pool, client, monitor_config, + mint_audit_service.clone(), ); monitor_handle = Some(tokio::spawn(worker.run(worker_shutdown_rx.clone()))); } else { @@ -566,6 +576,7 @@ async fn main() -> anyhow::Result<()> { client, std::sync::Arc::new(factory), config, + mint_audit_service.clone(), ); onramp_handle = Some(tokio::spawn(async move { if let Err(e) = processor.run(worker_shutdown_rx.clone()).await { diff --git a/src/workers/onramp_processor.rs b/src/workers/onramp_processor.rs index c3a4f65..2aa0e72 100644 --- a/src/workers/onramp_processor.rs +++ b/src/workers/onramp_processor.rs @@ -19,6 +19,7 @@ use crate::chains::stellar::errors::StellarError; use crate::chains::stellar::payment::{CngnMemo, CngnPaymentBuilder}; use crate::chains::stellar::trustline::CngnTrustlineManager; use crate::database::transaction_repository::{Transaction, TransactionRepository}; +use crate::audit::mint_authorization::{MintAuthorizationService, MintActionType}; use crate::payments::factory::PaymentProviderFactory; use crate::payments::types::{PaymentState, ProviderName, StatusRequest}; use bigdecimal::BigDecimal; @@ -214,6 +215,7 @@ pub struct OnrampProcessor { stellar: Arc, provider_factory: Arc, config: OnrampProcessorConfig, + mint_audit_service: Option>, } impl OnrampProcessor { @@ -222,12 +224,14 @@ impl OnrampProcessor { stellar: StellarClient, provider_factory: Arc, config: OnrampProcessorConfig, + mint_audit_service: Option>, ) -> Self { Self { db: Arc::new(db), stellar: Arc::new(stellar), provider_factory, config, + mint_audit_service, } } @@ -695,6 +699,16 @@ impl OnrampProcessor { .await? .ok_or_else(|| ProcessorError::Internal(format!("Transaction vanished: {}", tx_id)))?; + if let Err(e) = self + .audit_mint_action(&tx, MintActionType::MintRequested, json!({ + "stage": "payment_confirmed", + "status": "processing", + })) + .await + { + warn!(tx_id = %tx_id, error = %e, "Failed to record MintRequested audit event"); + } + // Execute the cNGN transfer on Stellar self.execute_cngn_transfer(&tx).await } @@ -729,6 +743,17 @@ impl OnrampProcessor { return Ok(()); } + if let Err(e) = self + .audit_mint_action( + tx, + MintActionType::MintSubmitted, + json!({"stage": "execute_cngn_transfer_start"}), + ) + .await + { + warn!(tx_id = %tx.transaction_id, error = %e, "Failed to record MintSubmitted audit event"); + } + // Pre-flight: verify destination trustline if !self.verify_trustline(&tx.wallet_address).await? { warn!( @@ -813,6 +838,18 @@ impl OnrampProcessor { error = %e, "All Stellar submission attempts exhausted — initiating refund" ); + + if let Err(audit_err) = self + .audit_mint_action( + tx, + MintActionType::MintFailed, + json!({"reason": e.to_string(), "stage": "submit_failed"}), + ) + .await + { + warn!(tx_id = %tx.transaction_id, error = %audit_err, "Failed to record MintFailed audit event"); + } + let reason = if e.is_transient() { FailureReason::StellarTransientError } else { @@ -888,6 +925,35 @@ impl OnrampProcessor { Ok(true) } + async fn audit_mint_action( + &self, + tx: &Transaction, + action_type: MintActionType, + request_payload: serde_json::Value, + ) -> Result<(), String> { + if let Some(service) = &self.mint_audit_service { + let payload = json!({ + "transaction_id": tx.transaction_id.to_string(), + "wallet_address": tx.wallet_address, + "amount_cngn": tx.cngn_amount.to_string(), + "status": tx.status, + "action": action_type.as_str(), + "request_payload": request_payload, + }); + + service + .record_event( + &self.config.system_wallet_address, + &self.config.system_wallet_address, + action_type, + payload, + ) + .await + } else { + Ok(()) + } + } + /// Submit the cNGN transfer with exponential backoff retry for transient errors. /// Returns the Stellar transaction hash on success. async fn submit_cngn_transfer_with_retry( diff --git a/src/workers/transaction_monitor.rs b/src/workers/transaction_monitor.rs index c0a3eff..b46b2b1 100644 --- a/src/workers/transaction_monitor.rs +++ b/src/workers/transaction_monitor.rs @@ -1,10 +1,12 @@ use crate::chains::stellar::client::{HorizonTransactionRecord, StellarClient}; +use crate::audit::mint_authorization::{MintAuthorizationService, MintActionType}; use crate::database::repository::Repository; use crate::database::transaction_repository::TransactionRepository; use crate::database::webhook_repository::WebhookRepository; use serde_json::{json, Value as JsonValue}; use sqlx::PgPool; -use std::time::Duration; +use std::sync::Arc; +use std::time::{Duration, Instant}; use tokio::sync::watch; use tracing::{error, info, warn}; use uuid::Uuid; @@ -67,6 +69,8 @@ pub struct TransactionMonitorConfig { pub monitoring_window_hours: i32, /// Maximum number of incoming transactions fetched per cursor page. pub incoming_limit: usize, + /// Interval at which to verify mint authorization hash chain (seconds). + pub mint_audit_verification_interval: Duration, /// If set, the worker also scans this address for incoming cNGN payments. pub system_wallet_address: Option, } @@ -80,6 +84,7 @@ impl Default for TransactionMonitorConfig { pending_batch_size: 200, monitoring_window_hours: 24, incoming_limit: 100, + mint_audit_verification_interval: Duration::from_secs(3_600), system_wallet_address: None, } } @@ -116,6 +121,13 @@ impl TransactionMonitorConfig { .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(cfg.incoming_limit); + cfg.mint_audit_verification_interval = Duration::from_secs( + std::env::var("MINT_AUDIT_VERIFICATION_INTERVAL_SECS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(cfg.mint_audit_verification_interval.as_secs()), + ); + cfg.system_wallet_address = std::env::var("SYSTEM_WALLET_ADDRESS").ok(); cfg } @@ -130,6 +142,8 @@ pub struct TransactionMonitorWorker { stellar_client: StellarClient, config: TransactionMonitorConfig, incoming_cursor: Option, + mint_audit_service: Option>, + last_mint_audit_verification: Instant, } impl TransactionMonitorWorker { @@ -137,12 +151,15 @@ impl TransactionMonitorWorker { pool: PgPool, stellar_client: StellarClient, config: TransactionMonitorConfig, + mint_audit_service: Option>, ) -> Self { Self { pool, stellar_client, config, incoming_cursor: None, + mint_audit_service, + last_mint_audit_verification: Instant::now(), } } @@ -185,6 +202,35 @@ impl TransactionMonitorWorker { self.process_pending_transactions().await?; self.scan_incoming_transactions().await?; + if let Some(service) = &self.mint_audit_service { + if self.last_mint_audit_verification.elapsed() >= self.config.mint_audit_verification_interval { + let now = chrono::Utc::now(); + let one_day_ago = now - chrono::Duration::hours(24); + + match service.verify_hash_chain(one_day_ago, now).await { + Ok(result) => { + if !result.valid { + error!( + tampered_count = result.tampered_entries.len(), + gaps_count = result.gaps_detected.len(), + "CRITICAL_SECURITY_ALERT: Mint authorization hash chain integrity failure" + ); + } else { + info!( + total_checked = result.total_checked, + "Mint authorization hash chain is valid" + ); + } + } + Err(e) => { + error!(error = %e, "CRITICAL_SECURITY_ALERT: Failed to verify mint authorization hash chain"); + } + } + + self.last_mint_audit_verification = Instant::now(); + } + } + // Update last-cycle timestamp for missed-cycle alert rule #[cfg(feature = "cache")] crate::metrics::alerting::worker_last_cycle_timestamp() @@ -332,6 +378,29 @@ impl TransactionMonitorWorker { "transaction confirmed on stellar ledger" ); + if let Some(service) = &self.mint_audit_service { + let actor = self + .config + .system_wallet_address + .as_deref() + .unwrap_or("system_wallet"); + if let Err(e) = service + .record_event( + actor, + actor, + MintActionType::MintCompleted, + json!({ + "transaction_id": transaction_id, + "stellar_hash": record.hash, + "ledger": record.ledger, + }), + ) + .await + { + warn!(transaction_id = %transaction_id, error = %e, "Failed to record MintCompleted audit event"); + } + } + self.log_webhook_event(transaction_id, "stellar.transaction.confirmed", updated) .await; } else { @@ -364,6 +433,28 @@ impl TransactionMonitorWorker { self.log_webhook_event(transaction_id, "stellar.transaction.timeout", updated) .await; + if let Some(service) = &self.mint_audit_service { + let actor = self + .config + .system_wallet_address + .as_deref() + .unwrap_or("system_wallet"); + if let Err(e) = service + .record_event( + actor, + actor, + MintActionType::MintFailed, + json!({ + "transaction_id": transaction_id, + "reason": "absolute_timeout", + }), + ) + .await + { + warn!(transaction_id = %transaction_id, error = %e, "Failed to record MintFailed audit event (timeout)"); + } + } + warn!( transaction_id = %transaction_id, "transaction failed: absolute timeout exceeded" @@ -405,6 +496,29 @@ impl TransactionMonitorWorker { .update_status_with_metadata(transaction_id, "failed", updated.clone()) .await?; + if let Some(service) = &self.mint_audit_service { + let actor = self + .config + .system_wallet_address + .as_deref() + .unwrap_or("system_wallet"); + if let Err(e) = service + .record_event( + actor, + actor, + MintActionType::MintFailed, + json!({ + "transaction_id": transaction_id, + "reason": error_message, + "retry_count": retries, + }), + ) + .await + { + warn!(transaction_id = %transaction_id, error = %e, "Failed to record MintFailed audit event (retry exhausted)"); + } + } + let err = MonitorError::RetryExceeded { tx_id: transaction_id.to_string(), attempts: retries,