Skip to content

Separate banned address backends from caches#4446

Open
jmg-duarte wants to merge 3 commits into
mainfrom
jmgd/hermod-clean
Open

Separate banned address backends from caches#4446
jmg-duarte wants to merge 3 commits into
mainfrom
jmgd/hermod-clean

Conversation

@jmg-duarte
Copy link
Copy Markdown
Contributor

Description

Separates the cache layer from the remote backends in the banned-user validator. Chainalysis and Hermod previously each owned their own caching; this consolidates it into one shared layer with pure fetchers behind it.

Changes

  • Split banned.rs into a banned/ module: mod.rs (public Users API), cached.rs (shared cache + refresh), onchain.rs (Chainalysis), hermod.rs (zeroShadow).
  • Added a Backend trait — backends only implement fetch(address) and name(); caching/batching/refresh is no longer their concern.
  • Single cache entry per address (not per address × backend); a miss fans out to all backends in parallel and ORs the results.
  • Background refresh runs from one place; transient backend failures are discarded instead of poisoning the cache.
  • Per-backend errors unified into a BackendError enum with #[from] conversions.

How to test

  1. cargo nextest run -p order-validation
  2. cargo clippy --locked --workspace --all-features --all-targets -- -D warnings

@jmg-duarte jmg-duarte requested a review from a team as a code owner May 26, 2026 16:35
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the banned user detection logic by introducing a shared Cached layer that handles caching, batching, and background refresh for all backends, making the Hermod and Onchain backends pure fetchers. A critical security issue was identified in fetch_all where a single backend failure could cause the entire check to fail-open, potentially bypassing banned status checks.

Comment on lines +120 to +126
async fn fetch_all(&self, address: Address) -> Option<bool> {
join_all(self.backends.iter().map(|b| fetch_one(b.as_ref(), address)))
.await
.into_iter()
.collect::<Option<Vec<bool>>>()
.map(|results| results.into_iter().any(|banned| banned))
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

If any backend fails (returns None), the current implementation of fetch_all returns None due to the behavior of collect::<Option<Vec<bool>>>(). This causes the entire check to fail-open and ignore any successful Some(true) (banned) results from other backends, creating a security bypass. Instead, if any backend successfully identifies an address as banned, fetch_all should return Some(true).

Suggested change
async fn fetch_all(&self, address: Address) -> Option<bool> {
join_all(self.backends.iter().map(|b| fetch_one(b.as_ref(), address)))
.await
.into_iter()
.collect::<Option<Vec<bool>>>()
.map(|results| results.into_iter().any(|banned| banned))
}
async fn fetch_all(&self, address: Address) -> Option<bool> {
let results = join_all(self.backends.iter().map(|b| fetch_one(b.as_ref(), address))).await;
if results.iter().any(|r| *r == Some(true)) {
Some(true)
} else if results.iter().any(|r| r.is_none()) {
None
} else {
Some(false)
}
}

@tilacog tilacog self-requested a review May 26, 2026 16:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants