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 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
49fn redacted_debug(value: &str) -> &'static str {
51 if value.trim().is_empty() { "<empty>" } else { "<redacted>" }
52}
53
54pub const ALPHA_USD_ADDRESS: Address = address!("0x20C0000000000000000000000000000000000001");
60pub const BETA_USD_ADDRESS: Address = address!("0x20C0000000000000000000000000000000000002");
61pub const THETA_USD_ADDRESS: Address = address!("0x20C0000000000000000000000000000000000003");
62
63pub 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 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
217pub 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
246pub 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#[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 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#[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
401pub 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}