Skip to content

Commit

Permalink
compliance: compliance-server: Fallback to chainalysis API if not cached
Browse files Browse the repository at this point in the history
  • Loading branch information
joeykraut committed Jun 29, 2024
1 parent 03e0455 commit 286816e
Show file tree
Hide file tree
Showing 9 changed files with 165 additions and 3 deletions.
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
3 changes: 3 additions & 0 deletions compliance/compliance-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions compliance/compliance-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
107 changes: 107 additions & 0 deletions compliance/compliance-server/src/chainalysis_api.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

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<ComplianceEntry, ComplianceServerError> {
// 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<RiskAssessmentResponse, ComplianceServerError> {
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)
}
26 changes: 25 additions & 1 deletion compliance/compliance-server/src/db.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -14,6 +14,9 @@ use crate::{
},
};

/// The default expiration duration for a compliance entry
const DEFAULT_EXPIRATION_DURATION: Duration = Duration::from_days(365);

// ----------
// | Models |
// ----------
Expand All @@ -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 {
Expand All @@ -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(())
}
9 changes: 9 additions & 0 deletions compliance/compliance-server/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<reqwest::Error> for ComplianceServerError {
fn from(e: reqwest::Error) -> Self {
ComplianceServerError::Chainalysis(e.to_string())
}
}
14 changes: 12 additions & 2 deletions compliance/compliance-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -56,7 +61,7 @@ async fn main() {
let chainalysis_key = cli.chainalysis_api_key.clone();
let compliance_check = warp::get()
.and(warp::path("v0"))
.and(warp::path("compliance-check"))
.and(warp::path("check-compliance"))
.and(warp::path::param::<String>()) // wallet_address
.and_then(move |wallet_address| {
let key = chainalysis_key.clone();
Expand Down Expand Up @@ -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())
}
1 change: 1 addition & 0 deletions compliance/compliance-server/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 286816e

Please sign in to comment.