Skip to main content

cast/cmd/tip20/
mod.rs

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