Skip to main content

foundry_cli/opts/
tempo.rs

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/// CLI options for Tempo transactions.
21#[derive(Clone, Debug, Default, Parser)]
22#[command(next_help_heading = "Tempo")]
23pub struct TempoOpts {
24    /// Fee token address for Tempo transactions.
25    ///
26    /// When set, builds a Tempo (type 0x76) transaction that pays gas fees
27    /// in the specified token.
28    ///
29    /// If this is not set, the fee token is chosen according to network rules. See the Tempo docs
30    /// for more information.
31    #[arg(long = "tempo.fee-token", value_parser = parse_fee_token_address)]
32    pub fee_token: Option<Address>,
33
34    /// Opt into TIP-1009 expiring-nonce mode with a validity window.
35    ///
36    /// Convenience flag that combines `--tempo.expiring-nonce` with a relative
37    /// `--tempo.valid-before`. Sets nonce_key = U256::MAX, nonce = 0, and valid_before = now +
38    /// seconds.
39    ///
40    /// Maximum value is 30 seconds. The transaction must be mined before the deadline or it
41    /// becomes permanently invalid, giving safe retry semantics: retries produce a fresh tx hash
42    /// and the old tx can never land late.
43    #[arg(long = "tempo.expires", value_name = "SECONDS", value_parser = parse_expires_seconds)]
44    pub expires: Option<u64>,
45
46    /// Nonce key for Tempo parallelizable nonces.
47    ///
48    /// When set, builds a Tempo (type 0x76) transaction with the specified nonce key,
49    /// allowing multiple transactions with the same nonce but different keys
50    /// to be executed in parallel. If not set, the protocol nonce key (0) will be used.
51    ///
52    /// For more information see <https://docs.tempo.xyz/protocol/transactions/spec-tempo-transaction#parallelizable-nonces>.
53    #[arg(long = "tempo.nonce-key", value_name = "NONCE_KEY", conflicts_with = "lane")]
54    pub nonce_key: Option<U256>,
55
56    /// Named nonce lane for Tempo parallelizable nonces.
57    ///
58    /// Resolves a friendly lane name (e.g. `deploy`, `payments`) to a `nonce_key` via a
59    /// shared lanes file (default: `tempo.lanes.toml` at the project root). The lanes file
60    /// is a TOML map of `name = <U256>` entries, e.g.:
61    ///
62    /// ```toml
63    /// deploy   = 1
64    /// ops      = 2
65    /// payments = 3
66    /// ```
67    ///
68    /// Mutually exclusive with `--tempo.nonce-key`.
69    #[arg(long = "tempo.lane", value_name = "NAME")]
70    pub lane: Option<String>,
71
72    /// Path to the Tempo lanes file used by `--tempo.lane`.
73    ///
74    /// Defaults to `tempo.lanes.toml` at the project root.
75    #[arg(long = "tempo.lanes-file", value_name = "PATH")]
76    pub lanes_file: Option<PathBuf>,
77
78    /// Sponsor (fee payer) address for Tempo sponsored transactions.
79    #[arg(long = "tempo.sponsor", value_name = "ADDRESS")]
80    pub sponsor: Option<Address>,
81
82    /// Sign Tempo sponsor digests in-band with the given signer URI.
83    ///
84    /// Supported forms include `env://VAR`, `keystore://PATH`, `account://NAME`,
85    /// `ledger://`, `trezor://`, `aws://`, `gcp://`, `turnkey://`, and
86    /// `private-key://KEY`.
87    #[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    /// Sponsor (fee payer) signature for Tempo sponsored transactions.
96    ///
97    /// The sponsor signs the `fee_payer_signature_hash` to commit to paying gas fees
98    /// on behalf of the sender. Provide as a hex-encoded signature.
99    #[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    /// Print the sponsor signature hash and exit.
109    ///
110    /// Computes the `fee_payer_signature_hash` for the transaction so that a sponsor
111    /// knows what hash to sign. The transaction is not sent.
112    #[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    /// Access key ID for Tempo Keychain signature transactions.
119    ///
120    /// Used during gas estimation to override the key_id that would normally be
121    /// recovered from the signature.
122    #[arg(long = "tempo.key-id")]
123    pub key_id: Option<Address>,
124
125    /// Enable expiring nonce mode for Tempo transactions.
126    ///
127    /// Sets nonce to 0 and nonce_key to U256::MAX, enabling time-bounded transaction
128    /// validity via `--tempo.valid-before` and `--tempo.valid-after`.
129    #[arg(long = "tempo.expiring-nonce", requires = "valid_before", conflicts_with = "expires")]
130    pub expiring_nonce: bool,
131
132    /// Upper bound timestamp for Tempo expiring nonce transactions.
133    ///
134    /// The transaction is only valid before this unix timestamp.
135    /// Requires `--tempo.expiring-nonce`.
136    #[arg(long = "tempo.valid-before", conflicts_with = "expires")]
137    pub valid_before: Option<u64>,
138
139    /// Lower bound timestamp for Tempo expiring nonce transactions.
140    ///
141    /// The transaction is only valid after this unix timestamp.
142    /// Requires `--tempo.expiring-nonce`.
143    #[arg(long = "tempo.valid-after")]
144    pub valid_after: Option<u64>,
145}
146
147impl TempoOpts {
148    /// Returns `true` if any Tempo-specific option is set.
149    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    /// Returns the absolute `valid_before` unix timestamp derived from `--tempo.expires`, if set.
165    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    /// Resolves `--tempo.expires` into concrete expiring-nonce fields.
172    ///
173    /// This computes the relative deadline once so later calls to [`Self::apply`] reuse the same
174    /// `valid_before` timestamp instead of deriving a fresh one.
175    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    /// Returns `true` if a sponsor signature should be attached before submission.
184    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    /// Resolves sponsor CLI options into a reusable sponsor config for transaction submission.
189    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    /// Applies Tempo-specific options to a transaction request.
219    ///
220    /// All setters are no-ops for non-Tempo networks, so this is safe to call unconditionally.
221    pub fn apply<N: Network>(&self, tx: &mut N::TransactionRequest, nonce: Option<u64>)
222    where
223        N::TransactionRequest: FoundryTransactionBuilder<N>,
224    {
225        // Handle expiring nonce mode: sets nonce=0 and nonce_key=U256::MAX.
226        // --tempo.expires is a convenience alias that also sets valid_before = now + duration.
227        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        // --tempo.expires sets valid_before relative to now; --tempo.valid-before takes a raw
244        // unix timestamp. The two flags are mutually exclusive (enforced by clap).
245        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        // Force AA tx type if sponsoring or printing sponsor hash.
262        // Note: the fee_payer_signature is NOT set here. It must be applied AFTER
263        // gas estimation so that `--tempo.print-sponsor-hash` and
264        // `--tempo.sponsor-signature` produce identical gas estimates. Callers
265        // should call `set_fee_payer_signature` on the built tx request.
266        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
276/// Parses a seconds value for `--tempo.expires`, capped at the protocol maximum of 30 seconds.
277fn 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        // exceeds 30s maximum
319        assert!(TempoOpts::try_parse_from(["", "--tempo.expires", "31"]).is_err());
320
321        // conflicts with --tempo.expiring-nonce
322        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        // AlphaUSD token ID is 1u64
364        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}