Spaces:
Build error
Build error
| //! KYC/AML — FinCEN, OFAC SDN screening, W-9/W-8BEN, EU AMLD6. | |
| //! | |
| //! Persistence: LMDB via persist::LmdbStore. | |
| //! Per-user auth: callers may only read/write their own KYC record. | |
| use crate::AppState; | |
| use axum::{ | |
| extract::{Path, State}, | |
| http::{HeaderMap, StatusCode}, | |
| response::Json, | |
| }; | |
| use serde::{Deserialize, Serialize}; | |
| use tracing::warn; | |
| pub enum KycTier { | |
| Tier0Unverified, | |
| Tier1Basic, | |
| Tier2Full, | |
| Suspended, | |
| } | |
| pub enum TaxForm { | |
| W9, | |
| W8Ben, | |
| W8BenE, | |
| } | |
| pub enum OfacStatus { | |
| Clear, | |
| PendingScreening, | |
| Flagged, | |
| Blocked, | |
| } | |
| pub struct KycRecord { | |
| pub user_id: String, | |
| pub tier: KycTier, | |
| pub legal_name: Option<String>, | |
| pub country_code: Option<String>, | |
| pub id_type: Option<String>, | |
| pub tax_form: Option<TaxForm>, | |
| pub tin_hash: Option<String>, | |
| pub ofac_status: OfacStatus, | |
| pub created_at: String, | |
| pub updated_at: String, | |
| pub payout_blocked: bool, | |
| } | |
| pub struct KycSubmission { | |
| pub legal_name: String, | |
| pub country_code: String, | |
| pub id_type: String, | |
| pub tax_form: TaxForm, | |
| pub tin_hash: Option<String>, | |
| } | |
| pub struct KycStore { | |
| db: crate::persist::LmdbStore, | |
| } | |
| impl KycStore { | |
| pub fn open(path: &str) -> anyhow::Result<Self> { | |
| Ok(Self { | |
| db: crate::persist::LmdbStore::open(path, "kyc_records")?, | |
| }) | |
| } | |
| pub fn get(&self, uid: &str) -> Option<KycRecord> { | |
| self.db.get(uid).ok().flatten() | |
| } | |
| pub fn upsert(&self, r: KycRecord) { | |
| if let Err(e) = self.db.put(&r.user_id, &r) { | |
| tracing::error!(err=%e, user=%r.user_id, "KYC persist error"); | |
| } | |
| } | |
| pub fn payout_permitted(&self, uid: &str, amount_usd: f64) -> bool { | |
| match self.get(uid) { | |
| None => false, | |
| Some(r) => { | |
| if r.payout_blocked { | |
| return false; | |
| } | |
| if r.ofac_status != OfacStatus::Clear { | |
| return false; | |
| } | |
| if amount_usd > 3000.0 && r.tier != KycTier::Tier2Full { | |
| return false; | |
| } | |
| r.tier != KycTier::Tier0Unverified | |
| } | |
| } | |
| } | |
| } | |
| // OFAC sanctioned countries (comprehensive programs, 2025) | |
| const SANCTIONED: &[&str] = &["CU", "IR", "KP", "RU", "SY", "VE"]; | |
| async fn screen_ofac(name: &str, country: &str) -> OfacStatus { | |
| if SANCTIONED.contains(&country) { | |
| warn!(name=%name, country=%country, "OFAC: sanctioned country"); | |
| return OfacStatus::Flagged; | |
| } | |
| // Production: call Refinitiv/ComplyAdvantage/LexisNexis SDN API | |
| OfacStatus::Clear | |
| } | |
| pub async fn submit_kyc( | |
| State(state): State<AppState>, | |
| headers: HeaderMap, | |
| Path(uid): Path<String>, | |
| Json(req): Json<KycSubmission>, | |
| ) -> Result<Json<serde_json::Value>, StatusCode> { | |
| // PER-USER AUTH: caller must own this uid | |
| let caller = crate::auth::extract_caller(&headers)?; | |
| if !caller.eq_ignore_ascii_case(&uid) { | |
| warn!(caller=%caller, uid=%uid, "KYC submit: caller != uid — forbidden"); | |
| return Err(StatusCode::FORBIDDEN); | |
| } | |
| let ofac = screen_ofac(&req.legal_name, &req.country_code).await; | |
| let blocked = ofac == OfacStatus::Flagged || ofac == OfacStatus::Blocked; | |
| let tier = if blocked { | |
| KycTier::Suspended | |
| } else { | |
| KycTier::Tier1Basic | |
| }; | |
| let now = chrono::Utc::now().to_rfc3339(); | |
| state.kyc_db.upsert(KycRecord { | |
| user_id: uid.clone(), | |
| tier: tier.clone(), | |
| legal_name: Some(req.legal_name.clone()), | |
| country_code: Some(req.country_code.clone()), | |
| id_type: Some(req.id_type), | |
| tax_form: Some(req.tax_form), | |
| tin_hash: req.tin_hash, | |
| ofac_status: ofac.clone(), | |
| created_at: now.clone(), | |
| updated_at: now, | |
| payout_blocked: blocked, | |
| }); | |
| state | |
| .audit_log | |
| .record(&format!( | |
| "KYC_SUBMIT user='{uid}' tier={tier:?} ofac={ofac:?}" | |
| )) | |
| .ok(); | |
| if blocked { | |
| warn!(user=%uid, "KYC: payout blocked — OFAC flag"); | |
| } | |
| Ok(Json(serde_json::json!({ | |
| "user_id": uid, "tier": format!("{:?}", tier), | |
| "ofac_status": format!("{:?}", ofac), "payout_blocked": blocked, | |
| }))) | |
| } | |
| pub async fn kyc_status( | |
| State(state): State<AppState>, | |
| headers: HeaderMap, | |
| Path(uid): Path<String>, | |
| ) -> Result<Json<KycRecord>, StatusCode> { | |
| // PER-USER AUTH: caller may only read their own record | |
| let caller = crate::auth::extract_caller(&headers)?; | |
| if !caller.eq_ignore_ascii_case(&uid) { | |
| warn!(caller=%caller, uid=%uid, "KYC status: caller != uid — forbidden"); | |
| return Err(StatusCode::FORBIDDEN); | |
| } | |
| state | |
| .kyc_db | |
| .get(&uid) | |
| .map(Json) | |
| .ok_or(StatusCode::NOT_FOUND) | |
| } | |