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    if let Some(browser) = send_tx.browser.run::<TempoNetwork>().await? {
281        let (mut tx, _) = builder.with_browser_wallet().build(browser.address()).await?;
282        maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
283        if let Some(sponsor) = &tempo_sponsor {
284            attach_sponsor(
285                sponsor,
286                (!config.eth_rpc_curl).then_some(&provider),
287                chain,
288                &mut tx,
289                browser.address(),
290            )
291            .await?;
292        } else {
293            let fee_token = resolve_and_set_fee_token(
294                (!config.eth_rpc_curl).then_some(&provider),
295                Some(chain),
296                &mut tx,
297                Some(browser.address()),
298            )
299            .await?;
300            maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token).await?;
301        }
302        let tx_hash = browser.send_transaction_via_browser(tx).await?;
303        CastTxSender::new(&provider)
304            .print_tx_result(tx_hash, send_tx.cast_async, send_tx.confirmations, timeout)
305            .await?;
306    } else if let Some(ak) = access_key {
307        let (mut tx, _, prepared) = builder.build_with_tempo_wallet(&ak).await?;
308        maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
309        if let Some(sponsor) = &tempo_sponsor {
310            attach_sponsor(
311                sponsor,
312                (!config.eth_rpc_curl).then_some(&provider),
313                chain,
314                &mut tx,
315                prepared.account(),
316            )
317            .await?;
318        }
319        if let Some(sponsor_url) = sponsor_url.as_deref() {
320            cast_send_with_tempo_wallet_via_sponsor(
321                &provider,
322                tx,
323                &prepared,
324                sponsor_url,
325                send_tx.cast_async,
326                send_tx.sync,
327                send_tx.confirmations,
328                timeout,
329            )
330            .await?;
331        } else {
332            cast_send_with_tempo_wallet(
333                &provider,
334                tx,
335                &prepared,
336                tempo_sponsor.is_none().then_some(chain),
337                None,
338                send_tx.cast_async,
339                send_tx.sync,
340                send_tx.confirmations,
341                timeout,
342                tempo_sponsor.is_none() && !config.eth_rpc_curl,
343            )
344            .await?;
345        }
346    } else if let Some(sponsor_url) = sponsor_url {
347        let (signer, _) = resolve_send_signer(pre_resolved_signer, &send_tx.eth).await?;
348
349        let (mut tx, _) = builder.build(&signer).await?;
350        maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
351        tx.set_fee_payer_signature(FEE_PAYER_SIGNATURE_MARKER);
352
353        let wallet = EthereumWallet::from(signer);
354        let connector = tempo::sponsor_relay_connector(&provider, &sponsor_url)?;
355        let provider = AlloyProviderBuilder::<_, _, TempoNetwork>::default()
356            .wallet(wallet)
357            .connect_with(&connector)
358            .await?;
359        cast_send(
360            provider,
361            tx,
362            None,
363            None,
364            send_tx.cast_async,
365            send_tx.sync,
366            send_tx.confirmations,
367            timeout,
368            false,
369        )
370        .await?;
371    } else {
372        let (signer, from) = resolve_send_signer(pre_resolved_signer, &send_tx.eth).await?;
373
374        let (mut tx, _) = builder.build(&signer).await?;
375        maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
376        if let Some(sponsor) = &tempo_sponsor {
377            attach_sponsor(
378                sponsor,
379                (!config.eth_rpc_curl).then_some(&provider),
380                chain,
381                &mut tx,
382                from,
383            )
384            .await?;
385        }
386
387        let wallet = EthereumWallet::from(signer);
388        let provider = AlloyProviderBuilder::<_, _, TempoNetwork>::default()
389            .wallet(wallet)
390            .connect_provider(&provider);
391        cast_send(
392            provider,
393            tx,
394            tempo_sponsor.is_none().then_some(chain),
395            None,
396            send_tx.cast_async,
397            send_tx.sync,
398            send_tx.confirmations,
399            timeout,
400            tempo_sponsor.is_none() && !config.eth_rpc_curl,
401        )
402        .await?;
403    }
404
405    Ok(())
406}
407
408/// Resolves the sending signer, falling back to the wallet options, and validates it against
409/// an explicit `--from`.
410async fn resolve_send_signer(
411    pre_resolved: Option<WalletSigner>,
412    eth: &foundry_cli::opts::EthereumOpts,
413) -> eyre::Result<(WalletSigner, Address)> {
414    let signer = match pre_resolved {
415        Some(signer) => signer,
416        None => eth.wallet.signer().await?,
417    };
418    let from = signer.address();
419    crate::tx::validate_from_address(eth.wallet.from, from)?;
420    Ok((signer, from))
421}
422
423/// Resolves the sponsored fee token and attaches the sponsor signature preview for `payer`.
424async fn attach_sponsor<P>(
425    sponsor: &crate::tempo::TempoSponsor,
426    provider: Option<&P>,
427    chain: foundry_config::Chain,
428    tx: &mut <TempoNetwork as alloy_network::Network>::TransactionRequest,
429    payer: Address,
430) -> eyre::Result<()>
431where
432    P: Provider<TempoNetwork>,
433{
434    sponsor
435        .resolve_and_set_fee_token(
436            provider.map(|p| p as &dyn Provider<TempoNetwork>),
437            Some(chain),
438            tx,
439        )
440        .await?;
441    sponsor.attach_and_print::<TempoNetwork>(tx, payer).await?;
442    Ok(())
443}
444
445impl TxParams {
446    fn into_transaction_opts(self) -> TransactionOpts {
447        TransactionOpts {
448            gas_limit: self.gas_limit,
449            gas_price: self.gas_price,
450            priority_gas_price: self.priority_gas_price,
451            value: None,
452            nonce: self.nonce,
453            legacy: false,
454            blob: false,
455            eip4844: false,
456            blob_gas_price: None,
457            auth: Vec::new(),
458            access_list: None,
459            tempo: self.tempo,
460        }
461    }
462}