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 std::{
11 num::NonZeroU64,
12 path::PathBuf,
13 str::FromStr,
14 sync::Arc,
15 time::{SystemTime, UNIX_EPOCH},
16};
17
18use crate::utils::parse_fee_token_address;
19
20mod session;
21pub use session::TEMPO_SESSION_ID_ENV;
22
23#[derive(Clone, Debug, Default, Parser)]
25#[command(next_help_heading = "Tempo")]
26pub struct TempoOpts {
27 #[arg(long = "tempo.session", id = "tempo_session", value_name = "SESSION_ID")]
33 pub session: Option<B256>,
34
35 #[arg(long = "tempo.fee-token", value_parser = parse_fee_token_address)]
43 pub fee_token: Option<Address>,
44
45 #[arg(long = "tempo.expires", value_name = "SECONDS", value_parser = parse_expires_seconds)]
55 pub expires: Option<u64>,
56
57 #[arg(long = "tempo.nonce-key", value_name = "NONCE_KEY", conflicts_with = "lane")]
65 pub nonce_key: Option<U256>,
66
67 #[arg(long = "tempo.lane", value_name = "NAME")]
81 pub lane: Option<String>,
82
83 #[arg(long = "tempo.lanes-file", value_name = "PATH")]
87 pub lanes_file: Option<PathBuf>,
88
89 #[arg(long = "tempo.sponsor", value_name = "ADDRESS")]
91 pub sponsor: Option<Address>,
92
93 #[arg(
99 long = "tempo.sponsor-signer",
100 value_name = "SIGNER",
101 requires = "sponsor",
102 conflicts_with = "sponsor_sig"
103 )]
104 pub sponsor_signer: Option<String>,
105
106 #[arg(
111 long = "tempo.sponsor-sig",
112 alias = "tempo.sponsor-signature",
113 value_parser = parse_signature,
114 requires = "sponsor",
115 conflicts_with = "sponsor_signer"
116 )]
117 pub sponsor_sig: Option<Signature>,
118
119 #[arg(
131 long = "sponsor-url",
132 alias = "tempo.sponsor-url",
133 value_name = "URL",
134 conflicts_with_all = &["sponsor", "sponsor_signer", "sponsor_sig", "print_sponsor_hash"],
135 env = "TEMPO_SPONSOR_URL"
136 )]
137 pub sponsor_url: Option<String>,
138
139 #[arg(
144 long = "tempo.print-sponsor-hash",
145 conflicts_with_all = &["sponsor_signer", "sponsor_sig", "sponsor_url"]
146 )]
147 pub print_sponsor_hash: bool,
148
149 #[arg(long = "tempo.key-id")]
154 pub key_id: Option<Address>,
155
156 #[arg(long = "tempo.expiring-nonce", requires = "valid_before", conflicts_with = "expires")]
161 pub expiring_nonce: bool,
162
163 #[arg(long = "tempo.valid-before", conflicts_with = "expires")]
168 pub valid_before: Option<u64>,
169
170 #[arg(long = "tempo.valid-after")]
175 pub valid_after: Option<u64>,
176}
177
178impl TempoOpts {
179 pub const fn is_tempo(&self) -> bool {
181 self.fee_token.is_some()
182 || self.expires.is_some()
183 || self.nonce_key.is_some()
184 || self.lane.is_some()
185 || self.sponsor.is_some()
186 || self.sponsor_signer.is_some()
187 || self.sponsor_sig.is_some()
188 || self.sponsor_url.is_some()
189 || self.print_sponsor_hash
190 || self.key_id.is_some()
191 || self.expiring_nonce
192 || self.valid_before.is_some()
193 || self.valid_after.is_some()
194 }
195
196 pub fn expires_at(&self) -> Option<u64> {
198 let secs = self.expires?;
199 let now = SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards");
200 Some(now.as_secs() + secs)
201 }
202
203 pub fn resolve_expires(&mut self) -> Option<u64> {
208 let ts = self.expires_at()?;
209 self.expiring_nonce = true;
210 self.valid_before = Some(ts);
211 self.expires = None;
212 Some(ts)
213 }
214
215 pub const fn has_sponsor_submission(&self) -> bool {
217 self.sponsor.is_some() || self.sponsor_signer.is_some() || self.sponsor_sig.is_some()
218 }
219
220 pub async fn sponsor_config(&self) -> Result<Option<TempoSponsor>> {
222 let Some(sponsor) = self.sponsor else {
223 return Ok(None);
224 };
225
226 let signer = if let Some(spec) = &self.sponsor_signer {
227 Some(Arc::new(Box::pin(resolve_tempo_sponsor_signer(spec)).await?))
228 } else {
229 None
230 };
231
232 if let Some(signer) = &signer {
233 let signer_address = signer.address();
234 if signer_address != sponsor {
235 eyre::bail!(
236 "Tempo sponsor signer address {signer_address} does not match --tempo.sponsor {sponsor}"
237 );
238 }
239 }
240
241 if signer.is_none() && self.sponsor_sig.is_none() {
242 eyre::bail!(
243 "--tempo.sponsor requires either --tempo.sponsor-signer or --tempo.sponsor-sig"
244 );
245 }
246
247 Ok(Some(TempoSponsor::new(sponsor, signer, self.sponsor_sig)))
248 }
249
250 pub fn apply<N: Network>(&self, tx: &mut N::TransactionRequest, nonce: Option<u64>)
254 where
255 N::TransactionRequest: FoundryTransactionBuilder<N>,
256 {
257 if self.expiring_nonce || self.expires.is_some() {
260 tx.set_nonce(0);
261 tx.set_nonce_key(U256::MAX);
262 } else {
263 if let Some(nonce) = nonce {
264 tx.set_nonce(nonce);
265 }
266 if let Some(nonce_key) = self.nonce_key {
267 tx.set_nonce_key(nonce_key);
268 }
269 }
270
271 if let Some(fee_token) = self.fee_token {
272 tx.set_fee_token(fee_token);
273 }
274
275 let effective_valid_before = self.expires_at().or(self.valid_before);
278 if let Some(valid_before) = effective_valid_before
279 && let Some(v) = NonZeroU64::new(valid_before)
280 {
281 tx.set_valid_before(v);
282 }
283 if let Some(valid_after) = self.valid_after
284 && let Some(v) = NonZeroU64::new(valid_after)
285 {
286 tx.set_valid_after(v);
287 }
288
289 if let Some(key_id) = self.key_id {
290 tx.set_key_id(key_id);
291 }
292
293 if (self.has_sponsor_submission() || self.sponsor_url.is_some() || self.print_sponsor_hash)
299 && tx.nonce_key().is_none()
300 {
301 tx.set_nonce_key(U256::ZERO);
302 }
303 }
304}
305
306fn parse_signature(s: &str) -> Result<Signature, String> {
307 Signature::from_str(s).map_err(|e| format!("invalid signature: {e}"))
308}
309
310fn parse_expires_seconds(s: &str) -> Result<u64, String> {
312 let secs: u64 = s
313 .parse()
314 .map_err(|_| format!("invalid value '{s}': expected an integer number of seconds"))?;
315 if secs > 30 {
316 return Err(format!("expires must be at most 30 seconds (got {secs})"));
317 }
318 Ok(secs)
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use alloy_primitives::address;
325 use foundry_common::tempo::{BETA_USD_ADDRESS, PATH_USD_ADDRESS};
326
327 #[test]
328 fn parses_lane_arg() {
329 let opts = TempoOpts::try_parse_from(["", "--tempo.lane", "deploy"]).unwrap();
330 assert_eq!(opts.lane.as_deref(), Some("deploy"));
331 assert!(opts.nonce_key.is_none());
332 }
333
334 #[test]
335 fn lane_conflicts_with_nonce_key() {
336 let err =
337 TempoOpts::try_parse_from(["", "--tempo.lane", "deploy", "--tempo.nonce-key", "1"])
338 .unwrap_err();
339 assert!(
340 err.to_string().contains("cannot be used with"),
341 "expected clap conflict error, got: {err}",
342 );
343 }
344
345 #[test]
346 fn parse_expires_flag() {
347 let opts = TempoOpts::try_parse_from(["", "--tempo.expires", "30"]).unwrap();
348 assert_eq!(opts.expires, Some(30));
349
350 let opts = TempoOpts::try_parse_from(["", "--tempo.expires", "10"]).unwrap();
351 assert_eq!(opts.expires, Some(10));
352
353 assert!(TempoOpts::try_parse_from(["", "--tempo.expires", "31"]).is_err());
355
356 assert!(
358 TempoOpts::try_parse_from([
359 "",
360 "--tempo.expires",
361 "30",
362 "--tempo.expiring-nonce",
363 "--tempo.valid-before",
364 "999"
365 ])
366 .is_err()
367 );
368 }
369
370 #[test]
371 fn resolve_expires_materializes_valid_before() {
372 let before =
373 SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards").as_secs();
374 let mut opts = TempoOpts::try_parse_from(["", "--tempo.expires", "10"]).unwrap();
375
376 let resolved = opts.resolve_expires().unwrap();
377 let after =
378 SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards").as_secs();
379
380 assert!(resolved >= before + 10);
381 assert!(resolved <= after + 10);
382 assert!(opts.expiring_nonce);
383 assert_eq!(opts.valid_before, Some(resolved));
384 assert_eq!(opts.expires, None);
385 assert_eq!(opts.expires_at(), None);
386 }
387
388 #[test]
389 fn parse_fee_token_address_id_and_symbol() {
390 let opts = TempoOpts::try_parse_from([
391 "",
392 "--tempo.fee-token",
393 "0x20C0000000000000000000000000000000000002",
394 ])
395 .unwrap();
396 assert_eq!(opts.fee_token, Some(address!("0x20C0000000000000000000000000000000000002")),);
397
398 let opts_with_id = TempoOpts::try_parse_from(["", "--tempo.fee-token", "1"]).unwrap();
400 assert_eq!(
401 opts_with_id.fee_token,
402 Some(address!("0x20C0000000000000000000000000000000000001")),
403 );
404
405 let opts_with_symbol =
406 TempoOpts::try_parse_from(["", "--tempo.fee-token", "PathUSD"]).unwrap();
407 assert_eq!(opts_with_symbol.fee_token, Some(PATH_USD_ADDRESS));
408
409 let opts_with_mixed_case_symbol =
410 TempoOpts::try_parse_from(["", "--tempo.fee-token", "bEtAuSd"]).unwrap();
411 assert_eq!(opts_with_mixed_case_symbol.fee_token, Some(BETA_USD_ADDRESS));
412
413 let err = TempoOpts::try_parse_from(["", "--tempo.fee-token", "unknownusd"]).unwrap_err();
414 let msg = err.to_string();
415 assert!(msg.contains("expected address, numeric TIP-20 token id"));
416 assert!(msg.contains("PathUSD, AlphaUSD, BetaUSD, ThetaUSD"));
417 }
418
419 #[test]
420 fn parse_sponsor_signer() {
421 let opts = TempoOpts::try_parse_from([
422 "",
423 "--tempo.sponsor",
424 "0x1111111111111111111111111111111111111111",
425 "--tempo.sponsor-signer",
426 "env://TEMPO_SPONSOR_PK",
427 ])
428 .unwrap();
429
430 assert_eq!(opts.sponsor, Some(address!("0x1111111111111111111111111111111111111111")));
431 assert_eq!(opts.sponsor_signer.as_deref(), Some("env://TEMPO_SPONSOR_PK"));
432 assert!(opts.sponsor_sig.is_none());
433 assert!(opts.is_tempo());
434 assert!(opts.has_sponsor_submission());
435 }
436
437 #[test]
438 fn sponsor_signer_requires_sponsor() {
439 assert!(
440 TempoOpts::try_parse_from(["", "--tempo.sponsor-signer", "env://SPONSOR"]).is_err()
441 );
442 }
443
444 #[test]
445 fn parse_sponsor_signature_alias() {
446 let opts = TempoOpts::try_parse_from([
447 "",
448 "--tempo.sponsor",
449 "0x1111111111111111111111111111111111111111",
450 "--tempo.sponsor-signature",
451 "0x0eb96ca19e8a77102767a41fc85a36afd5c61ccb09911cec5d3e86e193d9c5ae3a456401896b1b6055311536bf00a718568c744d8c1f9df59879e8350220ca182b",
452 ])
453 .unwrap();
454
455 assert_eq!(opts.sponsor, Some(address!("0x1111111111111111111111111111111111111111")));
456 assert!(opts.sponsor_sig.is_some());
457 }
458
459 #[test]
460 fn print_sponsor_hash_allows_sponsor_address() {
461 let opts = TempoOpts::try_parse_from([
462 "",
463 "--tempo.print-sponsor-hash",
464 "--tempo.sponsor",
465 "0x1111111111111111111111111111111111111111",
466 ])
467 .unwrap();
468
469 assert!(opts.print_sponsor_hash);
470 assert_eq!(opts.sponsor, Some(address!("0x1111111111111111111111111111111111111111")));
471 }
472
473 #[test]
474 fn print_sponsor_hash_conflicts_with_sponsor_signature() {
475 assert!(
476 TempoOpts::try_parse_from([
477 "",
478 "--tempo.print-sponsor-hash",
479 "--tempo.sponsor",
480 "0x1111111111111111111111111111111111111111",
481 "--tempo.sponsor-signature",
482 "0x0eb96ca19e8a77102767a41fc85a36afd5c61ccb09911cec5d3e86e193d9c5ae3a456401896b1b6055311536bf00a718568c744d8c1f9df59879e8350220ca182b",
483 ])
484 .is_err()
485 );
486 }
487
488 #[test]
489 fn parse_sponsor_url() {
490 let opts =
491 TempoOpts::try_parse_from(["", "--sponsor-url", "https://sponsor.tempo.xyz/tp_abc123"])
492 .unwrap();
493 assert_eq!(opts.sponsor_url.as_deref(), Some("https://sponsor.tempo.xyz/tp_abc123"));
494 assert!(opts.is_tempo());
495 }
496
497 #[test]
498 fn sponsor_url_alias() {
499 let opts = TempoOpts::try_parse_from([
500 "",
501 "--tempo.sponsor-url",
502 "https://sponsor.tempo.xyz/tp_abc123",
503 ])
504 .unwrap();
505 assert_eq!(opts.sponsor_url.as_deref(), Some("https://sponsor.tempo.xyz/tp_abc123"));
506 }
507
508 #[test]
509 fn sponsor_url_conflicts_with_sponsor() {
510 assert!(
511 TempoOpts::try_parse_from([
512 "",
513 "--sponsor-url",
514 "https://sponsor.tempo.xyz",
515 "--tempo.sponsor",
516 "0x1111111111111111111111111111111111111111",
517 ])
518 .is_err()
519 );
520 }
521}