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_provider::Provider;
6use alloy_rpc_client::BuiltInConnectionString;
7use alloy_transport::{BoxTransport, TransportConnect, TransportError};
8use eyre::Result;
9use foundry_cli::{
10    json::print_json_success,
11    opts::{EthereumOpts, TempoOpts},
12    utils::{LoadConfig, get_chain},
13};
14use foundry_common::{provider::ProviderBuilder, shell};
15use foundry_config::{Chain, Eip1559FeeEstimatePreset};
16use foundry_wallets::{TempoAccountsWallet, WalletOpts, WalletSigner};
17use serde_json::Value;
18use std::str::FromStr;
19use tempo_alloy::{
20    TempoNetwork,
21    transport::{RelayConnector, SponsorshipMode},
22};
23
24pub use foundry_common::tempo::{TempoSponsor, TempoSponsorPreview, resolve_tempo_sponsor_signer};
25
26/// Prints a command result: the raw payload in JSON mode, the human rendering otherwise.
27pub(crate) fn print_payload<F>(payload: Value, human: F) -> Result<()>
28where
29    F: FnOnce(&Value) -> Result<()>,
30{
31    if shell::is_json() {
32        print_json_success(payload)?;
33    } else {
34        human(&payload)?;
35    }
36    Ok(())
37}
38
39pub(crate) fn print_expires(expires_at: Option<u64>) -> Result<()> {
40    if let Some(ts) = expires_at {
41        sh_status!("Transaction expires at unix timestamp {ts}")?;
42    }
43    Ok(())
44}
45
46/// Resolves a command signer, preferring an explicitly selected Tempo session.
47///
48/// Session resolution is fail-closed: when `--tempo.session` or `TEMPO_SESSION_ID` is set, wallet
49/// signer options are rejected by [`TempoOpts::session_signer_for_wallet`] instead of falling back
50/// to a long-lived signer.
51pub(crate) async fn resolve_session_or_wallet_signer(
52    tempo: &TempoOpts,
53    wallet: &WalletOpts,
54    chain_id: u64,
55) -> Result<(Option<WalletSigner>, Option<TempoAccountsWallet>)> {
56    match tempo.session_signer_for_wallet(wallet, chain_id)? {
57        Some(session) => Ok((None, Some(session.access_key))),
58        None => wallet.maybe_signer_for_chain(chain_id).await,
59    }
60}
61
62pub(crate) fn ensure_session_not_browser(tempo: &TempoOpts, browser: bool) -> Result<()> {
63    if browser && tempo.session_id()?.is_some() {
64        eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --browser");
65    }
66    Ok(())
67}
68
69/// Connector for reusing an already-configured RPC transport.
70///
71/// This preserves Foundry transport behavior such as MPP payment handling when a sponsor relay is
72/// layered over the default RPC.
73#[derive(Clone, Debug)]
74pub(crate) struct ExistingTransportConnector {
75    transport: BoxTransport,
76    is_local: bool,
77}
78
79impl TransportConnect for ExistingTransportConnector {
80    fn is_local(&self) -> bool {
81        self.is_local
82    }
83
84    async fn get_transport(&self) -> Result<BoxTransport, TransportError> {
85        Ok(self.transport.clone())
86    }
87}
88
89pub(crate) fn sponsor_relay_connector<N: Network>(
90    provider: &impl Provider<N>,
91    sponsor_url: &str,
92) -> Result<RelayConnector<ExistingTransportConnector, BuiltInConnectionString>> {
93    let default = ExistingTransportConnector {
94        transport: provider.client().transport().clone(),
95        is_local: provider.client().is_local(),
96    };
97    let relay = BuiltInConnectionString::from_str(sponsor_url)?;
98    Ok(RelayConnector::with_config(default, relay, SponsorshipMode::SignOnly, false))
99}
100
101/// Resolves the transaction network and any configured signer without letting an unrelated Tempo
102/// Accounts store change ordinary Ethereum commands.
103///
104/// Explicit signer options are resolved with `from` cleared so the store fallback is not consulted.
105/// The fallback is only enabled when a Tempo transaction option is present or the RPC chain is a
106/// known Tempo chain.
107pub(crate) async fn resolve_transaction_network_and_signer(
108    tempo: &TempoOpts,
109    eth: &EthereumOpts,
110) -> Result<(bool, Option<WalletSigner>, Option<TempoAccountsWallet>)> {
111    let mut explicit_wallet = eth.wallet.clone();
112    explicit_wallet.from = None;
113    let (signer, access_key) = explicit_wallet.maybe_signer().await?;
114
115    if access_key.is_some() {
116        return Ok((true, signer, access_key));
117    }
118
119    if tempo.is_tempo() {
120        if signer.is_some() || eth.wallet.from.is_none() {
121            return Ok((true, signer, None));
122        }
123        let (signer, access_key) = eth.wallet.maybe_signer().await?;
124        return Ok((true, signer, access_key));
125    }
126
127    if signer.is_some() || eth.wallet.from.is_none() {
128        return Ok((false, signer, None));
129    }
130
131    let config = eth.load_config()?;
132    let provider = ProviderBuilder::<Ethereum>::from_config(&config)?.build()?;
133    let chain = get_chain(config.chain, &provider).await?;
134    if !chain.is_tempo() {
135        return Ok((false, None, None));
136    }
137
138    let (signer, access_key) = eth.wallet.maybe_signer_for_chain(chain.id()).await?;
139    Ok((true, signer, access_key))
140}
141
142/// Fills a Tempo transaction request that was built outside [`crate::tx::CastTxBuilder`] before
143/// access-key signing.
144pub(crate) async fn fill_access_key_transaction<P>(
145    provider: &P,
146    tx: &mut <TempoNetwork as Network>::TransactionRequest,
147    access_key: &TempoAccountsWallet,
148    chain: Chain,
149    eip1559_fee_estimate: Eip1559FeeEstimatePreset,
150) -> Result<TempoAccountsWallet>
151where
152    P: Provider<TempoNetwork>,
153{
154    tx.set_chain_id(chain.id());
155    let prepared = access_key.prepare_request(provider, tx).await?;
156
157    if tx.nonce().is_none() {
158        tx.set_nonce(provider.get_transaction_count(prepared.account()).await?);
159    }
160    fill_transaction_gas_fees(provider, tx, chain.is_legacy(), false, eip1559_fee_estimate).await?;
161    if tx.gas_limit().is_none() {
162        tx.set_gas_limit(provider.estimate_gas(tx.clone()).await?);
163    }
164
165    Ok(prepared)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use alloy_provider::{ProviderBuilder as AlloyProviderBuilder, mock::Asserter};
172    use alloy_rpc_client::RpcClient;
173
174    #[tokio::test]
175    async fn sponsor_relay_reuses_existing_default_transport() {
176        let asserter = Asserter::new();
177        asserter.push_success(&alloy_primitives::U64::from(42));
178        let provider = AlloyProviderBuilder::new().connect_mocked_client(asserter);
179        let connector =
180            sponsor_relay_connector(&provider, "http://127.0.0.1:1").expect("valid relay");
181        let transport = connector.get_transport().await.expect("relay transport");
182        let client = RpcClient::builder().transport(transport, true);
183        let relayed = AlloyProviderBuilder::new().connect_client(client);
184
185        assert_eq!(relayed.get_block_number().await.unwrap(), 42);
186    }
187}