1use crate::tx::fill_transaction_gas_fees;
4use alloy_network::{Ethereum, Network, TransactionBuilder};
5use alloy_primitives::{Address, B256};
6use alloy_provider::Provider;
7use alloy_rpc_client::BuiltInConnectionString;
8use alloy_transport::{BoxTransport, TransportConnect, TransportError};
9use eyre::Result;
10use foundry_cli::{
11 json::print_json_success,
12 opts::{EthereumOpts, TempoOpts},
13 utils::{LoadConfig, get_chain},
14};
15use foundry_common::{
16 FoundryTransactionBuilder,
17 provider::{ProviderBuilder, RetryProvider, is_rpc_method_not_found},
18 shell,
19 tempo::{maybe_print_fee_token, resolve_and_set_fee_token},
20};
21use foundry_config::{Chain, Config, Eip1559FeeEstimatePreset};
22use foundry_evm::hardfork::TempoHardfork;
23use foundry_evm_networks::NetworkVariant;
24use foundry_wallets::{TempoAccountsWallet, WalletOpts, WalletSigner};
25use serde::Deserialize;
26use serde_json::Value;
27use std::str::FromStr;
28use tempo_alloy::{
29 TempoNetwork,
30 provider::TempoProviderExt,
31 rpc::TempoTransactionRequest,
32 transport::{RelayConnector, SponsorshipMode},
33};
34
35pub use foundry_common::tempo::TempoSponsor;
36
37pub(crate) fn tempo_provider(
39 opts: &impl LoadConfig,
40) -> Result<(Config, RetryProvider<TempoNetwork>)> {
41 let config = opts.load_config()?;
42 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
43 Ok((config, provider))
44}
45
46pub(crate) async fn apply_fee_payment<N, P>(
49 sponsor: Option<&TempoSponsor>,
50 provider: Option<&P>,
51 tx: &mut N::TransactionRequest,
52 payer: Address,
53) -> Result<()>
54where
55 N: Network,
56 N::TransactionRequest: Default + FoundryTransactionBuilder<N>,
57 P: Provider<N>,
58{
59 if tx.gas_price().is_some() {
60 eyre::ensure!(
61 sponsor.is_none() && !tx.is_tempo_aa(),
62 "Tempo transaction options cannot be combined with a legacy transaction"
63 );
64 return Ok(());
65 }
66
67 if sponsor.is_some() {
68 maybe_attach_sponsor(sponsor, provider, tx, payer).await
69 } else {
70 resolve_and_print_fee_token(provider, tx, Some(payer)).await
71 }
72}
73
74pub(crate) async fn maybe_attach_sponsor<N, P>(
77 sponsor: Option<&TempoSponsor>,
78 provider: Option<&P>,
79 tx: &mut N::TransactionRequest,
80 payer: Address,
81) -> Result<()>
82where
83 N: Network,
84 N::TransactionRequest: Default + FoundryTransactionBuilder<N>,
85 P: Provider<N>,
86{
87 if let Some(sponsor) = sponsor {
88 let provider = provider.map(|p| p as &dyn Provider<N>);
89 sponsor.resolve_and_set_fee_token(provider, tx).await?;
90 sponsor.attach_and_print::<N>(tx, payer).await?;
91 }
92 Ok(())
93}
94
95pub(crate) async fn resolve_and_print_fee_token<N, P>(
97 provider: Option<&P>,
98 tx: &mut N::TransactionRequest,
99 fee_payer: Option<Address>,
100) -> Result<()>
101where
102 N: Network,
103 N::TransactionRequest: Default + FoundryTransactionBuilder<N>,
104 P: Provider<N>,
105{
106 let dyn_provider = provider.map(|p| p as &dyn Provider<N>);
107 let fee_token = resolve_and_set_fee_token(dyn_provider, tx, fee_payer).await?;
108 maybe_print_fee_token(provider, fee_token).await
109}
110
111pub(crate) async fn sponsor_hash<N, P>(
114 provider: Option<&P>,
115 tx: &mut N::TransactionRequest,
116 from: Address,
117 fee_payer: Option<Address>,
118) -> Result<B256>
119where
120 N: Network,
121 N::TransactionRequest: Default + FoundryTransactionBuilder<N>,
122 P: Provider<N>,
123{
124 if fee_payer.is_some() {
125 let provider = provider.map(|p| p as &dyn Provider<N>);
126 resolve_and_set_fee_token(provider, tx, fee_payer).await?;
127 }
128 tx.compute_sponsor_hash(from)
129 .ok_or_else(|| eyre::eyre!("This network does not support sponsored transactions"))
130}
131
132pub(crate) fn print_payload<F>(payload: Value, human: F) -> Result<()>
134where
135 F: FnOnce(&Value) -> Result<()>,
136{
137 if shell::is_json() {
138 print_json_success(payload)?;
139 } else {
140 human(&payload)?;
141 }
142 Ok(())
143}
144
145pub(crate) fn print_expires(expires_at: Option<u64>) -> Result<()> {
146 if let Some(ts) = expires_at {
147 sh_status!("Transaction expires at unix timestamp {ts}")?;
148 }
149 Ok(())
150}
151
152pub(crate) async fn resolve_session_or_wallet_signer(
158 tempo: &TempoOpts,
159 wallet: &WalletOpts,
160 chain_id: u64,
161) -> Result<(Option<WalletSigner>, Option<TempoAccountsWallet>)> {
162 let (signer, access_key) = match tempo.session_signer_for_wallet(wallet, chain_id)? {
163 Some(session) => (None, Some(session.access_key)),
164 None => wallet.maybe_signer_for_chain(chain_id).await?,
165 };
166 if let (Some(from), Some(access_key)) = (wallet.from, &access_key) {
167 eyre::ensure!(
168 access_key.account() == from,
169 "sender {from} does not match Tempo account {}",
170 access_key.account()
171 );
172 }
173 Ok((signer, access_key))
174}
175
176pub(crate) fn ensure_session_not_browser(tempo: &TempoOpts, browser: bool) -> Result<()> {
177 if browser && tempo.session_id()?.is_some() {
178 eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --browser");
179 }
180 Ok(())
181}
182
183pub(crate) async fn require_hardfork<P: Provider<TempoNetwork>>(
185 provider: &P,
186 hardfork: TempoHardfork,
187 message: &str,
188) -> Result<()> {
189 if !is_tempo_hardfork_active(provider, hardfork).await? {
190 eyre::bail!("{message}");
191 }
192 Ok(())
193}
194
195#[derive(Debug, Deserialize)]
196#[serde(rename_all = "camelCase")]
197struct AnvilNodeInfo {
198 hard_fork: Option<String>,
199 network: Option<String>,
200}
201
202pub(crate) async fn is_tempo_hardfork_active<P: Provider<TempoNetwork>>(
203 provider: &P,
204 hardfork: TempoHardfork,
205) -> Result<bool> {
206 Ok(active_tempo_hardfork(provider).await? >= hardfork)
207}
208
209pub(crate) async fn active_tempo_hardfork<P: Provider<TempoNetwork>>(
212 provider: &P,
213) -> Result<TempoHardfork> {
214 match provider.get_active_hardfork().await {
215 Ok(hardfork) => Ok(hardfork),
216 Err(err) if is_rpc_method_not_found(&err) => match anvil_tempo_hardfork(provider).await {
217 Ok(Some(hardfork)) => Ok(hardfork),
218 _ => Err(err.into()),
219 },
220 Err(err) => Err(err.into()),
221 }
222}
223
224pub(crate) async fn ensure_tempo_precompile_active<P: Provider<TempoNetwork>>(
228 provider: &P,
229 hardfork: TempoHardfork,
230 precompile: Address,
231 requirement: &str,
232) -> Result<()> {
233 let active = match is_tempo_hardfork_active(provider, hardfork).await {
234 Ok(active) => active,
235 Err(_) => !provider.get_code_at(precompile).await?.is_empty(),
236 };
237 eyre::ensure!(active, "{requirement}");
238 Ok(())
239}
240
241async fn anvil_tempo_hardfork<P: Provider<TempoNetwork>>(
242 provider: &P,
243) -> Result<Option<TempoHardfork>, TransportError> {
244 let info = provider.raw_request::<_, AnvilNodeInfo>("anvil_nodeInfo".into(), ()).await?;
245 Ok(hardfork_from_anvil_node_info(&info))
246}
247
248fn hardfork_from_anvil_node_info(info: &AnvilNodeInfo) -> Option<TempoHardfork> {
249 if info.network.as_deref() != Some("tempo") {
250 return None;
251 }
252 info.hard_fork.as_deref()?.parse().ok()
253}
254
255#[derive(Clone, Debug)]
260pub(crate) struct ExistingTransportConnector {
261 transport: BoxTransport,
262 is_local: bool,
263}
264
265impl TransportConnect for ExistingTransportConnector {
266 fn is_local(&self) -> bool {
267 self.is_local
268 }
269
270 async fn get_transport(&self) -> Result<BoxTransport, TransportError> {
271 Ok(self.transport.clone())
272 }
273}
274
275pub(crate) fn sponsor_relay_connector<N: Network>(
276 provider: &impl Provider<N>,
277 sponsor_url: &str,
278) -> Result<RelayConnector<ExistingTransportConnector, BuiltInConnectionString>> {
279 let default = ExistingTransportConnector {
280 transport: provider.client().transport().clone(),
281 is_local: provider.client().is_local(),
282 };
283 let relay = BuiltInConnectionString::from_str(sponsor_url)?;
284 Ok(RelayConnector::with_config(default, relay, SponsorshipMode::SignOnly, false))
285}
286
287pub(crate) async fn resolve_transaction_network_and_signer(
294 tempo: &TempoOpts,
295 eth: &EthereumOpts,
296) -> Result<(NetworkVariant, Option<WalletSigner>, Option<TempoAccountsWallet>)> {
297 let config = eth.load_config()?;
298 let has_session = tempo.session_id()?.is_some();
299 let requires_tempo = tempo.is_tempo() || has_session;
300 crate::cmd::validate_tempo_network(&config, requires_tempo)?;
301 if has_session {
302 return Ok((NetworkVariant::Tempo, None, None));
303 }
304
305 let mut explicit_wallet = eth.wallet.clone();
306 explicit_wallet.from = None;
307 let (signer, access_key) = explicit_wallet.maybe_signer().await?;
308 let network =
309 crate::cmd::resolve_transaction_network(&config, requires_tempo || access_key.is_some())
310 .await?;
311 if let (Some(from), Some(access_key)) = (eth.wallet.from, &access_key) {
312 eyre::ensure!(
313 access_key.account() == from,
314 "sender {from} does not match Tempo account {}",
315 access_key.account()
316 );
317 }
318 if !network.is_tempo() || signer.is_some() || access_key.is_some() || eth.wallet.from.is_none()
319 {
320 return Ok((network, signer, access_key));
321 }
322
323 let provider = ProviderBuilder::<Ethereum>::from_config(&config)?.build()?;
325 let chain = get_chain(config.chain, &provider).await?;
326 let (signer, access_key) = eth.wallet.maybe_signer_for_chain(chain.id()).await?;
327 if let Some(access_key) = &access_key {
328 let from = eth.wallet.from.expect("checked above");
329 eyre::ensure!(
330 access_key.account() == from,
331 "sender {from} does not match active Tempo account {}",
332 access_key.account()
333 );
334 }
335 Ok((network, signer, access_key))
336}
337
338pub(crate) async fn fill_access_key_transaction<P>(
341 provider: &P,
342 tx: &mut TempoTransactionRequest,
343 access_key: &TempoAccountsWallet,
344 chain: Chain,
345 eip1559_fee_estimate: Eip1559FeeEstimatePreset,
346) -> Result<TempoAccountsWallet>
347where
348 P: Provider<TempoNetwork>,
349{
350 tx.set_chain_id(chain.id());
351 let prepared = access_key.prepare_request(provider, tx).await?;
352
353 if tx.nonce().is_none() {
354 tx.set_nonce(provider.get_transaction_count(prepared.account()).await?);
355 }
356 fill_transaction_gas_fees(provider, tx, chain.is_legacy(), false, eip1559_fee_estimate).await?;
357 if tx.gas_limit().is_none() {
358 tx.set_gas_limit(provider.estimate_gas(tx.clone()).await?);
359 }
360
361 Ok(prepared)
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use alloy_json_rpc::ErrorPayload;
368 use alloy_provider::{ProviderBuilder as AlloyProviderBuilder, mock::Asserter};
369 use alloy_rpc_client::RpcClient;
370
371 #[tokio::test]
372 async fn tempo_fork_schedule_detects_t3_activation() {
373 for (active, expected) in [("T2", false), ("T3", true), ("T13", true), ("T14", true)] {
374 let asserter = Asserter::new();
375 asserter.push_success(&serde_json::json!({ "active": active, "schedule": [] }));
376 let provider = AlloyProviderBuilder::new()
377 .network::<TempoNetwork>()
378 .connect_mocked_client(asserter);
379 assert_eq!(
380 is_tempo_hardfork_active(&provider, TempoHardfork::T3).await.unwrap(),
381 expected
382 );
383 }
384 }
385
386 #[tokio::test]
387 async fn tempo_fork_schedule_rejects_unknown_hardfork() {
388 let asserter = Asserter::new();
389 asserter.push_success(&serde_json::json!({ "active": "FutureFork", "schedule": [] }));
390 let provider =
391 AlloyProviderBuilder::new().network::<TempoNetwork>().connect_mocked_client(asserter);
392 assert!(is_tempo_hardfork_active(&provider, TempoHardfork::T3).await.is_err());
393 }
394
395 #[test]
396 fn hardfork_from_anvil_node_info_requires_tempo_network() {
397 let info = |network: &str, hard_fork: &str| AnvilNodeInfo {
398 network: Some(network.to_string()),
399 hard_fork: Some(hard_fork.to_string()),
400 };
401 assert_eq!(hardfork_from_anvil_node_info(&info("tempo", "T3")), Some(TempoHardfork::T3));
402 assert_eq!(hardfork_from_anvil_node_info(&info("tempo", "T11")), Some(TempoHardfork::T11));
403 assert_eq!(hardfork_from_anvil_node_info(&info("tempo", "FutureFork")), None);
404 assert_eq!(hardfork_from_anvil_node_info(&info("ethereum", "T3")), None);
405 }
406
407 #[tokio::test]
408 async fn anvil_node_info_fallback_detects_hardfork_activation() {
409 let asserter = Asserter::new();
410 for _ in 0..2 {
411 asserter.push_failure(ErrorPayload {
412 code: -32601,
413 message: "Method not found".into(),
414 data: None,
415 });
416 asserter.push_success(&serde_json::json!({ "network": "tempo", "hardFork": "T3" }));
417 }
418 let provider =
419 AlloyProviderBuilder::new().network::<TempoNetwork>().connect_mocked_client(asserter);
420 assert!(is_tempo_hardfork_active(&provider, TempoHardfork::T3).await.unwrap());
421 assert!(!is_tempo_hardfork_active(&provider, TempoHardfork::T4).await.unwrap());
422 }
423
424 #[tokio::test]
425 async fn legacy_fee_payment_skips_stored_token_and_rejects_tempo_fields() {
426 let asserter = Asserter::new();
427 let provider =
428 AlloyProviderBuilder::new().network::<TempoNetwork>().connect_mocked_client(asserter);
429 let payer = Address::repeat_byte(0x11);
430 let mut tx = TempoTransactionRequest::default();
431 tx.set_gas_price(1);
432
433 apply_fee_payment(None, Some(&provider), &mut tx, payer).await.unwrap();
434 assert!(tx.fee_token().is_none());
435
436 tx.set_fee_token(Address::repeat_byte(0x22));
437 assert_eq!(
438 apply_fee_payment(None, Some(&provider), &mut tx, payer).await.unwrap_err().to_string(),
439 "Tempo transaction options cannot be combined with a legacy transaction"
440 );
441 }
442
443 #[tokio::test]
444 async fn sponsor_relay_reuses_existing_default_transport() {
445 let asserter = Asserter::new();
446 asserter.push_success(&alloy_primitives::U64::from(42));
447 let provider = AlloyProviderBuilder::new().connect_mocked_client(asserter);
448 let connector =
449 sponsor_relay_connector(&provider, "http://127.0.0.1:1").expect("valid relay");
450 let transport = connector.get_transport().await.expect("relay transport");
451 let client = RpcClient::builder().transport(transport, true);
452 let relayed = AlloyProviderBuilder::new().connect_client(client);
453
454 assert_eq!(relayed.get_block_number().await.unwrap(), 42);
455 }
456}