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 transport::{RelayConnector, SponsorshipMode},
32};
33
34pub use foundry_common::tempo::TempoSponsor;
35
36pub(crate) fn tempo_provider(
38 opts: &impl LoadConfig,
39) -> Result<(Config, RetryProvider<TempoNetwork>)> {
40 let config = opts.load_config()?;
41 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
42 Ok((config, provider))
43}
44
45pub(crate) async fn apply_fee_payment<N, P>(
48 sponsor: Option<&TempoSponsor>,
49 provider: Option<&P>,
50 chain: Chain,
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, chain, tx, payer).await
69 } else {
70 resolve_and_print_fee_token(provider, Some(chain), 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 chain: Chain,
80 tx: &mut N::TransactionRequest,
81 payer: Address,
82) -> Result<()>
83where
84 N: Network,
85 N::TransactionRequest: Default + FoundryTransactionBuilder<N>,
86 P: Provider<N>,
87{
88 if let Some(sponsor) = sponsor {
89 let provider = provider.map(|p| p as &dyn Provider<N>);
90 sponsor.resolve_and_set_fee_token(provider, Some(chain), tx).await?;
91 sponsor.attach_and_print::<N>(tx, payer).await?;
92 }
93 Ok(())
94}
95
96pub(crate) async fn resolve_and_print_fee_token<N, P>(
98 provider: Option<&P>,
99 chain: Option<Chain>,
100 tx: &mut N::TransactionRequest,
101 fee_payer: Option<Address>,
102) -> Result<()>
103where
104 N: Network,
105 N::TransactionRequest: Default + FoundryTransactionBuilder<N>,
106 P: Provider<N>,
107{
108 let dyn_provider = provider.map(|p| p as &dyn Provider<N>);
109 let fee_token = resolve_and_set_fee_token(dyn_provider, chain, tx, fee_payer).await?;
110 maybe_print_fee_token(provider, fee_token).await
111}
112
113pub(crate) async fn sponsor_hash<N, P>(
116 provider: Option<&P>,
117 chain: Chain,
118 tx: &mut N::TransactionRequest,
119 from: Address,
120 fee_payer: Option<Address>,
121) -> Result<B256>
122where
123 N: Network,
124 N::TransactionRequest: Default + FoundryTransactionBuilder<N>,
125 P: Provider<N>,
126{
127 if fee_payer.is_some() {
128 let provider = provider.map(|p| p as &dyn Provider<N>);
129 resolve_and_set_fee_token(provider, Some(chain), tx, fee_payer).await?;
130 }
131 tx.compute_sponsor_hash(from)
132 .ok_or_else(|| eyre::eyre!("This network does not support sponsored transactions"))
133}
134
135pub(crate) fn print_payload<F>(payload: Value, human: F) -> Result<()>
137where
138 F: FnOnce(&Value) -> Result<()>,
139{
140 if shell::is_json() {
141 print_json_success(payload)?;
142 } else {
143 human(&payload)?;
144 }
145 Ok(())
146}
147
148pub(crate) fn print_expires(expires_at: Option<u64>) -> Result<()> {
149 if let Some(ts) = expires_at {
150 sh_status!("Transaction expires at unix timestamp {ts}")?;
151 }
152 Ok(())
153}
154
155pub(crate) async fn resolve_session_or_wallet_signer(
161 tempo: &TempoOpts,
162 wallet: &WalletOpts,
163 chain_id: u64,
164) -> Result<(Option<WalletSigner>, Option<TempoAccountsWallet>)> {
165 match tempo.session_signer_for_wallet(wallet, chain_id)? {
166 Some(session) => Ok((None, Some(session.access_key))),
167 None => wallet.maybe_signer_for_chain(chain_id).await,
168 }
169}
170
171pub(crate) fn ensure_session_not_browser(tempo: &TempoOpts, browser: bool) -> Result<()> {
172 if browser && tempo.session_id()?.is_some() {
173 eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --browser");
174 }
175 Ok(())
176}
177
178pub(crate) async fn require_hardfork<P: Provider<TempoNetwork>>(
180 provider: &P,
181 hardfork: TempoHardfork,
182 message: &str,
183) -> Result<()> {
184 if !is_tempo_hardfork_active(provider, hardfork).await? {
185 eyre::bail!("{message}");
186 }
187 Ok(())
188}
189
190#[derive(Debug, Deserialize)]
191#[serde(rename_all = "camelCase")]
192struct AnvilNodeInfo {
193 hard_fork: Option<String>,
194 network: Option<String>,
195}
196
197pub(crate) async fn is_tempo_hardfork_active<P: Provider<TempoNetwork>>(
198 provider: &P,
199 hardfork: TempoHardfork,
200) -> Result<bool> {
201 match provider.is_hardfork_active(hardfork).await {
202 Ok(active) => Ok(active),
203 Err(err) if is_rpc_method_not_found(&err) => {
204 match anvil_tempo_hardfork_active(provider, hardfork).await {
205 Ok(Some(active)) => Ok(active),
206 _ => Err(err.into()),
207 }
208 }
209 Err(err) => Err(err.into()),
210 }
211}
212
213pub(crate) async fn ensure_tempo_precompile_active<P: Provider<TempoNetwork>>(
217 provider: &P,
218 hardfork: TempoHardfork,
219 precompile: Address,
220 requirement: &str,
221) -> Result<()> {
222 let active = match is_tempo_hardfork_active(provider, hardfork).await {
223 Ok(active) => active,
224 Err(_) => !provider.get_code_at(precompile).await?.is_empty(),
225 };
226 eyre::ensure!(active, "{requirement}");
227 Ok(())
228}
229
230async fn anvil_tempo_hardfork_active<P: Provider<TempoNetwork>>(
231 provider: &P,
232 hardfork: TempoHardfork,
233) -> Result<Option<bool>, TransportError> {
234 let info = provider.raw_request::<_, AnvilNodeInfo>("anvil_nodeInfo".into(), ()).await?;
235 Ok(active_from_anvil_node_info(&info, hardfork))
236}
237
238fn active_from_anvil_node_info(info: &AnvilNodeInfo, hardfork: TempoHardfork) -> Option<bool> {
239 (info.network.as_deref() == Some("tempo")).then(|| {
240 info.hard_fork
241 .as_deref()
242 .and_then(|active_hardfork| active_hardfork.parse::<TempoHardfork>().ok())
243 .is_some_and(|active_hardfork| active_hardfork >= hardfork)
244 })
245}
246
247#[derive(Clone, Debug)]
252pub(crate) struct ExistingTransportConnector {
253 transport: BoxTransport,
254 is_local: bool,
255}
256
257impl TransportConnect for ExistingTransportConnector {
258 fn is_local(&self) -> bool {
259 self.is_local
260 }
261
262 async fn get_transport(&self) -> Result<BoxTransport, TransportError> {
263 Ok(self.transport.clone())
264 }
265}
266
267pub(crate) fn sponsor_relay_connector<N: Network>(
268 provider: &impl Provider<N>,
269 sponsor_url: &str,
270) -> Result<RelayConnector<ExistingTransportConnector, BuiltInConnectionString>> {
271 let default = ExistingTransportConnector {
272 transport: provider.client().transport().clone(),
273 is_local: provider.client().is_local(),
274 };
275 let relay = BuiltInConnectionString::from_str(sponsor_url)?;
276 Ok(RelayConnector::with_config(default, relay, SponsorshipMode::SignOnly, false))
277}
278
279pub(crate) async fn resolve_transaction_network_and_signer(
286 tempo: &TempoOpts,
287 eth: &EthereumOpts,
288) -> Result<(NetworkVariant, Option<WalletSigner>, Option<TempoAccountsWallet>)> {
289 let config = eth.load_config()?;
290 let has_session = tempo.session_id()?.is_some();
291 let requires_tempo = tempo.is_tempo() || has_session;
292 crate::cmd::validate_tempo_network(&config, requires_tempo)?;
293 if has_session {
294 return Ok((NetworkVariant::Tempo, None, None));
295 }
296
297 let mut explicit_wallet = eth.wallet.clone();
298 explicit_wallet.from = None;
299 let (signer, access_key) = explicit_wallet.maybe_signer().await?;
300 let network =
301 crate::cmd::resolve_transaction_network(&config, requires_tempo || access_key.is_some())
302 .await?;
303 if let (Some(from), Some(access_key)) = (eth.wallet.from, &access_key) {
304 eyre::ensure!(
305 access_key.account() == from,
306 "sender {from} does not match Tempo account {}",
307 access_key.account()
308 );
309 }
310 if !network.is_tempo() || signer.is_some() || access_key.is_some() || eth.wallet.from.is_none()
311 {
312 return Ok((network, signer, access_key));
313 }
314
315 let provider = ProviderBuilder::<Ethereum>::from_config(&config)?.build()?;
317 let chain = get_chain(config.chain, &provider).await?;
318 let (signer, access_key) = eth.wallet.maybe_signer_for_chain(chain.id()).await?;
319 if let Some(access_key) = &access_key {
320 let from = eth.wallet.from.expect("checked above");
321 eyre::ensure!(
322 access_key.account() == from,
323 "sender {from} does not match active Tempo account {}",
324 access_key.account()
325 );
326 }
327 Ok((network, signer, access_key))
328}
329
330pub(crate) async fn fill_access_key_transaction<P>(
333 provider: &P,
334 tx: &mut <TempoNetwork as Network>::TransactionRequest,
335 access_key: &TempoAccountsWallet,
336 chain: Chain,
337 eip1559_fee_estimate: Eip1559FeeEstimatePreset,
338) -> Result<TempoAccountsWallet>
339where
340 P: Provider<TempoNetwork>,
341{
342 tx.set_chain_id(chain.id());
343 let prepared = access_key.prepare_request(provider, tx).await?;
344
345 if tx.nonce().is_none() {
346 tx.set_nonce(provider.get_transaction_count(prepared.account()).await?);
347 }
348 fill_transaction_gas_fees(provider, tx, chain.is_legacy(), false, eip1559_fee_estimate).await?;
349 if tx.gas_limit().is_none() {
350 tx.set_gas_limit(provider.estimate_gas(tx.clone()).await?);
351 }
352
353 Ok(prepared)
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use alloy_provider::{ProviderBuilder as AlloyProviderBuilder, mock::Asserter};
360 use alloy_rpc_client::RpcClient;
361
362 #[tokio::test]
363 async fn tempo_fork_schedule_detects_t13_as_t3_active() {
364 let asserter = Asserter::new();
365 asserter.push_success(&serde_json::json!({ "active": "T13" }));
366 let provider =
367 AlloyProviderBuilder::new().network::<TempoNetwork>().connect_mocked_client(asserter);
368 assert!(is_tempo_hardfork_active(&provider, TempoHardfork::T3).await.unwrap());
369 }
370
371 #[test]
372 fn active_from_anvil_node_info_requires_tempo_network() {
373 let info = |network: &str, hard_fork: &str| AnvilNodeInfo {
374 network: Some(network.to_string()),
375 hard_fork: Some(hard_fork.to_string()),
376 };
377 let tempo_t3 = info("tempo", "T3");
378 assert_eq!(active_from_anvil_node_info(&tempo_t3, TempoHardfork::T2), Some(true));
379 assert_eq!(active_from_anvil_node_info(&tempo_t3, TempoHardfork::T3), Some(true));
380 assert_eq!(active_from_anvil_node_info(&tempo_t3, TempoHardfork::T4), Some(false));
381 assert_eq!(
382 active_from_anvil_node_info(&info("tempo", "T11"), TempoHardfork::T11),
383 Some(true)
384 );
385 assert_eq!(active_from_anvil_node_info(&info("ethereum", "T3"), TempoHardfork::T3), None);
386 }
387
388 #[tokio::test]
389 async fn legacy_fee_payment_skips_stored_token_and_rejects_tempo_fields() {
390 let asserter = Asserter::new();
391 let provider =
392 AlloyProviderBuilder::new().network::<TempoNetwork>().connect_mocked_client(asserter);
393 let payer = Address::repeat_byte(0x11);
394 let chain = Chain::from_id(4217);
395 let mut tx = <TempoNetwork as Network>::TransactionRequest::default();
396 tx.set_gas_price(1);
397
398 apply_fee_payment(None, Some(&provider), chain, &mut tx, payer).await.unwrap();
399 assert!(tx.fee_token().is_none());
400
401 tx.set_fee_token(Address::repeat_byte(0x22));
402 assert_eq!(
403 apply_fee_payment(None, Some(&provider), chain, &mut tx, payer)
404 .await
405 .unwrap_err()
406 .to_string(),
407 "Tempo transaction options cannot be combined with a legacy transaction"
408 );
409 }
410
411 #[tokio::test]
412 async fn sponsor_relay_reuses_existing_default_transport() {
413 let asserter = Asserter::new();
414 asserter.push_success(&alloy_primitives::U64::from(42));
415 let provider = AlloyProviderBuilder::new().connect_mocked_client(asserter);
416 let connector =
417 sponsor_relay_connector(&provider, "http://127.0.0.1:1").expect("valid relay");
418 let transport = connector.get_transport().await.expect("relay transport");
419 let client = RpcClient::builder().transport(transport, true);
420 let relayed = AlloyProviderBuilder::new().connect_client(client);
421
422 assert_eq!(relayed.get_block_number().await.unwrap(), 42);
423 }
424}