Skip to main content

foundry_common/tempo/
mod.rs

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