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},
18    shell,
19    tempo::{is_tempo_hardfork_active, 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_json::Value;
26use std::str::FromStr;
27use tempo_alloy::{
28    TempoNetwork,
29    rpc::TempoTransactionRequest,
30    transport::{RelayConnector, SponsorshipMode},
31};
32
33pub use foundry_common::tempo::TempoSponsor;
34
35/// Loads the config for `opts` and builds a Tempo provider from it.
36pub(crate) fn tempo_provider(
37    opts: &impl LoadConfig,
38) -> Result<(Config, RetryProvider<TempoNetwork>)> {
39    let config = opts.load_config()?;
40    let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
41    Ok((config, provider))
42}
43
44/// Attaches the fee payment to a built transaction: the sponsor signature when a sponsor is
45/// configured, otherwise the resolved fee token for `payer` (printing it when it was resolved).
46pub(crate) async fn apply_fee_payment<N, P>(
47    sponsor: Option<&TempoSponsor>,
48    provider: Option<&P>,
49    tx: &mut N::TransactionRequest,
50    payer: Address,
51) -> Result<()>
52where
53    N: Network,
54    N::TransactionRequest: FoundryTransactionBuilder<N>,
55    P: Provider<N>,
56{
57    if tx.gas_price().is_some() {
58        eyre::ensure!(
59            sponsor.is_none() && !tx.is_tempo_aa(),
60            "Tempo transaction options cannot be combined with a legacy transaction"
61        );
62        return Ok(());
63    }
64
65    if sponsor.is_some() {
66        maybe_attach_sponsor(sponsor, provider, tx, payer).await
67    } else {
68        resolve_and_print_fee_token(provider, tx, Some(payer)).await
69    }
70}
71
72/// Resolves the sponsored fee token and attaches the sponsor signature preview for `payer` when a
73/// sponsor is configured.
74pub(crate) async fn maybe_attach_sponsor<N, P>(
75    sponsor: Option<&TempoSponsor>,
76    provider: Option<&P>,
77    tx: &mut N::TransactionRequest,
78    payer: Address,
79) -> Result<()>
80where
81    N: Network,
82    N::TransactionRequest: FoundryTransactionBuilder<N>,
83    P: Provider<N>,
84{
85    if let Some(sponsor) = sponsor {
86        let provider = provider.map(|p| p as &dyn Provider<N>);
87        sponsor.resolve_and_set_fee_token(provider, tx).await?;
88        sponsor.attach_and_print::<N>(tx, payer).await?;
89    }
90    Ok(())
91}
92
93/// Resolves and sets the fee token paid by `fee_payer`, printing it when it was resolved.
94pub(crate) async fn resolve_and_print_fee_token<N, P>(
95    provider: Option<&P>,
96    tx: &mut N::TransactionRequest,
97    fee_payer: Option<Address>,
98) -> Result<()>
99where
100    N: Network,
101    N::TransactionRequest: FoundryTransactionBuilder<N>,
102    P: Provider<N>,
103{
104    let dyn_provider = provider.map(|p| p as &dyn Provider<N>);
105    let fee_token = resolve_and_set_fee_token(dyn_provider, tx, fee_payer).await?;
106    maybe_print_fee_token(provider, fee_token).await
107}
108
109/// Computes the sponsor hash of a built transaction, resolving the fee token for `fee_payer` first
110/// when one is configured. Used by `--tempo.print-sponsor-hash`.
111pub(crate) async fn sponsor_hash<N, P>(
112    provider: Option<&P>,
113    tx: &mut N::TransactionRequest,
114    from: Address,
115    fee_payer: Option<Address>,
116) -> Result<B256>
117where
118    N: Network,
119    N::TransactionRequest: FoundryTransactionBuilder<N>,
120    P: Provider<N>,
121{
122    if fee_payer.is_some() {
123        let provider = provider.map(|p| p as &dyn Provider<N>);
124        resolve_and_set_fee_token(provider, tx, fee_payer).await?;
125    }
126    tx.compute_sponsor_hash(from)
127        .ok_or_else(|| eyre::eyre!("This network does not support sponsored transactions"))
128}
129
130/// Prints a command result: the raw payload in JSON mode, the human rendering otherwise.
131pub(crate) fn print_payload<F>(payload: Value, human: F) -> Result<()>
132where
133    F: FnOnce(&Value) -> Result<()>,
134{
135    if shell::is_json() {
136        print_json_success(payload)?;
137    } else {
138        human(&payload)?;
139    }
140    Ok(())
141}
142
143pub(crate) fn print_expires(expires_at: Option<u64>) -> Result<()> {
144    if let Some(ts) = expires_at {
145        sh_status!("Transaction expires at unix timestamp {ts}")?;
146    }
147    Ok(())
148}
149
150/// Resolves a command signer, preferring an explicitly selected Tempo session.
151///
152/// Session resolution is fail-closed: when `--tempo.session` or `TEMPO_SESSION_ID` is set, wallet
153/// signer options are rejected by [`TempoOpts::session_signer_for_wallet`] instead of falling back
154/// to a long-lived signer.
155pub(crate) async fn resolve_session_or_wallet_signer(
156    tempo: &TempoOpts,
157    wallet: &WalletOpts,
158    chain_id: u64,
159) -> Result<(Option<WalletSigner>, Option<TempoAccountsWallet>)> {
160    let (signer, access_key) = match tempo.session_signer_for_wallet(wallet, chain_id)? {
161        Some(session) => (None, Some(session.access_key)),
162        None => wallet.maybe_signer_for_chain(chain_id).await?,
163    };
164    if let (Some(from), Some(access_key)) = (wallet.from, &access_key) {
165        eyre::ensure!(
166            access_key.account() == from,
167            "sender {from} does not match Tempo account {}",
168            access_key.account()
169        );
170    }
171    Ok((signer, access_key))
172}
173
174pub(crate) fn ensure_session_not_browser(tempo: &TempoOpts, browser: bool) -> Result<()> {
175    if browser && tempo.session_id()?.is_some() {
176        eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --browser");
177    }
178    Ok(())
179}
180
181/// Fails with `message` unless `hardfork` is active on the RPC.
182pub(crate) async fn require_hardfork<P: Provider<TempoNetwork>>(
183    provider: &P,
184    hardfork: TempoHardfork,
185    message: &str,
186) -> Result<()> {
187    if !is_tempo_hardfork_active(provider, hardfork).await? {
188        eyre::bail!("{message}");
189    }
190    Ok(())
191}
192
193/// Fails early with `requirement` when a Tempo precompile is not active yet: a pre-fork call
194/// would succeed as a silent no-op instead of reverting. Prefers the hardfork query and falls
195/// back to checking the precompile's code when the RPC lacks the method.
196pub(crate) async fn ensure_tempo_precompile_active<P: Provider<TempoNetwork>>(
197    provider: &P,
198    hardfork: TempoHardfork,
199    precompile: Address,
200    requirement: &str,
201) -> Result<()> {
202    let active = match is_tempo_hardfork_active(provider, hardfork).await {
203        Ok(active) => active,
204        Err(_) => !provider.get_code_at(precompile).await?.is_empty(),
205    };
206    eyre::ensure!(active, "{requirement}");
207    Ok(())
208}
209
210/// Connector for reusing an already-configured RPC transport.
211///
212/// This preserves Foundry transport behavior such as MPP payment handling when a sponsor relay is
213/// layered over the default RPC.
214#[derive(Clone, Debug)]
215pub(crate) struct ExistingTransportConnector {
216    transport: BoxTransport,
217    is_local: bool,
218}
219
220impl TransportConnect for ExistingTransportConnector {
221    fn is_local(&self) -> bool {
222        self.is_local
223    }
224
225    async fn get_transport(&self) -> Result<BoxTransport, TransportError> {
226        Ok(self.transport.clone())
227    }
228}
229
230pub(crate) fn sponsor_relay_connector<N: Network>(
231    provider: &impl Provider<N>,
232    sponsor_url: &str,
233) -> Result<RelayConnector<ExistingTransportConnector, BuiltInConnectionString>> {
234    let default = ExistingTransportConnector {
235        transport: provider.client().transport().clone(),
236        is_local: provider.client().is_local(),
237    };
238    let relay = BuiltInConnectionString::from_str(sponsor_url)?;
239    Ok(RelayConnector::with_config(default, relay, SponsorshipMode::SignOnly, false))
240}
241
242/// Resolves the transaction network and any configured signer without letting an unrelated Tempo
243/// Accounts store change ordinary Ethereum commands.
244///
245/// Explicit signer options are resolved with `from` cleared so the store fallback is not consulted.
246/// The fallback is only enabled after selecting Tempo from transaction options, configuration,
247/// or the RPC chain.
248pub(crate) async fn resolve_transaction_network_and_signer(
249    tempo: &TempoOpts,
250    eth: &EthereumOpts,
251) -> Result<(NetworkVariant, Option<WalletSigner>, Option<TempoAccountsWallet>)> {
252    let config = eth.load_config()?;
253    let has_session = tempo.session_id()?.is_some();
254    let requires_tempo = tempo.is_tempo() || has_session;
255    crate::cmd::validate_tempo_network(&config, requires_tempo)?;
256    if has_session {
257        return Ok((NetworkVariant::Tempo, None, None));
258    }
259
260    let mut explicit_wallet = eth.wallet.clone();
261    explicit_wallet.from = None;
262    let (signer, access_key) = explicit_wallet.maybe_signer().await?;
263    let network =
264        crate::cmd::resolve_transaction_network(&config, requires_tempo || access_key.is_some())
265            .await?;
266    if let (Some(from), Some(access_key)) = (eth.wallet.from, &access_key) {
267        eyre::ensure!(
268            access_key.account() == from,
269            "sender {from} does not match Tempo account {}",
270            access_key.account()
271        );
272    }
273    if !network.is_tempo() || signer.is_some() || access_key.is_some() || eth.wallet.from.is_none()
274    {
275        return Ok((network, signer, access_key));
276    }
277
278    // Only consult the Accounts store after selecting Tempo.
279    let provider = ProviderBuilder::<Ethereum>::from_config(&config)?.build()?;
280    let chain = get_chain(config.chain, &provider).await?;
281    let (signer, access_key) = eth.wallet.maybe_signer_for_chain(chain.id()).await?;
282    if let Some(access_key) = &access_key {
283        let from = eth.wallet.from.expect("checked above");
284        eyre::ensure!(
285            access_key.account() == from,
286            "sender {from} does not match active Tempo account {}",
287            access_key.account()
288        );
289    }
290    Ok((network, signer, access_key))
291}
292
293/// Fills a Tempo transaction request that was built outside [`crate::tx::CastTxBuilder`] before
294/// access-key signing.
295pub(crate) async fn fill_access_key_transaction<P>(
296    provider: &P,
297    tx: &mut TempoTransactionRequest,
298    access_key: &TempoAccountsWallet,
299    chain: Chain,
300    eip1559_fee_estimate: Eip1559FeeEstimatePreset,
301) -> Result<TempoAccountsWallet>
302where
303    P: Provider<TempoNetwork>,
304{
305    tx.set_chain_id(chain.id());
306    let prepared = access_key.prepare_request(provider, tx).await?;
307
308    if tx.nonce().is_none() {
309        tx.set_nonce(provider.get_transaction_count(prepared.account()).await?);
310    }
311    fill_transaction_gas_fees(provider, tx, chain.is_legacy(), false, eip1559_fee_estimate).await?;
312    if tx.gas_limit().is_none() {
313        tx.set_gas_limit(provider.estimate_gas(tx.clone()).await?);
314    }
315
316    Ok(prepared)
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use alloy_provider::{ProviderBuilder as AlloyProviderBuilder, mock::Asserter};
323    use alloy_rpc_client::RpcClient;
324
325    #[tokio::test]
326    async fn legacy_fee_payment_skips_stored_token_and_rejects_tempo_fields() {
327        let asserter = Asserter::new();
328        let provider =
329            AlloyProviderBuilder::new().network::<TempoNetwork>().connect_mocked_client(asserter);
330        let payer = Address::repeat_byte(0x11);
331        let mut tx = TempoTransactionRequest::default();
332        tx.set_gas_price(1);
333
334        apply_fee_payment(None, Some(&provider), &mut tx, payer).await.unwrap();
335        assert!(tx.fee_token().is_none());
336
337        tx.set_fee_token(Address::repeat_byte(0x22));
338        assert_eq!(
339            apply_fee_payment(None, Some(&provider), &mut tx, payer).await.unwrap_err().to_string(),
340            "Tempo transaction options cannot be combined with a legacy transaction"
341        );
342    }
343
344    #[tokio::test]
345    async fn sponsor_relay_reuses_existing_default_transport() {
346        let asserter = Asserter::new();
347        asserter.push_success(&alloy_primitives::U64::from(42));
348        let provider = AlloyProviderBuilder::new().connect_mocked_client(asserter);
349        let connector =
350            sponsor_relay_connector(&provider, "http://127.0.0.1:1").expect("valid relay");
351        let transport = connector.get_transport().await.expect("relay transport");
352        let client = RpcClient::builder().transport(transport, true);
353        let relayed = AlloyProviderBuilder::new().connect_client(client);
354
355        assert_eq!(relayed.get_block_number().await.unwrap(), 42);
356    }
357}