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    transport::{RelayConnector, SponsorshipMode},
32};
33
34pub use foundry_common::tempo::TempoSponsor;
35
36/// Loads the config for `opts` and builds a Tempo provider from it.
37pub(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
45/// Attaches the fee payment to a built transaction: the sponsor signature when a sponsor is
46/// configured, otherwise the resolved fee token for `payer` (printing it when it was resolved).
47pub(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
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    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
96/// Resolves and sets the fee token paid by `fee_payer`, printing it when it was resolved.
97pub(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
113/// Computes the sponsor hash of a built transaction, resolving the fee token for `fee_payer` first
114/// when one is configured. Used by `--tempo.print-sponsor-hash`.
115pub(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
135/// Prints a command result: the raw payload in JSON mode, the human rendering otherwise.
136pub(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
155/// Resolves a command signer, preferring an explicitly selected Tempo session.
156///
157/// Session resolution is fail-closed: when `--tempo.session` or `TEMPO_SESSION_ID` is set, wallet
158/// signer options are rejected by [`TempoOpts::session_signer_for_wallet`] instead of falling back
159/// to a long-lived signer.
160pub(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
178/// Fails with `message` unless `hardfork` is active on the RPC.
179pub(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
213/// Fails early with `requirement` when a Tempo precompile is not active yet: a pre-fork call
214/// would succeed as a silent no-op instead of reverting. Prefers the hardfork query and falls
215/// back to checking the precompile's code when the RPC lacks the method.
216pub(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/// Connector for reusing an already-configured RPC transport.
248///
249/// This preserves Foundry transport behavior such as MPP payment handling when a sponsor relay is
250/// layered over the default RPC.
251#[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
279/// Resolves the transaction network and any configured signer without letting an unrelated Tempo
280/// Accounts store change ordinary Ethereum commands.
281///
282/// Explicit signer options are resolved with `from` cleared so the store fallback is not consulted.
283/// The fallback is only enabled after selecting Tempo from transaction options, configuration,
284/// or the RPC chain.
285pub(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    // Only consult the Accounts store after selecting Tempo.
316    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
330/// Fills a Tempo transaction request that was built outside [`crate::tx::CastTxBuilder`] before
331/// access-key signing.
332pub(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}