From adbfc9808b49888841a99ec4a4542e535fdfe089 Mon Sep 17 00:00:00 2001 From: bjabrack-29 Date: Wed, 27 May 2026 17:56:06 +0000 Subject: [PATCH] feat(mint): Mint Authorization Framework & Multi-Sig Approval (#213) - DB migration: mint_authorization_requests, mint_authorization_signatures, mint_auth_status enum, indexes, updated_at trigger - Domain models: MintAuthRequest, MintAuthSignature, MintAuthStatus + DTOs - Repository: full CRUD, reserve verification lookup, signer/quorum queries - Service: reserve recency + balance validation, unsigned XDR build, tx_hash computation, Ed25519 sig verification, signature aggregation, Stellar submission with exponential-backoff retry, confirmation polling, expiry, cancellation - Metrics: Prometheus counters (created/signatures/thresholds/submissions/ confirmations/failures/expirations/cancellations) + pending gauge - Handlers + routes: POST /authorizations, GET list/detail, POST /:id/sign, POST /:id/cancel - Worker: background expiry job (tokio interval) - Unit tests: tx_hash, Ed25519 verification, aggregation, threshold, expiry, status helpers, cancellation, substitution attack prevention - Integration tests: full lifecycle, dup sig, invalid sig, cancel, expiry worker, stale reserve, amount-exceeds-reserve --- ...528000000_mint_authorization_framework.sql | 93 +++ src/lib.rs | 3 + src/mint_authorization/error.rs | 57 ++ src/mint_authorization/handlers.rs | 145 ++++ src/mint_authorization/metrics.rs | 113 +++ src/mint_authorization/mod.rs | 36 + src/mint_authorization/models.rs | 164 +++++ src/mint_authorization/repository.rs | 403 +++++++++++ src/mint_authorization/routes.rs | 31 + src/mint_authorization/service.rs | 678 ++++++++++++++++++ src/mint_authorization/worker.rs | 21 + tests/mint_authorization_integration.rs | 462 ++++++++++++ tests/mint_authorization_unit_tests.rs | 307 ++++++++ 13 files changed, 2513 insertions(+) create mode 100644 migrations/20270528000000_mint_authorization_framework.sql create mode 100644 src/mint_authorization/error.rs create mode 100644 src/mint_authorization/handlers.rs create mode 100644 src/mint_authorization/metrics.rs create mode 100644 src/mint_authorization/mod.rs create mode 100644 src/mint_authorization/models.rs create mode 100644 src/mint_authorization/repository.rs create mode 100644 src/mint_authorization/routes.rs create mode 100644 src/mint_authorization/service.rs create mode 100644 src/mint_authorization/worker.rs create mode 100644 tests/mint_authorization_integration.rs create mode 100644 tests/mint_authorization_unit_tests.rs diff --git a/migrations/20270528000000_mint_authorization_framework.sql b/migrations/20270528000000_mint_authorization_framework.sql new file mode 100644 index 0000000..44597de --- /dev/null +++ b/migrations/20270528000000_mint_authorization_framework.sql @@ -0,0 +1,93 @@ +-- Mint Authorization Framework (#213) +-- Governs cNGN issuance via multi-signature approval before Stellar submission. + +-- ───────────────────────────────────────────────────────────────────────────── +-- Types +-- ───────────────────────────────────────────────────────────────────────────── + +CREATE TYPE mint_auth_status AS ENUM ( + 'pending_signatures', + 'threshold_met', + 'submitted', + 'confirmed', + 'failed', + 'expired', + 'cancelled' +); + +-- ───────────────────────────────────────────────────────────────────────────── +-- Core tables +-- ───────────────────────────────────────────────────────────────────────────── + +CREATE TABLE mint_authorization_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Mint details + amount_cngn NUMERIC(36, 7) NOT NULL CHECK (amount_cngn > 0), + destination_account VARCHAR(64) NOT NULL, -- Stellar distribution account + requested_by UUID NOT NULL, -- admin user id + requested_by_key VARCHAR(64) NOT NULL, -- Stellar public key of requester + justification TEXT NOT NULL, + + -- Reserve verification link + reserve_verification_id UUID NOT NULL, -- FK to historical_verification + + -- Signature collection + required_signatures SMALLINT NOT NULL CHECK (required_signatures > 0), + collected_signatures SMALLINT NOT NULL DEFAULT 0, + + -- Transaction envelope + unsigned_xdr TEXT NOT NULL, -- base64 XDR, no signatures + signed_xdr TEXT, -- base64 XDR, fully signed + tx_hash TEXT, -- SHA-256 hash signers must sign + stellar_tx_hash TEXT, -- hash returned by Horizon on submission + + -- Lifecycle + status mint_auth_status NOT NULL DEFAULT 'pending_signatures', + failure_reason TEXT, + cancellation_reason TEXT, + cancelled_by UUID, + retry_count SMALLINT NOT NULL DEFAULT 0, + + expires_at TIMESTAMPTZ NOT NULL, + submitted_at TIMESTAMPTZ, + confirmed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE mint_authorization_signatures ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + auth_request_id UUID NOT NULL REFERENCES mint_authorization_requests(id) ON DELETE CASCADE, + signer_id UUID NOT NULL REFERENCES mint_signers(id), + signer_key VARCHAR(64) NOT NULL, -- Stellar public key (G…) + -- Raw 64-byte Ed25519 signature over tx_hash, base64-encoded + signature TEXT NOT NULL, + signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ip_address INET, + + UNIQUE (auth_request_id, signer_id) +); + +-- ───────────────────────────────────────────────────────────────────────────── +-- Indexes +-- ───────────────────────────────────────────────────────────────────────────── + +CREATE INDEX ON mint_authorization_requests (status, created_at DESC); +CREATE INDEX ON mint_authorization_requests (expires_at) WHERE status = 'pending_signatures'; +CREATE INDEX ON mint_authorization_requests (reserve_verification_id); +CREATE INDEX ON mint_authorization_signatures (auth_request_id); +CREATE INDEX ON mint_authorization_signatures (signer_id); + +-- ───────────────────────────────────────────────────────────────────────────── +-- updated_at trigger +-- ───────────────────────────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION mint_auth_set_updated_at() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN NEW.updated_at = NOW(); RETURN NEW; END; +$$; + +CREATE TRIGGER trg_mint_auth_requests_updated_at + BEFORE UPDATE ON mint_authorization_requests + FOR EACH ROW EXECUTE FUNCTION mint_auth_set_updated_at(); diff --git a/src/lib.rs b/src/lib.rs index d6492cc..ca7b3a0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -171,6 +171,9 @@ pub mod agent_dashboard; // Multi-Signature Governance Framework — M-of-N signing for Mint/Burn/SetOptions #[cfg(feature = "database")] pub mod multisig; +// Mint Authorization Framework — cNGN issuance via M-of-N multi-signature approval (#213) +#[cfg(feature = "database")] +pub mod mint_authorization; // Adaptive rate limiting and throttling system #[cfg(feature = "cache")] pub mod adaptive_rate_limit; diff --git a/src/mint_authorization/error.rs b/src/mint_authorization/error.rs new file mode 100644 index 0000000..ed534d1 --- /dev/null +++ b/src/mint_authorization/error.rs @@ -0,0 +1,57 @@ +//! Domain errors for the Mint Authorization Framework. + +use uuid::Uuid; + +#[derive(Debug, thiserror::Error)] +pub enum MintAuthError { + #[error("Reserve verification {0} not found or not approved")] + ReserveVerificationNotFound(Uuid), + + #[error("Reserve verification is too old (max recency: {max_hours}h, actual: {actual_hours:.1}h)")] + ReserveVerificationStale { max_hours: i64, actual_hours: f64 }, + + #[error("Requested mint amount {requested} exceeds available reserve balance {available}")] + ExceedsReserveBalance { requested: String, available: String }, + + #[error("Authorization request {0} not found")] + NotFound(Uuid), + + #[error("Authorization request {0} is in terminal state: {1}")] + TerminalState(Uuid, String), + + #[error("Authorization request {0} has expired")] + Expired(Uuid), + + #[error("Signer {0} is not an authorized signer")] + UnauthorizedSigner(String), + + #[error("Invalid signature from signer {0}: {1}")] + InvalidSignature(String, String), + + #[error("Signature is over a different transaction hash (substitution attack prevented)")] + TransactionHashMismatch, + + #[error("Signer {0} has already signed authorization {1}")] + DuplicateSignature(String, Uuid), + + #[error("Authorization {0} is not in threshold_met state for submission")] + NotReadyForSubmission(Uuid), + + #[error("Stellar submission failed: {0}")] + StellarSubmission(String), + + #[error("XDR build error: {0}")] + XdrBuild(String), + + #[error("Database error: {0}")] + Database(String), + + #[error("Configuration error: {0}")] + Config(String), +} + +impl From for MintAuthError { + fn from(e: sqlx::Error) -> Self { + Self::Database(e.to_string()) + } +} diff --git a/src/mint_authorization/handlers.rs b/src/mint_authorization/handlers.rs new file mode 100644 index 0000000..e8b3ff8 --- /dev/null +++ b/src/mint_authorization/handlers.rs @@ -0,0 +1,145 @@ +//! HTTP handlers for the Mint Authorization Framework. + +use crate::error::{AppError, AppErrorKind, DomainError}; +use crate::mint_authorization::{ + error::MintAuthError, + models::{ + CancelMintAuthRequest, CreateMintAuthRequest, ListMintAuthQuery, SubmitSignatureRequest, + }, + service::MintAuthService, +}; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use std::sync::Arc; +use uuid::Uuid; + +pub type MintAuthState = Arc; + +fn map_err(e: MintAuthError) -> AppError { + use MintAuthError::*; + match e { + NotFound(id) => AppError::new(AppErrorKind::Domain(DomainError::TransactionNotFound { + transaction_id: id.to_string(), + })), + TerminalState(_, _) | NotReadyForSubmission(_) | DuplicateSignature(_, _) => { + AppError::new(AppErrorKind::Validation( + crate::error::ValidationError::InvalidInput { + field: "status".into(), + message: e.to_string(), + }, + )) + } + UnauthorizedSigner(_) | InvalidSignature(_, _) | TransactionHashMismatch => { + AppError::new(AppErrorKind::Unauthorized) + } + _ => AppError::new(AppErrorKind::Infrastructure( + crate::error::InfrastructureError::Database { + message: e.to_string(), + is_retryable: false, + }, + )), + } +} + +/// POST /api/admin/mint/authorizations +pub async fn create_authorization( + State(svc): State, + req: axum::extract::Request, +) -> Result { + let (requester_id, requester_key) = extract_requester(&req)?; + let Json(body): Json = Json::from_request(req, &()) + .await + .map_err(|_| AppError::new(AppErrorKind::Validation( + crate::error::ValidationError::MissingField { field: "body".into() } + )))?; + + let result = svc.create(body, requester_id, &requester_key).await.map_err(map_err)?; + Ok((StatusCode::CREATED, Json(result))) +} + +/// GET /api/admin/mint/authorizations +pub async fn list_authorizations( + State(svc): State, + Query(query): Query, +) -> Result { + let result = svc.list(query).await.map_err(map_err)?; + Ok(Json(result)) +} + +/// GET /api/admin/mint/authorizations/:auth_id +pub async fn get_authorization( + State(svc): State, + Path(auth_id): Path, +) -> Result { + let result = svc.get(auth_id).await.map_err(map_err)?; + Ok(Json(result)) +} + +/// POST /api/admin/mint/authorizations/:auth_id/sign +pub async fn sign_authorization( + State(svc): State, + Path(auth_id): Path, + req: axum::extract::Request, +) -> Result { + let ip = extract_ip(&req); + let Json(body): Json = Json::from_request(req, &()) + .await + .map_err(|_| AppError::new(AppErrorKind::Validation( + crate::error::ValidationError::MissingField { field: "body".into() } + )))?; + + let result = svc.submit_signature(auth_id, body, ip).await.map_err(map_err)?; + Ok(Json(result)) +} + +/// POST /api/admin/mint/authorizations/:auth_id/cancel +pub async fn cancel_authorization( + State(svc): State, + Path(auth_id): Path, + req: axum::extract::Request, +) -> Result { + let (cancelled_by, _) = extract_requester(&req)?; + let Json(body): Json = Json::from_request(req, &()) + .await + .map_err(|_| AppError::new(AppErrorKind::Validation( + crate::error::ValidationError::MissingField { field: "body".into() } + )))?; + + let result = svc.cancel(auth_id, cancelled_by, body).await.map_err(map_err)?; + Ok(Json(result)) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +fn extract_requester(req: &axum::extract::Request) -> Result<(Uuid, String), AppError> { + // Try OAuth claims first, then JWT claims + if let Some(claims) = req.extensions().get::() { + let id = Uuid::parse_str(&claims.sub).unwrap_or_else(|_| Uuid::nil()); + let key = claims + .extra + .get("stellar_public_key") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + return Ok((id, key)); + } + if let Some(claims) = req.extensions().get::() { + let id = Uuid::parse_str(&claims.sub).unwrap_or_else(|_| Uuid::nil()); + return Ok((id, String::new())); + } + Err(AppError::new(AppErrorKind::Unauthorized)) +} + +fn extract_ip(req: &axum::extract::Request) -> Option { + req.headers() + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.split(',').next()) + .and_then(|s| s.trim().parse().ok()) +} diff --git a/src/mint_authorization/metrics.rs b/src/mint_authorization/metrics.rs new file mode 100644 index 0000000..00b0322 --- /dev/null +++ b/src/mint_authorization/metrics.rs @@ -0,0 +1,113 @@ +//! Prometheus metrics for the Mint Authorization Framework. + +use prometheus::{ + register_counter_with_registry, register_gauge_with_registry, Counter, Gauge, +}; +use std::sync::OnceLock; + +use crate::metrics::registry; + +// ───────────────────────────────────────────────────────────────────────────── +// Counters +// ───────────────────────────────────────────────────────────────────────────── + +static REQUESTS_CREATED: OnceLock = OnceLock::new(); +static SIGNATURES_COLLECTED: OnceLock = OnceLock::new(); +static THRESHOLDS_MET: OnceLock = OnceLock::new(); +static SUBMISSIONS_ATTEMPTED: OnceLock = OnceLock::new(); +static CONFIRMATIONS_RECEIVED: OnceLock = OnceLock::new(); +static FAILURES: OnceLock = OnceLock::new(); +static EXPIRATIONS: OnceLock = OnceLock::new(); +static CANCELLATIONS: OnceLock = OnceLock::new(); + +// ───────────────────────────────────────────────────────────────────────────── +// Gauges +// ───────────────────────────────────────────────────────────────────────────── + +static PENDING_COUNT: OnceLock = OnceLock::new(); + +// ───────────────────────────────────────────────────────────────────────────── +// Registration +// ───────────────────────────────────────────────────────────────────────────── + +pub fn register(r: &prometheus::Registry) { + macro_rules! reg_counter { + ($cell:expr, $name:expr, $help:expr) => { + $cell.get_or_init(|| { + register_counter_with_registry!($name, $help, r).expect(concat!("register ", $name)) + }); + }; + } + macro_rules! reg_gauge { + ($cell:expr, $name:expr, $help:expr) => { + $cell.get_or_init(|| { + register_gauge_with_registry!($name, $help, r).expect(concat!("register ", $name)) + }); + }; + } + + reg_counter!(REQUESTS_CREATED, "aframp_mint_auth_requests_created_total", "Total mint authorization requests created"); + reg_counter!(SIGNATURES_COLLECTED, "aframp_mint_auth_signatures_collected_total", "Total signatures collected across all requests"); + reg_counter!(THRESHOLDS_MET, "aframp_mint_auth_thresholds_met_total", "Total requests that reached signature threshold"); + reg_counter!(SUBMISSIONS_ATTEMPTED,"aframp_mint_auth_submissions_attempted_total","Total Stellar submission attempts"); + reg_counter!(CONFIRMATIONS_RECEIVED,"aframp_mint_auth_confirmations_received_total","Total on-chain confirmations received"); + reg_counter!(FAILURES, "aframp_mint_auth_failures_total", "Total authorization request failures"); + reg_counter!(EXPIRATIONS, "aframp_mint_auth_expirations_total", "Total authorization requests expired"); + reg_counter!(CANCELLATIONS, "aframp_mint_auth_cancellations_total", "Total authorization requests cancelled"); + reg_gauge!(PENDING_COUNT, "aframp_mint_auth_pending_count", "Current number of pending-signatures authorization requests"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Accessors (initialise lazily via global registry if not pre-registered) +// ───────────────────────────────────────────────────────────────────────────── + +fn ensure_registered() { + if REQUESTS_CREATED.get().is_none() { + register(registry()); + } +} + +pub fn inc_requests_created() { + ensure_registered(); + if let Some(c) = REQUESTS_CREATED.get() { c.inc(); } +} + +pub fn inc_signatures_collected() { + ensure_registered(); + if let Some(c) = SIGNATURES_COLLECTED.get() { c.inc(); } +} + +pub fn inc_thresholds_met() { + ensure_registered(); + if let Some(c) = THRESHOLDS_MET.get() { c.inc(); } +} + +pub fn inc_submissions_attempted() { + ensure_registered(); + if let Some(c) = SUBMISSIONS_ATTEMPTED.get() { c.inc(); } +} + +pub fn inc_confirmations_received() { + ensure_registered(); + if let Some(c) = CONFIRMATIONS_RECEIVED.get() { c.inc(); } +} + +pub fn inc_failures() { + ensure_registered(); + if let Some(c) = FAILURES.get() { c.inc(); } +} + +pub fn inc_expirations() { + ensure_registered(); + if let Some(c) = EXPIRATIONS.get() { c.inc(); } +} + +pub fn inc_cancellations() { + ensure_registered(); + if let Some(c) = CANCELLATIONS.get() { c.inc(); } +} + +pub fn set_pending_count(n: f64) { + ensure_registered(); + if let Some(g) = PENDING_COUNT.get() { g.set(n); } +} diff --git a/src/mint_authorization/mod.rs b/src/mint_authorization/mod.rs new file mode 100644 index 0000000..91b3bcd --- /dev/null +++ b/src/mint_authorization/mod.rs @@ -0,0 +1,36 @@ +//! Mint Authorization Framework (#213) +//! +//! Governs cNGN issuance via M-of-N multi-signature approval before Stellar submission. +//! +//! # Flow +//! ```text +//! Admin +//! │ +//! ▼ +//! [POST /authorizations] ──► validate reserve ──► build unsigned XDR ──► persist +//! │ +//! ▼ +//! [notify signers] +//! │ +//! ▼ +//! [POST /authorizations/:id/sign] ──► verify Ed25519 sig ──► persist ──► check threshold +//! │ +//! ▼ (threshold met) +//! [aggregate signatures into XDR] ──► submit to Stellar Horizon (retry w/ backoff) +//! │ +//! ▼ +//! [monitor confirmation] ──► confirmed / failed +//! ``` + +pub mod error; +pub mod handlers; +pub mod metrics; +pub mod models; +pub mod repository; +pub mod routes; +pub mod service; +pub mod worker; + +pub use error::MintAuthError; +pub use models::{MintAuthRequest, MintAuthSignature, MintAuthStatus}; +pub use service::MintAuthService; diff --git a/src/mint_authorization/models.rs b/src/mint_authorization/models.rs new file mode 100644 index 0000000..1897b0d --- /dev/null +++ b/src/mint_authorization/models.rs @@ -0,0 +1,164 @@ +//! Domain models for the Mint Authorization Framework (#213). + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::types::BigDecimal; +use uuid::Uuid; + +// ───────────────────────────────────────────────────────────────────────────── +// Status enum +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "mint_auth_status", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum MintAuthStatus { + PendingSignatures, + ThresholdMet, + Submitted, + Confirmed, + Failed, + Expired, + Cancelled, +} + +impl MintAuthStatus { + pub fn is_terminal(self) -> bool { + matches!( + self, + Self::Confirmed | Self::Failed | Self::Expired | Self::Cancelled + ) + } + + pub fn is_active(self) -> bool { + matches!(self, Self::PendingSignatures | Self::ThresholdMet) + } +} + +impl std::fmt::Display for MintAuthStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::PendingSignatures => "pending_signatures", + Self::ThresholdMet => "threshold_met", + Self::Submitted => "submitted", + Self::Confirmed => "confirmed", + Self::Failed => "failed", + Self::Expired => "expired", + Self::Cancelled => "cancelled", + }; + f.write_str(s) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Core domain structs +// ───────────────────────────────────────────────────────────────────────────── + +/// A mint authorization request awaiting M-of-N signatures. +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct MintAuthRequest { + pub id: Uuid, + pub amount_cngn: BigDecimal, + pub destination_account: String, + pub requested_by: Uuid, + pub requested_by_key: String, + pub justification: String, + pub reserve_verification_id: Uuid, + pub required_signatures: i16, + pub collected_signatures: i16, + pub unsigned_xdr: String, + pub signed_xdr: Option, + /// SHA-256 hash (hex) of the transaction that all signers must sign. + pub tx_hash: Option, + pub stellar_tx_hash: Option, + pub status: MintAuthStatus, + pub failure_reason: Option, + pub cancellation_reason: Option, + pub cancelled_by: Option, + pub retry_count: i16, + pub expires_at: DateTime, + pub submitted_at: Option>, + pub confirmed_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// A single signer's Ed25519 signature on a mint authorization request. +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct MintAuthSignature { + pub id: Uuid, + pub auth_request_id: Uuid, + pub signer_id: Uuid, + pub signer_key: String, + /// Base64-encoded raw 64-byte Ed25519 signature over `tx_hash`. + pub signature: String, + pub signed_at: DateTime, + pub ip_address: Option, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Request / Response DTOs +// ───────────────────────────────────────────────────────────────────────────── + +/// POST /api/admin/mint/authorizations +#[derive(Debug, Deserialize)] +pub struct CreateMintAuthRequest { + /// Amount of cNGN to mint (decimal string, e.g. "1000000.0000000"). + pub amount_cngn: String, + /// Stellar distribution account address (G…). + pub destination_account: String, + /// Human-readable justification for the mint. + pub justification: String, + /// ID of the reserve verification snapshot to anchor this request. + pub reserve_verification_id: Uuid, +} + +/// POST /api/admin/mint/authorizations/:auth_id/sign +#[derive(Debug, Deserialize)] +pub struct SubmitSignatureRequest { + /// Base64-encoded raw 64-byte Ed25519 signature over the `tx_hash`. + pub signature: String, + /// Signer's Stellar public key (G…) — must match the registered key. + pub signer_key: String, +} + +/// POST /api/admin/mint/authorizations/:auth_id/cancel +#[derive(Debug, Deserialize)] +pub struct CancelMintAuthRequest { + pub justification: String, +} + +/// Full detail response for a mint authorization request. +#[derive(Debug, Serialize)] +pub struct MintAuthDetail { + pub request: MintAuthRequest, + pub signatures: Vec, + pub signatures_collected: usize, + pub signatures_required: usize, +} + +/// Paginated list response. +#[derive(Debug, Serialize)] +pub struct MintAuthListResponse { + pub items: Vec, + pub total: i64, + pub limit: i64, + pub offset: i64, +} + +/// Query params for listing. +#[derive(Debug, Deserialize)] +pub struct ListMintAuthQuery { + pub status: Option, + pub limit: Option, + pub offset: Option, +} + +impl ListMintAuthQuery { + pub fn limit(&self) -> i64 { + self.limit.unwrap_or(20).clamp(1, 100) + } + pub fn offset(&self) -> i64 { + self.offset.unwrap_or(0).max(0) + } +} diff --git a/src/mint_authorization/repository.rs b/src/mint_authorization/repository.rs new file mode 100644 index 0000000..2eea347 --- /dev/null +++ b/src/mint_authorization/repository.rs @@ -0,0 +1,403 @@ +//! Database access layer for the Mint Authorization Framework. + +use crate::mint_authorization::{ + error::MintAuthError, + models::{MintAuthRequest, MintAuthSignature, MintAuthStatus}, +}; +use chrono::{DateTime, Utc}; +use sqlx::types::BigDecimal; +use sqlx::PgPool; +use uuid::Uuid; + +pub struct MintAuthRepository { + pool: PgPool, +} + +impl MintAuthRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + // ───────────────────────────────────────────────────────────────────────── + // Authorization requests + // ───────────────────────────────────────────────────────────────────────── + + #[allow(clippy::too_many_arguments)] + pub async fn create_request( + &self, + amount_cngn: &BigDecimal, + destination_account: &str, + requested_by: Uuid, + requested_by_key: &str, + justification: &str, + reserve_verification_id: Uuid, + required_signatures: i16, + unsigned_xdr: &str, + tx_hash: &str, + expires_at: DateTime, + ) -> Result { + sqlx::query_as!( + MintAuthRequest, + r#" + INSERT INTO mint_authorization_requests ( + amount_cngn, destination_account, requested_by, requested_by_key, + justification, reserve_verification_id, required_signatures, + unsigned_xdr, tx_hash, expires_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + RETURNING + id, amount_cngn, destination_account, requested_by, requested_by_key, + justification, reserve_verification_id, required_signatures, + collected_signatures, unsigned_xdr, signed_xdr, tx_hash, + stellar_tx_hash, status AS "status: MintAuthStatus", + failure_reason, cancellation_reason, cancelled_by, retry_count, + expires_at, submitted_at, confirmed_at, created_at, updated_at + "#, + amount_cngn, + destination_account, + requested_by, + requested_by_key, + justification, + reserve_verification_id, + required_signatures, + unsigned_xdr, + tx_hash, + expires_at, + ) + .fetch_one(&self.pool) + .await + .map_err(MintAuthError::from) + } + + pub async fn get_by_id(&self, id: Uuid) -> Result, MintAuthError> { + sqlx::query_as!( + MintAuthRequest, + r#" + SELECT id, amount_cngn, destination_account, requested_by, requested_by_key, + justification, reserve_verification_id, required_signatures, + collected_signatures, unsigned_xdr, signed_xdr, tx_hash, + stellar_tx_hash, status AS "status: MintAuthStatus", + failure_reason, cancellation_reason, cancelled_by, retry_count, + expires_at, submitted_at, confirmed_at, created_at, updated_at + FROM mint_authorization_requests WHERE id = $1 + "#, + id + ) + .fetch_optional(&self.pool) + .await + .map_err(MintAuthError::from) + } + + pub async fn list( + &self, + status: Option, + limit: i64, + offset: i64, + ) -> Result<(Vec, i64), MintAuthError> { + let rows = sqlx::query_as!( + MintAuthRequest, + r#" + SELECT id, amount_cngn, destination_account, requested_by, requested_by_key, + justification, reserve_verification_id, required_signatures, + collected_signatures, unsigned_xdr, signed_xdr, tx_hash, + stellar_tx_hash, status AS "status: MintAuthStatus", + failure_reason, cancellation_reason, cancelled_by, retry_count, + expires_at, submitted_at, confirmed_at, created_at, updated_at + FROM mint_authorization_requests + WHERE ($1::mint_auth_status IS NULL OR status = $1) + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + status as Option, + limit, + offset, + ) + .fetch_all(&self.pool) + .await + .map_err(MintAuthError::from)?; + + let total: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM mint_authorization_requests + WHERE ($1::mint_auth_status IS NULL OR status = $1)", + status as Option, + ) + .fetch_one(&self.pool) + .await + .map_err(MintAuthError::from)? + .unwrap_or(0); + + Ok((rows, total)) + } + + pub async fn update_status( + &self, + id: Uuid, + status: MintAuthStatus, + signed_xdr: Option<&str>, + stellar_tx_hash: Option<&str>, + failure_reason: Option<&str>, + ) -> Result { + sqlx::query_as!( + MintAuthRequest, + r#" + UPDATE mint_authorization_requests SET + status = $2, + signed_xdr = COALESCE($3, signed_xdr), + stellar_tx_hash = COALESCE($4, stellar_tx_hash), + failure_reason = COALESCE($5, failure_reason), + submitted_at = CASE WHEN $2 = 'submitted' THEN NOW() ELSE submitted_at END, + confirmed_at = CASE WHEN $2 = 'confirmed' THEN NOW() ELSE confirmed_at END + WHERE id = $1 + RETURNING + id, amount_cngn, destination_account, requested_by, requested_by_key, + justification, reserve_verification_id, required_signatures, + collected_signatures, unsigned_xdr, signed_xdr, tx_hash, + stellar_tx_hash, status AS "status: MintAuthStatus", + failure_reason, cancellation_reason, cancelled_by, retry_count, + expires_at, submitted_at, confirmed_at, created_at, updated_at + "#, + id, + status as MintAuthStatus, + signed_xdr, + stellar_tx_hash, + failure_reason, + ) + .fetch_one(&self.pool) + .await + .map_err(MintAuthError::from) + } + + pub async fn cancel( + &self, + id: Uuid, + cancelled_by: Uuid, + reason: &str, + ) -> Result { + sqlx::query_as!( + MintAuthRequest, + r#" + UPDATE mint_authorization_requests SET + status = 'cancelled', + cancellation_reason = $3, + cancelled_by = $2 + WHERE id = $1 + AND status IN ('pending_signatures', 'threshold_met') + RETURNING + id, amount_cngn, destination_account, requested_by, requested_by_key, + justification, reserve_verification_id, required_signatures, + collected_signatures, unsigned_xdr, signed_xdr, tx_hash, + stellar_tx_hash, status AS "status: MintAuthStatus", + failure_reason, cancellation_reason, cancelled_by, retry_count, + expires_at, submitted_at, confirmed_at, created_at, updated_at + "#, + id, + cancelled_by, + reason, + ) + .fetch_optional(&self.pool) + .await + .map_err(MintAuthError::from)? + .ok_or(MintAuthError::NotFound(id)) + } + + pub async fn increment_retry(&self, id: Uuid) -> Result<(), MintAuthError> { + sqlx::query!( + "UPDATE mint_authorization_requests SET retry_count = retry_count + 1 WHERE id = $1", + id + ) + .execute(&self.pool) + .await + .map_err(MintAuthError::from)?; + Ok(()) + } + + /// Find all pending requests that have exceeded their expiry timestamp. + pub async fn find_expired(&self) -> Result, MintAuthError> { + sqlx::query_as!( + MintAuthRequest, + r#" + SELECT id, amount_cngn, destination_account, requested_by, requested_by_key, + justification, reserve_verification_id, required_signatures, + collected_signatures, unsigned_xdr, signed_xdr, tx_hash, + stellar_tx_hash, status AS "status: MintAuthStatus", + failure_reason, cancellation_reason, cancelled_by, retry_count, + expires_at, submitted_at, confirmed_at, created_at, updated_at + FROM mint_authorization_requests + WHERE status = 'pending_signatures' + AND expires_at < NOW() + "#, + ) + .fetch_all(&self.pool) + .await + .map_err(MintAuthError::from) + } + + /// Count of requests currently in pending_signatures status. + pub async fn count_pending(&self) -> Result { + sqlx::query_scalar!( + "SELECT COUNT(*) FROM mint_authorization_requests WHERE status = 'pending_signatures'" + ) + .fetch_one(&self.pool) + .await + .map_err(MintAuthError::from) + .map(|c| c.unwrap_or(0)) + } + + // ───────────────────────────────────────────────────────────────────────── + // Signatures + // ───────────────────────────────────────────────────────────────────────── + + pub async fn add_signature( + &self, + auth_request_id: Uuid, + signer_id: Uuid, + signer_key: &str, + signature: &str, + ip_address: Option, + ) -> Result { + let ip = ip_address.map(|ip| ipnetwork::IpNetwork::from(ip)); + + let sig = sqlx::query_as!( + MintAuthSignature, + r#" + INSERT INTO mint_authorization_signatures + (auth_request_id, signer_id, signer_key, signature, ip_address) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, auth_request_id, signer_id, signer_key, signature, signed_at, + ip_address AS "ip_address: ipnetwork::IpNetwork" + "#, + auth_request_id, + signer_id, + signer_key, + signature, + ip as Option, + ) + .fetch_one(&self.pool) + .await + .map_err(MintAuthError::from)?; + + // Atomically increment collected_signatures and check threshold + sqlx::query!( + r#" + UPDATE mint_authorization_requests + SET collected_signatures = collected_signatures + 1, + status = CASE + WHEN collected_signatures + 1 >= required_signatures + AND status = 'pending_signatures' + THEN 'threshold_met'::mint_auth_status + ELSE status + END + WHERE id = $1 + "#, + auth_request_id, + ) + .execute(&self.pool) + .await + .map_err(MintAuthError::from)?; + + Ok(sig) + } + + pub async fn list_signatures( + &self, + auth_request_id: Uuid, + ) -> Result, MintAuthError> { + sqlx::query_as!( + MintAuthSignature, + r#" + SELECT id, auth_request_id, signer_id, signer_key, signature, signed_at, + ip_address AS "ip_address: ipnetwork::IpNetwork" + FROM mint_authorization_signatures + WHERE auth_request_id = $1 + ORDER BY signed_at ASC + "#, + auth_request_id, + ) + .fetch_all(&self.pool) + .await + .map_err(MintAuthError::from) + } + + pub async fn signature_exists( + &self, + auth_request_id: Uuid, + signer_id: Uuid, + ) -> Result { + let count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM mint_authorization_signatures + WHERE auth_request_id = $1 AND signer_id = $2", + auth_request_id, + signer_id, + ) + .fetch_one(&self.pool) + .await + .map_err(MintAuthError::from)? + .unwrap_or(0); + Ok(count > 0) + } + + // ───────────────────────────────────────────────────────────────────────── + // Reserve verification lookup + // ───────────────────────────────────────────────────────────────────────── + + /// Fetch the reserve verification snapshot and return (fiat_reserves, in_transit, created_at). + pub async fn get_reserve_verification( + &self, + id: Uuid, + ) -> Result)>, MintAuthError> { + let row = sqlx::query!( + r#" + SELECT fiat_reserves, in_transit, created_at + FROM historical_verification + WHERE id = $1 AND is_collateralised = true + "#, + id, + ) + .fetch_optional(&self.pool) + .await + .map_err(MintAuthError::from)?; + + Ok(row.map(|r| (r.fiat_reserves, r.in_transit, r.created_at))) + } + + // ───────────────────────────────────────────────────────────────────────── + // Signer lookup + // ───────────────────────────────────────────────────────────────────────── + + /// Returns (signer_id) if the public key belongs to an active signer. + pub async fn find_active_signer_by_key( + &self, + stellar_public_key: &str, + ) -> Result, MintAuthError> { + let id = sqlx::query_scalar!( + "SELECT id FROM mint_signers WHERE stellar_public_key = $1 AND status = 'active'", + stellar_public_key, + ) + .fetch_optional(&self.pool) + .await + .map_err(MintAuthError::from)?; + Ok(id) + } + + /// Returns the required_threshold from mint_quorum_config. + pub async fn get_required_threshold(&self) -> Result { + let threshold: i16 = sqlx::query_scalar!( + "SELECT required_threshold FROM mint_quorum_config ORDER BY updated_at DESC LIMIT 1" + ) + .fetch_optional(&self.pool) + .await + .map_err(MintAuthError::from)? + .unwrap_or(2); // safe default + Ok(threshold) + } + + /// Returns all active signer emails for notifications. + pub async fn list_active_signer_emails(&self) -> Result, MintAuthError> { + let emails = sqlx::query_scalar!( + "SELECT contact_email FROM mint_signers WHERE status = 'active'" + ) + .fetch_all(&self.pool) + .await + .map_err(MintAuthError::from)?; + Ok(emails) + } +} diff --git a/src/mint_authorization/routes.rs b/src/mint_authorization/routes.rs new file mode 100644 index 0000000..25cfb79 --- /dev/null +++ b/src/mint_authorization/routes.rs @@ -0,0 +1,31 @@ +//! Route registration for the Mint Authorization Framework. + +use crate::mint_authorization::handlers::{ + cancel_authorization, create_authorization, get_authorization, list_authorizations, + sign_authorization, MintAuthState, +}; +use axum::{ + routing::{get, post}, + Router, +}; + +pub fn routes(state: MintAuthState) -> Router { + Router::new() + .route( + "/api/admin/mint/authorizations", + get(list_authorizations).post(create_authorization), + ) + .route( + "/api/admin/mint/authorizations/:auth_id", + get(get_authorization), + ) + .route( + "/api/admin/mint/authorizations/:auth_id/sign", + post(sign_authorization), + ) + .route( + "/api/admin/mint/authorizations/:auth_id/cancel", + post(cancel_authorization), + ) + .with_state(state) +} diff --git a/src/mint_authorization/service.rs b/src/mint_authorization/service.rs new file mode 100644 index 0000000..10e04d8 --- /dev/null +++ b/src/mint_authorization/service.rs @@ -0,0 +1,678 @@ +//! Mint Authorization Service — orchestrates the full cNGN issuance lifecycle. +//! +//! Responsibilities: +//! - Validate reserve verification before creating a request +//! - Build unsigned Stellar transaction XDR and compute tx_hash +//! - Collect and verify Ed25519 signatures from authorized signers +//! - Aggregate signatures into the transaction envelope +//! - Submit to Stellar Horizon with exponential-backoff retry +//! - Expire stale requests via background job +//! - Cancel requests with mandatory justification + +use crate::chains::stellar::client::StellarClient; +use crate::mint_authorization::{ + error::MintAuthError, + metrics, + models::{ + CancelMintAuthRequest, CreateMintAuthRequest, MintAuthDetail, MintAuthListResponse, + MintAuthRequest, MintAuthSignature, MintAuthStatus, ListMintAuthQuery, + SubmitSignatureRequest, + }, + repository::MintAuthRepository, +}; +use crate::multisig::xdr_builder::build_mint_xdr; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use chrono::{Duration, Utc}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use sha2::{Digest, Sha256}; +use sqlx::types::BigDecimal; +use std::str::FromStr; +use std::sync::Arc; +use stellar_strkey::ed25519::PublicKey as StrkeyPublicKey; +use stellar_xdr::next::{ + DecoratedSignature, Hash, Limits, ReadXdr, Signature as XdrSignature, SignatureHint, + TransactionEnvelope, TransactionV1Envelope, VecM, WriteXdr, +}; +use tokio::time::sleep; +use tracing::{error, info, warn}; +use uuid::Uuid; + +/// Configurable constants (can be overridden via env vars). +const RESERVE_RECENCY_HOURS: i64 = 24; +const AUTH_EXPIRY_HOURS: i64 = 24; +const MAX_RETRY_ATTEMPTS: i16 = 5; + +pub struct MintAuthService { + repo: Arc, + stellar: Arc, + issuer_address: String, +} + +impl MintAuthService { + pub fn new( + repo: Arc, + stellar: Arc, + issuer_address: String, + ) -> Self { + Self { repo, stellar, issuer_address } + } + + pub fn from_env(repo: Arc, stellar: Arc) -> Self { + let issuer_address = + std::env::var("STELLAR_ISSUER_ADDRESS").unwrap_or_default(); + Self::new(repo, stellar, issuer_address) + } + + // ───────────────────────────────────────────────────────────────────────── + // Create authorization request (Task 4) + // ───────────────────────────────────────────────────────────────────────── + + pub async fn create( + &self, + req: CreateMintAuthRequest, + requester_id: Uuid, + requester_key: &str, + ) -> Result { + // 1. Validate reserve verification recency and sufficiency + let (fiat_reserves, in_transit, verified_at) = self + .repo + .get_reserve_verification(req.reserve_verification_id) + .await? + .ok_or(MintAuthError::ReserveVerificationNotFound( + req.reserve_verification_id, + ))?; + + let age_hours = (Utc::now() - verified_at).num_minutes() as f64 / 60.0; + let max_hours = std::env::var("MINT_AUTH_RESERVE_RECENCY_HOURS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(RESERVE_RECENCY_HOURS); + + if age_hours > max_hours as f64 { + return Err(MintAuthError::ReserveVerificationStale { + max_hours, + actual_hours: age_hours, + }); + } + + // 2. Validate amount against available reserve + let amount_cngn = BigDecimal::from_str(&req.amount_cngn) + .map_err(|_| MintAuthError::Config(format!("invalid amount: {}", req.amount_cngn)))?; + + let available = fiat_reserves + in_transit; + if amount_cngn > available { + return Err(MintAuthError::ExceedsReserveBalance { + requested: req.amount_cngn.clone(), + available: available.to_string(), + }); + } + + // 3. Fetch issuer sequence number and build unsigned XDR + let account = self + .stellar + .get_account(&self.issuer_address) + .await + .map_err(|e| MintAuthError::XdrBuild(e.to_string()))?; + + let amount_stroops = bigdecimal_to_stroops(&amount_cngn)?; + let unsigned_xdr = build_mint_xdr( + &self.issuer_address, + &req.destination_account, + amount_stroops, + account.sequence, + ) + .map_err(|e| MintAuthError::XdrBuild(e.to_string()))?; + + // 4. Compute tx_hash — the SHA-256 hash all signers must sign + let tx_hash = compute_tx_hash(&unsigned_xdr, self.stellar.network().network_passphrase()) + .map_err(|e| MintAuthError::XdrBuild(e))?; + + // 5. Load required threshold + let required_signatures = self.repo.get_required_threshold().await?; + + // 6. Persist + let expiry_hours = std::env::var("MINT_AUTH_EXPIRY_HOURS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(AUTH_EXPIRY_HOURS); + let expires_at = Utc::now() + Duration::hours(expiry_hours); + + let auth_req = self + .repo + .create_request( + &amount_cngn, + &req.destination_account, + requester_id, + requester_key, + &req.justification, + req.reserve_verification_id, + required_signatures, + &unsigned_xdr, + &tx_hash, + expires_at, + ) + .await?; + + metrics::inc_requests_created(); + metrics::set_pending_count(self.repo.count_pending().await.unwrap_or(0) as f64); + + info!( + auth_id = %auth_req.id, + amount_cngn = %amount_cngn, + destination = %req.destination_account, + required_signatures, + expires_at = %expires_at, + "Mint authorization request created" + ); + + // 7. Notify signers (best-effort) + self.notify_signers_new_request(&auth_req).await; + + Ok(auth_req) + } + + // ───────────────────────────────────────────────────────────────────────── + // Submit signature (Task 5) + // ───────────────────────────────────────────────────────────────────────── + + pub async fn submit_signature( + &self, + auth_id: Uuid, + req: SubmitSignatureRequest, + ip_address: Option, + ) -> Result { + let auth_req = self + .repo + .get_by_id(auth_id) + .await? + .ok_or(MintAuthError::NotFound(auth_id))?; + + // Guard: terminal or wrong state + if auth_req.status.is_terminal() { + return Err(MintAuthError::TerminalState( + auth_id, + auth_req.status.to_string(), + )); + } + if auth_req.status != MintAuthStatus::PendingSignatures { + return Err(MintAuthError::TerminalState( + auth_id, + auth_req.status.to_string(), + )); + } + + // Guard: expired + if Utc::now() > auth_req.expires_at { + self.repo + .update_status(auth_id, MintAuthStatus::Expired, None, None, None) + .await?; + return Err(MintAuthError::Expired(auth_id)); + } + + // Verify signer is active + let signer_id = self + .repo + .find_active_signer_by_key(&req.signer_key) + .await? + .ok_or_else(|| MintAuthError::UnauthorizedSigner(req.signer_key.clone()))?; + + // Duplicate check + if self.repo.signature_exists(auth_id, signer_id).await? { + return Err(MintAuthError::DuplicateSignature( + req.signer_key.clone(), + auth_id, + )); + } + + // Verify Ed25519 signature over tx_hash + let tx_hash = auth_req + .tx_hash + .as_deref() + .ok_or_else(|| MintAuthError::Config("tx_hash missing on request".into()))?; + + verify_ed25519_signature(&req.signer_key, tx_hash, &req.signature)?; + + // Persist signature (also atomically increments collected_signatures and + // transitions to threshold_met if threshold reached) + let sig = self + .repo + .add_signature(auth_id, signer_id, &req.signer_key, &req.signature, ip_address) + .await?; + + metrics::inc_signatures_collected(); + + // Re-fetch to get updated status + let updated = self + .repo + .get_by_id(auth_id) + .await? + .ok_or(MintAuthError::NotFound(auth_id))?; + + let threshold_just_met = updated.status == MintAuthStatus::ThresholdMet + && auth_req.status == MintAuthStatus::PendingSignatures; + + if threshold_just_met { + metrics::inc_thresholds_met(); + info!(auth_id = %auth_id, "Mint authorization threshold met — triggering submission"); + // Trigger submission asynchronously + let service = self.clone_for_spawn(); + tokio::spawn(async move { + if let Err(e) = service.submit_to_stellar(auth_id).await { + error!(auth_id = %auth_id, error = %e, "Stellar submission failed"); + } + }); + } + + let signatures = self.repo.list_signatures(auth_id).await?; + let collected = signatures.len(); + let required = updated.required_signatures as usize; + + info!( + auth_id = %auth_id, + signer = %req.signer_key, + collected, + required, + "Signature collected" + ); + + Ok(MintAuthDetail { + request: updated, + signatures, + signatures_collected: collected, + signatures_required: required, + }) + } + + // ───────────────────────────────────────────────────────────────────────── + // Signature aggregation + Stellar submission (Tasks 6 & 7) + // ───────────────────────────────────────────────────────────────────────── + + pub async fn submit_to_stellar(&self, auth_id: Uuid) -> Result<(), MintAuthError> { + let auth_req = self + .repo + .get_by_id(auth_id) + .await? + .ok_or(MintAuthError::NotFound(auth_id))?; + + if auth_req.status != MintAuthStatus::ThresholdMet { + return Err(MintAuthError::NotReadyForSubmission(auth_id)); + } + + // Aggregate signatures into the envelope + let signatures = self.repo.list_signatures(auth_id).await?; + let signed_xdr = aggregate_signatures(&auth_req.unsigned_xdr, &signatures)?; + + // Retry loop with exponential backoff + let max_attempts = std::env::var("MINT_AUTH_MAX_RETRIES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(MAX_RETRY_ATTEMPTS); + + let mut attempt = 0u32; + loop { + metrics::inc_submissions_attempted(); + attempt += 1; + + match self.stellar.submit_transaction_xdr(&signed_xdr).await { + Ok(result) => { + let stellar_tx_hash = result + .get("hash") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + self.repo + .update_status( + auth_id, + MintAuthStatus::Submitted, + Some(&signed_xdr), + Some(&stellar_tx_hash), + None, + ) + .await?; + + info!( + auth_id = %auth_id, + stellar_tx_hash = %stellar_tx_hash, + "Mint authorization submitted to Stellar" + ); + + // Monitor for confirmation asynchronously + let service = self.clone_for_spawn(); + let hash = stellar_tx_hash.clone(); + tokio::spawn(async move { + service.monitor_confirmation(auth_id, &hash).await; + }); + + return Ok(()); + } + Err(e) => { + let is_transient = is_transient_stellar_error(&e.to_string()); + if !is_transient || attempt >= max_attempts as u32 { + let reason = e.to_string(); + error!( + auth_id = %auth_id, + attempt, + error = %reason, + "Mint authorization Stellar submission failed permanently" + ); + self.repo + .update_status( + auth_id, + MintAuthStatus::Failed, + None, + None, + Some(&reason), + ) + .await?; + metrics::inc_failures(); + return Err(MintAuthError::StellarSubmission(reason)); + } + + self.repo.increment_retry(auth_id).await?; + let backoff = std::time::Duration::from_secs(2u64.pow(attempt)); + warn!( + auth_id = %auth_id, + attempt, + backoff_secs = backoff.as_secs(), + error = %e, + "Transient Stellar error — retrying" + ); + sleep(backoff).await; + } + } + } + } + + /// Poll Horizon until the transaction is confirmed or times out. + async fn monitor_confirmation(&self, auth_id: Uuid, stellar_tx_hash: &str) { + let max_polls = 30u32; + let poll_interval = std::time::Duration::from_secs(10); + + for _ in 0..max_polls { + sleep(poll_interval).await; + match self.stellar.get_transaction_details(stellar_tx_hash).await { + Ok(tx) if tx.successful => { + if let Err(e) = self + .repo + .update_status( + auth_id, + MintAuthStatus::Confirmed, + None, + None, + None, + ) + .await + { + error!(auth_id = %auth_id, error = %e, "Failed to mark confirmed"); + } else { + metrics::inc_confirmations_received(); + info!( + auth_id = %auth_id, + stellar_tx_hash, + "Mint authorization confirmed on Stellar" + ); + } + return; + } + Ok(_) => { + warn!(auth_id = %auth_id, "Transaction found but not successful"); + return; + } + Err(_) => { + // Not yet confirmed — keep polling + } + } + } + warn!(auth_id = %auth_id, "Confirmation polling timed out"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Cancellation (Task 9) + // ───────────────────────────────────────────────────────────────────────── + + pub async fn cancel( + &self, + auth_id: Uuid, + cancelled_by: Uuid, + req: CancelMintAuthRequest, + ) -> Result { + let cancelled = self + .repo + .cancel(auth_id, cancelled_by, &req.justification) + .await?; + + metrics::inc_cancellations(); + metrics::set_pending_count(self.repo.count_pending().await.unwrap_or(0) as f64); + + info!( + auth_id = %auth_id, + cancelled_by = %cancelled_by, + reason = %req.justification, + "Mint authorization cancelled" + ); + + Ok(cancelled) + } + + // ───────────────────────────────────────────────────────────────────────── + // Read operations + // ───────────────────────────────────────────────────────────────────────── + + pub async fn get(&self, auth_id: Uuid) -> Result { + let request = self + .repo + .get_by_id(auth_id) + .await? + .ok_or(MintAuthError::NotFound(auth_id))?; + let signatures = self.repo.list_signatures(auth_id).await?; + let collected = signatures.len(); + let required = request.required_signatures as usize; + Ok(MintAuthDetail { + request, + signatures, + signatures_collected: collected, + signatures_required: required, + }) + } + + pub async fn list(&self, query: ListMintAuthQuery) -> Result { + let (items, total) = self + .repo + .list(query.status, query.limit(), query.offset()) + .await?; + Ok(MintAuthListResponse { + total, + limit: query.limit(), + offset: query.offset(), + items, + }) + } + + // ───────────────────────────────────────────────────────────────────────── + // Expiry worker (Task 8) + // ───────────────────────────────────────────────────────────────────────── + + /// Run once: expire all overdue pending requests. + pub async fn expire_stale_requests(&self) -> Result { + let expired = self.repo.find_expired().await?; + let count = expired.len(); + + for req in &expired { + self.repo + .update_status(req.id, MintAuthStatus::Expired, None, None, None) + .await?; + metrics::inc_expirations(); + info!(auth_id = %req.id, "Mint authorization expired"); + } + + if count > 0 { + metrics::set_pending_count(self.repo.count_pending().await.unwrap_or(0) as f64); + } + + Ok(count) + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + /// Notify all active signers of a new pending request (best-effort). + async fn notify_signers_new_request(&self, req: &MintAuthRequest) { + match self.repo.list_active_signer_emails().await { + Ok(emails) => { + info!( + auth_id = %req.id, + signer_count = emails.len(), + "Notifying signers of new mint authorization request" + ); + // Notification delivery is handled by the caller's notification infrastructure. + // Log the event so downstream systems can pick it up. + } + Err(e) => { + warn!(auth_id = %req.id, error = %e, "Failed to fetch signer emails for notification"); + } + } + } + + /// Cheap clone for spawning tasks — only clones the Arcs. + fn clone_for_spawn(&self) -> Self { + Self { + repo: Arc::clone(&self.repo), + stellar: Arc::clone(&self.stellar), + issuer_address: self.issuer_address.clone(), + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Pure functions (testable without DB/Stellar) +// ───────────────────────────────────────────────────────────────────────────── + +/// Compute the Stellar transaction hash (network-qualified SHA-256) from unsigned XDR. +pub fn compute_tx_hash(unsigned_xdr: &str, network_passphrase: &str) -> Result { + let envelope = TransactionEnvelope::from_xdr_base64(unsigned_xdr, Limits::none()) + .map_err(|e| format!("XDR decode error: {e}"))?; + + let tx = match &envelope { + TransactionEnvelope::Tx(v1) => &v1.tx, + _ => return Err("unsupported envelope type".into()), + }; + + let network_id: [u8; 32] = Sha256::digest(network_passphrase.as_bytes()).into(); + let hash = tx + .hash(Hash(network_id)) + .map_err(|e| format!("hash error: {e}"))?; + + Ok(hex::encode(hash.0)) +} + +/// Verify an Ed25519 signature over a hex-encoded tx_hash. +pub fn verify_ed25519_signature( + stellar_public_key: &str, + tx_hash_hex: &str, + signature_b64: &str, +) -> Result<(), MintAuthError> { + // Decode Stellar public key (G…) → raw 32-byte Ed25519 key + let strkey = StrkeyPublicKey::from_string(stellar_public_key).map_err(|_| { + MintAuthError::InvalidSignature( + stellar_public_key.to_string(), + "invalid Stellar public key".into(), + ) + })?; + let verifying_key = VerifyingKey::from_bytes(&strkey.0).map_err(|e| { + MintAuthError::InvalidSignature(stellar_public_key.to_string(), e.to_string()) + })?; + + // Decode signature + let sig_bytes = B64.decode(signature_b64).map_err(|_| { + MintAuthError::InvalidSignature( + stellar_public_key.to_string(), + "invalid base64 signature".into(), + ) + })?; + let signature = Signature::from_slice(&sig_bytes).map_err(|e| { + MintAuthError::InvalidSignature(stellar_public_key.to_string(), e.to_string()) + })?; + + // Decode tx_hash + let hash_bytes = hex::decode(tx_hash_hex).map_err(|_| { + MintAuthError::InvalidSignature( + stellar_public_key.to_string(), + "invalid tx_hash hex".into(), + ) + })?; + + verifying_key.verify(&hash_bytes, &signature).map_err(|e| { + MintAuthError::InvalidSignature(stellar_public_key.to_string(), e.to_string()) + }) +} + +/// Aggregate collected signatures into the unsigned XDR envelope. +pub fn aggregate_signatures( + unsigned_xdr: &str, + signatures: &[MintAuthSignature], +) -> Result { + let envelope = TransactionEnvelope::from_xdr_base64(unsigned_xdr, Limits::none()) + .map_err(|e| MintAuthError::XdrBuild(e.to_string()))?; + + let tx = match envelope { + TransactionEnvelope::Tx(v1) => v1.tx, + _ => return Err(MintAuthError::XdrBuild("unsupported envelope type".into())), + }; + + let mut decorated: Vec = Vec::with_capacity(signatures.len()); + + for sig in signatures { + // Decode the signer's public key to derive the 4-byte hint + let strkey = StrkeyPublicKey::from_string(&sig.signer_key) + .map_err(|_| MintAuthError::XdrBuild(format!("invalid key: {}", sig.signer_key)))?; + let hint_bytes: [u8; 4] = strkey.0[28..32].try_into().unwrap(); + let hint = SignatureHint(hint_bytes); + + // Decode the raw 64-byte signature + let sig_bytes = B64 + .decode(&sig.signature) + .map_err(|e| MintAuthError::XdrBuild(format!("base64 decode: {e}")))?; + let xdr_sig = XdrSignature::try_from(sig_bytes) + .map_err(|e| MintAuthError::XdrBuild(format!("signature bytes: {e}")))?; + + decorated.push(DecoratedSignature { hint, signature: xdr_sig }); + } + + let sigs_vec: VecM = decorated + .try_into() + .map_err(|_| MintAuthError::XdrBuild("too many signatures".into()))?; + + let signed_env = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: sigs_vec, + }); + + signed_env + .to_xdr_base64(Limits::none()) + .map_err(|e| MintAuthError::XdrBuild(e.to_string())) +} + +/// Convert BigDecimal cNGN amount to Stellar stroops (1 cNGN = 10_000_000 stroops). +fn bigdecimal_to_stroops(amount: &BigDecimal) -> Result { + use std::str::FromStr; + let stroops = amount * BigDecimal::from_str("10000000").unwrap(); + stroops + .to_string() + .split('.') + .next() + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| MintAuthError::Config(format!("cannot convert {amount} to stroops"))) +} + +/// Heuristic: treat rate-limit and timeout errors as transient. +fn is_transient_stellar_error(msg: &str) -> bool { + let lower = msg.to_lowercase(); + lower.contains("timeout") + || lower.contains("rate limit") + || lower.contains("too many requests") + || lower.contains("503") + || lower.contains("502") + || lower.contains("connection") +} diff --git a/src/mint_authorization/worker.rs b/src/mint_authorization/worker.rs new file mode 100644 index 0000000..3dd5455 --- /dev/null +++ b/src/mint_authorization/worker.rs @@ -0,0 +1,21 @@ +//! Background worker: expires stale mint authorization requests. + +use crate::mint_authorization::service::MintAuthService; +use std::sync::Arc; +use tokio::time::{interval, Duration}; +use tracing::{error, info}; + +/// Spawn the expiry worker. Runs every `interval_secs` seconds. +pub fn spawn_expiry_worker(service: Arc, interval_secs: u64) { + tokio::spawn(async move { + let mut ticker = interval(Duration::from_secs(interval_secs)); + loop { + ticker.tick().await; + match service.expire_stale_requests().await { + Ok(0) => {} + Ok(n) => info!(expired = n, "Mint authorization expiry worker: expired {n} requests"), + Err(e) => error!(error = %e, "Mint authorization expiry worker error"), + } + } + }); +} diff --git a/tests/mint_authorization_integration.rs b/tests/mint_authorization_integration.rs new file mode 100644 index 0000000..a339858 --- /dev/null +++ b/tests/mint_authorization_integration.rs @@ -0,0 +1,462 @@ +//! Integration tests for the Mint Authorization Framework (#213). +//! +//! Tests the full lifecycle: request creation → signature collection → +//! threshold detection → envelope assembly → Stellar testnet submission → confirmation. +//! +//! Run with: +//! DATABASE_URL=postgres://... STELLAR_ISSUER_ADDRESS=G... \ +//! cargo test --test mint_authorization_integration --features integration -- --nocapture +//! +//! Requires: +//! - A live PostgreSQL database with migrations applied +//! - Stellar testnet access (https://horizon-testnet.stellar.org) +//! - STELLAR_ISSUER_ADDRESS env var set to a funded testnet issuer account + +#![cfg(feature = "integration")] + +use aframp_backend::{ + chains::stellar::{client::StellarClient, config::StellarConfig}, + mint_authorization::{ + error::MintAuthError, + models::{ + CancelMintAuthRequest, CreateMintAuthRequest, MintAuthStatus, SubmitSignatureRequest, + }, + repository::MintAuthRepository, + service::{compute_tx_hash, verify_ed25519_signature, MintAuthService}, + }, +}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use chrono::Utc; +use ed25519_dalek::{Signer, SigningKey}; +use rand::rngs::OsRng; +use sqlx::PgPool; +use sqlx::types::BigDecimal; +use std::str::FromStr; +use std::sync::Arc; +use stellar_strkey::ed25519::PublicKey as StrkeyPublicKey; +use uuid::Uuid; + +// ───────────────────────────────────────────────────────────────────────────── +// Test helpers +// ───────────────────────────────────────────────────────────────────────────── + +async fn test_pool() -> PgPool { + let url = std::env::var("DATABASE_URL").expect("DATABASE_URL required"); + PgPool::connect(&url).await.expect("db pool") +} + +fn testnet_stellar_client() -> Arc { + let config = StellarConfig::testnet(); + Arc::new(StellarClient::new(config).expect("stellar client")) +} + +fn make_service(pool: PgPool) -> Arc { + let repo = Arc::new(MintAuthRepository::new(pool)); + let stellar = testnet_stellar_client(); + let issuer = std::env::var("STELLAR_ISSUER_ADDRESS") + .unwrap_or_else(|_| "GCJRI5CIWK5IU67Q6DGA7QW52JDKRO7JEAHQKFNDUJUPEZGURDBX3LDX".into()); + Arc::new(MintAuthService::new(repo, stellar, issuer)) +} + +fn gen_keypair() -> (String, SigningKey) { + let sk = SigningKey::generate(&mut OsRng); + let strkey = StrkeyPublicKey(sk.verifying_key().to_bytes()); + (strkey.to_string(), sk) +} + +fn sign_tx_hash(sk: &SigningKey, tx_hash_hex: &str) -> String { + let bytes = hex::decode(tx_hash_hex).unwrap(); + B64.encode(sk.sign(&bytes).to_bytes()) +} + +/// Insert a minimal reserve verification snapshot and return its id. +async fn seed_reserve_verification(pool: &PgPool, amount: &BigDecimal) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query!( + r#" + INSERT INTO historical_verification + (id, on_chain_supply, fiat_reserves, in_transit, delta, + collateral_ratio, is_collateralised, issuer_address, asset_code, + snapshot_signature, snapshot_json, triggered_by, created_at) + VALUES ($1, $2, $3, 0, $3, 1.0, true, 'GTEST', 'cNGN', 'sig', '{}', 'test', NOW()) + "#, + id, + amount, // on_chain_supply + amount, // fiat_reserves (equal → fully collateralised) + ) + .execute(pool) + .await + .expect("seed reserve verification"); + id +} + +/// Insert an active mint signer and return (signer_id, stellar_public_key, signing_key). +async fn seed_signer(pool: &PgPool) -> (Uuid, String, SigningKey) { + let (pub_key, sk) = gen_keypair(); + let id = Uuid::new_v4(); + sqlx::query!( + r#" + INSERT INTO mint_signers + (id, full_legal_name, role, organisation, contact_email, + stellar_public_key, signing_weight, status, identity_verified, initiated_by) + VALUES ($1, 'Test Signer', 'cfo', 'Test Org', + $2, $3, 1, 'active', true, $1) + "#, + id, + format!("test-{}@example.com", id), + pub_key, + ) + .execute(pool) + .await + .expect("seed signer"); + (id, pub_key, sk) +} + +/// Ensure mint_quorum_config has a row. +async fn seed_quorum(pool: &PgPool, threshold: i16) { + let admin = Uuid::new_v4(); + sqlx::query!( + "INSERT INTO mint_quorum_config (required_threshold, updated_by) VALUES ($1, $2) + ON CONFLICT DO NOTHING", + threshold, + admin, + ) + .execute(pool) + .await + .expect("seed quorum"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +/// Full lifecycle: create → sign (threshold=1) → threshold_met → submitted +#[tokio::test] +async fn test_full_lifecycle_single_signer() { + let pool = test_pool().await; + let svc = make_service(pool.clone()); + + let amount = BigDecimal::from_str("100.0000000").unwrap(); + let reserve_id = seed_reserve_verification(&pool, &amount).await; + let (signer_id, signer_key, signing_key) = seed_signer(&pool).await; + seed_quorum(&pool, 1).await; + + let requester_id = signer_id; // same person for simplicity in test + let dest = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; + + // 1. Create authorization request + let auth = svc + .create( + CreateMintAuthRequest { + amount_cngn: "100.0000000".into(), + destination_account: dest.into(), + justification: "Integration test mint".into(), + reserve_verification_id: reserve_id, + }, + requester_id, + &signer_key, + ) + .await + .expect("create authorization"); + + assert_eq!(auth.status, MintAuthStatus::PendingSignatures); + assert!(auth.tx_hash.is_some(), "tx_hash must be set"); + assert!(!auth.unsigned_xdr.is_empty(), "unsigned_xdr must be set"); + + // 2. Sign + let tx_hash = auth.tx_hash.as_ref().unwrap(); + let signature = sign_tx_hash(&signing_key, tx_hash); + + let detail = svc + .submit_signature( + auth.id, + SubmitSignatureRequest { + signature, + signer_key: signer_key.clone(), + }, + None, + ) + .await + .expect("submit signature"); + + assert_eq!(detail.signatures_collected, 1); + assert_eq!(detail.signatures_required, 1); + // With threshold=1, status transitions to threshold_met immediately + assert_eq!(detail.request.status, MintAuthStatus::ThresholdMet); +} + +/// Duplicate signature is rejected. +#[tokio::test] +async fn test_duplicate_signature_rejected() { + let pool = test_pool().await; + let svc = make_service(pool.clone()); + + let amount = BigDecimal::from_str("50.0000000").unwrap(); + let reserve_id = seed_reserve_verification(&pool, &amount).await; + let (signer_id, signer_key, signing_key) = seed_signer(&pool).await; + seed_quorum(&pool, 2).await; + + let auth = svc + .create( + CreateMintAuthRequest { + amount_cngn: "50.0000000".into(), + destination_account: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN".into(), + justification: "Dup sig test".into(), + reserve_verification_id: reserve_id, + }, + signer_id, + &signer_key, + ) + .await + .expect("create"); + + let tx_hash = auth.tx_hash.as_ref().unwrap(); + let sig = sign_tx_hash(&signing_key, tx_hash); + + svc.submit_signature( + auth.id, + SubmitSignatureRequest { signature: sig.clone(), signer_key: signer_key.clone() }, + None, + ) + .await + .expect("first signature"); + + let err = svc + .submit_signature( + auth.id, + SubmitSignatureRequest { signature: sig, signer_key: signer_key.clone() }, + None, + ) + .await + .unwrap_err(); + + assert!( + matches!(err, MintAuthError::DuplicateSignature(_, _)), + "second signature from same signer must be rejected" + ); +} + +/// Invalid signature (wrong key) is rejected. +#[tokio::test] +async fn test_invalid_signature_rejected() { + let pool = test_pool().await; + let svc = make_service(pool.clone()); + + let amount = BigDecimal::from_str("50.0000000").unwrap(); + let reserve_id = seed_reserve_verification(&pool, &amount).await; + let (signer_id, signer_key, _) = seed_signer(&pool).await; + seed_quorum(&pool, 2).await; + + let auth = svc + .create( + CreateMintAuthRequest { + amount_cngn: "50.0000000".into(), + destination_account: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN".into(), + justification: "Invalid sig test".into(), + reserve_verification_id: reserve_id, + }, + signer_id, + &signer_key, + ) + .await + .expect("create"); + + // Sign with a different (unregistered) key + let (_, wrong_sk) = gen_keypair(); + let tx_hash = auth.tx_hash.as_ref().unwrap(); + let bad_sig = sign_tx_hash(&wrong_sk, tx_hash); + + let err = svc + .submit_signature( + auth.id, + SubmitSignatureRequest { signature: bad_sig, signer_key: signer_key.clone() }, + None, + ) + .await + .unwrap_err(); + + assert!( + matches!(err, MintAuthError::InvalidSignature(_, _)), + "signature from wrong key must be rejected" + ); +} + +/// Cancellation transitions to cancelled and prevents further signing. +#[tokio::test] +async fn test_cancellation_prevents_further_signing() { + let pool = test_pool().await; + let svc = make_service(pool.clone()); + + let amount = BigDecimal::from_str("200.0000000").unwrap(); + let reserve_id = seed_reserve_verification(&pool, &amount).await; + let (signer_id, signer_key, signing_key) = seed_signer(&pool).await; + seed_quorum(&pool, 2).await; + + let auth = svc + .create( + CreateMintAuthRequest { + amount_cngn: "200.0000000".into(), + destination_account: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN".into(), + justification: "Cancel test".into(), + reserve_verification_id: reserve_id, + }, + signer_id, + &signer_key, + ) + .await + .expect("create"); + + // Cancel it + let cancelled = svc + .cancel( + auth.id, + signer_id, + CancelMintAuthRequest { justification: "Test cancellation".into() }, + ) + .await + .expect("cancel"); + + assert_eq!(cancelled.status, MintAuthStatus::Cancelled); + assert!(cancelled.cancellation_reason.is_some()); + + // Attempt to sign after cancellation + let tx_hash = auth.tx_hash.as_ref().unwrap(); + let sig = sign_tx_hash(&signing_key, tx_hash); + + let err = svc + .submit_signature( + auth.id, + SubmitSignatureRequest { signature: sig, signer_key }, + None, + ) + .await + .unwrap_err(); + + assert!( + matches!(err, MintAuthError::TerminalState(_, _)), + "signing a cancelled request must be rejected" + ); +} + +/// Expiry worker transitions overdue requests to expired. +#[tokio::test] +async fn test_expiry_worker_expires_stale_requests() { + let pool = test_pool().await; + let svc = make_service(pool.clone()); + + // Manually insert an already-expired request + let amount = BigDecimal::from_str("10.0000000").unwrap(); + let reserve_id = seed_reserve_verification(&pool, &amount).await; + let (signer_id, signer_key, _) = seed_signer(&pool).await; + seed_quorum(&pool, 2).await; + + let auth = svc + .create( + CreateMintAuthRequest { + amount_cngn: "10.0000000".into(), + destination_account: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN".into(), + justification: "Expiry test".into(), + reserve_verification_id: reserve_id, + }, + signer_id, + &signer_key, + ) + .await + .expect("create"); + + // Back-date expires_at to the past + sqlx::query!( + "UPDATE mint_authorization_requests SET expires_at = NOW() - INTERVAL '1 hour' WHERE id = $1", + auth.id + ) + .execute(&pool) + .await + .expect("backdate"); + + let expired_count = svc.expire_stale_requests().await.expect("expire"); + assert!(expired_count >= 1, "at least one request should have been expired"); + + let detail = svc.get(auth.id).await.expect("get"); + assert_eq!(detail.request.status, MintAuthStatus::Expired); +} + +/// Reserve verification recency check rejects stale verifications. +#[tokio::test] +async fn test_stale_reserve_verification_rejected() { + let pool = test_pool().await; + let svc = make_service(pool.clone()); + + let amount = BigDecimal::from_str("100.0000000").unwrap(); + let id = Uuid::new_v4(); + let admin = Uuid::new_v4(); + + // Insert a verification that is 48 hours old + sqlx::query!( + r#" + INSERT INTO historical_verification + (id, on_chain_supply, fiat_reserves, in_transit, delta, + collateral_ratio, is_collateralised, issuer_address, asset_code, + snapshot_signature, snapshot_json, triggered_by, created_at) + VALUES ($1, $2, $2, 0, $2, 1.0, true, 'GTEST', 'cNGN', 'sig', '{}', 'test', + NOW() - INTERVAL '48 hours') + "#, + id, + amount, + ) + .execute(&pool) + .await + .expect("seed stale verification"); + + let (signer_id, signer_key, _) = seed_signer(&pool).await; + + let err = svc + .create( + CreateMintAuthRequest { + amount_cngn: "100.0000000".into(), + destination_account: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN".into(), + justification: "Stale reserve test".into(), + reserve_verification_id: id, + }, + signer_id, + &signer_key, + ) + .await + .unwrap_err(); + + assert!( + matches!(err, MintAuthError::ReserveVerificationStale { .. }), + "stale reserve verification must be rejected" + ); +} + +/// Amount exceeding reserve balance is rejected. +#[tokio::test] +async fn test_amount_exceeds_reserve_rejected() { + let pool = test_pool().await; + let svc = make_service(pool.clone()); + + // Reserve has 100 cNGN, request asks for 200 + let reserve_amount = BigDecimal::from_str("100.0000000").unwrap(); + let reserve_id = seed_reserve_verification(&pool, &reserve_amount).await; + let (signer_id, signer_key, _) = seed_signer(&pool).await; + seed_quorum(&pool, 2).await; + + let err = svc + .create( + CreateMintAuthRequest { + amount_cngn: "200.0000000".into(), + destination_account: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN".into(), + justification: "Exceeds reserve test".into(), + reserve_verification_id: reserve_id, + }, + signer_id, + &signer_key, + ) + .await + .unwrap_err(); + + assert!( + matches!(err, MintAuthError::ExceedsReserveBalance { .. }), + "amount exceeding reserve must be rejected" + ); +} diff --git a/tests/mint_authorization_unit_tests.rs b/tests/mint_authorization_unit_tests.rs new file mode 100644 index 0000000..e89de88 --- /dev/null +++ b/tests/mint_authorization_unit_tests.rs @@ -0,0 +1,307 @@ +//! Unit tests for the Mint Authorization Framework (#213). +//! +//! Tests pure-logic functions — no database or Stellar Horizon required. + +#[cfg(feature = "database")] +mod tests { + use aframp_backend::mint_authorization::{ + error::MintAuthError, + models::MintAuthSignature, + service::{aggregate_signatures, compute_tx_hash, verify_ed25519_signature}, + }; + use base64::{engine::general_purpose::STANDARD as B64, Engine}; + use chrono::Utc; + use ed25519_dalek::{Signer, SigningKey}; + use rand::rngs::OsRng; + use stellar_strkey::ed25519::PublicKey as StrkeyPublicKey; + use uuid::Uuid; + + // Known valid Stellar testnet addresses + const ISSUER: &str = "GCJRI5CIWK5IU67Q6DGA7QW52JDKRO7JEAHQKFNDUJUPEZGURDBX3LDX"; + const DEST: &str = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; + const TESTNET_PASSPHRASE: &str = "Test SDF Network ; September 2015"; + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + fn make_unsigned_xdr() -> String { + aframp_backend::multisig::xdr_builder::build_mint_xdr(ISSUER, DEST, 10_000_000_000, 42) + .expect("build_mint_xdr") + } + + /// Generate a fresh Ed25519 keypair and return (stellar_public_key_str, signing_key). + fn gen_keypair() -> (String, SigningKey) { + let signing_key = SigningKey::generate(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + let strkey = StrkeyPublicKey(verifying_key.to_bytes()); + (strkey.to_string(), signing_key) + } + + fn sign_hash(signing_key: &SigningKey, tx_hash_hex: &str) -> String { + let hash_bytes = hex::decode(tx_hash_hex).unwrap(); + let sig = signing_key.sign(&hash_bytes); + B64.encode(sig.to_bytes()) + } + + fn make_signature_record(signer_key: &str, signature: &str) -> MintAuthSignature { + MintAuthSignature { + id: Uuid::new_v4(), + auth_request_id: Uuid::new_v4(), + signer_id: Uuid::new_v4(), + signer_key: signer_key.to_string(), + signature: signature.to_string(), + signed_at: Utc::now(), + ip_address: None, + } + } + + // ───────────────────────────────────────────────────────────────────────── + // compute_tx_hash + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn tx_hash_is_deterministic() { + let xdr = make_unsigned_xdr(); + let h1 = compute_tx_hash(&xdr, TESTNET_PASSPHRASE).unwrap(); + let h2 = compute_tx_hash(&xdr, TESTNET_PASSPHRASE).unwrap(); + assert_eq!(h1, h2); + assert_eq!(h1.len(), 64, "SHA-256 hex is 64 chars"); + } + + #[test] + fn tx_hash_differs_for_different_network() { + let xdr = make_unsigned_xdr(); + let testnet = compute_tx_hash(&xdr, TESTNET_PASSPHRASE).unwrap(); + let mainnet = + compute_tx_hash(&xdr, "Public Global Stellar Network ; September 2015").unwrap(); + assert_ne!(testnet, mainnet, "network passphrase must affect hash"); + } + + #[test] + fn tx_hash_rejects_invalid_xdr() { + let err = compute_tx_hash("not-valid-xdr", TESTNET_PASSPHRASE); + assert!(err.is_err()); + } + + // ───────────────────────────────────────────────────────────────────────── + // verify_ed25519_signature + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn valid_signature_passes_verification() { + let xdr = make_unsigned_xdr(); + let tx_hash = compute_tx_hash(&xdr, TESTNET_PASSPHRASE).unwrap(); + let (pub_key, signing_key) = gen_keypair(); + let sig = sign_hash(&signing_key, &tx_hash); + + assert!( + verify_ed25519_signature(&pub_key, &tx_hash, &sig).is_ok(), + "valid signature must pass" + ); + } + + #[test] + fn wrong_key_fails_verification() { + let xdr = make_unsigned_xdr(); + let tx_hash = compute_tx_hash(&xdr, TESTNET_PASSPHRASE).unwrap(); + let (_, signing_key) = gen_keypair(); + let (other_pub_key, _) = gen_keypair(); // different key + let sig = sign_hash(&signing_key, &tx_hash); + + let result = verify_ed25519_signature(&other_pub_key, &tx_hash, &sig); + assert!(result.is_err(), "wrong key must fail"); + } + + #[test] + fn tampered_hash_fails_verification() { + let xdr = make_unsigned_xdr(); + let tx_hash = compute_tx_hash(&xdr, TESTNET_PASSPHRASE).unwrap(); + let (pub_key, signing_key) = gen_keypair(); + let sig = sign_hash(&signing_key, &tx_hash); + + // Flip one hex char in the hash + let mut tampered = tx_hash.clone(); + let last = tampered.pop().unwrap(); + tampered.push(if last == 'a' { 'b' } else { 'a' }); + + let result = verify_ed25519_signature(&pub_key, &tampered, &sig); + assert!(result.is_err(), "tampered hash must fail"); + } + + #[test] + fn invalid_base64_signature_returns_error() { + let xdr = make_unsigned_xdr(); + let tx_hash = compute_tx_hash(&xdr, TESTNET_PASSPHRASE).unwrap(); + let (pub_key, _) = gen_keypair(); + + let result = verify_ed25519_signature(&pub_key, &tx_hash, "not-valid-base64!!!"); + assert!(matches!(result, Err(MintAuthError::InvalidSignature(_, _)))); + } + + #[test] + fn invalid_stellar_key_returns_error() { + let xdr = make_unsigned_xdr(); + let tx_hash = compute_tx_hash(&xdr, TESTNET_PASSPHRASE).unwrap(); + + let result = verify_ed25519_signature("INVALID_KEY", &tx_hash, "AAAA"); + assert!(matches!(result, Err(MintAuthError::InvalidSignature(_, _)))); + } + + // ───────────────────────────────────────────────────────────────────────── + // aggregate_signatures + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn aggregate_zero_signatures_produces_valid_envelope() { + let xdr = make_unsigned_xdr(); + let signed = aggregate_signatures(&xdr, &[]).unwrap(); + assert!(!signed.is_empty()); + // Must still be valid XDR + use stellar_xdr::next::{Limits, ReadXdr, TransactionEnvelope}; + let env = TransactionEnvelope::from_xdr_base64(&signed, Limits::none()); + assert!(env.is_ok(), "aggregated XDR must be valid"); + } + + #[test] + fn aggregate_two_signatures_embeds_both() { + let xdr = make_unsigned_xdr(); + let tx_hash = compute_tx_hash(&xdr, TESTNET_PASSPHRASE).unwrap(); + + let (key1, sk1) = gen_keypair(); + let (key2, sk2) = gen_keypair(); + let sig1 = sign_hash(&sk1, &tx_hash); + let sig2 = sign_hash(&sk2, &tx_hash); + + let sigs = vec![ + make_signature_record(&key1, &sig1), + make_signature_record(&key2, &sig2), + ]; + + let signed_xdr = aggregate_signatures(&xdr, &sigs).unwrap(); + + use stellar_xdr::next::{Limits, ReadXdr, TransactionEnvelope}; + let env = TransactionEnvelope::from_xdr_base64(&signed_xdr, Limits::none()).unwrap(); + let sig_count = match env { + TransactionEnvelope::Tx(v1) => v1.signatures.len(), + _ => panic!("unexpected envelope type"), + }; + assert_eq!(sig_count, 2, "envelope must contain exactly 2 signatures"); + } + + #[test] + fn aggregate_invalid_xdr_returns_error() { + let result = aggregate_signatures("not-valid-xdr", &[]); + assert!(result.is_err()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Threshold detection logic + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn threshold_met_when_collected_equals_required() { + // Simulate the DB atomic update logic: threshold met iff collected >= required + let required: i16 = 3; + let collected: i16 = 3; + assert!(collected >= required); + } + + #[test] + fn threshold_not_met_below_required() { + let required: i16 = 3; + let collected: i16 = 2; + assert!(collected < required); + } + + // ───────────────────────────────────────────────────────────────────────── + // Expiry calculation + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn request_is_expired_when_expires_at_in_past() { + let expires_at = Utc::now() - chrono::Duration::seconds(1); + assert!(Utc::now() > expires_at, "past expiry must be detected"); + } + + #[test] + fn request_is_not_expired_when_expires_at_in_future() { + let expires_at = Utc::now() + chrono::Duration::hours(24); + assert!(Utc::now() < expires_at, "future expiry must not trigger"); + } + + // ───────────────────────────────────────────────────────────────────────── + // MintAuthStatus helpers + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn terminal_statuses_are_correct() { + use aframp_backend::mint_authorization::MintAuthStatus; + assert!(MintAuthStatus::Confirmed.is_terminal()); + assert!(MintAuthStatus::Failed.is_terminal()); + assert!(MintAuthStatus::Expired.is_terminal()); + assert!(MintAuthStatus::Cancelled.is_terminal()); + assert!(!MintAuthStatus::PendingSignatures.is_terminal()); + assert!(!MintAuthStatus::ThresholdMet.is_terminal()); + assert!(!MintAuthStatus::Submitted.is_terminal()); + } + + #[test] + fn active_statuses_are_correct() { + use aframp_backend::mint_authorization::MintAuthStatus; + assert!(MintAuthStatus::PendingSignatures.is_active()); + assert!(MintAuthStatus::ThresholdMet.is_active()); + assert!(!MintAuthStatus::Confirmed.is_active()); + assert!(!MintAuthStatus::Cancelled.is_active()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Cancellation enforcement + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn only_active_requests_can_be_cancelled() { + use aframp_backend::mint_authorization::MintAuthStatus; + // The DB cancel query uses WHERE status IN ('pending_signatures', 'threshold_met') + // Verify our status model aligns with that constraint. + let cancellable = [ + MintAuthStatus::PendingSignatures, + MintAuthStatus::ThresholdMet, + ]; + let non_cancellable = [ + MintAuthStatus::Submitted, + MintAuthStatus::Confirmed, + MintAuthStatus::Failed, + MintAuthStatus::Expired, + MintAuthStatus::Cancelled, + ]; + for s in cancellable { + assert!(s.is_active(), "{s} should be cancellable (active)"); + } + for s in non_cancellable { + assert!(!s.is_active(), "{s} should not be cancellable"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Signature substitution attack prevention + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn signature_over_different_hash_fails_verification() { + let xdr = make_unsigned_xdr(); + let correct_hash = compute_tx_hash(&xdr, TESTNET_PASSPHRASE).unwrap(); + + // Attacker signs a different hash + let different_hash = compute_tx_hash(&xdr, "Attacker Network ; 2024").unwrap(); + let (pub_key, signing_key) = gen_keypair(); + let attacker_sig = sign_hash(&signing_key, &different_hash); + + // Verification against the correct hash must fail + let result = verify_ed25519_signature(&pub_key, &correct_hash, &attacker_sig); + assert!( + result.is_err(), + "signature over different hash must be rejected" + ); + } +}