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