diff --git a/Cargo.lock b/Cargo.lock index 75e53a07d8..580dbac9a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6019,11 +6019,16 @@ version = "0.1.0" dependencies = [ "alloy-contract", "alloy-primitives", + "const-hex", "contracts", "futures", + "hmac", "moka", + "reqwest 0.13.2", + "sha2", "tokio", "tracing", + "url", ] [[package]] diff --git a/crates/autopilot/src/run.rs b/crates/autopilot/src/run.rs index 5a93ebf879..663589b65c 100644 --- a/crates/autopilot/src/run.rs +++ b/crates/autopilot/src/run.rs @@ -477,6 +477,15 @@ pub async fn run(config: Configuration, shutdown_controller: ShutdownController) persistence.clone(), infra::banned::Users::new( eth.contracts().chainalysis_oracle().clone(), + config + .banned_users + .hermod + .clone() + .map(|hermod| infra::banned::HermodConfig { + url: hermod.url, + hmac_key: hermod.hmac_key, + api_key: hermod.api_key, + }), config.banned_users.addresses, config.banned_users.max_cache_size.get().to_u64().unwrap(), ), diff --git a/crates/configs/src/banned_users.rs b/crates/configs/src/banned_users.rs index 5942acc121..2c1b8e708f 100644 --- a/crates/configs/src/banned_users.rs +++ b/crates/configs/src/banned_users.rs @@ -1,7 +1,9 @@ use { + crate::deserialize_env::{deserialize_optional_string_from_env, deserialize_string_from_env}, alloy::primitives::Address, - serde::{Deserialize, Serialize}, - std::num::NonZeroUsize, + serde::Deserialize, + std::{fmt::Debug, num::NonZeroUsize}, + url::Url, }; fn default_max_cache_size() -> NonZeroUsize { @@ -11,7 +13,8 @@ fn default_max_cache_size() -> NonZeroUsize { } /// Addresses banned from creating orders, with a local cache. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize)] +#[cfg_attr(any(test, feature = "test-util"), derive(serde::Serialize))] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct BannedUsersConfig { /// List of account addresses to be denied from order creation. @@ -21,6 +24,10 @@ pub struct BannedUsersConfig { /// Maximum number of entries to keep in the banned users cache. #[serde(default = "default_max_cache_size")] pub max_cache_size: NonZeroUsize, + + /// Optional Hermod (zeroShadow) sanctioned address checker. + #[serde(default)] + pub hermod: Option, } impl Default for BannedUsersConfig { @@ -28,10 +35,41 @@ impl Default for BannedUsersConfig { Self { addresses: Vec::new(), max_cache_size: default_max_cache_size(), + hermod: None, } } } +/// Hermod is zeroShadow's self-hosted sanctioned-address checker. Queries +/// are made against an HMAC-SHA256 obfuscated form of the address using a +/// per-customer key. +#[derive(Clone, Deserialize)] +#[cfg_attr(any(test, feature = "test-util"), derive(serde::Serialize))] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct HermodConfig { + /// Base URL of the Hermod agent (e.g. `http://hermod:3000`). + pub url: Url, + + /// Per-customer HMAC key used to obfuscate addresses before sending. + #[serde(deserialize_with = "deserialize_string_from_env")] + pub hmac_key: String, + + /// Optional API key sent as a Bearer token, if the agent was started + /// with `API_KEY` set. + #[serde(default, deserialize_with = "deserialize_optional_string_from_env")] + pub api_key: Option, +} + +impl Debug for HermodConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HermodConfig") + .field("url", &self.url) + .field("hmac_key", &"") + .field("api_key", &self.api_key.as_ref().map(|_| "")) + .finish() + } +} + #[cfg(test)] mod tests { use {super::*, alloy::primitives::address}; @@ -42,6 +80,7 @@ mod tests { let config: BannedUsersConfig = toml::from_str(toml).unwrap(); assert!(config.addresses.is_empty()); assert_eq!(config.max_cache_size.get(), 10000); + assert!(config.hermod.is_none()); } #[test] @@ -58,4 +97,59 @@ mod tests { ); assert_eq!(config.max_cache_size.get(), 5000); } + + #[test] + fn deserialize_with_hermod() { + let toml = r#" + [hermod] + url = "http://hermod:3000" + hmac-key = "key" + api-key = "secret" + "#; + let config: BannedUsersConfig = toml::from_str(toml).unwrap(); + let hermod = config.hermod.unwrap(); + assert_eq!(hermod.url.as_str(), "http://hermod:3000/"); + assert_eq!(hermod.hmac_key, "key"); + assert_eq!(hermod.api_key.as_deref(), Some("secret")); + } + + #[test] + fn hermod_secrets_redacted() { + let config = HermodConfig { + url: "http://hermod:3000".parse().unwrap(), + hmac_key: "hmac-secret-value".to_string(), + api_key: Some("api-secret-value".to_string()), + }; + let debug = format!("{:?}", config); + assert!(debug.contains(r#"hmac_key: """#)); + assert!(debug.contains(r#"api_key: Some("")"#)); + assert!(!debug.contains("hmac-secret-value")); + assert!(!debug.contains("api-secret-value")); + } + + #[test] + fn hermod_secrets_from_env() { + let hmac_var = "TEST_HERMOD_HMAC_KEY"; + let api_var = "TEST_HERMOD_API_KEY"; + // SAFETY: no other threads access these env vars. + unsafe { std::env::set_var(hmac_var, "env-hmac") }; + unsafe { std::env::set_var(api_var, "env-api") }; + + let toml = format!( + r#" + [hermod] + url = "http://hermod:3000" + hmac-key = "%{hmac_var}" + api-key = "%{api_var}" + "#, + ); + let config: BannedUsersConfig = toml::from_str(&toml).unwrap(); + let hermod = config.hermod.unwrap(); + assert_eq!(hermod.hmac_key, "env-hmac"); + assert_eq!(hermod.api_key.as_deref(), Some("env-api")); + + // SAFETY: no other threads access these env vars. + unsafe { std::env::remove_var(hmac_var) }; + unsafe { std::env::remove_var(api_var) }; + } } diff --git a/crates/configs/src/deserialize_env.rs b/crates/configs/src/deserialize_env.rs index 798c99e681..b4cf979bf6 100644 --- a/crates/configs/src/deserialize_env.rs +++ b/crates/configs/src/deserialize_env.rs @@ -68,6 +68,26 @@ where } } +/// Deserializes an optional String from *either* an environment variable — +/// with the format `%` — or directly from the field value. A +/// missing env var is treated as `None` rather than an error, matching +/// [`deserialize_optional_url_from_env`]. +pub(crate) fn deserialize_optional_string_from_env<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let Some(value) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + match value.strip_prefix(ENV_VAR_PREFIX) { + // In the case of optional variables, we assume a missing env var as empty + Some(env_var_name) => Ok(std::env::var(env_var_name).ok()), + None => Ok(Some(value)), + } +} + /// Deserializes an optional URL from *either* an environment variable — with /// the format `%` — or interpreting a String as a URL. pub(crate) fn deserialize_optional_url_from_env<'de, D>( diff --git a/crates/order-validation/Cargo.toml b/crates/order-validation/Cargo.toml index 1f6686b5d0..bdf7918978 100644 --- a/crates/order-validation/Cargo.toml +++ b/crates/order-validation/Cargo.toml @@ -8,11 +8,16 @@ license = "MIT OR Apache-2.0" [dependencies] alloy-contract = { workspace = true } alloy-primitives = { workspace = true } +const-hex = { workspace = true } contracts = { workspace = true } futures = { workspace = true } +hmac = { workspace = true } moka = { workspace = true, features = ["sync"] } +reqwest = { workspace = true, features = ["json"] } +sha2 = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } +url = { workspace = true } [lints] workspace = true diff --git a/crates/order-validation/src/banned.rs b/crates/order-validation/src/banned.rs index 70781b1fb8..e50717058e 100644 --- a/crates/order-validation/src/banned.rs +++ b/crates/order-validation/src/banned.rs @@ -1,31 +1,111 @@ //! Banned user detection for order validation. //! //! Checks if addresses are banned using a hardcoded list and optionally the -//! Chainalysis Oracle on-chain registry. On-chain results are cached (1-hour -//! expiry, LRU eviction) with background refresh every 60 seconds. +//! Chainalysis Oracle on-chain registry and/or the Hermod (zeroShadow) agent. +//! Remote check results are cached (1-hour expiry, LRU eviction) with +//! background refresh every 60 seconds. +mod hermod; + +pub use hermod::HermodConfig; use { alloy_primitives::Address, contracts::ChainalysisOracle, - futures::future::join_all, + futures::{ + FutureExt, + StreamExt, + future::{BoxFuture, join_all}, + stream, + }, moka::sync::Cache, std::{ collections::HashSet, + fmt::Debug, + future::Future, sync::Arc, time::{Duration, Instant}, }, }; -/// A list of banned users and an optional registry that can be checked onchain. +/// Caps the number of in-flight per-address fetches so a large batch of cache +/// misses (or a large maintenance refresh) does not burst the backend. +pub(crate) const MAX_CONCURRENT_LOOKUPS: usize = 10; +const CACHE_EXPIRY: Duration = Duration::from_secs(60 * 60); +const MAINTENANCE_TIMEOUT: Duration = Duration::from_secs(60); + +/// A list of banned users and optional registries that can be checked. pub struct Users { list: HashSet
, onchain: Option>, + hermod: Option>, } #[derive(Clone)] -struct UserMetadata { - is_banned: bool, - last_updated: Instant, +pub(crate) struct UserMetadata { + pub(crate) is_banned: bool, + pub(crate) last_updated: Instant, +} + +/// A remote banned-user source backed by a cache. Each implementation only +/// needs to provide the underlying lookup; the shared `check` flow takes +/// care of cache hit/miss handling and writing fresh results back. +pub(crate) trait Backend: Send + Sync + 'static { + type Error: Debug + Send; + + /// Asks the underlying source whether the given address is banned. + fn fetch(&self, address: Address) -> impl Future> + Send; + + fn cache(&self) -> &Cache; + + fn name(&self) -> &'static str; + + /// Checks the given addresses against this backend and inserts any + /// reported hits into `banned`. Addresses already in `banned` are skipped + /// to avoid an unnecessary lookup. + async fn check(&self, addresses: &HashSet
, banned: &mut HashSet
) { + let mut need_lookup = Vec::new(); + for address in addresses { + if banned.contains(address) { + continue; + } + match self.cache().get(address) { + Some(metadata) => { + metadata.is_banned.then(|| banned.insert(*address)); + } + None => need_lookup.push(*address), + } + } + + let to_cache: Vec<_> = stream::iter(need_lookup) + .map(|address| async move { (address, self.fetch(address).await) }) + .buffer_unordered(MAX_CONCURRENT_LOOKUPS) + .collect() + .await; + + let now = Instant::now(); + for (address, result) in to_cache { + match result { + Ok(is_banned) => { + self.cache().insert( + address, + UserMetadata { + is_banned, + last_updated: now, + }, + ); + is_banned.then(|| banned.insert(address)); + } + Err(err) => { + tracing::warn!( + backend = self.name(), + ?err, + ?address, + "failed to fetch banned status", + ); + } + } + } + } } /// Onchain banned user checker using Chainalysis Oracle with caching and @@ -48,64 +128,41 @@ impl Onchain { onchain } - /// Spawns a background task that periodically checks the cache for expired - /// entries and re-run checks for them. - fn spawn_maintenance_task(self: Arc) { - let cache_expiry = Duration::from_secs(60 * 60); - let maintenance_timeout = Duration::from_secs(60); - let detector = Arc::clone(&self); - - tokio::task::spawn(async move { - loop { - let start = Instant::now(); + fn expired_data(&self, start: Instant) -> Vec<(Arc
, UserMetadata)> { + self.cache + .iter() + .filter_map(|(address, metadata)| { + let expired = start + .checked_duration_since(metadata.last_updated) + .unwrap_or_default() + >= CACHE_EXPIRY - MAINTENANCE_TIMEOUT; + expired.then_some((address, metadata)) + }) + .collect() + } - let expired_data: Vec<_> = detector - .cache - .iter() - .filter_map(|(address, metadata)| { - let expired = start - .checked_duration_since(metadata.last_updated) - .unwrap_or_default() - >= cache_expiry - maintenance_timeout; - - expired.then_some((address, metadata)) - }) - .collect(); - - let results = join_all(expired_data.into_iter().map(|(address, metadata)| { - let detector = detector.clone(); - async move { - match detector.fetch(*address).await { - Ok(result) => Some(( - *address, - UserMetadata { - is_banned: result, - ..metadata - }, - )), - Err(err) => { - tracing::warn!( - address = ?*address, - ?err, - "unable to determine banned status in the background task" - ); - None - } - } - } - })) - .await - .into_iter() - .flatten(); - - detector.insert_many_into_cache(results); - - let remaining_sleep = maintenance_timeout - .checked_sub(start.elapsed()) - .unwrap_or_default(); - tokio::time::sleep(remaining_sleep).await; + async fn determine_status( + &self, + address: Address, + metadata: UserMetadata, + ) -> Option<(Address, UserMetadata)> { + match self.fetch(address).await { + Ok(is_banned) => Some(( + address, + UserMetadata { + is_banned, + ..metadata + }, + )), + Err(err) => { + tracing::warn!( + ?address, + ?err, + "unable to determine banned status in the background task", + ); + None } - }); + } } fn insert_many_into_cache(&self, addresses: impl Iterator) { @@ -120,6 +177,44 @@ impl Onchain { ); } } + + fn spawn_maintenance_task(self: Arc) { + tokio::task::spawn(async move { + let mut interval = tokio::time::interval(MAINTENANCE_TIMEOUT); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + interval.tick().await; + let start = Instant::now(); + let expired_data = self.expired_data(start); + + let results = stream::iter(expired_data) + .map(|(address, metadata)| self.determine_status(*address, metadata)) + .buffer_unordered(MAX_CONCURRENT_LOOKUPS) + .collect::>() + .await + .into_iter() + .flatten(); + + self.insert_many_into_cache(results); + } + }); + } +} + +impl Backend for Onchain { + type Error = alloy_contract::Error; + + async fn fetch(&self, address: Address) -> Result { + self.contract.isSanctioned(address).call().await + } + + fn cache(&self) -> &Cache { + &self.cache + } + + fn name(&self) -> &'static str { + "chainalysis" + } } impl Users { @@ -128,12 +223,14 @@ impl Users { /// banned addresses is available. pub fn new( contract: Option, + hermod: Option, banned_users: Vec
, cache_max_size: u64, ) -> Self { Self { list: HashSet::from_iter(banned_users), onchain: contract.map(|instance| Onchain::new(instance, cache_max_size)), + hermod: hermod.map(|config| hermod::Hermod::new(config, cache_max_size)), } } @@ -142,6 +239,7 @@ impl Users { Self { list: HashSet::new(), onchain: None, + hermod: None, } } @@ -151,12 +249,14 @@ impl Users { Self { list, onchain: None, + hermod: None, } } /// Returns a subset of addresses from the input iterator which are banned. /// - /// On cache-misses, it will use the Chainalysis oracle to fetch the users. + /// On cache-misses, it will use the Chainalysis oracle and/or the Hermod + /// agent to determine status. pub async fn banned(&self, addresses: impl IntoIterator) -> HashSet
{ let mut banned = HashSet::new(); @@ -173,55 +273,30 @@ impl Users { // Need to collect here to make sure filter gets executed and we insert addresses .collect::>(); - let Some(onchain) = &self.onchain else { - return banned; - }; - let need_lookup: Vec<_> = { - let mut filtered = Vec::new(); - for address in need_lookup { - match onchain.cache.get(&address) { - Some(metadata) => { - metadata.is_banned.then(|| banned.insert(address)); - } - None => { - filtered.push(address); - } - } - } - filtered - }; - - let to_cache = join_all( - need_lookup - .into_iter() - .map(|address| async move { (address, onchain.fetch(address).await) }), - ) - .await; + let lookups: Vec>> = [ + self.onchain + .as_deref() + .map(|b| check_into_new(b, &need_lookup).boxed()), + self.hermod + .as_deref() + .map(|b| check_into_new(b, &need_lookup).boxed()), + ] + .into_iter() + .flatten() + .collect(); - let now = Instant::now(); - for (address, result) in to_cache { - match result { - Ok(is_banned) => { - onchain.cache.insert( - address, - UserMetadata { - is_banned, - last_updated: now, - }, - ); - is_banned.then(|| banned.insert(address)); - } - Err(err) => { - tracing::warn!(?err, ?address, "failed to fetch banned status"); - } - } + for found in join_all(lookups).await { + banned.extend(found); } + banned } } -impl Onchain { - async fn fetch(&self, address: Address) -> Result { - self.contract.isSanctioned(address).call().await - } +/// Runs `backend.check` against a fresh result set so multiple backends can be +/// driven concurrently without sharing a `&mut HashSet`. +async fn check_into_new(backend: &B, addresses: &HashSet
) -> HashSet
{ + let mut out = HashSet::new(); + backend.check(addresses, &mut out).await; + out } diff --git a/crates/order-validation/src/banned/hermod.rs b/crates/order-validation/src/banned/hermod.rs new file mode 100644 index 0000000000..af8c96758e --- /dev/null +++ b/crates/order-validation/src/banned/hermod.rs @@ -0,0 +1,239 @@ +//! Hermod (zeroShadow) sanctioned-address checker. +//! +//! Queries are HMAC-SHA256-signed using a per-customer key; a hit returns +//! HTTP 200 and a miss returns HTTP 404. Mirrors the structure of the +//! Chainalysis `Onchain` checker: same cache, same background refresh task. + +use { + super::{Backend, MAX_CONCURRENT_LOOKUPS, UserMetadata}, + alloy_primitives::Address, + futures::{StreamExt, stream}, + hmac::{Hmac, Mac}, + moka::sync::Cache, + sha2::Sha256, + std::{ + sync::Arc, + time::{Duration, Instant}, + }, + url::Url, +}; + +const CACHE_EXPIRY: Duration = Duration::from_secs(60 * 60); +const MAINTENANCE_TIMEOUT: Duration = Duration::from_secs(60); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); + +/// Configuration for the Hermod (zeroShadow) sanctioned-address checker. +#[derive(Debug, Clone)] +pub struct HermodConfig { + /// Base URL of the Hermod agent (e.g. `http://hermod:3000`). + pub url: Url, + /// Per-customer HMAC key used to obfuscate addresses before sending. + pub hmac_key: String, + /// Optional API key sent as a Bearer token. + pub api_key: Option, +} + +// Fields are read only through the `Debug` derive used in error logs, which +// rustc's dead-code analysis ignores — suppress the false-positive. +#[allow(dead_code)] +#[derive(Debug)] +pub(super) enum FetchError { + Request(reqwest::Error), + UnexpectedStatus(reqwest::StatusCode), +} + +impl From for FetchError { + fn from(err: reqwest::Error) -> Self { + Self::Request(err) + } +} + +/// Hermod banned user checker with caching and background refresh. +pub(super) struct Hermod { + client: reqwest::Client, + url: Url, + hmac_key: Vec, + api_key: Option, + cache: Cache, +} + +impl Hermod { + pub(super) fn new(config: HermodConfig, cache_max_size: u64) -> Arc { + // Make sure the URL ends with a slash so joining `addresses/` + // appends rather than replaces the last path segment. + let mut url = config.url; + if !url.path().ends_with('/') { + let with_slash = format!("{}/", url.path()); + url.set_path(&with_slash); + } + let hermod = Arc::new(Self { + client: reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build() + .expect("reqwest client builder with default TLS settings is infallible"), + url, + hmac_key: config.hmac_key.into_bytes(), + api_key: config.api_key, + cache: Cache::builder().max_capacity(cache_max_size).build(), + }); + + hermod.clone().spawn_maintenance_task(); + + hermod + } + + fn expired_data(&self, start: Instant) -> Vec<(Arc
, UserMetadata)> { + self.cache + .iter() + .filter_map(|(address, metadata)| { + let expired = start + .checked_duration_since(metadata.last_updated) + .unwrap_or_default() + >= CACHE_EXPIRY - MAINTENANCE_TIMEOUT; + expired.then_some((address, metadata)) + }) + .collect() + } + + async fn determine_status( + &self, + address: Address, + metadata: UserMetadata, + ) -> Option<(Address, UserMetadata)> { + match self.fetch(address).await { + Ok(is_banned) => Some(( + address, + UserMetadata { + is_banned, + ..metadata + }, + )), + Err(err) => { + tracing::warn!( + ?address, + ?err, + "unable to determine hermod banned status in the background task", + ); + None + } + } + } + + fn insert_many_into_cache(&self, addresses: impl Iterator) { + let now = Instant::now(); + for (address, metadata) in addresses { + self.cache.insert( + address, + UserMetadata { + last_updated: now, + ..metadata + }, + ); + } + } + + fn spawn_maintenance_task(self: Arc) { + tokio::task::spawn(async move { + let mut interval = tokio::time::interval(MAINTENANCE_TIMEOUT); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + interval.tick().await; + let start = Instant::now(); + let expired_data = self.expired_data(start); + + let results = stream::iter(expired_data) + .map(|(address, metadata)| self.determine_status(*address, metadata)) + .buffer_unordered(MAX_CONCURRENT_LOOKUPS) + .collect::>() + .await + .into_iter() + .flatten(); + + self.insert_many_into_cache(results); + } + }); + } + + /// HMAC-SHA256 of the address textual payload, encoded as lowercase hex. + /// The payload is the lowercase `0x`-prefixed 40-character form, matching + /// the EVM test address shown in the Hermod docs and what the agent is + /// configured against on our side. The exact textual form must agree with + /// the agent or every signature will be a miss. + fn sign(&self, address: Address) -> String { + let payload = format!("{address:#x}"); + let mut mac = Hmac::::new_from_slice(&self.hmac_key) + .expect("HMAC accepts keys of any length"); + mac.update(payload.as_bytes()); + const_hex::encode(mac.finalize().into_bytes()) + } +} + +impl Backend for Hermod { + type Error = FetchError; + + async fn fetch(&self, address: Address) -> Result { + let signature = self.sign(address); + let endpoint = self + .url + .join(&format!("addresses/{signature}")) + .expect("base url is valid and signature is hex"); + + let mut request = self.client.get(endpoint); + if let Some(api_key) = &self.api_key { + request = request.bearer_auth(api_key); + } + let response = request.send().await?; + match response.status() { + reqwest::StatusCode::OK => Ok(true), + reqwest::StatusCode::NOT_FOUND => Ok(false), + status => Err(FetchError::UnexpectedStatus(status)), + } + } + + fn cache(&self) -> &Cache { + &self.cache + } + + fn name(&self) -> &'static str { + "hermod" + } +} + +#[cfg(test)] +mod tests { + use {super::*, alloy_primitives::address}; + + fn backend() -> Arc { + Hermod::new( + HermodConfig { + url: "http://hermod:3000".parse().unwrap(), + hmac_key: "key".to_string(), + api_key: None, + }, + 10, + ) + } + + #[tokio::test] + async fn hmac_signature_is_deterministic() { + let hermod = backend(); + let addr = address!("dead000000000000000000000000000000000000"); + assert_eq!(hermod.sign(addr), hermod.sign(addr)); + assert_eq!(hermod.sign(addr).len(), 64); + } + + #[tokio::test] + async fn base_url_without_trailing_slash_is_normalised() { + let hermod = Hermod::new( + HermodConfig { + url: "http://hermod:3000/v1".parse().unwrap(), + hmac_key: "key".to_string(), + api_key: None, + }, + 10, + ); + assert!(hermod.url.as_str().ends_with('/')); + let joined = hermod.url.join("addresses/abc").unwrap(); + assert_eq!(joined.as_str(), "http://hermod:3000/v1/addresses/abc"); + } +} diff --git a/crates/orderbook/src/run.rs b/crates/orderbook/src/run.rs index e35a895c73..63a8dd9d60 100644 --- a/crates/orderbook/src/run.rs +++ b/crates/orderbook/src/run.rs @@ -394,6 +394,13 @@ pub async fn run(config: Configuration) { native_token.clone(), Arc::new(order_validation::banned::Users::new( chainalysis_oracle, + config.banned_users.hermod.clone().map(|hermod| { + order_validation::banned::HermodConfig { + url: hermod.url, + hmac_key: hermod.hmac_key, + api_key: hermod.api_key, + } + }), config.banned_users.addresses, config.banned_users.max_cache_size.get().to_u64().unwrap(), )),