Skip to main content

cast/cmd/tip20/
mod.rs

1use crate::{
2    cmd::send::{
3        cast_send, cast_send_with_tempo_wallet, cast_send_with_tempo_wallet_via_sponsor,
4        validate_sponsor_url,
5    },
6    tempo,
7    tx::{CastTxBuilder, CastTxSender, SendTxOpts, TxParams},
8};
9use alloy_ens::NameOrAddress;
10use alloy_network::{EthereumWallet, TransactionBuilder};
11use alloy_primitives::{Address, B256};
12use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder};
13use alloy_signer::Signer;
14use clap::Parser;
15use foundry_cli::{
16    opts::TransactionOpts,
17    utils::{LoadConfig, get_chain, maybe_print_resolved_lane, resolve_lane},
18};
19use foundry_common::{
20    FoundryTransactionBuilder,
21    provider::ProviderBuilder,
22    tempo::{maybe_print_fee_token, resolve_and_set_fee_token},
23};
24use foundry_wallets::{TempoAccountsWallet, WalletSigner};
25use std::{str::FromStr, time::Duration};
26use tempo_alloy::TempoNetwork;
27use tempo_primitives::transaction::FEE_PAYER_SIGNATURE_MARKER;
28
29mod create;
30pub(crate) use create::iso4217_warning_message;
31pub(crate) mod logo;
32pub(crate) mod mine;
33
34/// TIP-20 token operations (Tempo).
35#[derive(Debug, Parser, Clone)]
36pub enum Tip20Subcommand {
37    /// Create a new TIP-20 token via the TIP20Factory.
38    #[command(visible_alias = "c")]
39    Create {
40        /// The token name (e.g. "US Dollar Coin").
41        name: String,
42
43        /// The token symbol (e.g. "USDC").
44        symbol: String,
45
46        /// The ISO 4217 currency code (e.g. "USD", "EUR", "GBP").
47        /// This field is IMMUTABLE after creation and affects fee payment
48        /// eligibility, DEX routing, and quote token pairing.
49        currency: String,
50
51        /// The TIP-20 quote token address used for exchange pricing.
52        #[arg(value_parser = NameOrAddress::from_str)]
53        quote_token: NameOrAddress,
54
55        /// The admin address to receive DEFAULT_ADMIN_ROLE on the new token.
56        #[arg(value_parser = NameOrAddress::from_str)]
57        admin: NameOrAddress,
58
59        /// A unique salt for deterministic address derivation (hex-encoded bytes32).
60        salt: B256,
61
62        /// Optional T5 logo URI for the token.
63        #[arg(long, value_name = "URI")]
64        logo_uri: Option<String>,
65
66        /// Skip the ISO 4217 currency code validation warning.
67        #[arg(long)]
68        force: bool,
69
70        #[command(flatten)]
71        send_tx: SendTxOpts,
72
73        #[command(flatten)]
74        tx: TxParams,
75    },
76
77    /// Validate a TIP-20 logo URI offline against Tempo T5 constraints.
78    LogoCheck {
79        /// The logo URI to validate. Empty string is valid.
80        #[arg(value_name = "URI")]
81        logo_uri: String,
82    },
83
84    /// Update a TIP-20 token logo URI.
85    LogoSet {
86        /// The TIP-20 token contract address.
87        #[arg(value_parser = NameOrAddress::from_str)]
88        token: NameOrAddress,
89
90        /// The new logo URI. Empty string clears the on-chain value.
91        #[arg(value_name = "URI")]
92        logo_uri: String,
93
94        #[command(flatten)]
95        send_tx: SendTxOpts,
96
97        #[command(flatten)]
98        tx: TxParams,
99    },
100
101    /// Mine a TIP-1022 salt for virtual address' master registration on Tempo.
102    #[command(visible_alias = "m")]
103    Mine {
104        /// Address that will call `registerVirtualMaster(bytes32)`.
105        #[arg(value_name = "ADDRESS")]
106        master: Address,
107
108        /// Salt to validate directly instead of mining one.
109        #[arg(long, conflicts_with_all = ["seed", "no_random"], value_name = "HEX")]
110        salt: Option<B256>,
111
112        /// Number of threads to use. Specifying 0 defaults to the number of logical cores.
113        #[arg(global = true, long, short = 'j', visible_alias = "jobs")]
114        threads: Option<usize>,
115
116        /// The random number generator's seed, used to initialize the salt search.
117        #[arg(long, value_name = "HEX")]
118        seed: Option<B256>,
119
120        /// Don't initialize the salt with a random value, and instead use the default value of 0.
121        #[arg(long, conflicts_with = "seed")]
122        no_random: bool,
123
124        /// Submit `registerVirtualMaster(bytes32)` on Tempo after finding or validating the salt.
125        #[arg(long, conflicts_with_all = ["seed", "no_random"])]
126        register: bool,
127
128        #[command(flatten)]
129        send_tx: SendTxOpts,
130
131        #[command(flatten)]
132        tx: TxParams,
133    },
134}
135
136impl Tip20Subcommand {
137    pub async fn run(self) -> eyre::Result<()> {
138        match self {
139            Self::Create {
140                name,
141                symbol,
142                currency,
143                quote_token,
144                admin,
145                salt,
146                logo_uri,
147                force,
148                send_tx,
149                tx,
150            } => {
151                create::run(
152                    name,
153                    symbol,
154                    currency,
155                    quote_token,
156                    admin,
157                    salt,
158                    logo_uri,
159                    force,
160                    send_tx,
161                    tx,
162                )
163                .await?;
164            }
165            Self::LogoCheck { logo_uri } => {
166                logo::check(logo_uri)?;
167            }
168            Self::LogoSet { token, logo_uri, send_tx, tx } => {
169                logo::set(token, logo_uri, send_tx, tx).await?;
170            }
171            Self::Mine { master, salt, threads, seed, no_random, register, send_tx, tx } => {
172                let output = mine::run(master, salt, threads, seed, no_random)?;
173                if register {
174                    mine::register(master, output.salt, send_tx, tx).await?;
175                }
176            }
177        }
178        Ok(())
179    }
180}
181
182pub(crate) async fn resolve_tip20_signer(
183    send_tx: &SendTxOpts,
184    tx_params: &TxParams,
185) -> eyre::Result<(Option<WalletSigner>, Option<TempoAccountsWallet>)> {
186    if tx_params.tempo.session_id()?.is_some() {
187        tempo::ensure_session_not_browser(&tx_params.tempo, send_tx.browser.browser)?;
188    }
189
190    let config = send_tx.eth.load_config()?;
191    let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
192    let chain = get_chain(config.chain, &provider).await?;
193    tempo::resolve_session_or_wallet_signer(&tx_params.tempo, &send_tx.eth.wallet, chain.id()).await
194}
195
196pub(crate) async fn send_tip20_transaction(
197    to: NameOrAddress,
198    sig: &'static str,
199    args: Vec<String>,
200    send_tx: SendTxOpts,
201    tx_params: TxParams,
202    pre_resolved_signer: Option<WalletSigner>,
203    access_key: Option<TempoAccountsWallet>,
204) -> eyre::Result<()> {
205    let mut tx_opts = tx_params.into_transaction_opts();
206    let print_sponsor_hash = tx_opts.tempo.print_sponsor_hash;
207    let sponsor_url = tx_opts.tempo.sponsor_url.clone();
208    let sponsor_fee_payer = tx_opts.tempo.sponsor;
209    let expires_at = tx_opts.tempo.resolve_expires();
210    let tempo_sponsor = if print_sponsor_hash || sponsor_url.is_some() {
211        None
212    } else {
213        tx_opts.tempo.sponsor_config().await?
214    };
215
216    if let Some(ref url) = sponsor_url {
217        validate_sponsor_url(url)?;
218        if send_tx.browser.browser {
219            eyre::bail!("--sponsor-url cannot be combined with --browser");
220        }
221    }
222
223    let config = send_tx.eth.load_config()?;
224    let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
225    if let Some(interval) = send_tx.poll_interval {
226        provider.client().set_poll_interval(Duration::from_secs(interval))
227    }
228
229    let resolved_lane = resolve_lane(&mut tx_opts.tempo, &config.root)?;
230    if let Some(ref ak) = access_key {
231        tx_opts.tempo.key_id = Some(ak.key_id()?);
232    }
233
234    let builder = CastTxBuilder::new(&provider, tx_opts, &config)
235        .await?
236        .with_to(Some(to))
237        .await?
238        .with_code_sig_and_args(None, Some(sig.to_string()), args)
239        .await?;
240    let chain = builder.chain();
241
242    if print_sponsor_hash {
243        // Box this branch's future to keep its async state off the parent frame; otherwise
244        // `send_tip20_transaction` trips `clippy::large_stack_frames` by a small margin.
245        return Box::pin(async {
246            let (mut tx, from) = if let Some(ref ak) = access_key {
247                let (tx, _, prepared) = builder.build_with_tempo_wallet(ak).await?;
248                (tx, prepared.account())
249            } else {
250                let signer = pre_resolved_signer.as_ref().ok_or_else(|| {
251                    eyre::eyre!("--tempo.print-sponsor-hash requires a signer (e.g. --private-key)")
252                })?;
253                let from = signer.address();
254                let (tx, _) = builder.build(signer).await?;
255                (tx, from)
256            };
257            if let Some(fee_payer) = sponsor_fee_payer {
258                resolve_and_set_fee_token(
259                    (!config.eth_rpc_curl).then_some(&provider),
260                    Some(chain),
261                    &mut tx,
262                    Some(fee_payer),
263                )
264                .await?;
265            }
266            let hash = tx.compute_sponsor_hash(from).ok_or_else(|| {
267                eyre::eyre!("This network does not support sponsored transactions")
268            })?;
269            sh_println!("{hash:?}")?;
270            eyre::Ok(())
271        })
272        .await;
273    }
274
275    if let Some(ts) = expires_at {
276        sh_status!("Transaction expires at unix timestamp {ts}")?;
277    }
278
279    let timeout = send_tx.timeout.unwrap_or(config.transaction_timeout);
280    // Box the larger branch-specific build futures to keep the parent async frame small.
281    if let Some(browser) = send_tx.browser.run::<TempoNetwork>().await? {
282        let (mut tx, _) = Box::pin(builder.with_browser_wallet().build(browser.address())).await?;
283        maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
284        if let Some(sponsor) = &tempo_sponsor {
285            attach_sponsor(
286                sponsor,
287                (!config.eth_rpc_curl).then_some(&provider),
288                chain,
289                &mut tx,
290                browser.address(),
291            )
292            .await?;
293        } else {
294            let fee_token = resolve_and_set_fee_token(
295                (!config.eth_rpc_curl).then_some(&provider),
296                Some(chain),
297                &mut tx,
298                Some(browser.address()),
299            )
300            .await?;
301            maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token).await?;
302        }
303        let tx_hash = browser.send_transaction_via_browser(tx).await?;
304        CastTxSender::new(&provider)
305            .print_tx_result(tx_hash, send_tx.cast_async, send_tx.confirmations, timeout)
306            .await?;
307    } else if let Some(ak) = access_key {
308        let (mut tx, _, prepared) = Box::pin(builder.build_with_tempo_wallet(&ak)).await?;
309        maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
310        if let Some(sponsor) = &tempo_sponsor {
311            attach_sponsor(
312                sponsor,
313                (!config.eth_rpc_curl).then_some(&provider),
314                chain,
315                &mut tx,
316                prepared.account(),
317            )
318            .await?;
319        }
320        if let Some(sponsor_url) = sponsor_url.as_deref() {
321            cast_send_with_tempo_wallet_via_sponsor(
322                &provider,
323                tx,
324                &prepared,
325                sponsor_url,
326                send_tx.cast_async,
327                send_tx.sync,
328                send_tx.confirmations,
329                timeout,
330            )
331            .await?;
332        } else {
333            cast_send_with_tempo_wallet(
334                &provider,
335                tx,
336                &prepared,
337                tempo_sponsor.is_none().then_some(chain),
338                None,
339                send_tx.cast_async,
340                send_tx.sync,
341                send_tx.confirmations,
342                timeout,
343                tempo_sponsor.is_none() && !config.eth_rpc_curl,
344            )
345            .await?;
346        }
347    } else if let Some(sponsor_url) = sponsor_url {
348        let (signer, _) = resolve_send_signer(pre_resolved_signer, &send_tx.eth).await?;
349
350        let (mut tx, _) = Box::pin(builder.build(&signer)).await?;
351        maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
352        tx.set_fee_payer_signature(FEE_PAYER_SIGNATURE_MARKER);
353
354        let wallet = EthereumWallet::from(signer);
355        let connector = tempo::sponsor_relay_connector(&provider, &sponsor_url)?;
356        let provider = AlloyProviderBuilder::<_, _, TempoNetwork>::default()
357            .wallet(wallet)
358            .connect_with(&connector)
359            .await?;
360        cast_send(
361            provider,
362            tx,
363            None,
364            None,
365            send_tx.cast_async,
366            send_tx.sync,
367            send_tx.confirmations,
368            timeout,
369            false,
370        )
371        .await?;
372    } else {
373        let (signer, from) = resolve_send_signer(pre_resolved_signer, &send_tx.eth).await?;
374
375        let (mut tx, _) = builder.build(&signer).await?;
376        maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
377        if let Some(sponsor) = &tempo_sponsor {
378            attach_sponsor(
379                sponsor,
380                (!config.eth_rpc_curl).then_some(&provider),
381                chain,
382                &mut tx,
383                from,
384            )
385            .await?;
386        }
387
388        let wallet = EthereumWallet::from(signer);
389        let provider = AlloyProviderBuilder::<_, _, TempoNetwork>::default()
390            .wallet(wallet)
391            .connect_provider(&provider);
392        cast_send(
393            provider,
394            tx,
395            tempo_sponsor.is_none().then_some(chain),
396            None,
397            send_tx.cast_async,
398            send_tx.sync,
399            send_tx.confirmations,
400            timeout,
401            tempo_sponsor.is_none() && !config.eth_rpc_curl,
402        )
403        .await?;
404    }
405
406    Ok(())
407}
408
409/// Resolves the sending signer, falling back to the wallet options, and validates it against
410/// an explicit `--from`.
411async fn resolve_send_signer(
412    pre_resolved: Option<WalletSigner>,
413    eth: &foundry_cli::opts::EthereumOpts,
414) -> eyre::Result<(WalletSigner, Address)> {
415    let signer = match pre_resolved {
416        Some(signer) => signer,
417        None => eth.wallet.signer().await?,
418    };
419    let from = signer.address();
420    crate::tx::validate_from_address(eth.wallet.from, from)?;
421    Ok((signer, from))
422}
423
424/// Resolves the sponsored fee token and attaches the sponsor signature preview for `payer`.
425async fn attach_sponsor<P>(
426    sponsor: &crate::tempo::TempoSponsor,
427    provider: Option<&P>,
428    chain: foundry_config::Chain,
429    tx: &mut <TempoNetwork as alloy_network::Network>::TransactionRequest,
430    payer: Address,
431) -> eyre::Result<()>
432where
433    P: Provider<TempoNetwork>,
434{
435    sponsor
436        .resolve_and_set_fee_token(
437            provider.map(|p| p as &dyn Provider<TempoNetwork>),
438            Some(chain),
439            tx,
440        )
441        .await?;
442    sponsor.attach_and_print::<TempoNetwork>(tx, payer).await?;
443    Ok(())
444}
445
446impl TxParams {
447    fn into_transaction_opts(self) -> TransactionOpts {
448        TransactionOpts {
449            gas_limit: self.gas_limit,
450            gas_price: self.gas_price,
451            priority_gas_price: self.priority_gas_price,
452            value: None,
453            nonce: self.nonce,
454            legacy: false,
455            blob: false,
456            eip4844: false,
457            blob_gas_price: None,
458            auth: Vec::new(),
459            access_list: None,
460            tempo: self.tempo,
461        }
462    }
463}