Skip to main content

foundry_cli/opts/
tempo.rs

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/// CLI options for Tempo transactions.
24#[derive(Clone, Debug, Default, Parser)]
25#[command(next_help_heading = "Tempo")]
26pub struct TempoOpts {
27    /// Use a live Tempo wallet session for signing.
28    ///
29    /// When set, Foundry resolves the session from `$TEMPO_HOME/wallet/sessions.toml` and signs
30    /// Tempo transactions with the session's temporary access key on behalf of its root account.
31    #[arg(long = "tempo.session", id = "tempo_session", value_name = "SESSION_ID")]
32    pub session: Option<B256>,
33
34    /// Fee token address for Tempo transactions.
35    ///
36    /// When set, builds a Tempo (type 0x76) transaction that pays gas fees
37    /// in the specified token.
38    ///
39    /// If this is not set, the fee token is chosen according to network rules. See the Tempo docs
40    /// for more information.
41    #[arg(long = "tempo.fee-token", value_parser = parse_fee_token_address)]
42    pub fee_token: Option<Address>,
43
44    /// Opt into TIP-1009 expiring-nonce mode with a validity window.
45    ///
46    /// Convenience flag that combines `--tempo.expiring-nonce` with a relative
47    /// `--tempo.valid-before`. Sets nonce_key = U256::MAX, nonce = 0, and valid_before = now +
48    /// seconds.
49    ///
50    /// Maximum value is 30 seconds. The transaction must be mined before the deadline or it
51    /// becomes permanently invalid, giving safe retry semantics: retries produce a fresh tx hash
52    /// and the old tx can never land late.
53    #[arg(long = "tempo.expires", value_name = "SECONDS", value_parser = parse_expires_seconds)]
54    pub expires: Option<u64>,
55
56    /// Nonce key for Tempo parallelizable nonces.
57    ///
58    /// When set, builds a Tempo (type 0x76) transaction with the specified nonce key,
59    /// allowing multiple transactions with the same nonce but different keys
60    /// to be executed in parallel. If not set, the protocol nonce key (0) will be used.
61    ///
62    /// For more information see <https://docs.tempo.xyz/protocol/transactions/spec-tempo-transaction#parallelizable-nonces>.
63    #[arg(long = "tempo.nonce-key", value_name = "NONCE_KEY", conflicts_with = "lane")]
64    pub nonce_key: Option<U256>,
65
66    /// Named nonce lane for Tempo parallelizable nonces.
67    ///
68    /// Resolves a friendly lane name (e.g. `deploy`, `payments`) to a `nonce_key` via a
69    /// shared lanes file (default: `tempo.lanes.toml` at the project root). The lanes file
70    /// is a TOML map of `name = <U256>` entries, e.g.:
71    ///
72    /// ```toml
73    /// deploy   = 1
74    /// ops      = 2
75    /// payments = 3
76    /// ```
77    ///
78    /// Mutually exclusive with `--tempo.nonce-key`.
79    #[arg(long = "tempo.lane", value_name = "NAME")]
80    pub lane: Option<String>,
81
82    /// Path to the Tempo lanes file used by `--tempo.lane`.
83    ///
84    /// Defaults to `tempo.lanes.toml` at the project root.
85    #[arg(long = "tempo.lanes-file", value_name = "PATH")]
86    pub lanes_file: Option<PathBuf>,
87
88    /// Sponsor (fee payer) address for Tempo sponsored transactions.
89    #[arg(long = "tempo.sponsor", value_name = "ADDRESS")]
90    pub sponsor: Option<Address>,
91
92    /// Sign Tempo sponsor digests in-band with the given signer URI.
93    ///
94    /// Supported forms include `env://VAR`, `keystore://PATH`, `account://NAME`,
95    /// `ledger://`, `trezor://`, `aws://`, `gcp://`, `turnkey://`, and
96    /// `private-key://KEY`.
97    #[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    /// Sponsor (fee payer) signature for Tempo sponsored transactions.
106    ///
107    /// The sponsor signs the `fee_payer_signature_hash` to commit to paying gas fees
108    /// on behalf of the sender. Provide as a hex-encoded signature.
109    #[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    /// Remote sponsor (fee payer) service URL.
119    ///
120    /// When set, the user-signed transaction is forwarded to this URL via
121    /// `eth_signRawTransaction`. The service adds its fee payer signature and returns
122    /// the fully-sponsored transaction, which is then submitted via the regular RPC.
123    /// No local sponsor key is required.
124    ///
125    /// Example: `cast send 0x... --sponsor-url https://sponsor.tempo.xyz/tp_abc123`
126    #[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    /// Print the sponsor signature hash and exit.
136    ///
137    /// Computes the `fee_payer_signature_hash` for the transaction so that a sponsor
138    /// knows what hash to sign. The transaction is not sent.
139    #[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    /// Access key ID for Tempo Keychain signature transactions.
146    ///
147    /// Used during gas estimation to override the key_id that would normally be
148    /// recovered from the signature.
149    #[arg(long = "tempo.key-id")]
150    pub key_id: Option<Address>,
151
152    /// Enable expiring nonce mode for Tempo transactions.
153    ///
154    /// Sets nonce to 0 and nonce_key to U256::MAX, enabling time-bounded transaction
155    /// validity via `--tempo.valid-before` and `--tempo.valid-after`.
156    #[arg(long = "tempo.expiring-nonce", requires = "valid_before", conflicts_with = "expires")]
157    pub expiring_nonce: bool,
158
159    /// Upper bound timestamp for Tempo expiring nonce transactions.
160    ///
161    /// The transaction is only valid before this unix timestamp.
162    /// Requires `--tempo.expiring-nonce`.
163    #[arg(long = "tempo.valid-before", conflicts_with = "expires")]
164    pub valid_before: Option<u64>,
165
166    /// Lower bound timestamp for Tempo expiring nonce transactions.
167    ///
168    /// The transaction is only valid after this unix timestamp.
169    /// Requires `--tempo.expiring-nonce`.
170    #[arg(long = "tempo.valid-after")]
171    pub valid_after: Option<u64>,
172}
173
174impl TempoOpts {
175    /// Returns `true` if any Tempo-specific option is set.
176    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    /// Returns the absolute `valid_before` unix timestamp derived from `--tempo.expires`, if set.
193    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    /// Resolves `--tempo.expires` into concrete expiring-nonce fields.
200    ///
201    /// This computes the relative deadline once so later calls to [`Self::apply`] reuse the same
202    /// `valid_before` timestamp instead of deriving a fresh one.
203    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    /// Returns `true` if a sponsor signature should be attached before submission.
212    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    /// Resolves sponsor CLI options into a reusable sponsor config for transaction submission.
217    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    /// Applies Tempo-specific options to a transaction request.
247    ///
248    /// All setters are no-ops for non-Tempo networks, so this is safe to call unconditionally.
249    pub fn apply<N: Network>(&self, tx: &mut N::TransactionRequest, nonce: Option<u64>)
250    where
251        N::TransactionRequest: FoundryTransactionBuilder<N>,
252    {
253        // Handle expiring nonce mode: sets nonce=0 and nonce_key=U256::MAX.
254        // --tempo.expires is a convenience alias that also sets valid_before = now + duration.
255        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        // --tempo.expires sets valid_before relative to now; --tempo.valid-before takes a raw
272        // unix timestamp. The two flags are mutually exclusive (enforced by clap).
273        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        // Force AA tx type if sponsoring or printing sponsor hash.
290        // Note: the fee_payer_signature is NOT set here. It must be applied AFTER
291        // gas estimation so that `--tempo.print-sponsor-hash` and
292        // `--tempo.sponsor-signature` produce identical gas estimates. Callers
293        // should call `set_fee_payer_signature` on the built tx request.
294        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
306/// Parses a seconds value for `--tempo.expires`, capped at the protocol maximum of 30 seconds.
307fn 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        // exceeds 30s maximum
349        assert!(TempoOpts::try_parse_from(["", "--tempo.expires", "31"]).is_err());
350
351        // conflicts with --tempo.expiring-nonce
352        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        // AlphaUSD token ID is 1u64
394        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}