1use alloy_network::{Network, TransactionBuilder};
2use alloy_primitives::{Address, B256, ruint::aliases::U256};
3use alloy_signer::{Signature, Signer};
4use clap::Parser;
5use eyre::Result;
6use foundry_common::{
7 FoundryTransactionBuilder,
8 tempo::{TempoSponsor, resolve_tempo_sponsor_signer},
9};
10use foundry_evm::hardfork::TempoHardfork;
11use std::{num::NonZeroU64, path::PathBuf, str::FromStr, sync::Arc};
12
13use crate::utils::{now, parse_fee_token_address};
14
15mod session;
16pub use session::TEMPO_SESSION_ID_ENV;
17
18#[derive(Clone, Debug, Default, Parser)]
20#[command(next_help_heading = "Tempo")]
21pub struct TempoOpts {
22 #[arg(long = "tempo.session", id = "tempo_session", value_name = "SESSION_ID")]
28 pub session: Option<B256>,
29
30 #[arg(long = "tempo.fee-token", value_parser = parse_fee_token_address)]
39 pub fee_token: Option<Address>,
40
41 #[arg(long = "tempo.expires", value_name = "SECONDS", value_parser = parse_expires_seconds)]
52 pub expires: Option<u64>,
53
54 #[arg(long = "tempo.nonce-key", value_name = "NONCE_KEY", conflicts_with = "lane")]
62 pub nonce_key: Option<U256>,
63
64 #[arg(long = "tempo.lane", value_name = "NAME")]
78 pub lane: Option<String>,
79
80 #[arg(long = "tempo.lanes-file", value_name = "PATH")]
84 pub lanes_file: Option<PathBuf>,
85
86 #[arg(long = "tempo.sponsor", value_name = "ADDRESS")]
88 pub sponsor: Option<Address>,
89
90 #[arg(
96 long = "tempo.sponsor-signer",
97 value_name = "SIGNER",
98 requires = "sponsor",
99 conflicts_with = "sponsor_sig"
100 )]
101 pub sponsor_signer: Option<String>,
102
103 #[arg(
108 long = "tempo.sponsor-sig",
109 alias = "tempo.sponsor-signature",
110 value_parser = parse_signature,
111 requires = "sponsor",
112 conflicts_with = "sponsor_signer"
113 )]
114 pub sponsor_sig: Option<Signature>,
115
116 #[arg(
128 long = "sponsor-url",
129 alias = "tempo.sponsor-url",
130 value_name = "URL",
131 conflicts_with_all = &["sponsor", "sponsor_signer", "sponsor_sig", "print_sponsor_hash"],
132 env = "TEMPO_SPONSOR_URL"
133 )]
134 pub sponsor_url: Option<String>,
135
136 #[arg(
141 long = "tempo.print-sponsor-hash",
142 conflicts_with_all = &["sponsor_signer", "sponsor_sig", "sponsor_url"]
143 )]
144 pub print_sponsor_hash: bool,
145
146 #[arg(long = "tempo.key-id")]
151 pub key_id: Option<Address>,
152
153 #[arg(long = "tempo.expiring-nonce", requires = "valid_before", conflicts_with = "expires")]
164 pub expiring_nonce: bool,
165
166 #[arg(long = "tempo.valid-before", conflicts_with = "expires")]
171 pub valid_before: Option<u64>,
172
173 #[arg(long = "tempo.valid-after")]
178 pub valid_after: Option<u64>,
179}
180
181impl TempoOpts {
182 pub const fn is_tempo(&self) -> bool {
184 self.fee_token.is_some()
185 || self.expires.is_some()
186 || self.nonce_key.is_some()
187 || self.lane.is_some()
188 || self.sponsor.is_some()
189 || self.sponsor_signer.is_some()
190 || self.sponsor_sig.is_some()
191 || self.sponsor_url.is_some()
192 || self.print_sponsor_hash
193 || self.key_id.is_some()
194 || self.expiring_nonce
195 || self.valid_before.is_some()
196 || self.valid_after.is_some()
197 }
198
199 pub fn expires_at(&self) -> Option<u64> {
201 let secs = self.expires?;
202 Some(now().as_secs() + secs)
203 }
204
205 pub fn resolve_expires(&mut self) -> Option<u64> {
210 let ts = self.expires_at()?;
211 self.expiring_nonce = true;
212 self.valid_before = Some(ts);
213 self.expires = None;
214 Some(ts)
215 }
216
217 pub const fn has_sponsor_submission(&self) -> bool {
219 self.sponsor.is_some() || self.sponsor_signer.is_some() || self.sponsor_sig.is_some()
220 }
221
222 pub async fn sponsor_config(&self) -> Result<Option<TempoSponsor>> {
224 let Some(sponsor) = self.sponsor else {
225 return Ok(None);
226 };
227
228 let signer = if let Some(spec) = &self.sponsor_signer {
229 Some(Arc::new(Box::pin(resolve_tempo_sponsor_signer(spec)).await?))
230 } else {
231 None
232 };
233
234 if let Some(signer) = &signer {
235 let signer_address = signer.address();
236 if signer_address != sponsor {
237 eyre::bail!(
238 "Tempo sponsor signer address {signer_address} does not match --tempo.sponsor {sponsor}"
239 );
240 }
241 }
242
243 if signer.is_none() && self.sponsor_sig.is_none() {
244 eyre::bail!(
245 "--tempo.sponsor requires either --tempo.sponsor-signer or --tempo.sponsor-sig"
246 );
247 }
248
249 Ok(Some(TempoSponsor::new(sponsor, signer, self.sponsor_sig)))
250 }
251
252 pub fn apply<N: Network>(&self, tx: &mut N::TransactionRequest, nonce: Option<u64>)
259 where
260 N::TransactionRequest: FoundryTransactionBuilder<N>,
261 {
262 if self.expiring_nonce || self.expires.is_some() {
265 tx.set_nonce(nonce.unwrap_or_default());
266 tx.set_nonce_key(U256::MAX);
267 } else {
268 if let Some(nonce) = nonce {
269 tx.set_nonce(nonce);
270 }
271 if let Some(nonce_key) = self.nonce_key {
272 tx.set_nonce_key(nonce_key);
273 }
274 }
275
276 if let Some(fee_token) = self.fee_token {
277 tx.set_fee_token(fee_token);
278 }
279
280 let effective_valid_before = self.expires_at().or(self.valid_before);
283 if let Some(valid_before) = effective_valid_before
284 && let Some(v) = NonZeroU64::new(valid_before)
285 {
286 tx.set_valid_before(v);
287 }
288 if let Some(valid_after) = self.valid_after
289 && let Some(v) = NonZeroU64::new(valid_after)
290 {
291 tx.set_valid_after(v);
292 }
293
294 if let Some(key_id) = self.key_id {
295 tx.set_key_id(key_id);
296 }
297
298 if (self.has_sponsor_submission() || self.sponsor_url.is_some() || self.print_sponsor_hash)
304 && tx.nonce_key().is_none()
305 {
306 tx.set_nonce_key(U256::ZERO);
307 }
308 }
309}
310
311fn parse_signature(s: &str) -> Result<Signature, String> {
312 Signature::from_str(s).map_err(|e| format!("invalid signature: {e}"))
313}
314
315const MAX_EXPIRES_SECS: u64 = TempoHardfork::T11.expiring_nonce_max_expiry_secs();
318
319fn parse_expires_seconds(s: &str) -> Result<u64, String> {
321 let secs: u64 = s
322 .parse()
323 .map_err(|_| format!("invalid value '{s}': expected an integer number of seconds"))?;
324 if secs > MAX_EXPIRES_SECS {
325 return Err(format!("expires must be at most {MAX_EXPIRES_SECS} seconds (got {secs})"));
326 }
327 Ok(secs)
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use alloy_primitives::address;
334 use foundry_common::tempo::{BETA_USD_ADDRESS, OUSD_ADDRESS, PATH_USD_ADDRESS};
335
336 #[test]
337 fn parses_lane_arg() {
338 let opts = TempoOpts::try_parse_from(["", "--tempo.lane", "deploy"]).unwrap();
339 assert_eq!(opts.lane.as_deref(), Some("deploy"));
340 assert!(opts.nonce_key.is_none());
341 }
342
343 #[test]
344 fn lane_conflicts_with_nonce_key() {
345 let err =
346 TempoOpts::try_parse_from(["", "--tempo.lane", "deploy", "--tempo.nonce-key", "1"])
347 .unwrap_err();
348 assert!(
349 err.to_string().contains("cannot be used with"),
350 "expected clap conflict error, got: {err}",
351 );
352 }
353
354 #[test]
355 fn parse_expires_flag() {
356 let opts = TempoOpts::try_parse_from(["", "--tempo.expires", "300"]).unwrap();
357 assert_eq!(opts.expires, Some(300));
358
359 let opts = TempoOpts::try_parse_from(["", "--tempo.expires", "10"]).unwrap();
360 assert_eq!(opts.expires, Some(10));
361
362 assert!(TempoOpts::try_parse_from(["", "--tempo.expires", "301"]).is_err());
364
365 assert!(
367 TempoOpts::try_parse_from([
368 "",
369 "--tempo.expires",
370 "30",
371 "--tempo.expiring-nonce",
372 "--tempo.valid-before",
373 "999"
374 ])
375 .is_err()
376 );
377 }
378
379 #[test]
380 fn resolve_expires_materializes_valid_before() {
381 let before = now().as_secs();
382 let mut opts = TempoOpts::try_parse_from(["", "--tempo.expires", "10"]).unwrap();
383
384 let resolved = opts.resolve_expires().unwrap();
385 let after = now().as_secs();
386
387 assert!(resolved >= before + 10);
388 assert!(resolved <= after + 10);
389 assert!(opts.expiring_nonce);
390 assert_eq!(opts.valid_before, Some(resolved));
391 assert_eq!(opts.expires, None);
392 assert_eq!(opts.expires_at(), None);
393 }
394
395 #[test]
396 fn parse_fee_token_address_id_and_symbol() {
397 let opts = TempoOpts::try_parse_from([
398 "",
399 "--tempo.fee-token",
400 "0x20C0000000000000000000000000000000000002",
401 ])
402 .unwrap();
403 assert_eq!(opts.fee_token, Some(address!("0x20C0000000000000000000000000000000000002")),);
404
405 let opts_with_id = TempoOpts::try_parse_from(["", "--tempo.fee-token", "1"]).unwrap();
407 assert_eq!(
408 opts_with_id.fee_token,
409 Some(address!("0x20C0000000000000000000000000000000000001")),
410 );
411
412 let opts_with_symbol =
413 TempoOpts::try_parse_from(["", "--tempo.fee-token", "PathUSD"]).unwrap();
414 assert_eq!(opts_with_symbol.fee_token, Some(PATH_USD_ADDRESS));
415
416 let opts_with_mixed_case_symbol =
417 TempoOpts::try_parse_from(["", "--tempo.fee-token", "bEtAuSd"]).unwrap();
418 assert_eq!(opts_with_mixed_case_symbol.fee_token, Some(BETA_USD_ADDRESS));
419
420 let opts_with_ousd = TempoOpts::try_parse_from(["", "--tempo.fee-token", "OUSD"]).unwrap();
421 assert_eq!(opts_with_ousd.fee_token, Some(OUSD_ADDRESS));
422
423 let err = TempoOpts::try_parse_from(["", "--tempo.fee-token", "unknownusd"]).unwrap_err();
424 let msg = err.to_string();
425 assert!(msg.contains("expected address, numeric TIP-20 token id"));
426 assert!(msg.contains("PathUSD, AlphaUSD, BetaUSD, ThetaUSD, OUSD"));
427 }
428
429 #[test]
430 fn parse_sponsor_signer() {
431 let opts = TempoOpts::try_parse_from([
432 "",
433 "--tempo.sponsor",
434 "0x1111111111111111111111111111111111111111",
435 "--tempo.sponsor-signer",
436 "env://TEMPO_SPONSOR_PK",
437 ])
438 .unwrap();
439
440 assert_eq!(opts.sponsor, Some(Address::repeat_byte(0x11)));
441 assert_eq!(opts.sponsor_signer.as_deref(), Some("env://TEMPO_SPONSOR_PK"));
442 assert!(opts.sponsor_sig.is_none());
443 assert!(opts.is_tempo());
444 assert!(opts.has_sponsor_submission());
445 }
446
447 #[test]
448 fn sponsor_signer_requires_sponsor() {
449 assert!(
450 TempoOpts::try_parse_from(["", "--tempo.sponsor-signer", "env://SPONSOR"]).is_err()
451 );
452 }
453
454 #[test]
455 fn parse_sponsor_signature_alias() {
456 let opts = TempoOpts::try_parse_from([
457 "",
458 "--tempo.sponsor",
459 "0x1111111111111111111111111111111111111111",
460 "--tempo.sponsor-signature",
461 "0x0eb96ca19e8a77102767a41fc85a36afd5c61ccb09911cec5d3e86e193d9c5ae3a456401896b1b6055311536bf00a718568c744d8c1f9df59879e8350220ca182b",
462 ])
463 .unwrap();
464
465 assert_eq!(opts.sponsor, Some(Address::repeat_byte(0x11)));
466 assert!(opts.sponsor_sig.is_some());
467 }
468
469 #[test]
470 fn print_sponsor_hash_allows_sponsor_address() {
471 let opts = TempoOpts::try_parse_from([
472 "",
473 "--tempo.print-sponsor-hash",
474 "--tempo.sponsor",
475 "0x1111111111111111111111111111111111111111",
476 ])
477 .unwrap();
478
479 assert!(opts.print_sponsor_hash);
480 assert_eq!(opts.sponsor, Some(Address::repeat_byte(0x11)));
481 }
482
483 #[test]
484 fn print_sponsor_hash_conflicts_with_sponsor_signature() {
485 assert!(
486 TempoOpts::try_parse_from([
487 "",
488 "--tempo.print-sponsor-hash",
489 "--tempo.sponsor",
490 "0x1111111111111111111111111111111111111111",
491 "--tempo.sponsor-signature",
492 "0x0eb96ca19e8a77102767a41fc85a36afd5c61ccb09911cec5d3e86e193d9c5ae3a456401896b1b6055311536bf00a718568c744d8c1f9df59879e8350220ca182b",
493 ])
494 .is_err()
495 );
496 }
497
498 #[test]
499 fn parse_sponsor_url() {
500 let opts =
501 TempoOpts::try_parse_from(["", "--sponsor-url", "https://sponsor.tempo.xyz/tp_abc123"])
502 .unwrap();
503 assert_eq!(opts.sponsor_url.as_deref(), Some("https://sponsor.tempo.xyz/tp_abc123"));
504 assert!(opts.is_tempo());
505 }
506
507 #[test]
508 fn sponsor_url_alias() {
509 let opts = TempoOpts::try_parse_from([
510 "",
511 "--tempo.sponsor-url",
512 "https://sponsor.tempo.xyz/tp_abc123",
513 ])
514 .unwrap();
515 assert_eq!(opts.sponsor_url.as_deref(), Some("https://sponsor.tempo.xyz/tp_abc123"));
516 }
517
518 #[test]
519 fn sponsor_url_conflicts_with_sponsor() {
520 assert!(
521 TempoOpts::try_parse_from([
522 "",
523 "--sponsor-url",
524 "https://sponsor.tempo.xyz",
525 "--tempo.sponsor",
526 "0x1111111111111111111111111111111111111111",
527 ])
528 .is_err()
529 );
530 }
531}