1use alloy_network::{Network, TransactionBuilder};
2use alloy_primitives::{Address, 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
20#[derive(Clone, Debug, Default, Parser)]
22#[command(next_help_heading = "Tempo")]
23pub struct TempoOpts {
24 #[arg(long = "tempo.fee-token", value_parser = parse_fee_token_address)]
32 pub fee_token: Option<Address>,
33
34 #[arg(long = "tempo.expires", value_name = "SECONDS", value_parser = parse_expires_seconds)]
44 pub expires: Option<u64>,
45
46 #[arg(long = "tempo.nonce-key", value_name = "NONCE_KEY", conflicts_with = "lane")]
54 pub nonce_key: Option<U256>,
55
56 #[arg(long = "tempo.lane", value_name = "NAME")]
70 pub lane: Option<String>,
71
72 #[arg(long = "tempo.lanes-file", value_name = "PATH")]
76 pub lanes_file: Option<PathBuf>,
77
78 #[arg(long = "tempo.sponsor", value_name = "ADDRESS")]
80 pub sponsor: Option<Address>,
81
82 #[arg(
88 long = "tempo.sponsor-signer",
89 value_name = "SIGNER",
90 requires = "sponsor",
91 conflicts_with = "sponsor_sig"
92 )]
93 pub sponsor_signer: Option<String>,
94
95 #[arg(
100 long = "tempo.sponsor-sig",
101 alias = "tempo.sponsor-signature",
102 value_parser = parse_signature,
103 requires = "sponsor",
104 conflicts_with = "sponsor_signer"
105 )]
106 pub sponsor_sig: Option<Signature>,
107
108 #[arg(
113 long = "tempo.print-sponsor-hash",
114 conflicts_with_all = &["sponsor", "sponsor_signer", "sponsor_sig"]
115 )]
116 pub print_sponsor_hash: bool,
117
118 #[arg(long = "tempo.key-id")]
123 pub key_id: Option<Address>,
124
125 #[arg(long = "tempo.expiring-nonce", requires = "valid_before", conflicts_with = "expires")]
130 pub expiring_nonce: bool,
131
132 #[arg(long = "tempo.valid-before", conflicts_with = "expires")]
137 pub valid_before: Option<u64>,
138
139 #[arg(long = "tempo.valid-after")]
144 pub valid_after: Option<u64>,
145}
146
147impl TempoOpts {
148 pub const fn is_tempo(&self) -> bool {
150 self.fee_token.is_some()
151 || self.expires.is_some()
152 || self.nonce_key.is_some()
153 || self.lane.is_some()
154 || self.sponsor.is_some()
155 || self.sponsor_signer.is_some()
156 || self.sponsor_sig.is_some()
157 || self.print_sponsor_hash
158 || self.key_id.is_some()
159 || self.expiring_nonce
160 || self.valid_before.is_some()
161 || self.valid_after.is_some()
162 }
163
164 pub fn expires_at(&self) -> Option<u64> {
166 let secs = self.expires?;
167 let now = SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards");
168 Some(now.as_secs() + secs)
169 }
170
171 pub fn resolve_expires(&mut self) -> Option<u64> {
176 let ts = self.expires_at()?;
177 self.expiring_nonce = true;
178 self.valid_before = Some(ts);
179 self.expires = None;
180 Some(ts)
181 }
182
183 pub const fn has_sponsor_submission(&self) -> bool {
185 self.sponsor.is_some() || self.sponsor_signer.is_some() || self.sponsor_sig.is_some()
186 }
187
188 pub async fn sponsor_config(&self) -> Result<Option<TempoSponsor>> {
190 let Some(sponsor) = self.sponsor else {
191 return Ok(None);
192 };
193
194 let signer = if let Some(spec) = &self.sponsor_signer {
195 Some(Arc::new(Box::pin(resolve_tempo_sponsor_signer(spec)).await?))
196 } else {
197 None
198 };
199
200 if let Some(signer) = &signer {
201 let signer_address = signer.address();
202 if signer_address != sponsor {
203 eyre::bail!(
204 "Tempo sponsor signer address {signer_address} does not match --tempo.sponsor {sponsor}"
205 );
206 }
207 }
208
209 if signer.is_none() && self.sponsor_sig.is_none() {
210 eyre::bail!(
211 "--tempo.sponsor requires either --tempo.sponsor-signer or --tempo.sponsor-sig"
212 );
213 }
214
215 Ok(Some(TempoSponsor::new(sponsor, signer, self.sponsor_sig)))
216 }
217
218 pub fn apply<N: Network>(&self, tx: &mut N::TransactionRequest, nonce: Option<u64>)
222 where
223 N::TransactionRequest: FoundryTransactionBuilder<N>,
224 {
225 if self.expiring_nonce || self.expires.is_some() {
228 tx.set_nonce(0);
229 tx.set_nonce_key(U256::MAX);
230 } else {
231 if let Some(nonce) = nonce {
232 tx.set_nonce(nonce);
233 }
234 if let Some(nonce_key) = self.nonce_key {
235 tx.set_nonce_key(nonce_key);
236 }
237 }
238
239 if let Some(fee_token) = self.fee_token {
240 tx.set_fee_token(fee_token);
241 }
242
243 let effective_valid_before = self.expires_at().or(self.valid_before);
246 if let Some(valid_before) = effective_valid_before
247 && let Some(v) = NonZeroU64::new(valid_before)
248 {
249 tx.set_valid_before(v);
250 }
251 if let Some(valid_after) = self.valid_after
252 && let Some(v) = NonZeroU64::new(valid_after)
253 {
254 tx.set_valid_after(v);
255 }
256
257 if let Some(key_id) = self.key_id {
258 tx.set_key_id(key_id);
259 }
260
261 if (self.has_sponsor_submission() || self.print_sponsor_hash) && tx.nonce_key().is_none() {
267 tx.set_nonce_key(U256::ZERO);
268 }
269 }
270}
271
272fn parse_signature(s: &str) -> Result<Signature, String> {
273 Signature::from_str(s).map_err(|e| format!("invalid signature: {e}"))
274}
275
276fn parse_expires_seconds(s: &str) -> Result<u64, String> {
278 let secs: u64 = s
279 .parse()
280 .map_err(|_| format!("invalid value '{s}': expected an integer number of seconds"))?;
281 if secs > 30 {
282 return Err(format!("expires must be at most 30 seconds (got {secs})"));
283 }
284 Ok(secs)
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use alloy_primitives::address;
291
292 #[test]
293 fn parses_lane_arg() {
294 let opts = TempoOpts::try_parse_from(["", "--tempo.lane", "deploy"]).unwrap();
295 assert_eq!(opts.lane.as_deref(), Some("deploy"));
296 assert!(opts.nonce_key.is_none());
297 }
298
299 #[test]
300 fn lane_conflicts_with_nonce_key() {
301 let err =
302 TempoOpts::try_parse_from(["", "--tempo.lane", "deploy", "--tempo.nonce-key", "1"])
303 .unwrap_err();
304 assert!(
305 err.to_string().contains("cannot be used with"),
306 "expected clap conflict error, got: {err}",
307 );
308 }
309
310 #[test]
311 fn parse_expires_flag() {
312 let opts = TempoOpts::try_parse_from(["", "--tempo.expires", "30"]).unwrap();
313 assert_eq!(opts.expires, Some(30));
314
315 let opts = TempoOpts::try_parse_from(["", "--tempo.expires", "10"]).unwrap();
316 assert_eq!(opts.expires, Some(10));
317
318 assert!(TempoOpts::try_parse_from(["", "--tempo.expires", "31"]).is_err());
320
321 assert!(
323 TempoOpts::try_parse_from([
324 "",
325 "--tempo.expires",
326 "30",
327 "--tempo.expiring-nonce",
328 "--tempo.valid-before",
329 "999"
330 ])
331 .is_err()
332 );
333 }
334
335 #[test]
336 fn resolve_expires_materializes_valid_before() {
337 let before =
338 SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards").as_secs();
339 let mut opts = TempoOpts::try_parse_from(["", "--tempo.expires", "10"]).unwrap();
340
341 let resolved = opts.resolve_expires().unwrap();
342 let after =
343 SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards").as_secs();
344
345 assert!(resolved >= before + 10);
346 assert!(resolved <= after + 10);
347 assert!(opts.expiring_nonce);
348 assert_eq!(opts.valid_before, Some(resolved));
349 assert_eq!(opts.expires, None);
350 assert_eq!(opts.expires_at(), None);
351 }
352
353 #[test]
354 fn parse_fee_token_id() {
355 let opts = TempoOpts::try_parse_from([
356 "",
357 "--tempo.fee-token",
358 "0x20C0000000000000000000000000000000000002",
359 ])
360 .unwrap();
361 assert_eq!(opts.fee_token, Some(address!("0x20C0000000000000000000000000000000000002")),);
362
363 let opts_with_id = TempoOpts::try_parse_from(["", "--tempo.fee-token", "1"]).unwrap();
365 assert_eq!(
366 opts_with_id.fee_token,
367 Some(address!("0x20C0000000000000000000000000000000000001")),
368 );
369 }
370
371 #[test]
372 fn parse_sponsor_signer() {
373 let opts = TempoOpts::try_parse_from([
374 "",
375 "--tempo.sponsor",
376 "0x1111111111111111111111111111111111111111",
377 "--tempo.sponsor-signer",
378 "env://TEMPO_SPONSOR_PK",
379 ])
380 .unwrap();
381
382 assert_eq!(opts.sponsor, Some(address!("0x1111111111111111111111111111111111111111")));
383 assert_eq!(opts.sponsor_signer.as_deref(), Some("env://TEMPO_SPONSOR_PK"));
384 assert!(opts.sponsor_sig.is_none());
385 assert!(opts.is_tempo());
386 assert!(opts.has_sponsor_submission());
387 }
388
389 #[test]
390 fn sponsor_signer_requires_sponsor() {
391 assert!(
392 TempoOpts::try_parse_from(["", "--tempo.sponsor-signer", "env://SPONSOR"]).is_err()
393 );
394 }
395
396 #[test]
397 fn parse_sponsor_signature_alias() {
398 let opts = TempoOpts::try_parse_from([
399 "",
400 "--tempo.sponsor",
401 "0x1111111111111111111111111111111111111111",
402 "--tempo.sponsor-signature",
403 "0x0eb96ca19e8a77102767a41fc85a36afd5c61ccb09911cec5d3e86e193d9c5ae3a456401896b1b6055311536bf00a718568c744d8c1f9df59879e8350220ca182b",
404 ])
405 .unwrap();
406
407 assert_eq!(opts.sponsor, Some(address!("0x1111111111111111111111111111111111111111")));
408 assert!(opts.sponsor_sig.is_some());
409 }
410
411 #[test]
412 fn print_sponsor_hash_conflicts_with_sponsor_submission() {
413 assert!(
414 TempoOpts::try_parse_from([
415 "",
416 "--tempo.print-sponsor-hash",
417 "--tempo.sponsor",
418 "0x1111111111111111111111111111111111111111",
419 ])
420 .is_err()
421 );
422 }
423}