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 foundry_evm::hardfork::TempoHardfork;
11use std::{num::NonZeroU64, path::PathBuf, str::FromStr, sync::Arc};
12
13use crate::utils::{now, parse_fee_token_address};
14
15mod session;
16pub use session::TEMPO_SESSION_ID_ENV;
17
18/// CLI options for Tempo transactions.
19#[derive(Clone, Debug, Default, Parser)]
20#[command(next_help_heading = "Tempo")]
21pub struct TempoOpts {
22    /// Use a live Tempo wallet session for signing.
23    ///
24    /// When set, Foundry resolves the authorization witness from
25    /// `$TEMPO_HOME/wallet/store.json` and signs with that Accounts access key on behalf of its
26    /// root account.
27    #[arg(long = "tempo.session", id = "tempo_session", value_name = "SESSION_ID")]
28    pub session: Option<B256>,
29
30    /// Fee token address, numeric TIP-20 token id, or known symbol for Tempo transactions.
31    ///
32    /// When set, builds a Tempo (type 0x76) transaction that pays gas fees
33    /// in the specified token. Known symbols are PathUSD, AlphaUSD, BetaUSD, ThetaUSD,
34    /// and OUSD.
35    ///
36    /// If this is not set, the fee token is chosen according to network rules. See the Tempo docs
37    /// for more information.
38    #[arg(long = "tempo.fee-token", value_parser = parse_fee_token_address)]
39    pub fee_token: Option<Address>,
40
41    /// Opt into TIP-1009 expiring-nonce mode with a validity window.
42    ///
43    /// Convenience flag that combines `--tempo.expiring-nonce` with a relative
44    /// `--tempo.valid-before`. Sets nonce_key = U256::MAX and valid_before = now + seconds. The
45    /// nonce defaults to 0; see `--tempo.expiring-nonce` for how an explicit nonce is used.
46    ///
47    /// Maximum value is 300 seconds; networks that have not activated the T11 hardfork reject
48    /// windows above 30 seconds. The transaction must be mined before the deadline or it becomes
49    /// permanently invalid, giving safe retry semantics: retries produce a fresh tx hash and the
50    /// old tx can never land late.
51    #[arg(long = "tempo.expires", value_name = "SECONDS", value_parser = parse_expires_seconds)]
52    pub expires: Option<u64>,
53
54    /// Nonce key for Tempo parallelizable nonces.
55    ///
56    /// When set, builds a Tempo (type 0x76) transaction with the specified nonce key,
57    /// allowing multiple transactions with the same nonce but different keys
58    /// to be executed in parallel. If not set, the protocol nonce key (0) will be used.
59    ///
60    /// For more information see <https://docs.tempo.xyz/protocol/transactions/spec-tempo-transaction#parallelizable-nonces>.
61    #[arg(long = "tempo.nonce-key", value_name = "NONCE_KEY", conflicts_with = "lane")]
62    pub nonce_key: Option<U256>,
63
64    /// Named nonce lane for Tempo parallelizable nonces.
65    ///
66    /// Resolves a friendly lane name (e.g. `deploy`, `payments`) to a `nonce_key` via a
67    /// shared lanes file (default: `tempo.lanes.toml` at the project root). The lanes file
68    /// is a TOML map of `name = <U256>` entries, e.g.:
69    ///
70    /// ```toml
71    /// deploy   = 1
72    /// ops      = 2
73    /// payments = 3
74    /// ```
75    ///
76    /// Mutually exclusive with `--tempo.nonce-key`.
77    #[arg(long = "tempo.lane", value_name = "NAME")]
78    pub lane: Option<String>,
79
80    /// Path to the Tempo lanes file used by `--tempo.lane`.
81    ///
82    /// Defaults to `tempo.lanes.toml` at the project root.
83    #[arg(long = "tempo.lanes-file", value_name = "PATH")]
84    pub lanes_file: Option<PathBuf>,
85
86    /// Sponsor (fee payer) address for Tempo sponsored transactions.
87    #[arg(long = "tempo.sponsor", value_name = "ADDRESS")]
88    pub sponsor: Option<Address>,
89
90    /// Sign Tempo sponsor digests in-band with the given signer URI.
91    ///
92    /// Supported forms include `env://VAR`, `keystore://PATH`, `account://NAME`,
93    /// `ledger://`, `trezor://`, `aws://`, `gcp://`, `turnkey://`, and
94    /// `private-key://KEY`.
95    #[arg(
96        long = "tempo.sponsor-signer",
97        value_name = "SIGNER",
98        requires = "sponsor",
99        conflicts_with = "sponsor_sig"
100    )]
101    pub sponsor_signer: Option<String>,
102
103    /// Sponsor (fee payer) signature for Tempo sponsored transactions.
104    ///
105    /// The sponsor signs the `fee_payer_signature_hash` to commit to paying gas fees
106    /// on behalf of the sender. Provide as a hex-encoded signature.
107    #[arg(
108        long = "tempo.sponsor-sig",
109        alias = "tempo.sponsor-signature",
110        value_parser = parse_signature,
111        requires = "sponsor",
112        conflicts_with = "sponsor_signer"
113    )]
114    pub sponsor_sig: Option<Signature>,
115
116    /// Remote sponsor (fee payer) service URL for Cast transaction commands.
117    ///
118    /// When set, the user-signed transaction is forwarded to this URL via
119    /// `eth_signRawTransaction`. The service adds its fee payer signature and returns
120    /// the fully-sponsored transaction, which is then submitted via the regular RPC.
121    /// No local sponsor key is required.
122    ///
123    /// This option is supported by `cast send` and `cast erc20`. Forge commands currently support
124    /// local sponsorship through `--tempo.sponsor` and `--tempo.sponsor-signer` instead.
125    ///
126    /// Example: `cast send 0x... --sponsor-url https://sponsor.moderato.tempo.xyz`
127    #[arg(
128        long = "sponsor-url",
129        alias = "tempo.sponsor-url",
130        value_name = "URL",
131        conflicts_with_all = &["sponsor", "sponsor_signer", "sponsor_sig", "print_sponsor_hash"],
132        env = "TEMPO_SPONSOR_URL"
133    )]
134    pub sponsor_url: Option<String>,
135
136    /// Print the sponsor signature hash and exit.
137    ///
138    /// Computes the `fee_payer_signature_hash` for the transaction so that a sponsor
139    /// knows what hash to sign. The transaction is not sent.
140    #[arg(
141        long = "tempo.print-sponsor-hash",
142        conflicts_with_all = &["sponsor_signer", "sponsor_sig", "sponsor_url"]
143    )]
144    pub print_sponsor_hash: bool,
145
146    /// Access key ID for Tempo Keychain signature transactions.
147    ///
148    /// Used during gas estimation to override the key_id that would normally be
149    /// recovered from the signature.
150    #[arg(long = "tempo.key-id")]
151    pub key_id: Option<Address>,
152
153    /// Enable expiring nonce mode for Tempo transactions.
154    ///
155    /// Sets nonce_key to U256::MAX, enabling time-bounded transaction validity via
156    /// `--tempo.valid-before` and `--tempo.valid-after`.
157    ///
158    /// The nonce is not sequential in this mode and defaults to 0. From the T12 hardfork
159    /// (TIP-1106) an explicitly supplied nonce is kept as an opaque discriminator, so otherwise
160    /// identical transactions with different nonces are distinct. Earlier hardforks reject any
161    /// nonce other than 0. `forge script` takes no nonce and from T12 uses each transaction's
162    /// ordinal across the complete script plan, including completed transactions on resume.
163    #[arg(long = "tempo.expiring-nonce", requires = "valid_before", conflicts_with = "expires")]
164    pub expiring_nonce: bool,
165
166    /// Upper bound timestamp for Tempo expiring nonce transactions.
167    ///
168    /// The transaction is only valid before this unix timestamp.
169    /// Requires `--tempo.expiring-nonce`.
170    #[arg(long = "tempo.valid-before", conflicts_with = "expires")]
171    pub valid_before: Option<u64>,
172
173    /// Lower bound timestamp for Tempo expiring nonce transactions.
174    ///
175    /// The transaction is only valid after this unix timestamp.
176    /// Requires `--tempo.expiring-nonce`.
177    #[arg(long = "tempo.valid-after")]
178    pub valid_after: Option<u64>,
179}
180
181impl TempoOpts {
182    /// Returns `true` if any Tempo-specific option is set.
183    pub const fn is_tempo(&self) -> bool {
184        self.fee_token.is_some()
185            || self.expires.is_some()
186            || self.nonce_key.is_some()
187            || self.lane.is_some()
188            || self.sponsor.is_some()
189            || self.sponsor_signer.is_some()
190            || self.sponsor_sig.is_some()
191            || self.sponsor_url.is_some()
192            || self.print_sponsor_hash
193            || self.key_id.is_some()
194            || self.expiring_nonce
195            || self.valid_before.is_some()
196            || self.valid_after.is_some()
197    }
198
199    /// Returns the absolute `valid_before` unix timestamp derived from `--tempo.expires`, if set.
200    pub fn expires_at(&self) -> Option<u64> {
201        let secs = self.expires?;
202        Some(now().as_secs() + secs)
203    }
204
205    /// Resolves `--tempo.expires` into concrete expiring-nonce fields.
206    ///
207    /// This computes the relative deadline once so later calls to [`Self::apply`] reuse the same
208    /// `valid_before` timestamp instead of deriving a fresh one.
209    pub fn resolve_expires(&mut self) -> Option<u64> {
210        let ts = self.expires_at()?;
211        self.expiring_nonce = true;
212        self.valid_before = Some(ts);
213        self.expires = None;
214        Some(ts)
215    }
216
217    /// Returns `true` if a sponsor signature should be attached before submission.
218    pub const fn has_sponsor_submission(&self) -> bool {
219        self.sponsor.is_some() || self.sponsor_signer.is_some() || self.sponsor_sig.is_some()
220    }
221
222    /// Resolves sponsor CLI options into a reusable sponsor config for transaction submission.
223    pub async fn sponsor_config(&self) -> Result<Option<TempoSponsor>> {
224        let Some(sponsor) = self.sponsor else {
225            return Ok(None);
226        };
227
228        let signer = if let Some(spec) = &self.sponsor_signer {
229            Some(Arc::new(Box::pin(resolve_tempo_sponsor_signer(spec)).await?))
230        } else {
231            None
232        };
233
234        if let Some(signer) = &signer {
235            let signer_address = signer.address();
236            if signer_address != sponsor {
237                eyre::bail!(
238                    "Tempo sponsor signer address {signer_address} does not match --tempo.sponsor {sponsor}"
239                );
240            }
241        }
242
243        if signer.is_none() && self.sponsor_sig.is_none() {
244            eyre::bail!(
245                "--tempo.sponsor requires either --tempo.sponsor-signer or --tempo.sponsor-sig"
246            );
247        }
248
249        Ok(Some(TempoSponsor::new(sponsor, signer, self.sponsor_sig)))
250    }
251
252    /// Applies Tempo-specific options to a transaction request.
253    ///
254    /// All setters are no-ops for non-Tempo networks, so this is safe to call unconditionally.
255    ///
256    /// In expiring nonce mode `nonce` is the TIP-1106 discriminator rather than a sequential
257    /// nonce: an explicit value is kept as supplied and `None` selects 0.
258    pub fn apply<N: Network>(&self, tx: &mut N::TransactionRequest, nonce: Option<u64>)
259    where
260        N::TransactionRequest: FoundryTransactionBuilder<N>,
261    {
262        // Handle expiring nonce mode: sets nonce_key=U256::MAX and never reads nonce state.
263        // --tempo.expires is a convenience alias that also sets valid_before = now + duration.
264        if self.expiring_nonce || self.expires.is_some() {
265            tx.set_nonce(nonce.unwrap_or_default());
266            tx.set_nonce_key(U256::MAX);
267        } else {
268            if let Some(nonce) = nonce {
269                tx.set_nonce(nonce);
270            }
271            if let Some(nonce_key) = self.nonce_key {
272                tx.set_nonce_key(nonce_key);
273            }
274        }
275
276        if let Some(fee_token) = self.fee_token {
277            tx.set_fee_token(fee_token);
278        }
279
280        // --tempo.expires sets valid_before relative to now; --tempo.valid-before takes a raw
281        // unix timestamp. The two flags are mutually exclusive (enforced by clap).
282        let effective_valid_before = self.expires_at().or(self.valid_before);
283        if let Some(valid_before) = effective_valid_before
284            && let Some(v) = NonZeroU64::new(valid_before)
285        {
286            tx.set_valid_before(v);
287        }
288        if let Some(valid_after) = self.valid_after
289            && let Some(v) = NonZeroU64::new(valid_after)
290        {
291            tx.set_valid_after(v);
292        }
293
294        if let Some(key_id) = self.key_id {
295            tx.set_key_id(key_id);
296        }
297
298        // Force AA tx type if sponsoring or printing sponsor hash.
299        // Note: the fee_payer_signature is NOT set here. It must be applied AFTER
300        // gas estimation so that `--tempo.print-sponsor-hash` and
301        // `--tempo.sponsor-signature` produce identical gas estimates. Callers
302        // should call `set_fee_payer_signature` on the built tx request.
303        if (self.has_sponsor_submission() || self.sponsor_url.is_some() || self.print_sponsor_hash)
304            && tx.nonce_key().is_none()
305        {
306            tx.set_nonce_key(U256::ZERO);
307        }
308    }
309}
310
311fn parse_signature(s: &str) -> Result<Signature, String> {
312    Signature::from_str(s).map_err(|e| format!("invalid signature: {e}"))
313}
314
315/// Largest TIP-1009 validity window accepted by any hardfork. The window was 30 seconds before
316/// T11; the node enforces the limit of its active hardfork.
317const MAX_EXPIRES_SECS: u64 = TempoHardfork::T11.expiring_nonce_max_expiry_secs();
318
319/// Parses a seconds value for `--tempo.expires`, capped at [`MAX_EXPIRES_SECS`].
320fn parse_expires_seconds(s: &str) -> Result<u64, String> {
321    let secs: u64 = s
322        .parse()
323        .map_err(|_| format!("invalid value '{s}': expected an integer number of seconds"))?;
324    if secs > MAX_EXPIRES_SECS {
325        return Err(format!("expires must be at most {MAX_EXPIRES_SECS} seconds (got {secs})"));
326    }
327    Ok(secs)
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use alloy_primitives::address;
334    use foundry_common::tempo::{BETA_USD_ADDRESS, OUSD_ADDRESS, PATH_USD_ADDRESS};
335
336    #[test]
337    fn parses_lane_arg() {
338        let opts = TempoOpts::try_parse_from(["", "--tempo.lane", "deploy"]).unwrap();
339        assert_eq!(opts.lane.as_deref(), Some("deploy"));
340        assert!(opts.nonce_key.is_none());
341    }
342
343    #[test]
344    fn lane_conflicts_with_nonce_key() {
345        let err =
346            TempoOpts::try_parse_from(["", "--tempo.lane", "deploy", "--tempo.nonce-key", "1"])
347                .unwrap_err();
348        assert!(
349            err.to_string().contains("cannot be used with"),
350            "expected clap conflict error, got: {err}",
351        );
352    }
353
354    #[test]
355    fn parse_expires_flag() {
356        let opts = TempoOpts::try_parse_from(["", "--tempo.expires", "300"]).unwrap();
357        assert_eq!(opts.expires, Some(300));
358
359        let opts = TempoOpts::try_parse_from(["", "--tempo.expires", "10"]).unwrap();
360        assert_eq!(opts.expires, Some(10));
361
362        // exceeds the post-T11 maximum
363        assert!(TempoOpts::try_parse_from(["", "--tempo.expires", "301"]).is_err());
364
365        // conflicts with --tempo.expiring-nonce
366        assert!(
367            TempoOpts::try_parse_from([
368                "",
369                "--tempo.expires",
370                "30",
371                "--tempo.expiring-nonce",
372                "--tempo.valid-before",
373                "999"
374            ])
375            .is_err()
376        );
377    }
378
379    #[test]
380    fn resolve_expires_materializes_valid_before() {
381        let before = now().as_secs();
382        let mut opts = TempoOpts::try_parse_from(["", "--tempo.expires", "10"]).unwrap();
383
384        let resolved = opts.resolve_expires().unwrap();
385        let after = now().as_secs();
386
387        assert!(resolved >= before + 10);
388        assert!(resolved <= after + 10);
389        assert!(opts.expiring_nonce);
390        assert_eq!(opts.valid_before, Some(resolved));
391        assert_eq!(opts.expires, None);
392        assert_eq!(opts.expires_at(), None);
393    }
394
395    #[test]
396    fn parse_fee_token_address_id_and_symbol() {
397        let opts = TempoOpts::try_parse_from([
398            "",
399            "--tempo.fee-token",
400            "0x20C0000000000000000000000000000000000002",
401        ])
402        .unwrap();
403        assert_eq!(opts.fee_token, Some(address!("0x20C0000000000000000000000000000000000002")),);
404
405        // AlphaUSD token ID is 1u64
406        let opts_with_id = TempoOpts::try_parse_from(["", "--tempo.fee-token", "1"]).unwrap();
407        assert_eq!(
408            opts_with_id.fee_token,
409            Some(address!("0x20C0000000000000000000000000000000000001")),
410        );
411
412        let opts_with_symbol =
413            TempoOpts::try_parse_from(["", "--tempo.fee-token", "PathUSD"]).unwrap();
414        assert_eq!(opts_with_symbol.fee_token, Some(PATH_USD_ADDRESS));
415
416        let opts_with_mixed_case_symbol =
417            TempoOpts::try_parse_from(["", "--tempo.fee-token", "bEtAuSd"]).unwrap();
418        assert_eq!(opts_with_mixed_case_symbol.fee_token, Some(BETA_USD_ADDRESS));
419
420        let opts_with_ousd = TempoOpts::try_parse_from(["", "--tempo.fee-token", "OUSD"]).unwrap();
421        assert_eq!(opts_with_ousd.fee_token, Some(OUSD_ADDRESS));
422
423        let err = TempoOpts::try_parse_from(["", "--tempo.fee-token", "unknownusd"]).unwrap_err();
424        let msg = err.to_string();
425        assert!(msg.contains("expected address, numeric TIP-20 token id"));
426        assert!(msg.contains("PathUSD, AlphaUSD, BetaUSD, ThetaUSD, OUSD"));
427    }
428
429    #[test]
430    fn parse_sponsor_signer() {
431        let opts = TempoOpts::try_parse_from([
432            "",
433            "--tempo.sponsor",
434            "0x1111111111111111111111111111111111111111",
435            "--tempo.sponsor-signer",
436            "env://TEMPO_SPONSOR_PK",
437        ])
438        .unwrap();
439
440        assert_eq!(opts.sponsor, Some(Address::repeat_byte(0x11)));
441        assert_eq!(opts.sponsor_signer.as_deref(), Some("env://TEMPO_SPONSOR_PK"));
442        assert!(opts.sponsor_sig.is_none());
443        assert!(opts.is_tempo());
444        assert!(opts.has_sponsor_submission());
445    }
446
447    #[test]
448    fn sponsor_signer_requires_sponsor() {
449        assert!(
450            TempoOpts::try_parse_from(["", "--tempo.sponsor-signer", "env://SPONSOR"]).is_err()
451        );
452    }
453
454    #[test]
455    fn parse_sponsor_signature_alias() {
456        let opts = TempoOpts::try_parse_from([
457            "",
458            "--tempo.sponsor",
459            "0x1111111111111111111111111111111111111111",
460            "--tempo.sponsor-signature",
461            "0x0eb96ca19e8a77102767a41fc85a36afd5c61ccb09911cec5d3e86e193d9c5ae3a456401896b1b6055311536bf00a718568c744d8c1f9df59879e8350220ca182b",
462        ])
463        .unwrap();
464
465        assert_eq!(opts.sponsor, Some(Address::repeat_byte(0x11)));
466        assert!(opts.sponsor_sig.is_some());
467    }
468
469    #[test]
470    fn print_sponsor_hash_allows_sponsor_address() {
471        let opts = TempoOpts::try_parse_from([
472            "",
473            "--tempo.print-sponsor-hash",
474            "--tempo.sponsor",
475            "0x1111111111111111111111111111111111111111",
476        ])
477        .unwrap();
478
479        assert!(opts.print_sponsor_hash);
480        assert_eq!(opts.sponsor, Some(Address::repeat_byte(0x11)));
481    }
482
483    #[test]
484    fn print_sponsor_hash_conflicts_with_sponsor_signature() {
485        assert!(
486            TempoOpts::try_parse_from([
487                "",
488                "--tempo.print-sponsor-hash",
489                "--tempo.sponsor",
490                "0x1111111111111111111111111111111111111111",
491                "--tempo.sponsor-signature",
492                "0x0eb96ca19e8a77102767a41fc85a36afd5c61ccb09911cec5d3e86e193d9c5ae3a456401896b1b6055311536bf00a718568c744d8c1f9df59879e8350220ca182b",
493            ])
494            .is_err()
495        );
496    }
497
498    #[test]
499    fn parse_sponsor_url() {
500        let opts =
501            TempoOpts::try_parse_from(["", "--sponsor-url", "https://sponsor.tempo.xyz/tp_abc123"])
502                .unwrap();
503        assert_eq!(opts.sponsor_url.as_deref(), Some("https://sponsor.tempo.xyz/tp_abc123"));
504        assert!(opts.is_tempo());
505    }
506
507    #[test]
508    fn sponsor_url_alias() {
509        let opts = TempoOpts::try_parse_from([
510            "",
511            "--tempo.sponsor-url",
512            "https://sponsor.tempo.xyz/tp_abc123",
513        ])
514        .unwrap();
515        assert_eq!(opts.sponsor_url.as_deref(), Some("https://sponsor.tempo.xyz/tp_abc123"));
516    }
517
518    #[test]
519    fn sponsor_url_conflicts_with_sponsor() {
520        assert!(
521            TempoOpts::try_parse_from([
522                "",
523                "--sponsor-url",
524                "https://sponsor.tempo.xyz",
525                "--tempo.sponsor",
526                "0x1111111111111111111111111111111111111111",
527            ])
528            .is_err()
529        );
530    }
531}