1pub 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
48pub 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#[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 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#[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
183pub 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
252fn redacted_debug(value: &str) -> &'static str {
254 if value.trim().is_empty() { "<empty>" } else { "<redacted>" }
255}
256
257pub 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 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
411pub 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
441pub 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}