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
49pub 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#[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 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#[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
184pub 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
253fn redacted_debug(value: &str) -> &'static str {
255 if value.trim().is_empty() { "<empty>" } else { "<redacted>" }
256}
257
258pub 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 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
412pub 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
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}