1pub 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
50fn redacted_debug(value: &str) -> &'static str {
52 if value.trim().is_empty() { "<empty>" } else { "<redacted>" }
53}
54
55pub const TEMPO_BROWSER_GAS_BUFFER: u64 = 7_000;
65
66pub const ALPHA_USD_ADDRESS: Address = address!("0x20C0000000000000000000000000000000000001");
72pub const BETA_USD_ADDRESS: Address = address!("0x20C0000000000000000000000000000000000002");
73pub const THETA_USD_ADDRESS: Address = address!("0x20C0000000000000000000000000000000000003");
74
75pub 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 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
229const 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
258pub 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#[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 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#[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
407pub 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}