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")]
32 pub session: Option<B256>,
33
34 #[arg(long = "tempo.fee-token", value_parser = parse_fee_token_address)]
42 pub fee_token: Option<Address>,
43
44 #[arg(long = "tempo.expires", value_name = "SECONDS", value_parser = parse_expires_seconds)]
54 pub expires: Option<u64>,
55
56 #[arg(long = "tempo.nonce-key", value_name = "NONCE_KEY", conflicts_with = "lane")]
64 pub nonce_key: Option<U256>,
65
66 #[arg(long = "tempo.lane", value_name = "NAME")]
80 pub lane: Option<String>,
81
82 #[arg(long = "tempo.lanes-file", value_name = "PATH")]
86 pub lanes_file: Option<PathBuf>,
87
88 #[arg(long = "tempo.sponsor", value_name = "ADDRESS")]
90 pub sponsor: Option<Address>,
91
92 #[arg(
98 long = "tempo.sponsor-signer",
99 value_name = "SIGNER",
100 requires = "sponsor",
101 conflicts_with = "sponsor_sig"
102 )]
103 pub sponsor_signer: Option<String>,
104
105 #[arg(
110 long = "tempo.sponsor-sig",
111 alias = "tempo.sponsor-signature",
112 value_parser = parse_signature,
113 requires = "sponsor",
114 conflicts_with = "sponsor_signer"
115 )]
116 pub sponsor_sig: Option<Signature>,
117
118 #[arg(
127 long = "sponsor-url",
128 alias = "tempo.sponsor-url",
129 value_name = "URL",
130 conflicts_with_all = &["sponsor", "sponsor_signer", "sponsor_sig", "print_sponsor_hash"],
131 env = "TEMPO_SPONSOR_URL"
132 )]
133 pub sponsor_url: Option<String>,
134
135 #[arg(
140 long = "tempo.print-sponsor-hash",
141 conflicts_with_all = &["sponsor", "sponsor_signer", "sponsor_sig", "sponsor_url"]
142 )]
143 pub print_sponsor_hash: bool,
144
145 #[arg(long = "tempo.key-id")]
150 pub key_id: Option<Address>,
151
152 #[arg(long = "tempo.expiring-nonce", requires = "valid_before", conflicts_with = "expires")]
157 pub expiring_nonce: bool,
158
159 #[arg(long = "tempo.valid-before", conflicts_with = "expires")]
164 pub valid_before: Option<u64>,
165
166 #[arg(long = "tempo.valid-after")]
171 pub valid_after: Option<u64>,
172}
173
174impl TempoOpts {
175 pub const fn is_tempo(&self) -> bool {
177 self.fee_token.is_some()
178 || self.expires.is_some()
179 || self.nonce_key.is_some()
180 || self.lane.is_some()
181 || self.sponsor.is_some()
182 || self.sponsor_signer.is_some()
183 || self.sponsor_sig.is_some()
184 || self.sponsor_url.is_some()
185 || self.print_sponsor_hash
186 || self.key_id.is_some()
187 || self.expiring_nonce
188 || self.valid_before.is_some()
189 || self.valid_after.is_some()
190 }
191
192 pub fn expires_at(&self) -> Option<u64> {
194 let secs = self.expires?;
195 let now = SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards");
196 Some(now.as_secs() + secs)
197 }
198
199 pub fn resolve_expires(&mut self) -> Option<u64> {
204 let ts = self.expires_at()?;
205 self.expiring_nonce = true;
206 self.valid_before = Some(ts);
207 self.expires = None;
208 Some(ts)
209 }
210
211 pub const fn has_sponsor_submission(&self) -> bool {
213 self.sponsor.is_some() || self.sponsor_signer.is_some() || self.sponsor_sig.is_some()
214 }
215
216 pub async fn sponsor_config(&self) -> Result<Option<TempoSponsor>> {
218 let Some(sponsor) = self.sponsor else {
219 return Ok(None);
220 };
221
222 let signer = if let Some(spec) = &self.sponsor_signer {
223 Some(Arc::new(Box::pin(resolve_tempo_sponsor_signer(spec)).await?))
224 } else {
225 None
226 };
227
228 if let Some(signer) = &signer {
229 let signer_address = signer.address();
230 if signer_address != sponsor {
231 eyre::bail!(
232 "Tempo sponsor signer address {signer_address} does not match --tempo.sponsor {sponsor}"
233 );
234 }
235 }
236
237 if signer.is_none() && self.sponsor_sig.is_none() {
238 eyre::bail!(
239 "--tempo.sponsor requires either --tempo.sponsor-signer or --tempo.sponsor-sig"
240 );
241 }
242
243 Ok(Some(TempoSponsor::new(sponsor, signer, self.sponsor_sig)))
244 }
245
246 pub fn apply<N: Network>(&self, tx: &mut N::TransactionRequest, nonce: Option<u64>)
250 where
251 N::TransactionRequest: FoundryTransactionBuilder<N>,
252 {
253 if self.expiring_nonce || self.expires.is_some() {
256 tx.set_nonce(0);
257 tx.set_nonce_key(U256::MAX);
258 } else {
259 if let Some(nonce) = nonce {
260 tx.set_nonce(nonce);
261 }
262 if let Some(nonce_key) = self.nonce_key {
263 tx.set_nonce_key(nonce_key);
264 }
265 }
266
267 if let Some(fee_token) = self.fee_token {
268 tx.set_fee_token(fee_token);
269 }
270
271 let effective_valid_before = self.expires_at().or(self.valid_before);
274 if let Some(valid_before) = effective_valid_before
275 && let Some(v) = NonZeroU64::new(valid_before)
276 {
277 tx.set_valid_before(v);
278 }
279 if let Some(valid_after) = self.valid_after
280 && let Some(v) = NonZeroU64::new(valid_after)
281 {
282 tx.set_valid_after(v);
283 }
284
285 if let Some(key_id) = self.key_id {
286 tx.set_key_id(key_id);
287 }
288
289 if (self.has_sponsor_submission() || self.sponsor_url.is_some() || self.print_sponsor_hash)
295 && tx.nonce_key().is_none()
296 {
297 tx.set_nonce_key(U256::ZERO);
298 }
299 }
300}
301
302fn parse_signature(s: &str) -> Result<Signature, String> {
303 Signature::from_str(s).map_err(|e| format!("invalid signature: {e}"))
304}
305
306fn parse_expires_seconds(s: &str) -> Result<u64, String> {
308 let secs: u64 = s
309 .parse()
310 .map_err(|_| format!("invalid value '{s}': expected an integer number of seconds"))?;
311 if secs > 30 {
312 return Err(format!("expires must be at most 30 seconds (got {secs})"));
313 }
314 Ok(secs)
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use alloy_primitives::address;
321
322 #[test]
323 fn parses_lane_arg() {
324 let opts = TempoOpts::try_parse_from(["", "--tempo.lane", "deploy"]).unwrap();
325 assert_eq!(opts.lane.as_deref(), Some("deploy"));
326 assert!(opts.nonce_key.is_none());
327 }
328
329 #[test]
330 fn lane_conflicts_with_nonce_key() {
331 let err =
332 TempoOpts::try_parse_from(["", "--tempo.lane", "deploy", "--tempo.nonce-key", "1"])
333 .unwrap_err();
334 assert!(
335 err.to_string().contains("cannot be used with"),
336 "expected clap conflict error, got: {err}",
337 );
338 }
339
340 #[test]
341 fn parse_expires_flag() {
342 let opts = TempoOpts::try_parse_from(["", "--tempo.expires", "30"]).unwrap();
343 assert_eq!(opts.expires, Some(30));
344
345 let opts = TempoOpts::try_parse_from(["", "--tempo.expires", "10"]).unwrap();
346 assert_eq!(opts.expires, Some(10));
347
348 assert!(TempoOpts::try_parse_from(["", "--tempo.expires", "31"]).is_err());
350
351 assert!(
353 TempoOpts::try_parse_from([
354 "",
355 "--tempo.expires",
356 "30",
357 "--tempo.expiring-nonce",
358 "--tempo.valid-before",
359 "999"
360 ])
361 .is_err()
362 );
363 }
364
365 #[test]
366 fn resolve_expires_materializes_valid_before() {
367 let before =
368 SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards").as_secs();
369 let mut opts = TempoOpts::try_parse_from(["", "--tempo.expires", "10"]).unwrap();
370
371 let resolved = opts.resolve_expires().unwrap();
372 let after =
373 SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards").as_secs();
374
375 assert!(resolved >= before + 10);
376 assert!(resolved <= after + 10);
377 assert!(opts.expiring_nonce);
378 assert_eq!(opts.valid_before, Some(resolved));
379 assert_eq!(opts.expires, None);
380 assert_eq!(opts.expires_at(), None);
381 }
382
383 #[test]
384 fn parse_fee_token_id() {
385 let opts = TempoOpts::try_parse_from([
386 "",
387 "--tempo.fee-token",
388 "0x20C0000000000000000000000000000000000002",
389 ])
390 .unwrap();
391 assert_eq!(opts.fee_token, Some(address!("0x20C0000000000000000000000000000000000002")),);
392
393 let opts_with_id = TempoOpts::try_parse_from(["", "--tempo.fee-token", "1"]).unwrap();
395 assert_eq!(
396 opts_with_id.fee_token,
397 Some(address!("0x20C0000000000000000000000000000000000001")),
398 );
399 }
400
401 #[test]
402 fn parse_sponsor_signer() {
403 let opts = TempoOpts::try_parse_from([
404 "",
405 "--tempo.sponsor",
406 "0x1111111111111111111111111111111111111111",
407 "--tempo.sponsor-signer",
408 "env://TEMPO_SPONSOR_PK",
409 ])
410 .unwrap();
411
412 assert_eq!(opts.sponsor, Some(address!("0x1111111111111111111111111111111111111111")));
413 assert_eq!(opts.sponsor_signer.as_deref(), Some("env://TEMPO_SPONSOR_PK"));
414 assert!(opts.sponsor_sig.is_none());
415 assert!(opts.is_tempo());
416 assert!(opts.has_sponsor_submission());
417 }
418
419 #[test]
420 fn sponsor_signer_requires_sponsor() {
421 assert!(
422 TempoOpts::try_parse_from(["", "--tempo.sponsor-signer", "env://SPONSOR"]).is_err()
423 );
424 }
425
426 #[test]
427 fn parse_sponsor_signature_alias() {
428 let opts = TempoOpts::try_parse_from([
429 "",
430 "--tempo.sponsor",
431 "0x1111111111111111111111111111111111111111",
432 "--tempo.sponsor-signature",
433 "0x0eb96ca19e8a77102767a41fc85a36afd5c61ccb09911cec5d3e86e193d9c5ae3a456401896b1b6055311536bf00a718568c744d8c1f9df59879e8350220ca182b",
434 ])
435 .unwrap();
436
437 assert_eq!(opts.sponsor, Some(address!("0x1111111111111111111111111111111111111111")));
438 assert!(opts.sponsor_sig.is_some());
439 }
440
441 #[test]
442 fn print_sponsor_hash_conflicts_with_sponsor_submission() {
443 assert!(
444 TempoOpts::try_parse_from([
445 "",
446 "--tempo.print-sponsor-hash",
447 "--tempo.sponsor",
448 "0x1111111111111111111111111111111111111111",
449 ])
450 .is_err()
451 );
452 }
453
454 #[test]
455 fn parse_sponsor_url() {
456 let opts =
457 TempoOpts::try_parse_from(["", "--sponsor-url", "https://sponsor.tempo.xyz/tp_abc123"])
458 .unwrap();
459 assert_eq!(opts.sponsor_url.as_deref(), Some("https://sponsor.tempo.xyz/tp_abc123"));
460 assert!(opts.is_tempo());
461 }
462
463 #[test]
464 fn sponsor_url_alias() {
465 let opts = TempoOpts::try_parse_from([
466 "",
467 "--tempo.sponsor-url",
468 "https://sponsor.tempo.xyz/tp_abc123",
469 ])
470 .unwrap();
471 assert_eq!(opts.sponsor_url.as_deref(), Some("https://sponsor.tempo.xyz/tp_abc123"));
472 }
473
474 #[test]
475 fn sponsor_url_conflicts_with_sponsor() {
476 assert!(
477 TempoOpts::try_parse_from([
478 "",
479 "--sponsor-url",
480 "https://sponsor.tempo.xyz",
481 "--tempo.sponsor",
482 "0x1111111111111111111111111111111111111111",
483 ])
484 .is_err()
485 );
486 }
487}