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