Skip to main content

foundry_common/tempo/
mod.rs

1//! Tempo network utilities.
2
3pub mod auth;
4
5use crate::FoundryTransactionBuilder;
6use alloy_chains::Chain;
7use alloy_network::{Network, NetworkTransactionBuilder, TransactionBuilder};
8use alloy_primitives::{Address, B256, Signature, TxKind, address};
9use alloy_provider::Provider;
10use alloy_signer::Signer;
11use alloy_sol_types::SolCall;
12use eyre::{Context, Result};
13use foundry_wallets::{RawWalletOpts, WalletOpts, WalletSigner};
14use std::sync::Arc;
15pub use tempo_alloy::contracts::precompiles::PATH_USD_ADDRESS;
16use tempo_alloy::contracts::precompiles::{
17    IFeeManager, IStablecoinDEX, ITIP20, STABLECOIN_DEX_ADDRESS, TIP_FEE_MANAGER_ADDRESS,
18};
19use tempo_primitives::TempoAddressExt;
20
21mod keystore;
22mod lane;
23mod registry;
24mod session;
25mod session_policy;
26#[cfg(test)]
27mod test_utils;
28mod tip20;
29
30pub(crate) use auth::is_known_tempo_endpoint;
31pub use auth::{AccessKeyOutcome, EnsureAccessKeyConfig, ensure_access_key};
32pub use keystore::*;
33pub use lane::{PaymentLane, PaymentLaneClassification, PaymentLaneReason, classify_payment_lane};
34pub use session::*;
35pub use session_policy::{
36    GeneratedSessionKey, PreparedSessionAuthorization, SessionAuthorizationRequest,
37    SessionSpendLimit,
38};
39pub use tip20::{
40    TIP20_ALLOWED_LOGO_URI_SCHEMES, TIP20_MAX_LOGO_URI_BYTES, Tip20LogoUriValidationError,
41    validate_tip20_logo_uri,
42};
43
44#[cfg(test)]
45pub(crate) use test_utils::{test_env_mutex, with_tempo_home};
46
47#[cfg(test)]
48mod tests;
49
50/// Placeholder rendered by `Debug` impls in place of secret key material.
51fn redacted_debug(value: &str) -> &'static str {
52    if value.trim().is_empty() { "<empty>" } else { "<redacted>" }
53}
54
55/// Conservative gas buffer for browser wallet transactions on Tempo chains.
56///
57/// Browser wallets may sign with P256 or WebAuthn instead of secp256k1, which costs more gas
58/// for signature verification. Since we can't determine the signature type before signing,
59/// we add the worst-case (WebAuthn) overhead:
60///   - P256: +5,000 gas (P256 precompile cost minus ecrecover savings)
61///   - WebAuthn: ~6,500 gas (P256 cost + calldata for webauthn_data)
62///
63/// See <https://github.com/tempoxyz/tempo/blob/6ebf1a8/crates/revm/src/handler.rs#L108-L124>
64pub const TEMPO_BROWSER_GAS_BUFFER: u64 = 7_000;
65
66/// Reserved Tempo TIP20 fee-token addresses created during Foundry genesis.
67///
68/// Unlike [`PATH_USD_ADDRESS`], these tokens are not defined by the canonical
69/// `tempo-contracts` crate; they only exist in Foundry's local genesis setup, so
70/// they are defined here as the single source of truth and re-exported elsewhere.
71pub const ALPHA_USD_ADDRESS: Address = address!("0x20C0000000000000000000000000000000000001");
72pub const BETA_USD_ADDRESS: Address = address!("0x20C0000000000000000000000000000000000002");
73pub const THETA_USD_ADDRESS: Address = address!("0x20C0000000000000000000000000000000000003");
74
75/// Resolves and applies the Tempo fee token selected by the network.
76///
77/// This must happen before computing a sponsor digest, because Tempo sponsor signatures commit to
78/// the fee token.
79pub async fn resolve_and_set_fee_token<N>(
80    provider: Option<&dyn Provider<N>>,
81    chain: Option<Chain>,
82    tx: &mut N::TransactionRequest,
83    fee_payer: Option<Address>,
84) -> Result<Option<Address>>
85where
86    N: Network,
87    N::TransactionRequest: Default + FoundryTransactionBuilder<N>,
88{
89    if let Some(fee_token) = tx.fee_token() {
90        return Ok(Some(fee_token));
91    }
92    if !chain.is_some_and(Chain::is_tempo) {
93        return Ok(None);
94    }
95    let fee_payer = fee_payer.or_else(|| tx.from());
96    let calls = tx.tempo_calls();
97    let has_call_list = tx.has_tempo_call_list();
98    let is_aa = tx.is_tempo_aa();
99    let tx_from = tx.from();
100
101    // A stored fee-token preference would classify a contract creation as Tempo AA, but AA
102    // transactions require a non-empty call list. Leave CREATE requests as Ethereum transactions;
103    // the protocol still applies the account's stored fee-token preference when charging fees.
104    if !has_call_list && calls.iter().any(|(to, _)| matches!(to, TxKind::Create)) {
105        return Ok(None);
106    }
107
108    let immediate_user_token =
109        infer_fee_token_from_set_user_token_call(&calls, is_aa, tx_from, fee_payer);
110    let stored_fee_token = if immediate_user_token.is_none()
111        && let (Some(provider), Some(fee_payer)) = (provider, fee_payer)
112    {
113        stored_user_fee_token(provider, fee_payer).await?
114    } else {
115        None
116    };
117    let inferred_fee_token =
118        if immediate_user_token.is_none() && stored_fee_token.is_none() && !calls.is_empty() {
119            infer_fee_token_from_tip20_calls(&calls, tx_from, fee_payer)
120                .or_else(|| infer_fee_token_from_stablecoin_dex_calls(&calls, has_call_list))
121        } else {
122            None
123        };
124    let fee_token = immediate_user_token.or(stored_fee_token).or(inferred_fee_token);
125
126    let Some(fee_token) = fee_token else { return Ok(None) };
127    tx.set_fee_token(fee_token);
128    Ok(Some(fee_token))
129}
130
131async fn stored_user_fee_token<N>(
132    provider: &dyn Provider<N>,
133    fee_payer: Address,
134) -> Result<Option<Address>>
135where
136    N: Network,
137    N::TransactionRequest: Default + NetworkTransactionBuilder<N>,
138{
139    let call = IFeeManager::userTokensCall { user: fee_payer };
140    let tx = N::TransactionRequest::default()
141        .with_to(TIP_FEE_MANAGER_ADDRESS)
142        .with_input(call.abi_encode());
143    let output = provider
144        .call(tx)
145        .await
146        .wrap_err_with(|| format!("failed to resolve Tempo fee token for {fee_payer}"))?;
147    let fee_token = IFeeManager::userTokensCall::abi_decode_returns(&output)
148        .wrap_err("failed to decode Tempo fee token lookup")?;
149    Ok((!fee_token.is_zero()).then_some(fee_token))
150}
151
152fn infer_fee_token_from_tip20_calls(
153    calls: &[(TxKind, &[u8])],
154    tx_from: Option<Address>,
155    fee_payer: Option<Address>,
156) -> Option<Address> {
157    if calls.is_empty() || !calls.iter().all(|(_, input)| is_tip20_fee_token_call(input)) {
158        return None;
159    }
160
161    let target = common_call_target(calls)?;
162    if fee_payer != tx_from {
163        return None;
164    }
165    target.is_tip20().then_some(target)
166}
167
168fn infer_fee_token_from_set_user_token_call(
169    calls: &[(TxKind, &[u8])],
170    is_aa: bool,
171    tx_from: Option<Address>,
172    fee_payer: Option<Address>,
173) -> Option<Address> {
174    if is_aa || fee_payer != tx_from {
175        return None;
176    }
177
178    let (to, input) = calls.first()?;
179    if *to != TxKind::Call(TIP_FEE_MANAGER_ADDRESS) {
180        return None;
181    }
182
183    let call = IFeeManager::setUserTokenCall::abi_decode(input).ok()?;
184    call.token.is_tip20().then_some(call.token)
185}
186
187fn infer_fee_token_from_stablecoin_dex_calls(
188    calls: &[(TxKind, &[u8])],
189    has_call_list: bool,
190) -> Option<Address> {
191    if has_call_list && calls.len() != 1 {
192        return None;
193    }
194    let (to, input) = calls.first()?;
195    if *to != TxKind::Call(STABLECOIN_DEX_ADDRESS) {
196        return None;
197    }
198    decode_stablecoin_dex_fee_token(input)
199}
200
201fn common_call_target(calls: &[(TxKind, &[u8])]) -> Option<Address> {
202    let mut targets = calls.iter().map(|(to, _)| match to {
203        TxKind::Call(target) => Some(*target),
204        TxKind::Create => None,
205    });
206    let target = targets.next()??;
207    targets.all(|next| next == Some(target)).then_some(target)
208}
209
210fn is_tip20_fee_token_call(input: &[u8]) -> bool {
211    input.get(..4).is_some_and(|selector| {
212        selector == ITIP20::transferCall::SELECTOR
213            || selector == ITIP20::transferWithMemoCall::SELECTOR
214    })
215}
216
217fn decode_stablecoin_dex_fee_token(input: &[u8]) -> Option<Address> {
218    let selector = input.get(..4)?;
219    let token = if selector == IStablecoinDEX::swapExactAmountInCall::SELECTOR {
220        IStablecoinDEX::swapExactAmountInCall::abi_decode(input).ok().map(|call| call.tokenIn)
221    } else if selector == IStablecoinDEX::swapExactAmountOutCall::SELECTOR {
222        IStablecoinDEX::swapExactAmountOutCall::abi_decode(input).ok().map(|call| call.tokenIn)
223    } else {
224        None
225    }?;
226    token.is_tip20().then_some(token)
227}
228
229/// Returns the known symbol for a Tempo fee token without making an RPC call.
230const fn known_fee_token_symbol(fee_token: Address) -> Option<&'static str> {
231    match fee_token {
232        PATH_USD_ADDRESS => Some("PathUSD"),
233        ALPHA_USD_ADDRESS => Some("AlphaUSD"),
234        BETA_USD_ADDRESS => Some("BetaUSD"),
235        THETA_USD_ADDRESS => Some("ThetaUSD"),
236        _ => None,
237    }
238}
239
240async fn resolve_fee_token_symbol<N, P>(provider: &P, fee_token: Address) -> Option<String>
241where
242    N: Network,
243    N::TransactionRequest: Default + NetworkTransactionBuilder<N>,
244    P: Provider<N>,
245{
246    if let Some(symbol) = known_fee_token_symbol(fee_token) {
247        return Some(symbol.to_string());
248    }
249
250    let tx = N::TransactionRequest::default()
251        .with_to(fee_token)
252        .with_input(ITIP20::symbolCall.abi_encode());
253    let output = provider.call(tx).await.ok()?;
254    let symbol = ITIP20::symbolCall::abi_decode_returns(&output).ok()?;
255    (!symbol.is_empty()).then_some(symbol)
256}
257
258/// Prints the fee token selected for display.
259///
260/// Unknown symbols are resolved on-chain only when a provider is supplied, because some provider
261/// modes such as `--curl` must preserve the first RPC request for the user's intended action.
262pub async fn maybe_print_fee_token<N, P>(
263    provider: Option<&P>,
264    fee_token: Option<Address>,
265) -> Result<()>
266where
267    N: Network,
268    N::TransactionRequest: Default + NetworkTransactionBuilder<N>,
269    P: Provider<N>,
270{
271    if let Some(fee_token) = fee_token {
272        let symbol = if let Some(symbol) = known_fee_token_symbol(fee_token) {
273            Some(symbol.to_string())
274        } else if let Some(provider) = provider {
275            resolve_fee_token_symbol(provider, fee_token).await
276        } else {
277            None
278        };
279        match symbol {
280            Some(symbol) => sh_status!("Paying gas in {} ({})", symbol, fee_token)?,
281            None => sh_status!("Paying gas in {}", fee_token)?,
282        }
283    }
284    Ok(())
285}
286
287/// Gas sponsor configuration for Tempo fee-payer signatures.
288#[derive(Clone, Debug)]
289pub struct TempoSponsor {
290    sponsor: Address,
291    signer: Option<Arc<WalletSigner>>,
292    signature: Option<Signature>,
293}
294
295impl TempoSponsor {
296    pub const fn new(
297        sponsor: Address,
298        signer: Option<Arc<WalletSigner>>,
299        signature: Option<Signature>,
300    ) -> Self {
301        Self { sponsor, signer, signature }
302    }
303
304    pub const fn sponsor(&self) -> Address {
305        self.sponsor
306    }
307
308    /// Resolves the fee token paid by this sponsor and applies it to the transaction request.
309    ///
310    /// This must happen before computing a sponsor digest, because Tempo sponsor signatures commit
311    /// to the fee token.
312    pub async fn resolve_and_set_fee_token<N>(
313        &self,
314        provider: Option<&dyn Provider<N>>,
315        chain: Option<Chain>,
316        tx: &mut N::TransactionRequest,
317    ) -> Result<Option<Address>>
318    where
319        N: Network,
320        N::TransactionRequest: Default + FoundryTransactionBuilder<N>,
321    {
322        resolve_and_set_fee_token(provider, chain, tx, Some(self.sponsor)).await
323    }
324
325    pub async fn attach_and_print<N: Network>(
326        &self,
327        tx: &mut N::TransactionRequest,
328        sender: Address,
329    ) -> Result<TempoSponsorPreview>
330    where
331        N::TransactionRequest: FoundryTransactionBuilder<N>,
332    {
333        if self.sponsor == sender {
334            eyre::bail!(
335                "invalid Tempo sponsorship: sponsor {} must not equal transaction sender",
336                self.sponsor
337            );
338        }
339
340        let digest = tx.compute_sponsor_hash(sender).ok_or_else(|| {
341            eyre::eyre!(
342                "failed to compute Tempo sponsor digest; make sure this is a complete Tempo AA transaction"
343            )
344        })?;
345
346        let preview = TempoSponsorPreview {
347            sponsor: self.sponsor,
348            fee_token: tx.fee_token(),
349            valid_before: tx.valid_before().map(|v| v.get()),
350            valid_after: tx.valid_after().map(|v| v.get()),
351            digest,
352        };
353        preview.print()?;
354
355        let signature = if let Some(signature) = self.signature {
356            signature
357        } else if let Some(signer) = &self.signer {
358            signer.sign_hash(&digest).await.context("failed to sign Tempo sponsor digest")?
359        } else {
360            eyre::bail!("missing Tempo sponsor signature or signer");
361        };
362
363        let recovered = signature
364            .recover_address_from_prehash(&digest)
365            .context("failed to recover Tempo sponsor signature")?;
366        if recovered != self.sponsor {
367            eyre::bail!("Tempo sponsor signature recovered {recovered}, expected {}", self.sponsor);
368        }
369        if recovered == sender {
370            eyre::bail!(
371                "invalid Tempo sponsorship: recovered fee payer {recovered} must not equal transaction sender"
372            );
373        }
374
375        tx.set_fee_payer_signature(signature);
376        Ok(preview)
377    }
378}
379
380/// User-visible sponsor digest metadata for a single outgoing Tempo transaction.
381#[derive(Clone, Copy, Debug, PartialEq, Eq)]
382pub struct TempoSponsorPreview {
383    pub sponsor: Address,
384    pub fee_token: Option<Address>,
385    pub valid_before: Option<u64>,
386    pub valid_after: Option<u64>,
387    pub digest: B256,
388}
389
390impl TempoSponsorPreview {
391    pub fn print(&self) -> Result<()> {
392        crate::sh_eprintln!("Tempo sponsor: {}", self.sponsor)?;
393        crate::sh_eprintln!(
394            "Tempo fee token: {}",
395            self.fee_token.map_or_else(|| "network default".to_string(), |addr| addr.to_string())
396        )?;
397        crate::sh_eprintln!(
398            "Tempo validity: after {}, before {}",
399            self.valid_after.map_or_else(|| "none".to_string(), |v| v.to_string()),
400            self.valid_before.map_or_else(|| "none".to_string(), |v| v.to_string())
401        )?;
402        crate::sh_eprintln!("Tempo sponsor digest: {:?}", self.digest)?;
403        Ok(())
404    }
405}
406
407/// Resolves a `--tempo.sponsor-signer` URI into a Foundry wallet signer.
408pub async fn resolve_tempo_sponsor_signer(spec: &str) -> Result<WalletSigner> {
409    let spec = spec.trim();
410    let (scheme, value) = spec
411        .split_once("://")
412        .map(|(scheme, value)| (scheme.to_ascii_lowercase(), value))
413        .unwrap_or_else(|| (spec.to_ascii_lowercase(), ""));
414
415    match scheme.as_str() {
416        "env" => {
417            if value.is_empty() {
418                eyre::bail!("env:// sponsor signer requires an environment variable name");
419            }
420            let private_key = std::env::var(value)
421                .wrap_err_with(|| format!("{value} environment variable is required"))?;
422            foundry_wallets::utils::create_private_key_signer(&private_key)
423        }
424        "private-key" => {
425            if value.is_empty() {
426                eyre::bail!("private-key:// sponsor signer requires a private key");
427            }
428            foundry_wallets::utils::create_private_key_signer(value)
429        }
430        "keystore" => {
431            if value.is_empty() {
432                eyre::bail!("keystore:// sponsor signer requires a keystore path");
433            }
434            WalletOpts { keystore_path: Some(value.to_string()), ..Default::default() }
435                .signer()
436                .await
437        }
438        "account" => {
439            if value.is_empty() {
440                eyre::bail!("account:// sponsor signer requires an account name");
441            }
442            WalletOpts { keystore_account_name: Some(value.to_string()), ..Default::default() }
443                .signer()
444                .await
445        }
446        "ledger" => {
447            let raw = RawWalletOpts {
448                hd_path: (!value.is_empty()).then(|| value.to_string()),
449                ..Default::default()
450            };
451            WalletOpts { ledger: true, raw, ..Default::default() }.signer().await
452        }
453        "trezor" => {
454            let raw = RawWalletOpts {
455                hd_path: (!value.is_empty()).then(|| value.to_string()),
456                ..Default::default()
457            };
458            WalletOpts { trezor: true, raw, ..Default::default() }.signer().await
459        }
460        "aws" => WalletOpts { aws: true, ..Default::default() }.signer().await,
461        "gcp" => WalletOpts { gcp: true, ..Default::default() }.signer().await,
462        "turnkey" => WalletOpts { turnkey: true, ..Default::default() }.signer().await,
463        "browser" => {
464            eyre::bail!(
465                "browser:// sponsor signing is not supported by the current browser wallet API; use --tempo.sponsor-sig or another sponsor signer"
466            );
467        }
468        _ => {
469            eyre::bail!(
470                "unsupported Tempo sponsor signer `{spec}`; expected env://VAR, keystore://PATH, account://NAME, ledger://, trezor://, aws://, gcp://, turnkey://, or private-key://KEY"
471            );
472        }
473    }
474}