Skip to content

Commit c5c0c18

Browse files
feat(core): add random relayer selection endpoint (#28)
* feat(core): add random relayer selection endpoint * fix(core): cargo clippy * feat(sdk): add send random api support for rust and ts sdk * refactor(core): update send random api route to kebab case * docs: add docs for sending txns to random relayer * feat(core): add allowed_random_relayers config for send random endpoint * docs: add docs for allowed_random_relayers in config * feat(sdk): expose sendRandom method in typescript RelayerClient * fix(core): change allowed_random_relayers to opt-in model * Update changelog.mdx * Update send_random_transaction.rs * fix: some changes to the sdks and docs * remove --------- Co-authored-by: Josh Stevens <[email protected]>
1 parent 5fa227f commit c5c0c18

File tree

28 files changed

+1029
-5013
lines changed

28 files changed

+1029
-5013
lines changed

crates/cli/src/commands/network.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ async fn handle_add(project_path: &ProjectLocation) -> Result<(), NetworkError>
110110
enable_sending_blobs: Some(true),
111111
gas_bump_blocks_every: Default::default(),
112112
max_gas_price_multiplier: 4,
113+
allowed_random_relayers: None,
113114
});
114115

115116
project_path.overwrite_setup_config(setup_config)?;

crates/cli/src/commands/new.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ pub async fn handle_init(path: &Path) -> Result<(), InitError> {
7474
enable_sending_blobs: Some(true),
7575
gas_bump_blocks_every: Default::default(),
7676
max_gas_price_multiplier: 4,
77+
allowed_random_relayers: None,
7778
}],
7879
gas_providers: None,
7980
api_config: ApiConfig {

crates/core/src/app_state.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::collections::HashMap;
12
use std::sync::Arc;
23

34
use tokio::sync::Mutex;
@@ -34,6 +35,26 @@ impl RelayersInternalOnly {
3435
}
3536
}
3637

38+
pub struct RelayersAllowedForRandom {
39+
values: HashMap<ChainId, Vec<EvmAddress>>,
40+
}
41+
42+
impl RelayersAllowedForRandom {
43+
pub fn new(values: HashMap<ChainId, Vec<EvmAddress>>) -> Self {
44+
Self { values }
45+
}
46+
47+
pub fn is_allowed(&self, relayer: &EvmAddress, chain_id: &ChainId) -> bool {
48+
if let Some(allowed_relayers) = self.values.get(chain_id) {
49+
// If the list is empty, it means all relayers are allowed (equivalent to "*")
50+
allowed_relayers.is_empty() || allowed_relayers.contains(relayer)
51+
} else {
52+
// If no configuration exists for this chain, the feature is disabled
53+
false
54+
}
55+
}
56+
}
57+
3758
pub struct AppState {
3859
/// Database client with connection pooling
3960
pub db: Arc<PostgresClient>,
@@ -59,6 +80,8 @@ pub struct AppState {
5980
pub safe_proxy_manager: Arc<SafeProxyManager>,
6081
/// Any relayers which can only be called by internal logic
6182
pub relayer_internal_only: Arc<RelayersInternalOnly>,
83+
/// Relayers allowed for random selection per network
84+
pub relayers_allowed_for_random: Arc<RelayersAllowedForRandom>,
6285
/// Hold all networks permissions
6386
pub network_permissions: Arc<Vec<(ChainId, Vec<NetworkPermissionsConfig>)>>,
6487
/// The API keys mapped to be able to be used

crates/core/src/startup.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
use crate::app_state::RelayersInternalOnly;
1+
use crate::app_state::{RelayersAllowedForRandom, RelayersInternalOnly};
22
use crate::authentication::{create_basic_auth_routes, inject_basic_auth_status};
33
use crate::background_tasks::run_background_tasks;
44
use crate::common_types::EvmAddress;
55
use crate::gas::{BlobGasOracleCache, GasOracleCache};
66
use crate::network::{create_network_routes, ChainId};
77
use crate::shared::HttpError;
88
use crate::webhooks::WebhookManager;
9-
use crate::yaml::{ApiKey, NetworkPermissionsConfig, ReadYamlError};
9+
use crate::yaml::{AllOrOneOrManyAddresses, ApiKey, NetworkPermissionsConfig, ReadYamlError};
1010
use crate::{
1111
app_state::AppState,
1212
postgres::{PostgresClient, PostgresConnectionError, PostgresError},
@@ -38,6 +38,7 @@ use axum::{
3838
Json, Router,
3939
};
4040
use dotenv::dotenv;
41+
use std::collections::HashMap;
4142
use std::path::Path;
4243
use std::{
4344
net::SocketAddr,
@@ -170,6 +171,7 @@ async fn start_api(
170171
db: Arc<PostgresClient>,
171172
safe_proxy_manager: Arc<SafeProxyManager>,
172173
relayer_internal_only: RelayersInternalOnly,
174+
relayers_allowed_for_random: RelayersAllowedForRandom,
173175
config: &SetupConfig,
174176
) -> Result<(), StartApiError> {
175177
// Calculate which networks are configured with only private keys
@@ -217,6 +219,7 @@ async fn start_api(
217219
relayer_creation_mutex: Arc::new(Mutex::new(())),
218220
safe_proxy_manager,
219221
relayer_internal_only: Arc::new(relayer_internal_only),
222+
relayers_allowed_for_random: Arc::new(relayers_allowed_for_random),
220223
network_permissions: Arc::new(network_permissions),
221224
api_keys: Arc::new(api_keys),
222225
network_configs: Arc::new(config.networks.clone()),
@@ -406,11 +409,27 @@ pub async fn start(project_path: &Path) -> Result<(), StartError> {
406409

407410
let mut safe_configs: Vec<SafeProxyConfig> = vec![];
408411
let mut relayer_internal_only: Vec<(ChainId, EvmAddress)> = vec![];
412+
let mut relayers_allowed_for_random: HashMap<ChainId, Vec<EvmAddress>> = HashMap::new();
409413
let mut network_permissions: Vec<(ChainId, Vec<NetworkPermissionsConfig>)> = vec![];
410414
let mut api_keys: Vec<(ChainId, Vec<ApiKey>)> = vec![];
411415
for network_config in &config.networks {
412416
api_keys
413417
.push((network_config.chain_id, network_config.api_keys.clone().unwrap_or_default()));
418+
419+
if let Some(allowed_random) = &network_config.allowed_random_relayers {
420+
let allowed_addresses = match allowed_random {
421+
AllOrOneOrManyAddresses::All => {
422+
// Empty vector means all relayers are allowed
423+
vec![]
424+
}
425+
AllOrOneOrManyAddresses::One(address) => {
426+
vec![*address]
427+
}
428+
AllOrOneOrManyAddresses::Many(addresses) => addresses.clone(),
429+
};
430+
relayers_allowed_for_random.insert(network_config.chain_id, allowed_addresses);
431+
}
432+
414433
if let Some(automatic_top_up_configs) = &network_config.automatic_top_up {
415434
for automatic_top_up in automatic_top_up_configs {
416435
if let Some(safe_address) = &automatic_top_up.from.safe {
@@ -434,6 +453,7 @@ pub async fn start(project_path: &Path) -> Result<(), StartError> {
434453

435454
let safe_proxy_manager = Arc::new(SafeProxyManager::new(safe_configs));
436455
let relayer_internal_only = RelayersInternalOnly::new(relayer_internal_only);
456+
let relayers_allowed_for_random = RelayersAllowedForRandom::new(relayers_allowed_for_random);
437457

438458
let transaction_queue = startup_transactions_queues(
439459
gas_oracle_cache.clone(),
@@ -485,6 +505,7 @@ pub async fn start(project_path: &Path) -> Result<(), StartError> {
485505
postgres_client,
486506
safe_proxy_manager,
487507
relayer_internal_only,
508+
relayers_allowed_for_random,
488509
&config,
489510
)
490511
.await?;

crates/core/src/transaction/api/mod.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ mod get_transactions_pending_count;
1818
mod replace_transaction;
1919
mod send_transaction;
2020
pub use send_transaction::{RelayTransactionRequest, SendTransactionResult};
21+
mod send_random_transaction;
2122
mod types;
2223
pub use types::TransactionSpeed;
2324

@@ -26,7 +27,11 @@ pub fn create_transactions_routes() -> Router<Arc<AppState>> {
2627
Router::new()
2728
.route("/:id", get(get_transaction_by_id::get_transaction_by_id_api))
2829
.route("/status/:id", get(get_transaction_status::get_transaction_status))
29-
.route("/relayers/:relayer_id/send", post(send_transaction::send_transaction))
30+
.route("/relayers/:relayer_id/send", post(send_transaction::handle_send_transaction))
31+
.route(
32+
"/relayers/:chain_id/send-random",
33+
post(send_random_transaction::send_transaction_random),
34+
)
3035
.route("/replace/:transaction_id", put(replace_transaction::replace_transaction))
3136
.route("/cancel/:transaction_id", put(cancel_transaction::cancel_transaction))
3237
.route("/relayers/:relayer_id", get(get_relayer_transactions::get_relayer_transactions))
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
use crate::app_state::AppState;
2+
use crate::network::ChainId;
3+
use crate::relayer::Relayer;
4+
use crate::shared::{bad_request, not_found, HttpError};
5+
use crate::transaction::api::send_transaction::send_transaction;
6+
use crate::transaction::api::{RelayTransactionRequest, SendTransactionResult};
7+
use axum::{
8+
extract::{Path, State},
9+
http::HeaderMap,
10+
Json,
11+
};
12+
use rand::seq::SliceRandom;
13+
use std::sync::Arc;
14+
15+
/// Handles random relayer selection for transaction requests
16+
/// across multiple relayers on the same chain.
17+
///
18+
/// This endpoint selects a random available (non-paused, non-internal) relayer
19+
/// and forwards the transaction request to it.
20+
pub async fn send_transaction_random(
21+
State(state): State<Arc<AppState>>,
22+
Path(chain_id): Path<ChainId>,
23+
headers: HeaderMap,
24+
Json(transaction): Json<RelayTransactionRequest>,
25+
) -> Result<Json<SendTransactionResult>, HttpError> {
26+
state.validate_allowed_passed_basic_auth(&headers)?;
27+
let relayer = select_random_relayer(&state, &chain_id).await?;
28+
let result = send_transaction(relayer, transaction, &state, &headers).await?;
29+
Ok(Json(result))
30+
}
31+
32+
/// Selects a random available relayer for the specified chain.
33+
///
34+
/// Filters out paused, internal-only, and relayers only allowed for random selection.
35+
/// Note: The random relayer feature must be explicitly enabled via `allowed_random_relayers`
36+
/// config for the network, otherwise all relayers will be filtered out.
37+
async fn select_random_relayer(
38+
state: &Arc<AppState>,
39+
chain_id: &ChainId,
40+
) -> Result<Relayer, HttpError> {
41+
let relayers = state.db.get_all_relayers_for_chain(chain_id).await?;
42+
43+
if relayers.is_empty() {
44+
return Err(not_found(format!("No relayers found for chain {}", chain_id)));
45+
}
46+
47+
let mut rng = rand::thread_rng();
48+
// TODO: it should be smart enough to also only pick the one with enough native funds to send the tx
49+
let available_relayers: Vec<_> = relayers
50+
.into_iter()
51+
.filter(|r| {
52+
!r.paused
53+
&& !state.relayer_internal_only.restricted(&r.address, &r.chain_id)
54+
&& state.relayers_allowed_for_random.is_allowed(&r.address, &r.chain_id)
55+
})
56+
.collect();
57+
available_relayers.choose(&mut rng).cloned().ok_or_else(|| {
58+
bad_request(format!(
59+
"No available relayers for chain {} (all relayers are paused, internal-only, or not allowed for random selection)",
60+
chain_id
61+
))
62+
})
63+
}

crates/core/src/transaction/api/send_transaction.rs

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use super::types::TransactionSpeed;
22
use crate::rate_limiting::RateLimiter;
3-
use crate::relayer::get_relayer;
3+
use crate::relayer::{get_relayer, Relayer};
44
use crate::shared::utils::convert_blob_strings_to_blobs;
55
use crate::shared::{internal_server_error, not_found, unauthorized, HttpError};
66
use crate::{
@@ -52,7 +52,7 @@ pub struct SendTransactionResult {
5252
}
5353

5454
/// API endpoint to send a new transaction through a relayer.
55-
pub async fn send_transaction(
55+
pub async fn handle_send_transaction(
5656
State(state): State<Arc<AppState>>,
5757
Path(relayer_id): Path<RelayerId>,
5858
headers: HeaderMap,
@@ -64,7 +64,18 @@ pub async fn send_transaction(
6464
.await?
6565
.ok_or(not_found("Relayer does not exist".to_string()))?;
6666

67-
state.validate_auth_basic_or_api_key(&headers, &relayer.address, &relayer.chain_id)?;
67+
let result = send_transaction(relayer, transaction, &state, &headers).await?;
68+
69+
Ok(Json(result))
70+
}
71+
72+
pub async fn send_transaction(
73+
relayer: Relayer,
74+
transaction: RelayTransactionRequest,
75+
state: &Arc<AppState>,
76+
headers: &HeaderMap,
77+
) -> Result<SendTransactionResult, HttpError> {
78+
state.validate_auth_basic_or_api_key(headers, &relayer.address, &relayer.chain_id)?;
6879

6980
if state.relayer_internal_only.restricted(&relayer.address, &relayer.chain_id) {
7081
return Err(unauthorized(Some("Relayer can only be used internally".to_string())));
@@ -93,9 +104,9 @@ pub async fn send_transaction(
93104
}
94105

95106
let rate_limit_reservation = RateLimiter::check_and_reserve_rate_limit(
96-
&state,
97-
&headers,
98-
&relayer_id,
107+
state,
108+
headers,
109+
&relayer.id,
99110
RateLimitOperation::Transaction,
100111
)
101112
.await?;
@@ -113,7 +124,7 @@ pub async fn send_transaction(
113124
.transactions_queues
114125
.lock()
115126
.await
116-
.add_transaction(&relayer_id, &transaction_to_send)
127+
.add_transaction(&relayer.id, &transaction_to_send)
117128
.await?;
118129

119130
let result = SendTransactionResult {
@@ -127,5 +138,5 @@ pub async fn send_transaction(
127138
reservation.commit();
128139
}
129140

130-
Ok(Json(result))
141+
Ok(result)
131142
}

crates/core/src/yaml.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,8 @@ pub struct NetworkSetupConfig {
474474
pub gas_bump_blocks_every: GasBumpBlockConfig,
475475
#[serde(default = "default_max_gas_price_multiplier")]
476476
pub max_gas_price_multiplier: u64,
477+
#[serde(skip_serializing_if = "Option::is_none", default)]
478+
pub allowed_random_relayers: Option<AllOrOneOrManyAddresses>,
477479
}
478480

479481
impl From<NetworkSetupConfig> for Network {

crates/e2e-tests/src/tests/transactions/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mod send_blob;
1212
mod send_contract_interaction;
1313
mod send_eth;
1414
mod send_eth_legacy;
15+
mod send_random_fails;
1516
mod status;
1617
mod validation;
1718

@@ -140,6 +141,11 @@ impl TestModule for TransactionTests {
140141
"Transaction validation - balance edge cases",
141142
|runner| Box::pin(runner.transaction_validation_balance_edge_cases()),
142143
),
144+
TestDefinition::new(
145+
"send_random_fails",
146+
"Transaction should not send random when not enabled - send random fails",
147+
|runner| Box::pin(runner.send_random_fails()),
148+
),
143149
]
144150
}
145151
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
use crate::tests::test_runner::TestRunner;
2+
use anyhow::anyhow;
3+
use rrelayer_core::transaction::api::{RelayTransactionRequest, TransactionSpeed};
4+
use rrelayer_core::transaction::types::TransactionData;
5+
use tracing::info;
6+
7+
impl TestRunner {
8+
/// run single with:
9+
/// RRELAYER_PROVIDERS="raw" make run-test-debug TEST=send_random_fails
10+
/// RRELAYER_PROVIDERS="privy" make run-test-debug TEST=send_random_fails
11+
/// RRELAYER_PROVIDERS="aws_secret_manager" make run-test-debug TEST=send_random_fails
12+
/// RRELAYER_PROVIDERS="aws_kms" make run-test-debug TEST=send_random_fails
13+
/// RRELAYER_PROVIDERS="gcp_secret_manager" make run-test-debug TEST=send_random_fails
14+
/// RRELAYER_PROVIDERS="turnkey" make run-test-debug TEST=send_random_fails
15+
/// RRELAYER_PROVIDERS="pkcs11" make run-test-debug TEST=send_random_fails
16+
pub async fn send_random_fails(&self) -> anyhow::Result<()> {
17+
info!("Testing simple eth transfer...");
18+
19+
let relayer = self.create_and_fund_relayer("send-random-fails").await?;
20+
info!("Created relayer: {:?}", relayer);
21+
22+
let tx_request = RelayTransactionRequest {
23+
to: self.config.anvil_accounts[1],
24+
value: alloy::primitives::utils::parse_ether("0.1")?.into(),
25+
data: TransactionData::empty(),
26+
speed: Some(TransactionSpeed::FAST),
27+
external_id: Some("send-random-fails".to_string()),
28+
blobs: None,
29+
};
30+
31+
let relayer_client = self
32+
.relayer_client
33+
.client
34+
.transaction()
35+
.send_random(self.config.chain_id, &tx_request, None)
36+
.await;
37+
match relayer_client {
38+
Err(_) => Ok(()),
39+
Ok(_) => Err(anyhow!("Should not send random tx")),
40+
}
41+
}
42+
}

0 commit comments

Comments
 (0)