Skip to content

Commit f15108c

Browse files
decofezerosnacksmablrstevencartaviazerosnacks
authored
feat(anvil): support multiple fork URLs with round-robin load balancing (foundry-rs#14280)
* feat(anvil): support multiple fork URLs with fallback Allow `--fork-url` to be specified multiple times to distribute RPC requests across endpoints using Alloy's FallbackService. Endpoints are scored by latency and success rate; unhealthy endpoints (429s, timeouts) are automatically deprioritized. Uses active_transport_count=1 for sequential best-endpoint routing. Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d85e6-b08f-748c-8f05-17ebee8e17f8 * fix: fmt * feat(config): support multi-endpoint `endpoints` array in foundry.toml Add `endpoints` key to `[rpc_endpoints]` config as a backwards-compatible alternative to `endpoint`. When an alias with multiple endpoints is used as `--fork-url`, all URLs are expanded for multi-endpoint forking. Example: [rpc_endpoints] mainnet = { endpoints = ["https://rpc1.example.com", "https://rpc2.example.com"], retries = 5 } Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d85e6-b08f-748c-8f05-17ebee8e17f8 * test: add guardrail tests and handle curl_mode in build_fallback - Add test confirming `requires = "fork_url"` guards still fire with Vec - Bail on curl_mode in build_fallback (incompatible with multi-endpoint) - Clean up redundant extra_endpoints default in From impl Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d85e6-b08f-748c-8f05-17ebee8e17f8 * refactor(anvil): consolidate eth_rpc_url into fork_urls Remove duplicate `eth_rpc_url` field from `NodeConfig` and `ClientForkConfig`. The primary URL is now always `fork_urls[0]`, eliminating the invariant that `eth_rpc_url == fork_urls[0]`. - `ClientForkConfig::eth_rpc_url()` is now an accessor returning `&str` - `NodeConfig::with_eth_rpc_url()` kept as convenience, wraps into `fork_urls` - Checks for forking enabled use `fork_urls.is_empty()` instead of `eth_rpc_url.is_none()` Co-Authored-By: zerosnacks <95942363+zerosnacks@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d8b67-c7cc-764f-bb5e-74b6e8fa6858 * refactor(anvil): simplify update_url to replace all fork_urls When resetting the fork via anvil_reset or anvil_setRpcUrl, the intent is to switch the fork target entirely — not swap one endpoint in a load-balanced pool. Replace the whole fork_urls vec with the single new URL instead of mutating fork_urls[0] in-place. Co-Authored-By: zerosnacks <95942363+zerosnacks@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d8b67-c7cc-764f-bb5e-74b6e8fa6858 * refactor(anvil): rename update_url to update_urls taking Vec<String> Accepts a Vec so the reset/update path can reconstruct a fallback provider when given multiple URLs, instead of always collapsing to a single endpoint. Co-Authored-By: zerosnacks <95942363+zerosnacks@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d8b67-c7cc-764f-bb5e-74b6e8fa6858 * refactor(anvil): update ClientFork::reset to accept Vec<String> Change reset(url: Option<String>) to reset(urls: Vec<String>) so the reset path can preserve multi-endpoint fallback. The underlying MaybeForkedDatabase::maybe_reset still takes Option<String> since it ignores the url anyway (marked TODO upstream). Co-Authored-By: zerosnacks <95942363+zerosnacks@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d8b67-c7cc-764f-bb5e-74b6e8fa6858 * refactor: use Vec<String> throughout reset path Commit to Vec<String> in MaybeForkedDatabase::maybe_reset, ForkedDatabase::reset, and ClientFork::reset. The Option<String> → Vec<String> conversion now happens only at the RPC boundary (Forking.json_rpc_url in reset_fork). Co-Authored-By: zerosnacks <95942363+zerosnacks@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d8b67-c7cc-764f-bb5e-74b6e8fa6858 * fix: fmt Co-Authored-By: zerosnacks <95942363+zerosnacks@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d8b67-c7cc-764f-bb5e-74b6e8fa6858 * round-robin for load-balance * comments * fix(anvil): sync fork_urls after anvil_setRpcUrl and anvil_reset Fix two bugs where node_config.fork_urls could get out of sync: 1. reset_block_number() now updates node_config.fork_urls before calling setup_fork_db_config(), preventing stale multi-URL lists from persisting after anvil_reset with a new URL. 2. anvil_setRpcUrl now also updates node_config.fork_urls, so subsequent anvil_reset(None) uses the correct URL instead of reverting to the original startup URL. Added regression tests for both scenarios. Amp-Thread-ID: https://ampcode.com/threads/T-019db0c0-6038-7428-8483-365402ff31a3 Co-authored-by: Amp <amp@ampcode.com> * chore: fmt Amp-Thread-ID: https://ampcode.com/threads/T-019db0c0-6038-7428-8483-365402ff31a3 Co-authored-by: Amp <amp@ampcode.com> --------- Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com> Co-authored-by: Mablr <59505383+mablr@users.noreply.github.com> Co-authored-by: steven <corderosteven6@gmail.com> Co-authored-by: stevencartavia <112043913+stevencartavia@users.noreply.github.com> Co-authored-by: zerosnacks <zerosnacks@protonmail.com> Co-authored-by: Amp <amp@ampcode.com>
1 parent f609179 commit f15108c

13 files changed

Lines changed: 565 additions & 71 deletions

File tree

crates/anvil/src/cmd.rs

Lines changed: 117 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,18 @@ impl NodeArgs {
227227
let compute_units_per_second =
228228
if self.evm.no_rate_limit { Some(u64::MAX) } else { self.evm.compute_units_per_second };
229229

230+
// Validate that secondary fork URLs don't have conflicting block number suffixes
231+
if self.evm.fork_url.len() > 1 {
232+
for fork in &self.evm.fork_url[1..] {
233+
if fork.block.is_some() {
234+
eyre::bail!(
235+
"Block number suffixes (@block) on secondary --fork-url values are not supported. \
236+
Use --fork-block-number to set the fork block for all endpoints."
237+
);
238+
}
239+
}
240+
}
241+
230242
let hardfork = match &self.hardfork {
231243
Some(hf) => {
232244
if self.evm.networks.is_optimism() {
@@ -260,7 +272,7 @@ impl NodeArgs {
260272
_ => self
261273
.evm
262274
.fork_url
263-
.as_ref()
275+
.first()
264276
.and_then(|f| f.block)
265277
.map(|num| ForkChoice::Block(num as i128)),
266278
})
@@ -270,7 +282,7 @@ impl NodeArgs {
270282
.fork_request_retries(self.evm.fork_request_retries)
271283
.fork_retry_backoff(self.evm.fork_retry_backoff.map(Duration::from_millis))
272284
.fork_compute_units_per_second(compute_units_per_second)
273-
.with_eth_rpc_url(self.evm.fork_url.map(|fork| fork.url))
285+
.with_fork_urls(self.evm.fork_url.into_iter().map(|f| f.url).collect())
274286
.with_base_fee(self.evm.block_base_fee_per_gas)
275287
.disable_min_priority_fee(self.evm.disable_min_priority_fee)
276288
.with_no_storage_caching(self.evm.no_storage_caching)
@@ -426,14 +438,18 @@ pub struct AnvilEvmArgs {
426438
/// Fetch state over a remote endpoint instead of starting from an empty state.
427439
///
428440
/// If you want to fetch state from a specific block number, add a block number like `http://localhost:8545@1400000` or use the `--fork-block-number` argument.
441+
///
442+
/// Multiple `--fork-url` flags can be provided to distribute requests across endpoints
443+
/// using round-robin load balancing. On failure, the retry layer rotates to the next
444+
/// endpoint.
429445
#[arg(
430446
long,
431447
short,
432448
visible_alias = "rpc-url",
433449
value_name = "URL",
434450
help_heading = "Fork config"
435451
)]
436-
pub fork_url: Option<ForkUrl>,
452+
pub fork_url: Vec<ForkUrl>,
437453

438454
/// Headers to use for the rpc client, e.g. "User-Agent: test-agent"
439455
///
@@ -630,13 +646,45 @@ pub struct AnvilEvmArgs {
630646
/// Resolves an alias passed as fork-url to the matching url defined in the rpc_endpoints section
631647
/// of the project configuration file.
632648
/// Does nothing if the fork-url is not a configured alias.
649+
///
650+
/// When an alias maps to an `RpcEndpoint` with multiple `endpoints`, all URLs are expanded
651+
/// into additional `--fork-url` entries for multi-endpoint load balancing.
633652
impl AnvilEvmArgs {
634653
pub fn resolve_rpc_alias(&mut self) {
635-
if let Some(fork_url) = &self.fork_url
636-
&& let Ok(config) = Config::load_with_providers(FigmentProviders::Anvil)
637-
&& let Some(Ok(url)) = config.get_rpc_url_with_alias(&fork_url.url)
638-
{
639-
self.fork_url = Some(ForkUrl { url: url.to_string(), block: fork_url.block });
654+
if let Ok(config) = Config::load_with_providers(FigmentProviders::Anvil) {
655+
let mut resolved_urls = Vec::new();
656+
for fork_url in &self.fork_url {
657+
let mut endpoints = config.rpc_endpoints.clone().resolved();
658+
if let Some(endpoint) = endpoints.remove(&fork_url.url) {
659+
// Alias matched — expand all URLs from the endpoint config
660+
match endpoint.all_urls() {
661+
Ok(urls) => {
662+
for (i, url) in urls.into_iter().enumerate() {
663+
resolved_urls.push(ForkUrl {
664+
url,
665+
// Only the first URL inherits the block suffix
666+
block: if i == 0 { fork_url.block } else { None },
667+
});
668+
}
669+
}
670+
Err(e) => {
671+
warn!(target: "node", alias=%fork_url.url, %e, "could not resolve all endpoints, using primary endpoint only");
672+
if let Ok(url) = endpoint.url() {
673+
resolved_urls.push(ForkUrl { url, block: fork_url.block });
674+
} else {
675+
resolved_urls.push(fork_url.clone());
676+
}
677+
}
678+
}
679+
} else if let Some(Ok(url)) = config.get_rpc_url_with_alias(&fork_url.url) {
680+
// Try mesc or other resolution
681+
resolved_urls.push(ForkUrl { url: url.to_string(), block: fork_url.block });
682+
} else {
683+
// Not an alias — keep as-is
684+
resolved_urls.push(fork_url.clone());
685+
}
686+
}
687+
self.fork_url = resolved_urls;
640688
}
641689
}
642690
}
@@ -965,4 +1013,65 @@ mod tests {
9651013
["::1", "1.1.1.1", "2.2.2.2"].map(|ip| ip.parse::<IpAddr>().unwrap()).to_vec()
9661014
);
9671015
}
1016+
1017+
#[test]
1018+
fn can_parse_multiple_fork_urls() {
1019+
let args: NodeArgs = NodeArgs::parse_from([
1020+
"anvil",
1021+
"--fork-url",
1022+
"http://localhost:8545",
1023+
"--fork-url",
1024+
"http://localhost:8546",
1025+
"--fork-url",
1026+
"http://localhost:8547",
1027+
]);
1028+
assert_eq!(args.evm.fork_url.len(), 3);
1029+
assert_eq!(args.evm.fork_url[0].url, "http://localhost:8545");
1030+
assert_eq!(args.evm.fork_url[1].url, "http://localhost:8546");
1031+
assert_eq!(args.evm.fork_url[2].url, "http://localhost:8547");
1032+
1033+
// Block suffix on first URL should work
1034+
let args: NodeArgs = NodeArgs::parse_from([
1035+
"anvil",
1036+
"--fork-url",
1037+
"http://localhost:8545@1000000",
1038+
"--fork-url",
1039+
"http://localhost:8546",
1040+
]);
1041+
assert_eq!(args.evm.fork_url[0].block, Some(1000000));
1042+
assert_eq!(args.evm.fork_url[1].block, None);
1043+
}
1044+
1045+
#[test]
1046+
fn rejects_block_suffix_on_secondary_fork_urls() {
1047+
let args: NodeArgs = NodeArgs::parse_from([
1048+
"anvil",
1049+
"--fork-url",
1050+
"http://localhost:8545@1000000",
1051+
"--fork-url",
1052+
"http://localhost:8546@2000000",
1053+
]);
1054+
let result = args.into_node_config();
1055+
assert!(result.is_err());
1056+
assert!(
1057+
result.unwrap_err().to_string().contains("Block number suffixes"),
1058+
"should reject block suffix on secondary fork URL"
1059+
);
1060+
}
1061+
1062+
#[test]
1063+
fn fork_dependent_args_require_fork_url() {
1064+
// All these args have `requires = "fork_url"` — they should fail without --fork-url
1065+
let cases = [
1066+
vec!["anvil", "--fork-header", "X-Api-Key: test"],
1067+
vec!["anvil", "--timeout", "5000"],
1068+
vec!["anvil", "--retries", "3"],
1069+
vec!["anvil", "--fork-block-number", "100"],
1070+
vec!["anvil", "--fork-retry-backoff", "500"],
1071+
];
1072+
for args in &cases {
1073+
let result = NodeArgs::try_parse_from(args);
1074+
assert!(result.is_err(), "expected error when using {:?} without --fork-url", args[1]);
1075+
}
1076+
}
9681077
}

crates/anvil/src/config.rs

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,13 @@ pub struct NodeConfig {
134134
pub port: u16,
135135
/// maximum number of transactions in a block
136136
pub max_transactions: usize,
137-
/// url of the rpc server that should be used for any rpc calls
138-
pub eth_rpc_url: Option<String>,
137+
/// Fork URLs for RPC calls. The first entry is the primary endpoint.
138+
/// When multiple URLs are provided, requests are distributed using
139+
/// round-robin load balancing with retry-based failover.
140+
pub fork_urls: Vec<String>,
139141
/// pins the block number or transaction hash for the state fork
140142
pub fork_choice: Option<ForkChoice>,
141-
/// headers to use with `eth_rpc_url`
143+
/// headers to use with fork RPC endpoints
142144
pub fork_headers: Vec<String>,
143145
/// specifies chain id for cache to skip fetching from remote in offline-start mode
144146
pub fork_chain_id: Option<U256>,
@@ -268,12 +270,19 @@ Block number: {}
268270
Block hash: {:?}
269271
Chain ID: {}
270272
"#,
271-
fork.eth_rpc_url(),
273+
fork.eth_rpc_url().as_deref().unwrap_or("none"),
272274
fork.block_number(),
273275
fork.block_hash(),
274276
fork.chain_id()
275277
);
276278

279+
if self.fork_urls.len() > 1 {
280+
let _ = writeln!(s, "Endpoints: {}", self.fork_urls.len());
281+
for (i, url) in self.fork_urls.iter().enumerate() {
282+
let _ = writeln!(s, " ({i}) {url}");
283+
}
284+
}
285+
277286
if let Some(tx_hash) = fork.transaction_hash() {
278287
let _ = writeln!(s, "Transaction hash: {tx_hash}");
279288
}
@@ -393,7 +402,7 @@ Genesis Number
393402
json!({
394403
"available_accounts": available_accounts,
395404
"private_keys": private_keys,
396-
"endpoint": fork.eth_rpc_url(),
405+
"endpoint": fork.eth_rpc_url().unwrap_or_default(),
397406
"block_number": fork.block_number(),
398407
"block_hash": fork.block_hash(),
399408
"chain_id": fork.chain_id(),
@@ -466,7 +475,7 @@ impl Default for NodeConfig {
466475
mixed_mining: false,
467476
port: NODE_PORT,
468477
max_transactions: 1_000,
469-
eth_rpc_url: None,
478+
fork_urls: vec![],
470479
fork_choice: None,
471480
account_generator: None,
472481
base_fee: None,
@@ -855,10 +864,19 @@ impl NodeConfig {
855864
self
856865
}
857866

858-
/// Sets the `eth_rpc_url` to use when forking
867+
/// Sets the `eth_rpc_url` to use when forking (single endpoint convenience).
859868
#[must_use]
860869
pub fn with_eth_rpc_url<U: Into<String>>(mut self, eth_rpc_url: Option<U>) -> Self {
861-
self.eth_rpc_url = eth_rpc_url.map(Into::into);
870+
if let Some(url) = eth_rpc_url {
871+
self.fork_urls = vec![url.into()];
872+
}
873+
self
874+
}
875+
876+
/// Sets the fork URLs for load-balanced multi-endpoint forking.
877+
#[must_use]
878+
pub fn with_fork_urls(mut self, fork_urls: Vec<String>) -> Self {
879+
self.fork_urls = fork_urls;
862880
self
863881
}
864882

@@ -891,7 +909,7 @@ impl NodeConfig {
891909
self
892910
}
893911

894-
/// Sets the `fork_headers` to use with `eth_rpc_url`
912+
/// Sets the `fork_headers` to use with fork RPC endpoints
895913
#[must_use]
896914
pub fn with_fork_headers(mut self, headers: Vec<String>) -> Self {
897915
self.fork_headers = headers;
@@ -1017,7 +1035,7 @@ impl NodeConfig {
10171035
///
10181036
/// See also [ Config::foundry_block_cache_file()]
10191037
pub fn block_cache_path(&self, block: u64) -> Option<PathBuf> {
1020-
if self.no_storage_caching || self.eth_rpc_url.is_none() {
1038+
if self.no_storage_caching || self.fork_urls.is_empty() {
10211039
return None;
10221040
}
10231041
let chain_id = self.get_chain_id();
@@ -1145,7 +1163,7 @@ impl NodeConfig {
11451163
);
11461164

11471165
let (db, fork): (Arc<TokioRwLock<Box<dyn Db>>>, Option<ClientFork>) =
1148-
if let Some(eth_rpc_url) = self.eth_rpc_url.clone() {
1166+
if let Some(eth_rpc_url) = self.fork_urls.first().cloned() {
11491167
self.setup_fork_db(eth_rpc_url, &mut evm_env, &fees).await?
11501168
} else {
11511169
(Arc::new(TokioRwLock::new(Box::<MemDb>::default())), None)
@@ -1208,7 +1226,7 @@ impl NodeConfig {
12081226

12091227
// Writes the default create2 deployer to the backend,
12101228
// if the option is not disabled and we are not forking.
1211-
if !self.disable_default_create2_deployer && self.eth_rpc_url.is_none() {
1229+
if !self.disable_default_create2_deployer && self.fork_urls.is_empty() {
12121230
backend
12131231
.set_create2_deployer(DEFAULT_CREATE2_DEPLOYER)
12141232
.await
@@ -1248,6 +1266,10 @@ impl NodeConfig {
12481266
fees: &FeeManager,
12491267
) -> Result<(ForkedDatabase<AnyNetwork>, ClientForkConfig)> {
12501268
debug!(target: "node", ?eth_rpc_url, "setting up fork db");
1269+
1270+
// Always bootstrap with the primary URL only to avoid race conditions
1271+
// where discovery calls (get_chain_id, find_latest_fork_block, get_block)
1272+
// hit different endpoints that may be at different chain tips.
12511273
let provider = Arc::new(
12521274
ProviderBuilder::new(&eth_rpc_url)
12531275
.timeout(self.fork_request_timeout)
@@ -1409,6 +1431,25 @@ latest block number: {latest_block}"
14091431
BlockchainDb::new(meta, self.block_cache_path(fork_block_number))
14101432
};
14111433

1434+
// After bootstrap, rebuild the provider with round-robin if multiple URLs are
1435+
// configured. This ensures bootstrap used only the primary endpoint for consistency,
1436+
// while ongoing requests are distributed across all endpoints.
1437+
let provider = if self.fork_urls.len() > 1 {
1438+
debug!(target: "node", urls=?self.fork_urls, "using multi-endpoint round-robin provider");
1439+
Arc::new(
1440+
ProviderBuilder::new(&eth_rpc_url)
1441+
.timeout(self.fork_request_timeout)
1442+
.initial_backoff(self.fork_retry_backoff.as_millis() as u64)
1443+
.compute_units_per_second(self.compute_units_per_second)
1444+
.max_retry(self.fork_request_retries)
1445+
.headers(self.fork_headers.clone())
1446+
.build_fallback(self.fork_urls.clone())
1447+
.wrap_err("failed to establish round-robin provider to fork urls")?,
1448+
)
1449+
} else {
1450+
provider
1451+
};
1452+
14121453
// This will spawn the background thread that will use the provider to fetch
14131454
// blockchain data from the other client
14141455
let backend = SharedBackend::spawn_backend(
@@ -1419,7 +1460,7 @@ latest block number: {latest_block}"
14191460
.await;
14201461

14211462
let config = ClientForkConfig {
1422-
eth_rpc_url,
1463+
fork_urls: self.fork_urls.clone(),
14231464
block_number: fork_block_number,
14241465
block_hash,
14251466
transaction_hash: self.fork_choice.and_then(|fc| fc.transaction_hash()),
@@ -1432,6 +1473,7 @@ latest block number: {latest_block}"
14321473
retries: self.fork_request_retries,
14331474
backoff: self.fork_retry_backoff,
14341475
compute_units_per_second: self.compute_units_per_second,
1476+
headers: self.fork_headers.clone(),
14351477
total_difficulty: block.header.total_difficulty.unwrap_or_default(),
14361478
blob_gas_used: block.header.blob_gas_used().map(|g| g as u128),
14371479
blob_excess_gas_and_price: evm_env.block_env.blob_excess_gas_and_price,

crates/anvil/src/eth/api.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ impl<N: Network> EthApi<N> {
416416
let config = fork.config.read();
417417

418418
NodeForkConfig {
419-
fork_url: Some(config.eth_rpc_url.clone()),
419+
fork_url: config.eth_rpc_url().map(|s| s.to_string()),
420420
fork_block_number: Some(config.block_number),
421421
fork_retry_backoff: Some(config.backoff.as_millis()),
422422
}
@@ -527,7 +527,7 @@ impl<N: Network> EthApi<N> {
527527
/// Sets the backend rpc url
528528
///
529529
/// Handler for ETH RPC call: `anvil_setRpcUrl`
530-
pub fn anvil_set_rpc_url(&self, url: String) -> Result<()> {
530+
pub async fn anvil_set_rpc_url(&self, url: String) -> Result<()> {
531531
node_info!("anvil_setRpcUrl");
532532
if let Some(fork) = self.backend.get_fork() {
533533
let mut config = fork.config.write();
@@ -543,9 +543,11 @@ impl<N: Network> EthApi<N> {
543543
)?, // .interval(interval),
544544
);
545545
config.provider = new_provider;
546-
trace!(target: "backend", "Updated fork rpc from \"{}\" to \"{}\"", config.eth_rpc_url, url);
547-
config.eth_rpc_url = url;
546+
trace!(target: "backend", "Updated fork rpc from \"{}\" to \"{}\"", config.eth_rpc_url().unwrap_or("none"), url);
547+
config.fork_urls = vec![url.clone()];
548548
}
549+
// Keep node_config in sync so anvil_reset(None) uses the updated URL
550+
self.backend.node_config.write().await.fork_urls = vec![url];
549551
Ok(())
550552
}
551553

@@ -1791,7 +1793,7 @@ impl EthApi<FoundryNetwork> {
17911793
EthRequest::EvmMineDetailed(mine) => {
17921794
self.evm_mine_detailed(mine.and_then(|p| p.params)).await.to_rpc_result()
17931795
}
1794-
EthRequest::SetRpcUrl(url) => self.anvil_set_rpc_url(url).to_rpc_result(),
1796+
EthRequest::SetRpcUrl(url) => self.anvil_set_rpc_url(url).await.to_rpc_result(),
17951797
EthRequest::EthSendUnsignedTransaction(tx) => {
17961798
self.eth_send_unsigned_transaction(*tx).await.to_rpc_result()
17971799
}

0 commit comments

Comments
 (0)