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