Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions migrations/20270528000000_mint_authorization_framework.sql
Original file line number Diff line number Diff line change
@@ -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();
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
57 changes: 57 additions & 0 deletions src/mint_authorization/error.rs
Original file line number Diff line number Diff line change
@@ -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<sqlx::Error> for MintAuthError {
fn from(e: sqlx::Error) -> Self {
Self::Database(e.to_string())
}
}
145 changes: 145 additions & 0 deletions src/mint_authorization/handlers.rs
Original file line number Diff line number Diff line change
@@ -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<MintAuthService>;

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<MintAuthState>,
req: axum::extract::Request,
) -> Result<impl IntoResponse, AppError> {
let (requester_id, requester_key) = extract_requester(&req)?;
let Json(body): Json<CreateMintAuthRequest> = 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<MintAuthState>,
Query(query): Query<ListMintAuthQuery>,
) -> Result<impl IntoResponse, AppError> {
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<MintAuthState>,
Path(auth_id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
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<MintAuthState>,
Path(auth_id): Path<Uuid>,
req: axum::extract::Request,
) -> Result<impl IntoResponse, AppError> {
let ip = extract_ip(&req);
let Json(body): Json<SubmitSignatureRequest> = 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<MintAuthState>,
Path(auth_id): Path<Uuid>,
req: axum::extract::Request,
) -> Result<impl IntoResponse, AppError> {
let (cancelled_by, _) = extract_requester(&req)?;
let Json(body): Json<CancelMintAuthRequest> = 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::<crate::auth::OAuthTokenClaims>() {
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::<crate::auth::jwt::TokenClaims>() {
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<std::net::IpAddr> {
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())
}
Loading