1pub mod auth;
4
5use crate::FoundryTransactionBuilder;
6use alloy_network::{Network, 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 hardfork;
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 hardfork::{
32 active_tempo_hardfork, ensure_expiring_nonce_discriminator_active, is_tempo_hardfork_active,
33};
34pub use keystore::*;
35pub use lane::{PaymentLane, PaymentLaneClassification, PaymentLaneReason, classify_payment_lane};
36pub use session::*;
37pub use session_policy::{
38 GeneratedSessionKey, PreparedSessionAuthorization, SessionAuthorizationRequest,
39 SessionSpendLimit,
40};
41pub use tip20::{
42 TIP20_ALLOWED_LOGO_URI_SCHEMES, TIP20_MAX_LOGO_URI_BYTES, Tip20LogoUriValidationError,
43 validate_tip20_logo_uri,
44};
45
46#[cfg(test)]
47pub(crate) use test_utils::test_env_mutex;
48
49#[cfg(test)]
50mod tests;
51
52pub const ALPHA_USD_ADDRESS: Address = address!("0x20C0000000000000000000000000000000000001");
58pub const BETA_USD_ADDRESS: Address = address!("0x20C0000000000000000000000000000000000002");
59pub const THETA_USD_ADDRESS: Address = address!("0x20C0000000000000000000000000000000000003");
60pub const OUSD_ADDRESS: Address = address!("0x20c0000000000000000000006a37DA5C996874BE");
61
62#[derive(Clone, Debug)]
64pub struct TempoSponsor {
65 sponsor: Address,
66 signer: Option<Arc<WalletSigner>>,
67 signature: Option<Signature>,
68}
69
70impl TempoSponsor {
71 pub const fn new(
72 sponsor: Address,
73 signer: Option<Arc<WalletSigner>>,
74 signature: Option<Signature>,
75 ) -> Self {
76 Self { sponsor, signer, signature }
77 }
78
79 pub const fn sponsor(&self) -> Address {
80 self.sponsor
81 }
82
83 pub async fn resolve_and_set_fee_token<N>(
88 &self,
89 provider: Option<&dyn Provider<N>>,
90 tx: &mut N::TransactionRequest,
91 ) -> Result<Option<Address>>
92 where
93 N: Network,
94 N::TransactionRequest: FoundryTransactionBuilder<N>,
95 {
96 resolve_and_set_fee_token(provider, tx, Some(self.sponsor)).await
97 }
98
99 pub async fn attach_and_print<N: Network>(
100 &self,
101 tx: &mut N::TransactionRequest,
102 sender: Address,
103 ) -> Result<TempoSponsorPreview>
104 where
105 N::TransactionRequest: FoundryTransactionBuilder<N>,
106 {
107 if self.sponsor == sender {
108 eyre::bail!(
109 "invalid Tempo sponsorship: sponsor {} must not equal transaction sender",
110 self.sponsor
111 );
112 }
113
114 let digest = tx.compute_sponsor_hash(sender).ok_or_else(|| {
115 eyre::eyre!(
116 "failed to compute Tempo sponsor digest; make sure this is a complete Tempo AA transaction"
117 )
118 })?;
119
120 let preview = TempoSponsorPreview {
121 sponsor: self.sponsor,
122 fee_token: tx.fee_token(),
123 valid_before: tx.valid_before().map(|v| v.get()),
124 valid_after: tx.valid_after().map(|v| v.get()),
125 digest,
126 };
127 preview.print()?;
128
129 let signature = if let Some(signature) = self.signature {
130 signature
131 } else if let Some(signer) = &self.signer {
132 signer.sign_hash(&digest).await.context("failed to sign Tempo sponsor digest")?
133 } else {
134 eyre::bail!("missing Tempo sponsor signature or signer");
135 };
136
137 let recovered = signature
138 .recover_address_from_prehash(&digest)
139 .context("failed to recover Tempo sponsor signature")?;
140 if recovered != self.sponsor {
141 eyre::bail!(
142 "Tempo sponsor signature recovered {recovered}, expected {}; the signature must \
143 cover this exact transaction's sponsor digest — when signing a digest produced \
144 with `--tempo.print-sponsor-hash`, pin --nonce, --gas-limit, --gas-price and \
145 --priority-gas-price on both commands so the digest does not change in between",
146 self.sponsor
147 );
148 }
149 if recovered == sender {
150 eyre::bail!(
151 "invalid Tempo sponsorship: recovered fee payer {recovered} must not equal transaction sender"
152 );
153 }
154
155 tx.set_fee_payer_signature(signature);
156 Ok(preview)
157 }
158}
159
160#[derive(Clone, Copy, Debug, PartialEq, Eq)]
162pub struct TempoSponsorPreview {
163 pub sponsor: Address,
164 pub fee_token: Option<Address>,
165 pub valid_before: Option<u64>,
166 pub valid_after: Option<u64>,
167 pub digest: B256,
168}
169
170impl TempoSponsorPreview {
171 pub fn print(&self) -> Result<()> {
172 crate::sh_eprintln!("Tempo sponsor: {}", self.sponsor)?;
173 crate::sh_eprintln!(
174 "Tempo fee token: {}",
175 self.fee_token.map_or_else(|| "network default".to_string(), |addr| addr.to_string())
176 )?;
177 crate::sh_eprintln!(
178 "Tempo validity: after {}, before {}",
179 self.valid_after.map_or_else(|| "none".to_string(), |v| v.to_string()),
180 self.valid_before.map_or_else(|| "none".to_string(), |v| v.to_string())
181 )?;
182 crate::sh_eprintln!("Tempo sponsor digest: {:?}", self.digest)?;
183 Ok(())
184 }
185}
186
187pub async fn resolve_tempo_sponsor_signer(spec: &str) -> Result<WalletSigner> {
189 let spec = spec.trim();
190 let (scheme, value) = spec
191 .split_once("://")
192 .map(|(scheme, value)| (scheme.to_ascii_lowercase(), value))
193 .unwrap_or_else(|| (spec.to_ascii_lowercase(), ""));
194
195 match scheme.as_str() {
196 "env" => {
197 if value.is_empty() {
198 eyre::bail!("env:// sponsor signer requires an environment variable name");
199 }
200 let private_key = std::env::var(value)
201 .wrap_err_with(|| format!("{value} environment variable is required"))?;
202 foundry_wallets::utils::create_private_key_signer(&private_key)
203 }
204 "private-key" => {
205 if value.is_empty() {
206 eyre::bail!("private-key:// sponsor signer requires a private key");
207 }
208 foundry_wallets::utils::create_private_key_signer(value)
209 }
210 "keystore" => {
211 if value.is_empty() {
212 eyre::bail!("keystore:// sponsor signer requires a keystore path");
213 }
214 WalletOpts { keystore_path: Some(value.to_string()), ..Default::default() }
215 .signer()
216 .await
217 }
218 "account" => {
219 if value.is_empty() {
220 eyre::bail!("account:// sponsor signer requires an account name");
221 }
222 WalletOpts { keystore_account_name: Some(value.to_string()), ..Default::default() }
223 .signer()
224 .await
225 }
226 "ledger" => {
227 let raw = RawWalletOpts {
228 hd_path: (!value.is_empty()).then(|| value.to_string()),
229 ..Default::default()
230 };
231 WalletOpts { ledger: true, raw, ..Default::default() }.signer().await
232 }
233 "trezor" => {
234 let raw = RawWalletOpts {
235 hd_path: (!value.is_empty()).then(|| value.to_string()),
236 ..Default::default()
237 };
238 WalletOpts { trezor: true, raw, ..Default::default() }.signer().await
239 }
240 "aws" => WalletOpts { aws: true, ..Default::default() }.signer().await,
241 "gcp" => WalletOpts { gcp: true, ..Default::default() }.signer().await,
242 "turnkey" => WalletOpts { turnkey: true, ..Default::default() }.signer().await,
243 "browser" => {
244 eyre::bail!(
245 "browser:// sponsor signing is not supported by the current browser wallet API; use --tempo.sponsor-sig or another sponsor signer"
246 );
247 }
248 _ => {
249 eyre::bail!(
250 "unsupported Tempo sponsor signer `{spec}`; expected env://VAR, keystore://PATH, account://NAME, ledger://, trezor://, aws://, gcp://, turnkey://, or private-key://KEY"
251 );
252 }
253 }
254}
255
256fn redacted_debug(value: &str) -> &'static str {
258 if value.trim().is_empty() { "<empty>" } else { "<redacted>" }
259}
260
261pub async fn resolve_and_set_fee_token<N>(
267 provider: Option<&dyn Provider<N>>,
268 tx: &mut N::TransactionRequest,
269 fee_payer: Option<Address>,
270) -> Result<Option<Address>>
271where
272 N: Network,
273 N::TransactionRequest: FoundryTransactionBuilder<N>,
274{
275 if let Some(fee_token) = tx.fee_token() {
276 return Ok(Some(fee_token));
277 }
278 if !tx.supports_fee_token() {
279 return Ok(None);
280 }
281 let fee_payer = fee_payer.or_else(|| tx.from());
282 let calls = tx.tempo_calls();
283 let has_call_list = tx.has_tempo_call_list();
284 let is_aa = tx.is_tempo_aa();
285 let tx_from = tx.from();
286
287 if !has_call_list && calls.iter().any(|(to, _)| to.is_create()) {
291 return Ok(None);
292 }
293
294 let immediate_user_token =
295 infer_fee_token_from_set_user_token_call(&calls, is_aa, tx_from, fee_payer);
296 let stored_fee_token = if immediate_user_token.is_none()
297 && let (Some(provider), Some(fee_payer)) = (provider, fee_payer)
298 {
299 stored_user_fee_token(provider, fee_payer).await?
300 } else {
301 None
302 };
303 let inferred_fee_token =
304 if immediate_user_token.is_none() && stored_fee_token.is_none() && !calls.is_empty() {
305 infer_fee_token_from_tip20_calls(&calls, tx_from, fee_payer)
306 .or_else(|| infer_fee_token_from_stablecoin_dex_calls(&calls, has_call_list))
307 } else {
308 None
309 };
310 let fee_token = immediate_user_token.or(stored_fee_token).or(inferred_fee_token);
311
312 let Some(fee_token) = fee_token else { return Ok(None) };
313 tx.set_fee_token(fee_token);
314 Ok(Some(fee_token))
315}
316
317async fn stored_user_fee_token<N>(
318 provider: &dyn Provider<N>,
319 fee_payer: Address,
320) -> Result<Option<Address>>
321where
322 N: Network,
323{
324 let call = IFeeManager::userTokensCall { user: fee_payer };
325 let tx = N::TransactionRequest::default()
326 .with_to(TIP_FEE_MANAGER_ADDRESS)
327 .with_input(call.abi_encode());
328 let output = provider
329 .call(tx)
330 .await
331 .wrap_err_with(|| format!("failed to resolve Tempo fee token for {fee_payer}"))?;
332 let fee_token = IFeeManager::userTokensCall::abi_decode_returns(&output)
333 .wrap_err("failed to decode Tempo fee token lookup")?;
334 Ok((!fee_token.is_zero()).then_some(fee_token))
335}
336
337fn infer_fee_token_from_tip20_calls(
338 calls: &[(TxKind, &[u8])],
339 tx_from: Option<Address>,
340 fee_payer: Option<Address>,
341) -> Option<Address> {
342 if calls.is_empty() || !calls.iter().all(|(_, input)| is_tip20_fee_token_call(input)) {
343 return None;
344 }
345
346 let target = common_call_target(calls)?;
347 if fee_payer != tx_from {
348 return None;
349 }
350 target.is_tip20().then_some(target)
351}
352
353fn infer_fee_token_from_set_user_token_call(
354 calls: &[(TxKind, &[u8])],
355 is_aa: bool,
356 tx_from: Option<Address>,
357 fee_payer: Option<Address>,
358) -> Option<Address> {
359 if is_aa || fee_payer != tx_from {
360 return None;
361 }
362
363 let (to, input) = calls.first()?;
364 if *to != TxKind::Call(TIP_FEE_MANAGER_ADDRESS) {
365 return None;
366 }
367
368 let call = IFeeManager::setUserTokenCall::abi_decode(input).ok()?;
369 call.token.is_tip20().then_some(call.token)
370}
371
372fn infer_fee_token_from_stablecoin_dex_calls(
373 calls: &[(TxKind, &[u8])],
374 has_call_list: bool,
375) -> Option<Address> {
376 if has_call_list && calls.len() != 1 {
377 return None;
378 }
379 let (to, input) = calls.first()?;
380 if *to != TxKind::Call(STABLECOIN_DEX_ADDRESS) {
381 return None;
382 }
383 decode_stablecoin_dex_fee_token(input)
384}
385
386fn common_call_target(calls: &[(TxKind, &[u8])]) -> Option<Address> {
387 let mut targets = calls.iter().map(|(to, _)| match to {
388 TxKind::Call(target) => Some(*target),
389 TxKind::Create => None,
390 });
391 let target = targets.next()??;
392 targets.all(|next| next == Some(target)).then_some(target)
393}
394
395fn is_tip20_fee_token_call(input: &[u8]) -> bool {
396 input.get(..4).is_some_and(|selector| {
397 selector == ITIP20::transferCall::SELECTOR
398 || selector == ITIP20::transferWithMemoCall::SELECTOR
399 })
400}
401
402fn decode_stablecoin_dex_fee_token(input: &[u8]) -> Option<Address> {
403 let selector = input.get(..4)?;
404 let token = if selector == IStablecoinDEX::swapExactAmountInCall::SELECTOR {
405 IStablecoinDEX::swapExactAmountInCall::abi_decode(input).ok().map(|call| call.tokenIn)
406 } else if selector == IStablecoinDEX::swapExactAmountOutCall::SELECTOR {
407 IStablecoinDEX::swapExactAmountOutCall::abi_decode(input).ok().map(|call| call.tokenIn)
408 } else {
409 None
410 }?;
411 token.is_tip20().then_some(token)
412}
413
414pub const fn known_fee_token_symbol(fee_token: Address) -> Option<&'static str> {
416 match fee_token {
417 PATH_USD_ADDRESS => Some("PathUSD"),
418 ALPHA_USD_ADDRESS => Some("AlphaUSD"),
419 BETA_USD_ADDRESS => Some("BetaUSD"),
420 THETA_USD_ADDRESS => Some("ThetaUSD"),
421 OUSD_ADDRESS => Some("OUSD"),
422 _ => None,
423 }
424}
425
426async fn resolve_fee_token_symbol<N, P>(provider: &P, fee_token: Address) -> Option<String>
427where
428 N: Network,
429 P: Provider<N>,
430{
431 if let Some(symbol) = known_fee_token_symbol(fee_token) {
432 return Some(symbol.to_string());
433 }
434
435 let tx = N::TransactionRequest::default()
436 .with_to(fee_token)
437 .with_input(ITIP20::symbolCall.abi_encode());
438 let output = provider.call(tx).await.ok()?;
439 let symbol = ITIP20::symbolCall::abi_decode_returns(&output).ok()?;
440 (!symbol.is_empty()).then_some(symbol)
441}
442
443pub async fn maybe_print_fee_token<N, P>(
448 provider: Option<&P>,
449 fee_token: Option<Address>,
450) -> Result<()>
451where
452 N: Network,
453 P: Provider<N>,
454{
455 if let Some(fee_token) = fee_token {
456 let symbol = if let Some(symbol) = known_fee_token_symbol(fee_token) {
457 Some(symbol.to_string())
458 } else if let Some(provider) = provider {
459 resolve_fee_token_symbol(provider, fee_token).await
460 } else {
461 None
462 };
463 match symbol {
464 Some(symbol) => sh_status!("Paying gas in {} ({})", symbol, fee_token)?,
465 None => sh_status!("Paying gas in {}", fee_token)?,
466 }
467 }
468 Ok(())
469}