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