feat: address blacklisting in the relayer (#4000)

### Description

See context here
https://discord.com/channels/935678348330434570/1236041564673806427/1252918894755319920
and
https://discord.com/channels/935678348330434570/1252953507833708605/1252954456400592997

This is a rudimentary first step toward blacklisting addresses. Future
options are outlined in
https://discord.com/channels/935678348330434570/1236041564673806427/1252918894755319920.
Eventually we can also fetch these addresses directly within the agent
from an updated list that we cache and re-fetch occasionally. For now,
this just relies on configured addresses.

The approach is:
- Allow hex addresses to be configured. These are intentionally treated
as a Vec<u8> instead of an H160 / H256. This is because treating them as
H160 is a leak of Ethereum-specific logic into non-chain-specific code
in the relayer, and treating them as H256 would pad Ethereum addresses
with a bunch of zeroes which would diminish the accuracy of our
heuristic implemented in this. The subsequence stuff is a bit
unfortunate because it's O(n^2) but 🤷‍♂️
- The heuristic is as follows - if any of the configured addresses are
found as a subsequence in the message sender, recipient, or body, it's
thrown out by the message processor, and the message will never make its
way to the op_submitter. This heuristic for the body is to prevent warp
route transfers that include that address. We could maybe convert
Vec<u8> to H256 for the sender and recipient, I'm open to this - I just
went with the substring approach bc it felt easiest tbh.

### Drive-by changes

<!--
Are there any minor or drive-by changes also included?
-->

### Related issues

- Fixes https://github.com/hyperlane-xyz/issues/issues/1285

### Backward compatibility

<!--
Are these changes backward compatible? Are there any infrastructure
implications, e.g. changes that would prohibit deploying older commits
using this infra tooling?

Yes/No
-->

### Testing

<!--
What kind of testing have these changes undergone?

None/Manual/Unit Tests
-->
pull/4022/head
Trevor Porter 5 months ago committed by GitHub
parent c6ba381e44
commit c84a1dc73b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 123
      rust/agents/relayer/src/msg/blacklist.rs
  2. 1
      rust/agents/relayer/src/msg/mod.rs
  3. 46
      rust/agents/relayer/src/msg/processor.rs
  4. 33
      rust/agents/relayer/src/relayer.rs
  5. 64
      rust/agents/relayer/src/settings/mod.rs
  6. 8
      rust/hyperlane-base/src/contract_sync/cursors/sequence_aware/forward.rs
  7. 2
      typescript/infra/config/environments/mainnet3/agent.ts
  8. 53
      typescript/infra/src/config/agent/relayer.ts
  9. 4
      typescript/sdk/src/metadata/agentConfig.ts

@ -0,0 +1,123 @@
use hyperlane_core::HyperlaneMessage;
#[derive(Debug, Clone, Default)]
pub struct AddressBlacklist {
// A list of addresses that are blocked from being relayed.
// Addresses are any length to support different address types.
pub blacklist: Vec<Vec<u8>>,
}
impl AddressBlacklist {
pub fn new(blacklist: Vec<Vec<u8>>) -> Self {
Self { blacklist }
}
/// Returns true if the message is blocked by the blacklist.
/// At the moment, this only checks if the sender, recipient, or body of the
/// message contains any of the blocked addresses.
pub fn find_blacklisted_address(&self, message: &HyperlaneMessage) -> Option<Vec<u8>> {
self.blacklist.iter().find_map(|address| {
if is_subsequence(message.sender.as_bytes(), address)
|| is_subsequence(message.recipient.as_bytes(), address)
|| is_subsequence(&message.body, address)
{
// Return the blocked address that was found.
Some(address.clone())
} else {
None
}
})
}
}
/// Returns true if `needle` is a subsequence of `haystack`.
fn is_subsequence<T: PartialEq>(mut haystack: &[T], needle: &[T]) -> bool {
if needle.is_empty() {
return true;
}
while !haystack.is_empty() {
if needle.len() > haystack.len() {
return false;
}
if haystack.starts_with(needle) {
return true;
}
haystack = &haystack[1..];
}
false
}
#[cfg(test)]
mod test {
use hyperlane_core::H256;
use super::*;
#[test]
fn test_is_subsequence() {
assert!(is_subsequence(b"hello", b"hello"));
assert!(is_subsequence(b"hello", b"he"));
assert!(is_subsequence(b"hello", b"lo"));
assert!(is_subsequence(b"hello", b""));
assert!(is_subsequence(b"hello", b"o"));
assert!(!is_subsequence(b"hello", b"hello world"));
assert!(!is_subsequence(b"hello", b"world"));
assert!(!is_subsequence(b"hello", b"world hello"));
}
#[test]
fn test_is_blocked() {
let blocked = b"blocked";
let blocklist = AddressBlacklist::new(vec![blocked.to_vec()]);
let bytes_with_subsequence = |subsequence: &[u8], index: usize, len: usize| {
let mut bytes = vec![0; len];
bytes[index..index + subsequence.len()].copy_from_slice(subsequence);
bytes
};
let h256_with_subsequence = |subsequence: &[u8], index: usize| {
let bytes = bytes_with_subsequence(subsequence, index, H256::len_bytes());
H256::from_slice(&bytes)
};
// Blocked - sender includes the blocked address
let message = HyperlaneMessage {
sender: h256_with_subsequence(blocked, 0),
..Default::default()
};
assert_eq!(
blocklist.find_blacklisted_address(&message),
Some(blocked.to_vec())
);
// Blocked - recipient includes the blocked address
let message = HyperlaneMessage {
recipient: h256_with_subsequence(blocked, 20),
..Default::default()
};
assert_eq!(
blocklist.find_blacklisted_address(&message),
Some(blocked.to_vec())
);
// Blocked - body includes the blocked address
let message = HyperlaneMessage {
body: bytes_with_subsequence(blocked, 100 - blocked.len(), 100),
..Default::default()
};
assert_eq!(
blocklist.find_blacklisted_address(&message),
Some(blocked.to_vec())
);
// Not blocked - sender, recipient, and body do not include the blocked address
let message = HyperlaneMessage {
body: vec![1; 100],
..Default::default()
};
assert!(blocklist.find_blacklisted_address(&message).is_none());
}
}

@ -25,6 +25,7 @@
//! - FallbackProviderSubmitter (Serialized, but if some RPC provider sucks,
//! switch everyone to new one)
pub(crate) mod blacklist;
pub(crate) mod gas_payment;
pub(crate) mod metadata;
pub(crate) mod op_queue;

@ -8,6 +8,7 @@ use std::{
use async_trait::async_trait;
use derive_new::new;
use ethers::utils::hex;
use eyre::Result;
use hyperlane_base::{
db::{HyperlaneRocksDB, ProcessMessage},
@ -18,15 +19,19 @@ use prometheus::IntGauge;
use tokio::sync::mpsc::UnboundedSender;
use tracing::{debug, instrument, trace};
use super::{metadata::AppContextClassifier, pending_message::*};
use super::{blacklist::AddressBlacklist, metadata::AppContextClassifier, pending_message::*};
use crate::{processor::ProcessorExt, settings::matching_list::MatchingList};
/// Finds unprocessed messages from an origin and submits then through a channel
/// for to the appropriate destination.
#[allow(clippy::too_many_arguments)]
pub struct MessageProcessor {
whitelist: Arc<MatchingList>,
blacklist: Arc<MatchingList>,
/// A matching list of messages that should be whitelisted.
message_whitelist: Arc<MatchingList>,
/// A matching list of messages that should be blacklisted.
message_blacklist: Arc<MatchingList>,
/// Addresses that messages may not interact with.
address_blacklist: Arc<AddressBlacklist>,
metrics: MessageProcessorMetrics,
/// channel for each destination chain to send operations (i.e. message
/// submissions) to
@ -217,8 +222,8 @@ impl Debug for MessageProcessor {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"MessageProcessor {{ whitelist: {:?}, blacklist: {:?}, nonce_iterator: {:?}}}",
self.whitelist, self.blacklist, self.nonce_iterator
"MessageProcessor {{ message_whitelist: {:?}, message_blacklist: {:?}, address_blacklist: {:?}, nonce_iterator: {:?}}}",
self.message_whitelist, self.message_blacklist, self.address_blacklist, self.nonce_iterator
)
}
}
@ -247,14 +252,25 @@ impl ProcessorExt for MessageProcessor {
let destination = msg.destination;
// Skip if not whitelisted.
if !self.whitelist.msg_matches(&msg, true) {
debug!(?msg, whitelist=?self.whitelist, "Message not whitelisted, skipping");
if !self.message_whitelist.msg_matches(&msg, true) {
debug!(?msg, whitelist=?self.message_whitelist, "Message not whitelisted, skipping");
return Ok(());
}
// Skip if the message is blacklisted
if self.blacklist.msg_matches(&msg, false) {
debug!(?msg, blacklist=?self.blacklist, "Message blacklisted, skipping");
if self.message_whitelist.msg_matches(&msg, false) {
debug!(?msg, blacklist=?self.message_whitelist, "Message blacklisted, skipping");
return Ok(());
}
// Skip if the message involves a blacklisted address
if let Some(blacklisted_address) = self.address_blacklist.find_blacklisted_address(&msg)
{
debug!(
?msg,
blacklisted_address = hex::encode(blacklisted_address),
"Message involves blacklisted address, skipping"
);
return Ok(());
}
@ -291,18 +307,21 @@ impl ProcessorExt for MessageProcessor {
}
impl MessageProcessor {
#[allow(clippy::too_many_arguments)]
pub fn new(
db: HyperlaneRocksDB,
whitelist: Arc<MatchingList>,
blacklist: Arc<MatchingList>,
message_whitelist: Arc<MatchingList>,
message_blacklist: Arc<MatchingList>,
address_blacklist: Arc<AddressBlacklist>,
metrics: MessageProcessorMetrics,
send_channels: HashMap<u32, UnboundedSender<QueueOperation>>,
destination_ctxs: HashMap<u32, Arc<MessageContext>>,
metric_app_contexts: Vec<(MatchingList, String)>,
) -> Self {
Self {
whitelist,
blacklist,
message_whitelist,
message_blacklist,
address_blacklist,
metrics,
send_channels,
destination_ctxs,
@ -477,6 +496,7 @@ mod test {
db.clone(),
Default::default(),
Default::default(),
Default::default(),
dummy_processor_metrics(origin_domain.id()),
HashMap::from([(destination_domain.id(), send_channel)]),
HashMap::from([(destination_domain.id(), message_context)]),

@ -33,6 +33,7 @@ use tracing::{error, info, info_span, instrument::Instrumented, warn, Instrument
use crate::{
merkle_tree::builder::MerkleTreeBuilder,
msg::{
blacklist::AddressBlacklist,
gas_payment::GasPaymentEnforcer,
metadata::{BaseMetadataBuilder, IsmAwareAppContextClassifier},
op_submitter::{SerialSubmitter, SerialSubmitterMetrics},
@ -70,8 +71,9 @@ pub struct Relayer {
prover_syncs: HashMap<HyperlaneDomain, Arc<RwLock<MerkleTreeBuilder>>>,
merkle_tree_hook_syncs: HashMap<HyperlaneDomain, Arc<dyn ContractSyncer<MerkleTreeInsertion>>>,
dbs: HashMap<HyperlaneDomain, HyperlaneRocksDB>,
whitelist: Arc<MatchingList>,
blacklist: Arc<MatchingList>,
message_whitelist: Arc<MatchingList>,
message_blacklist: Arc<MatchingList>,
address_blacklist: Arc<AddressBlacklist>,
transaction_gas_limit: Option<U256>,
skip_transaction_gas_limit_for: HashSet<u32>,
allow_local_checkpoint_syncers: bool,
@ -89,11 +91,12 @@ impl Debug for Relayer {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Relayer {{ origin_chains: {:?}, destination_chains: {:?}, whitelist: {:?}, blacklist: {:?}, transaction_gas_limit: {:?}, skip_transaction_gas_limit_for: {:?}, allow_local_checkpoint_syncers: {:?} }}",
"Relayer {{ origin_chains: {:?}, destination_chains: {:?}, message_whitelist: {:?}, message_blacklist: {:?}, address_blacklist: {:?}, transaction_gas_limit: {:?}, skip_transaction_gas_limit_for: {:?}, allow_local_checkpoint_syncers: {:?} }}",
self.origin_chains,
self.destination_chains,
self.whitelist,
self.blacklist,
self.message_whitelist,
self.message_blacklist,
self.address_blacklist,
self.transaction_gas_limit,
self.skip_transaction_gas_limit_for,
self.allow_local_checkpoint_syncers
@ -177,14 +180,16 @@ impl BaseAgent for Relayer {
.map(|(k, v)| (k, v as _))
.collect();
let whitelist = Arc::new(settings.whitelist);
let blacklist = Arc::new(settings.blacklist);
let message_whitelist = Arc::new(settings.whitelist);
let message_blacklist = Arc::new(settings.blacklist);
let address_blacklist = Arc::new(AddressBlacklist::new(settings.address_blacklist));
let skip_transaction_gas_limit_for = settings.skip_transaction_gas_limit_for;
let transaction_gas_limit = settings.transaction_gas_limit;
info!(
%whitelist,
%blacklist,
%message_whitelist,
%message_blacklist,
?address_blacklist,
?transaction_gas_limit,
?skip_transaction_gas_limit_for,
"Whitelist configuration"
@ -275,8 +280,9 @@ impl BaseAgent for Relayer {
interchain_gas_payment_syncs,
prover_syncs,
merkle_tree_hook_syncs,
whitelist,
blacklist,
message_whitelist,
message_blacklist,
address_blacklist,
transaction_gas_limit,
skip_transaction_gas_limit_for,
allow_local_checkpoint_syncers: settings.allow_local_checkpoint_syncers,
@ -487,8 +493,9 @@ impl Relayer {
let message_processor = MessageProcessor::new(
self.dbs.get(origin).unwrap().clone(),
self.whitelist.clone(),
self.blacklist.clone(),
self.message_whitelist.clone(),
self.message_blacklist.clone(),
self.address_blacklist.clone(),
metrics,
send_channels,
destination_ctxs,

@ -8,6 +8,7 @@ use std::{collections::HashSet, path::PathBuf};
use convert_case::Case;
use derive_more::{AsMut, AsRef, Deref, DerefMut};
use ethers::utils::hex;
use eyre::{eyre, Context};
use hyperlane_base::{
impl_loadable_from_settings,
@ -46,6 +47,10 @@ pub struct RelayerSettings {
pub whitelist: MatchingList,
/// Filter for what messages to block.
pub blacklist: MatchingList,
/// Filter for what addresses to block interactions with.
/// This is intentionally not an H256 to allow for addresses of any length without
/// adding any padding.
pub address_blacklist: Vec<Vec<u8>>,
/// This is optional. If not specified, any amount of gas will be valid, otherwise this
/// is the max allowed gas in wei to relay a transaction.
pub transaction_gas_limit: Option<U256>,
@ -191,6 +196,14 @@ impl FromRawConf<RawRelayerSettings> for RelayerSettings {
.and_then(parse_matching_list)
.unwrap_or_default();
let address_blacklist = p
.chain(&mut err)
.get_opt_key("addressBlacklist")
.parse_string()
.end()
.map(|str| parse_address_list(str, &mut err, || &p.cwp + "address_blacklist"))
.unwrap_or_default();
let transaction_gas_limit = p
.chain(&mut err)
.get_opt_key("transactionGasLimit")
@ -268,6 +281,7 @@ impl FromRawConf<RawRelayerSettings> for RelayerSettings {
gas_payment_enforcement,
whitelist,
blacklist,
address_blacklist,
transaction_gas_limit,
skip_transaction_gas_limit_for,
allow_local_checkpoint_syncers,
@ -311,3 +325,53 @@ fn parse_matching_list(p: ValueParser) -> ConfigResult<MatchingList> {
err.into_result(ml)
}
fn parse_address_list(
str: &str,
err: &mut ConfigParsingError,
err_path: impl Fn() -> ConfigPath,
) -> Vec<Vec<u8>> {
str.split(',')
.filter_map(|s| {
let mut s = s.trim().to_owned();
if let Some(stripped) = s.strip_prefix("0x") {
s = stripped.to_owned();
}
hex::decode(s).take_err(err, &err_path)
})
.collect_vec()
}
#[cfg(test)]
mod test {
use super::*;
use hyperlane_core::H160;
#[test]
fn test_parse_address_blacklist() {
let valid_address1 = b"valid".to_vec();
let valid_address2 = H160::random().as_bytes().to_vec();
// Successful parsing
let input = format!(
"0x{}, {}",
hex::encode(&valid_address1),
hex::encode(&valid_address2)
);
let mut err = ConfigParsingError::default();
let res = parse_address_list(&input, &mut err, ConfigPath::default);
assert_eq!(res, vec![valid_address1.clone(), valid_address2.clone()]);
assert!(err.is_ok());
// An error in the final address provided
let input = format!(
"0x{}, {}, 0xaazz",
hex::encode(&valid_address1),
hex::encode(&valid_address2)
);
let mut err = ConfigParsingError::default();
let res = parse_address_list(&input, &mut err, ConfigPath::default);
assert_eq!(res, vec![valid_address1, valid_address2]);
assert!(!err.is_ok());
}
}

@ -546,10 +546,10 @@ pub(crate) mod test {
pub sequence: u32,
}
impl Into<Indexed<MockSequencedData>> for MockSequencedData {
fn into(self) -> Indexed<MockSequencedData> {
let sequence = self.sequence;
Indexed::new(self).with_sequence(sequence)
impl From<MockSequencedData> for Indexed<MockSequencedData> {
fn from(val: MockSequencedData) -> Self {
let sequence = val.sequence;
Indexed::new(val).with_sequence(sequence)
}
}

@ -223,7 +223,7 @@ const hyperlane: RootAgentConfig = {
rpcConsensusType: RpcConsensusType.Fallback,
docker: {
repo,
tag: '3bb9d0a-20240619-130157',
tag: '3fddaeb-20240619-163111',
},
gasPaymentEnforcement: gasPaymentEnforcement,
metricAppContexts,

@ -1,4 +1,6 @@
import { BigNumberish } from 'ethers';
import { Logger } from 'pino';
import { z } from 'zod';
import {
AgentConfig,
@ -10,7 +12,13 @@ import {
MatchingList,
RelayerConfig as RelayerAgentConfig,
} from '@hyperlane-xyz/sdk';
import { Address, ProtocolType, addressToBytes32 } from '@hyperlane-xyz/utils';
import {
Address,
ProtocolType,
addressToBytes32,
isValidAddressEvm,
rootLogger,
} from '@hyperlane-xyz/utils';
import { getChain, getDomainId } from '../../../config/registry.js';
import { AgentAwsUser } from '../../agents/aws/user.js';
@ -34,6 +42,7 @@ export interface BaseRelayerConfig {
gasPaymentEnforcement: GasPaymentEnforcement[];
whitelist?: MatchingList;
blacklist?: MatchingList;
addressBlacklist?: string;
transactionGasLimit?: BigNumberish;
skipTransactionGasLimitFor?: string[];
metricAppContexts?: MetricAppContext[];
@ -58,12 +67,15 @@ export interface HelmRelayerChainValues {
export class RelayerConfigHelper extends AgentConfigHelper<RelayerConfig> {
readonly #relayerConfig: BaseRelayerConfig;
readonly logger: Logger<never>;
constructor(agentConfig: RootAgentConfig) {
if (!agentConfig.relayer)
throw Error('Relayer is not defined for this context');
super(agentConfig, agentConfig.relayer);
this.#relayerConfig = agentConfig.relayer;
this.logger = rootLogger.child({ module: 'RelayerConfigHelper' });
}
async buildConfig(): Promise<RelayerConfig> {
@ -80,6 +92,11 @@ export class RelayerConfigHelper extends AgentConfigHelper<RelayerConfig> {
if (baseConfig.blacklist) {
relayerConfig.blacklist = JSON.stringify(baseConfig.blacklist);
}
relayerConfig.addressBlacklist = (await this.getSanctionedAddresses()).join(
',',
);
if (baseConfig.transactionGasLimit) {
relayerConfig.transactionGasLimit =
baseConfig.transactionGasLimit.toString();
@ -130,6 +147,40 @@ export class RelayerConfigHelper extends AgentConfigHelper<RelayerConfig> {
return chainSigners;
}
async getSanctionedAddresses() {
// All Ethereum-style addresses from https://github.com/0xB10C/ofac-sanctioned-digital-currency-addresses/tree/lists
const currencies = ['ARB', 'ETC', 'ETH', 'USDC', 'USDT'];
const schema = z.array(z.string());
const allSanctionedAddresses = await Promise.all(
currencies.map(async (currency) => {
const rawUrl = `https://raw.githubusercontent.com/0xB10C/ofac-sanctioned-digital-currency-addresses/lists/sanctioned_addresses_${currency}.json`;
this.logger.debug(
{
currency,
rawUrl,
},
'Fetching sanctioned addresses',
);
const json = await fetch(rawUrl);
const sanctionedAddresses = schema.parse(await json.json());
return sanctionedAddresses;
}),
);
return allSanctionedAddresses.flat().filter((address) => {
if (!isValidAddressEvm(address)) {
this.logger.debug(
{ address },
'Invalid sanctioned address, throwing out',
);
return false;
}
return true;
});
}
// Returns whether the relayer requires AWS credentials
get requiresAwsCredentials(): boolean {
// If AWS is present on the agentConfig, we are using AWS keys and need credentials regardless.

@ -314,6 +314,10 @@ export const RelayerAgentConfigSchema = AgentConfigSchema.extend({
.describe(
'If no blacklist is provided ALL will be considered to not be on the blacklist.',
),
addressBlacklist: z
.string()
.optional()
.describe('Comma separated list of addresses to blacklist.'),
transactionGasLimit: ZUWei.optional().describe(
'This is optional. If not specified, any amount of gas will be valid, otherwise this is the max allowed gas in wei to relay a transaction.',
),

Loading…
Cancel
Save