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, print_resolved_fee_token_selection},
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(super) 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(super) 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 expires_at = tx_opts.tempo.resolve_expires();
212    let tempo_sponsor = if print_sponsor_hash || sponsor_url.is_some() {
213        None
214    } else {
215        tx_opts.tempo.sponsor_config().await?
216    };
217
218    if let Some(ref url) = sponsor_url {
219        validate_sponsor_url(url)?;
220        if send_tx.browser.browser {
221            eyre::bail!("--sponsor-url cannot be combined with --browser");
222        }
223        if access_key.is_some() {
224            eyre::bail!("--sponsor-url cannot be combined with a Tempo access key");
225        }
226    }
227
228    let config = send_tx.eth.load_config()?;
229    let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
230    if let Some(interval) = send_tx.poll_interval {
231        provider.client().set_poll_interval(Duration::from_secs(interval))
232    }
233
234    let resolved_lane = resolve_lane(&mut tx_opts.tempo, &config.root)?;
235    if let Some(ref ak) = access_key {
236        tx_opts.tempo.key_id = Some(ak.key_address);
237    }
238
239    let builder = CastTxBuilder::new(&provider, tx_opts, &config)
240        .await?
241        .with_to(Some(to))
242        .await?
243        .with_code_sig_and_args(None, Some(sig.to_string()), args)
244        .await?;
245    let chain = builder.chain();
246
247    if print_sponsor_hash {
248        let (tx, from) = if let Some(ref ak) = access_key {
249            let (tx, _) = builder.build_with_access_key(ak.wallet_address, ak).await?;
250            (tx, ak.wallet_address)
251        } else {
252            let signer = pre_resolved_signer.as_ref().ok_or_else(|| {
253                eyre::eyre!("--tempo.print-sponsor-hash requires a signer (e.g. --private-key)")
254            })?;
255            let from = signer.address();
256            let (tx, _) = builder.build(signer).await?;
257            (tx, from)
258        };
259        let hash = tx
260            .compute_sponsor_hash(from)
261            .ok_or_else(|| eyre::eyre!("This network does not support sponsored transactions"))?;
262        sh_println!("{hash:?}")?;
263        return Ok(());
264    }
265
266    if let Some(ts) = expires_at {
267        sh_status!("Transaction expires at unix timestamp {ts}")?;
268    }
269
270    let timeout = send_tx.timeout.unwrap_or(config.transaction_timeout);
271    if let Some(browser) = send_tx.browser.run::<TempoNetwork>().await? {
272        let (mut tx, _) = builder.with_browser_wallet().build(browser.address()).await?;
273        maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
274        if let Some(gas) = tx.gas_limit() {
275            tx.set_gas_limit(gas + TEMPO_BROWSER_GAS_BUFFER);
276        }
277        if let Some(sponsor) = &tempo_sponsor {
278            sponsor.attach_and_print::<TempoNetwork>(&mut tx, browser.address()).await?;
279        }
280        print_resolved_fee_token_selection(Some(chain), tx.fee_token())?;
281        let tx_hash = browser.send_transaction_via_browser(tx).await?;
282        CastTxSender::new(&provider)
283            .print_tx_result(tx_hash, send_tx.cast_async, send_tx.confirmations, timeout)
284            .await?;
285    } else if let Some(ak) = access_key {
286        let signer = pre_resolved_signer
287            .as_ref()
288            .ok_or_else(|| eyre::eyre!("signer required for access key"))?;
289        let (mut tx, _) = builder.build_with_access_key(ak.wallet_address, &ak).await?;
290        maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
291        if let Some(sponsor) = &tempo_sponsor {
292            sponsor.attach_and_print::<TempoNetwork>(&mut tx, ak.wallet_address).await?;
293        }
294        cast_send_with_access_key(
295            &provider,
296            tx,
297            signer,
298            &ak,
299            Some(chain),
300            send_tx.cast_async,
301            send_tx.confirmations,
302            timeout,
303        )
304        .await?;
305    } else if let Some(sponsor_url) = sponsor_url {
306        let signer = match pre_resolved_signer {
307            Some(signer) => signer,
308            None => send_tx.eth.wallet.signer().await?,
309        };
310        let from = signer.address();
311        crate::tx::validate_from_address(send_tx.eth.wallet.from, from)?;
312
313        let (mut tx, _) = builder.build(&signer).await?;
314        maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
315        tx.set_fee_payer_signature(FEE_PAYER_SIGNATURE_MARKER);
316
317        let wallet = EthereumWallet::from(signer);
318        let default_rpc = config.get_rpc_url_or_localhost_http()?.into_owned();
319        let default = BuiltInConnectionString::from_str(&default_rpc)?;
320        let relay = BuiltInConnectionString::from_str(&sponsor_url)?;
321        let connector =
322            RelayConnector::with_config(default, relay, SponsorshipMode::SignOnly, false);
323        let provider = AlloyProviderBuilder::<_, _, TempoNetwork>::default()
324            .wallet(wallet)
325            .connect_with(&connector)
326            .await?;
327        cast_send(
328            provider,
329            tx,
330            Some(chain),
331            send_tx.cast_async,
332            send_tx.sync,
333            send_tx.confirmations,
334            timeout,
335        )
336        .await?;
337    } else {
338        let signer = match pre_resolved_signer {
339            Some(signer) => signer,
340            None => send_tx.eth.wallet.signer().await?,
341        };
342        let from = signer.address();
343        crate::tx::validate_from_address(send_tx.eth.wallet.from, from)?;
344
345        let (mut tx, _) = builder.build(&signer).await?;
346        maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
347        if let Some(sponsor) = &tempo_sponsor {
348            sponsor.attach_and_print::<TempoNetwork>(&mut tx, from).await?;
349        }
350
351        let wallet = EthereumWallet::from(signer);
352        let provider = AlloyProviderBuilder::<_, _, TempoNetwork>::default()
353            .wallet(wallet)
354            .connect_provider(&provider);
355        cast_send(
356            provider,
357            tx,
358            Some(chain),
359            send_tx.cast_async,
360            send_tx.sync,
361            send_tx.confirmations,
362            timeout,
363        )
364        .await?;
365    }
366
367    Ok(())
368}
369
370impl TxParams {
371    fn into_transaction_opts(self) -> TransactionOpts {
372        TransactionOpts {
373            gas_limit: self.gas_limit,
374            gas_price: self.gas_price,
375            priority_gas_price: self.priority_gas_price,
376            value: None,
377            nonce: self.nonce,
378            legacy: false,
379            blob: false,
380            eip4844: false,
381            blob_gas_price: None,
382            auth: Vec::new(),
383            access_list: None,
384            tempo: self.tempo,
385        }
386    }
387}