Skip to main content

cast/
tempo.rs

1//! Tempo transaction helpers used by Cast-facing commands.
2
3use 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
37/// Loads the config for `opts` and builds a Tempo provider from it.
38pub(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
46/// Attaches the fee payment to a built transaction: the sponsor signature when a sponsor is
47/// configured, otherwise the resolved fee token for `payer` (printing it when it was resolved).
48pub(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
74/// Resolves the sponsored fee token and attaches the sponsor signature preview for `payer` when a
75/// sponsor is configured.
76pub(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
95/// Resolves and sets the fee token paid by `fee_payer`, printing it when it was resolved.
96pub(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
111/// Computes the sponsor hash of a built transaction, resolving the fee token for `fee_payer` first
112/// when one is configured. Used by `--tempo.print-sponsor-hash`.
113pub(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
132/// Prints a command result: the raw payload in JSON mode, the human rendering otherwise.
133pub(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
152/// Resolves a command signer, preferring an explicitly selected Tempo session.
153///
154/// Session resolution is fail-closed: when `--tempo.session` or `TEMPO_SESSION_ID` is set, wallet
155/// signer options are rejected by [`TempoOpts::session_signer_for_wallet`] instead of falling back
156/// to a long-lived signer.
157pub(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
183/// Fails with `message` unless `hardfork` is active on the RPC.
184pub(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
209/// Returns the Tempo hardfork active on the RPC, falling back to `anvil_nodeInfo` for nodes that
210/// do not serve the fork schedule.
211pub(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
224/// Fails early with `requirement` when a Tempo precompile is not active yet: a pre-fork call
225/// would succeed as a silent no-op instead of reverting. Prefers the hardfork query and falls
226/// back to checking the precompile's code when the RPC lacks the method.
227pub(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/// Connector for reusing an already-configured RPC transport.
256///
257/// This preserves Foundry transport behavior such as MPP payment handling when a sponsor relay is
258/// layered over the default RPC.
259#[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
287/// Resolves the transaction network and any configured signer without letting an unrelated Tempo
288/// Accounts store change ordinary Ethereum commands.
289///
290/// Explicit signer options are resolved with `from` cleared so the store fallback is not consulted.
291/// The fallback is only enabled after selecting Tempo from transaction options, configuration,
292/// or the RPC chain.
293pub(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    // Only consult the Accounts store after selecting Tempo.
324    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
338/// Fills a Tempo transaction request that was built outside [`crate::tx::CastTxBuilder`] before
339/// access-key signing.
340pub(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}