Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

compliance: compliance-server: Fallback to chainalysis API if not cached #3

Merged
merged 1 commit into from
Jun 29, 2024
Merged
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
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,
joeykraut marked this conversation as resolved.
Show resolved Hide resolved
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 {
joeykraut marked this conversation as resolved.
Show resolved Hide resolved
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())
}
}
12 changes: 11 additions & 1 deletion 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 @@ -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
Loading