diff --git a/Cargo.toml b/Cargo.toml index ed5608a..91f38e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,6 @@ renegade-circuits = { package = "circuits", git = "https://github.com/renegade-f renegade-circuit-types = { package = "circuit-types", git = "https://github.com/renegade-fi/renegade.git" } renegade-crypto = { git = "https://github.com/renegade-fi/renegade.git" } renegade-util = { package = "util", git = "https://github.com/renegade-fi/renegade.git" } + +# === Misc Dependencies === # +tracing = "0.1" diff --git a/compliance/compliance-api/src/lib.rs b/compliance/compliance-api/src/lib.rs index 5957068..1613290 100644 --- a/compliance/compliance-api/src/lib.rs +++ b/compliance/compliance-api/src/lib.rs @@ -8,6 +8,9 @@ use serde::{Deserialize, Serialize}; +/// The API endpoint for screening an address for compliance +pub const WALLET_SCREEN_PATH: &str = "/v0/check-compliance"; + /// The response type for a compliance check #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ComplianceCheckResponse { diff --git a/compliance/compliance-server/Cargo.toml b/compliance/compliance-server/Cargo.toml index 68a06c6..bc367c6 100644 --- a/compliance/compliance-server/Cargo.toml +++ b/compliance/compliance-server/Cargo.toml @@ -17,4 +17,8 @@ renegade-util = { workspace = true } # === Misc === # clap = { version = "4.5", features = ["derive"] } +reqwest = { version = "0.12", features = ["json"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" tokio = { version = "1.37", features = ["full"] } +tracing = { workspace = true } diff --git a/compliance/compliance-server/migrations/2024-06-28-231756_create_compliance_table/up.sql b/compliance/compliance-server/migrations/2024-06-28-231756_create_compliance_table/up.sql index 975a99b..da22847 100644 --- a/compliance/compliance-server/migrations/2024-06-28-231756_create_compliance_table/up.sql +++ b/compliance/compliance-server/migrations/2024-06-28-231756_create_compliance_table/up.sql @@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS wallet_compliance ( address TEXT PRIMARY KEY, is_compliant BOOLEAN NOT NULL, + risk_level TEXT NOT NULL, reason TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW(), expires_at TIMESTAMP NOT NULL DEFAULT NOW() + INTERVAL '1 year' diff --git a/compliance/compliance-server/src/chainalysis_api.rs b/compliance/compliance-server/src/chainalysis_api.rs new file mode 100644 index 0000000..3052c4f --- /dev/null +++ b/compliance/compliance-server/src/chainalysis_api.rs @@ -0,0 +1,107 @@ +//! Helpers for interacting with the chainalysis API + +use serde::{Deserialize, Serialize}; +use tracing::warn; + +use crate::{db::ComplianceEntry, error::ComplianceServerError}; + +// ------------- +// | API Types | +// ------------- + +/// The base URL for the chainalysis entities API +const CHAINALYSIS_API_BASE: &str = "https://api.chainalysis.com/api/risk/v2/entities"; +/// The header name for the auth token +const TOKEN_HEADER: &str = "Token"; + +/// The register address request body +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegisterAddressRequest { + /// The address to register + pub address: String, +} + +/// The response to a risk assessment query +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RiskAssessmentResponse { + /// The address that the assessment is for + pub address: String, + /// The risk assessment status + pub risk: String, + /// The reason for the risk assessment + #[serde(rename = "riskReason")] + pub risk_reason: Option, +} + +impl RiskAssessmentResponse { + /// Get a compliance entry from the risk assessment + pub fn as_compliance_entry(self) -> ComplianceEntry { + // We allow low and medium risk entries, high and severe are marked + // non-compliant + let compliant = match self.risk.as_str() { + "Low" | "Medium" => true, + "High" | "Severe" => false, + x => { + // For now we don't block on an unknown assessment, this should be unreachable + warn!("Unexpected risk assessment: {x}"); + true + }, + }; + + let risk_reason = self.risk_reason.unwrap_or_default(); + ComplianceEntry::new(self.address, compliant, self.risk, risk_reason) + } +} + +// --------------- +// | Client Impl | +// --------------- + +/// Query chainalysis for the compliance status of a wallet +pub async fn query_chainalysis( + wallet_address: &str, + chainalysis_api_key: &str, +) -> Result { + // 1. Register the wallet + register_addr(wallet_address, chainalysis_api_key).await?; + + // 2. Query the risk assessment + let risk_assessment = query_risk_assessment(wallet_address, chainalysis_api_key).await?; + Ok(risk_assessment.as_compliance_entry()) +} + +/// Register a wallet with chainalysis +async fn register_addr( + wallet_address: &str, + chainalysis_api_key: &str, +) -> Result<(), ComplianceServerError> { + let body = RegisterAddressRequest { address: wallet_address.to_string() }; + let client = reqwest::Client::new(); + client + .post(CHAINALYSIS_API_BASE) + .header(TOKEN_HEADER, chainalysis_api_key) + .json(&body) + .send() + .await? + .error_for_status()?; + + Ok(()) +} + +/// Query the risk assessment from chainalysis +async fn query_risk_assessment( + wallet_address: &str, + chainalysis_api_key: &str, +) -> Result { + let url = format!("{CHAINALYSIS_API_BASE}/{wallet_address}"); + let client = reqwest::Client::new(); + let resp = client + .get(url) + .header(TOKEN_HEADER, chainalysis_api_key) + .send() + .await? + .error_for_status()?; + + let risk_assessment: RiskAssessmentResponse = resp.json().await?; + Ok(risk_assessment) +} diff --git a/compliance/compliance-server/src/db.rs b/compliance/compliance-server/src/db.rs index e165538..b447f93 100644 --- a/compliance/compliance-server/src/db.rs +++ b/compliance/compliance-server/src/db.rs @@ -1,6 +1,6 @@ //! Database helpers for the server -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; use compliance_api::ComplianceStatus; use diesel::{ExpressionMethods, Insertable, PgConnection, QueryDsl, Queryable, RunQueryDsl}; @@ -14,6 +14,9 @@ use crate::{ }, }; +/// The default expiration duration for a compliance entry +const DEFAULT_EXPIRATION_DURATION: Duration = Duration::from_days(365); + // ---------- // | Models | // ---------- @@ -25,12 +28,20 @@ use crate::{ pub struct ComplianceEntry { pub address: String, pub is_compliant: bool, + pub risk_level: String, pub reason: String, pub created_at: SystemTime, pub expires_at: SystemTime, } impl ComplianceEntry { + /// Create a new entry from a risk assessment + pub fn new(address: String, is_compliant: bool, risk_level: String, reason: String) -> Self { + let created_at = SystemTime::now(); + let expires_at = created_at + DEFAULT_EXPIRATION_DURATION; + ComplianceEntry { address, is_compliant, risk_level, reason, created_at, expires_at } + } + /// Get the compliance status for an entry pub fn compliance_status(&self) -> ComplianceStatus { if self.is_compliant { @@ -57,3 +68,16 @@ pub fn get_compliance_entry( Ok(query.first().cloned()) } + +/// Insert a compliance entry into the database +pub fn insert_compliance_entry( + entry: ComplianceEntry, + conn: &mut PgConnection, +) -> Result<(), ComplianceServerError> { + diesel::insert_into(compliance_table) + .values(entry) + .execute(conn) + .map_err(err_str!(ComplianceServerError::Db))?; + + Ok(()) +} diff --git a/compliance/compliance-server/src/error.rs b/compliance/compliance-server/src/error.rs index b822b5a..4d56a83 100644 --- a/compliance/compliance-server/src/error.rs +++ b/compliance/compliance-server/src/error.rs @@ -9,14 +9,23 @@ use warp::reject::Reject; pub enum ComplianceServerError { /// An error with a database query Db(String), + /// An error with the chainalysis API + Chainalysis(String), } impl Display for ComplianceServerError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ComplianceServerError::Db(e) => write!(f, "Database error: {}", e), + ComplianceServerError::Chainalysis(e) => write!(f, "Chainalysis error: {}", e), } } } impl Error for ComplianceServerError {} impl Reject for ComplianceServerError {} + +impl From for ComplianceServerError { + fn from(e: reqwest::Error) -> Self { + ComplianceServerError::Chainalysis(e.to_string()) + } +} diff --git a/compliance/compliance-server/src/main.rs b/compliance/compliance-server/src/main.rs index 9d97560..187018c 100644 --- a/compliance/compliance-server/src/main.rs +++ b/compliance/compliance-server/src/main.rs @@ -5,20 +5,25 @@ #![deny(unsafe_code)] #![deny(clippy::needless_pass_by_value)] #![deny(clippy::needless_pass_by_ref_mut)] +#![feature(duration_constructors)] use std::sync::Arc; +use chainalysis_api::query_chainalysis; use clap::Parser; use compliance_api::{ComplianceCheckResponse, ComplianceStatus}; +use db::insert_compliance_entry; use diesel::pg::PgConnection; use diesel::r2d2::{ConnectionManager, Pool}; use error::ComplianceServerError; use renegade_util::err_str; use renegade_util::telemetry::{setup_system_logger, LevelFilter}; +use tracing::info; use warp::{reply::Json, Filter}; use crate::db::get_compliance_entry; +pub mod chainalysis_api; pub mod db; pub mod error; #[allow(missing_docs, clippy::missing_docs_in_private_items)] @@ -102,5 +107,10 @@ async fn check_wallet_compliance( } // 2. If not present, check the chainalysis API - Ok(ComplianceStatus::Compliant) + info!("address not cached in DB, querying Chainalysis"); + let compliance_entry = query_chainalysis(&wallet_address, chainalysis_api_key).await?; + + // 3. Cache in the DB + insert_compliance_entry(compliance_entry.clone(), &mut conn)?; + Ok(compliance_entry.compliance_status()) } diff --git a/compliance/compliance-server/src/schema.rs b/compliance/compliance-server/src/schema.rs index 8e02bf7..6748bec 100644 --- a/compliance/compliance-server/src/schema.rs +++ b/compliance/compliance-server/src/schema.rs @@ -4,6 +4,7 @@ diesel::table! { wallet_compliance (address) { address -> Text, is_compliant -> Bool, + risk_level -> Text, reason -> Text, created_at -> Timestamp, expires_at -> Timestamp,