Skip to main content

cast/cmd/tempo/zone/
mod.rs

1//! Deposits to Tempo L1 portals and authenticated zone withdrawals.
2
3use crate::tempo::tempo_provider;
4use alloy_network::{EthereumWallet, Network, primitives::ReceiptResponse};
5use alloy_primitives::{Address, B256, Bytes, U256};
6use alloy_provider::{PendingTransactionBuilder, Provider, ProviderBuilder};
7use alloy_rpc_types::BlockId;
8use alloy_signer::Signer;
9use clap::Parser;
10use eyre::{Result, WrapErr, ensure};
11use foundry_cli::{json::print_scalar, opts::RpcOpts, utils::LoadConfig};
12use foundry_common::{sh_status, shell};
13use foundry_wallets::{WalletOpts, WalletSigner};
14use std::time::Duration;
15use tempo_alloy::TempoNetwork;
16use tempo_contracts::precompiles::{ITIP20, PATH_USD_ADDRESS};
17
18mod abi;
19mod auth;
20mod earn;
21mod encryption;
22mod l1;
23
24// Mirrored from zones/crates/primitives and zones/crates/precompiles at a1c15e9f.
25const MAX_CALLBACK_GAS_LIMIT: u64 = 10_000_000;
26const MAX_CALLBACK_DATA_SIZE: usize = 1024;
27
28/// Tempo zone operations.
29#[derive(Debug, Parser)]
30pub struct ZoneArgs {
31    #[command(subcommand)]
32    command: ZoneSubcommand,
33}
34
35#[derive(Debug, Parser)]
36enum ZoneSubcommand {
37    /// Encrypt a deposit and submit it to a portal on Tempo L1.
38    ///
39    /// --rpc-url selects the L1 RPC. The receipt confirms L1 submission, not zone completion.
40    Deposit(DepositArgs),
41    /// Request a withdrawal using the authenticated zone RPC.
42    ///
43    /// --rpc-url selects the zone RPC. The receipt confirms the request, not L1 settlement.
44    Withdraw(WithdrawArgs),
45    /// Build encrypted callbacks for the scoped Earn router.
46    Earn(earn::EarnArgs),
47}
48
49#[derive(Debug, Parser)]
50struct TransferArgs {
51    /// TIP-20 token address on the source chain.
52    #[arg(long, default_value_t = PATH_USD_ADDRESS)]
53    token: Address,
54    /// Amount in the token's smallest units.
55    #[arg(long)]
56    amount: u128,
57    /// Destination address. Defaults to the signing wallet.
58    #[arg(long)]
59    to: Option<Address>,
60    /// Transfer memo.
61    #[arg(long, default_value_t = B256::ZERO)]
62    memo: B256,
63    /// Approve the required amount (including withdrawal fees) if allowance is insufficient.
64    #[arg(long)]
65    approve: bool,
66    #[command(flatten)]
67    rpc: RpcOpts,
68    #[command(flatten)]
69    wallet: WalletOpts,
70}
71
72#[derive(Debug, Parser)]
73struct DepositArgs {
74    /// Zone portal address on Tempo L1.
75    #[arg(long, env = "L1_PORTAL_ADDRESS")]
76    portal: Address,
77    /// Tempo L1 refund recipient if the deposit fails. Defaults to the signing wallet.
78    #[arg(long)]
79    refund_recipient: Option<Address>,
80    #[command(flatten)]
81    transfer: TransferArgs,
82}
83
84#[derive(Debug, Parser)]
85struct WithdrawArgs {
86    /// Zone ID used to scope the RPC authorization token.
87    #[arg(long, env = "ZONE_ID")]
88    zone_id: u32,
89    /// Zone chain ID used to sign the RPC authorization token before connecting.
90    #[arg(long, env = "ZONE_CHAIN_ID")]
91    zone_chain_id: u64,
92    /// L1 callback gas limit. Zero disables the callback.
93    #[arg(long, default_value_t = 0)]
94    callback_gas_limit: u64,
95    /// Zone recipient for bounced withdrawals if L1 execution fails. Defaults to the signing
96    /// wallet.
97    #[arg(long)]
98    fallback_recipient: Option<Address>,
99    /// Data passed to the L1 receiver's withdrawal callback, without a function selector.
100    #[arg(long, default_value = "0x")]
101    callback_data: Bytes,
102    /// Compressed secp256k1 key for revealing the withdrawal sender.
103    #[arg(long, default_value = "0x")]
104    reveal_to: Bytes,
105    /// Wait for successful L1 delivery, including callback execution. Does not wait for a return
106    /// deposit.
107    #[arg(long)]
108    wait_l1: bool,
109    /// Maximum seconds to wait for L1 settlement after zone inclusion.
110    #[arg(long, default_value_t = 180, value_parser = clap::value_parser!(u64).range(1..))]
111    wait_timeout: u64,
112    #[command(flatten)]
113    l1: l1::L1Args,
114    #[command(flatten)]
115    transfer: TransferArgs,
116}
117
118impl ZoneArgs {
119    pub async fn run(self) -> Result<()> {
120        match self.command {
121            ZoneSubcommand::Deposit(args) => args.run().await,
122            ZoneSubcommand::Withdraw(args) => args.run().await,
123            ZoneSubcommand::Earn(args) => args.run().await,
124        }
125    }
126}
127
128impl TransferArgs {
129    async fn signer(&self) -> Result<WalletSigner> {
130        ensure!(!self.rpc.curl, "zone operations do not support --curl");
131        ensure!(self.amount > 0, "amount must be greater than zero");
132        let signer = self.wallet.signer().await?;
133        if let Some(from) = self.wallet.from {
134            ensure!(from == signer.address(), "--from does not match the signing wallet");
135        }
136        Ok(signer)
137    }
138
139    async fn ensure_allowance(
140        &self,
141        provider: &impl Provider<TempoNetwork>,
142        sender: Address,
143        spender: Address,
144        amount: u128,
145        gas_price: Option<u128>,
146    ) -> Result<()> {
147        let token = ITIP20::new(self.token, provider);
148        let amount = U256::from(amount);
149        if token.allowance(sender, spender).from(sender).call().await? < amount {
150            ensure!(
151                self.approve,
152                "insufficient token allowance for {spender}; use --approve to approve the deposit or withdrawal amount"
153            );
154            sh_status!("Approving {} for {}", amount, spender)?;
155            let mut approval = token.approve(spender, amount).from(sender);
156            if let Some(gas_price) = gas_price {
157                approval = approval.max_fee_per_gas(gas_price).max_priority_fee_per_gas(0);
158            }
159            let receipt = submitted_receipt("token approval", approval.send().await?).await?;
160            ensure!(receipt.status(), "token approval reverted: {}", receipt.transaction_hash());
161        }
162        Ok(())
163    }
164}
165
166impl DepositArgs {
167    async fn run(self) -> Result<()> {
168        let signer = self.transfer.signer().await?;
169        let sender = signer.address();
170        let (_, provider) = tempo_provider(&self.transfer.rpc)?;
171        let provider = ProviderBuilder::new_with_network::<TempoNetwork>()
172            .wallet(EthereumWallet::from(signer))
173            .connect_provider(provider);
174        let portal = abi::IZonePortal::new(self.portal, &provider);
175        let block = provider.get_block_number().await?;
176        let key = portal
177            .encryptionKeyAtBlock(block)
178            .block(BlockId::number(block))
179            .call()
180            .await
181            .wrap_err("failed to fetch portal encryption key")?;
182        let payload = encryption::encrypt_deposit(
183            key.x,
184            key.yParity,
185            self.transfer.to.unwrap_or(sender),
186            self.transfer.memo,
187            sender,
188            self.portal,
189            key.keyIndex,
190        )?;
191        self.transfer
192            .ensure_allowance(&provider, sender, self.portal, self.transfer.amount, None)
193            .await?;
194        let pending = portal
195            .deposit(
196                self.transfer.token,
197                self.transfer.amount,
198                key.keyIndex,
199                payload,
200                self.refund_recipient.unwrap_or(sender),
201            )
202            .from(sender)
203            .send()
204            .await?;
205        let receipt = submitted_receipt("deposit", pending).await?;
206        ensure!(receipt.status(), "deposit reverted: {}", receipt.transaction_hash());
207        let _ = sh_status!("Deposit submitted on L1; zone processing is asynchronous.");
208        print_receipt(&receipt)
209    }
210}
211
212impl WithdrawArgs {
213    async fn run(self) -> Result<()> {
214        validate_callback(&self.callback_data, self.callback_gas_limit)?;
215        ensure!(self.zone_id != 0, "--zone-id must be nonzero");
216        ensure!(self.zone_chain_id != 0, "--zone-chain-id must be nonzero");
217        if !self.reveal_to.is_empty() {
218            ensure!(
219                self.reveal_to.len() == 33
220                    && k256::PublicKey::from_sec1_bytes(&self.reveal_to).is_ok(),
221                "--reveal-to must be a compressed secp256k1 public key"
222            );
223        }
224        let signer = self.transfer.signer().await?;
225        let sender = signer.address();
226        let token = auth::sign_token(&signer, self.zone_id, self.zone_chain_id)
227            .await
228            .wrap_err("wallet could not sign zone RPC authorization")?;
229        let mut config = self.transfer.rpc.load_config()?;
230        let headers = config.eth_rpc_headers.get_or_insert_default();
231        headers.retain(|header| {
232            !header
233                .split_once(':')
234                .is_some_and(|(name, _)| name.trim().eq_ignore_ascii_case(auth::HEADER))
235        });
236        headers.push(format!("{}: {token}", auth::HEADER));
237        let provider =
238            foundry_common::provider::ProviderBuilder::<TempoNetwork>::from_config(&config)?
239                .build()?;
240        let provider = ProviderBuilder::new_with_network::<TempoNetwork>()
241            .wallet(EthereumWallet::from(signer))
242            .connect_provider(provider);
243        ensure!(
244            provider.get_chain_id().await? == self.zone_chain_id,
245            "zone RPC chain ID differs from --zone-chain-id"
246        );
247        // Snapshot L1 before submitting, so fast settlement cannot be missed.
248        let wait = if self.wait_l1 {
249            let l1_provider = self.l1.provider(self.zone_chain_id, self.zone_id).await?;
250            let from_block = l1_provider.get_block_number().await?;
251            Some((l1_provider, from_block))
252        } else {
253            None
254        };
255        let outbox = abi::IZoneOutbox::new(abi::OUTBOX, &provider);
256        let fee =
257            outbox.calculateWithdrawalFee(self.callback_gas_limit).from(sender).call().await?;
258        let total = self
259            .transfer
260            .amount
261            .checked_add(fee)
262            .ok_or_else(|| eyre::eyre!("withdrawal amount plus fee exceeds uint128"))?;
263        // Use the private RPC's gas-price quote without requiring fee-history support.
264        let gas_price = provider.get_gas_price().await?;
265        self.transfer
266            .ensure_allowance(&provider, sender, abi::OUTBOX, total, Some(gas_price))
267            .await?;
268        let to = self.transfer.to.unwrap_or(sender);
269        let pending = outbox
270            .requestWithdrawal(
271                self.transfer.token,
272                to,
273                self.transfer.amount,
274                self.transfer.memo,
275                self.callback_gas_limit,
276                self.fallback_recipient.unwrap_or(sender),
277                self.callback_data,
278                self.reveal_to,
279            )
280            .max_fee_per_gas(gas_price)
281            .max_priority_fee_per_gas(0)
282            .from(sender)
283            .send()
284            .await?;
285        let receipt = submitted_receipt("withdrawal", pending).await?;
286        ensure!(receipt.status(), "withdrawal reverted: {}", receipt.transaction_hash());
287        let hash = receipt.transaction_hash();
288        if let Some((l1_provider, from_block)) = wait {
289            let _ = sh_status!("Withdrawal requested: {hash}; waiting for L1 delivery.");
290            let result = l1::wait_for_withdrawal(
291                &provider,
292                &l1_provider,
293                self.l1.portal()?,
294                from_block,
295                receipt.block_number().ok_or_else(|| eyre::eyre!("missing zone receipt block"))?,
296                hash,
297            );
298            let l1_hash = tokio::time::timeout(Duration::from_secs(self.wait_timeout), result)
299                .await
300                .wrap_err_with(|| {
301                    format!(
302                        "L1 wait timed out; withdrawal {hash} is already submitted; do not resubmit"
303                    )
304                })?
305                .wrap_err_with(|| format!("L1 wait failed for submitted withdrawal {hash}"))?;
306            let _ = sh_status!("Withdrawal delivered on L1: {l1_hash}");
307        } else {
308            let _ = sh_status!("Withdrawal requested on the zone; L1 settlement is asynchronous.");
309        }
310        print_receipt(&receipt)
311    }
312}
313
314async fn submitted_receipt(
315    action: &str,
316    pending: PendingTransactionBuilder<TempoNetwork>,
317) -> Result<<TempoNetwork as Network>::ReceiptResponse> {
318    let hash = *pending.tx_hash();
319    let receipt = pending.with_timeout(Some(Duration::from_secs(120))).get_receipt();
320    tokio::time::timeout(Duration::from_secs(120), receipt)
321        .await
322        .wrap_err_with(|| {
323            format!("{action} {hash} was submitted; receipt polling timed out; do not resubmit")
324        })?
325        .wrap_err_with(|| {
326            format!("{action} {hash} was submitted; receipt polling failed; do not resubmit")
327        })
328}
329
330fn validate_callback(data: &Bytes, gas_limit: u64) -> Result<()> {
331    ensure!(
332        data.is_empty() || gas_limit > 0,
333        "--callback-data requires a nonzero --callback-gas-limit"
334    );
335    ensure!(
336        data.len() <= MAX_CALLBACK_DATA_SIZE,
337        "--callback-data exceeds the protocol limit of {MAX_CALLBACK_DATA_SIZE} bytes"
338    );
339    ensure!(
340        gas_limit <= MAX_CALLBACK_GAS_LIMIT,
341        "--callback-gas-limit exceeds the protocol limit of {MAX_CALLBACK_GAS_LIMIT}"
342    );
343    Ok(())
344}
345
346fn print_receipt(receipt: &(impl ReceiptResponse + serde::Serialize)) -> Result<()> {
347    if shell::is_json() {
348        foundry_common::sh_println!("{}", serde_json::to_string(receipt)?)?;
349        Ok(())
350    } else {
351        print_scalar(receipt.transaction_hash())
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use crate::cmd::tempo::{TempoArgs, TempoSubcommand};
359
360    #[test]
361    fn zone_commands_accept_cast_keystore_wallets() {
362        for args in [
363            vec![
364                "tempo",
365                "zone",
366                "deposit",
367                "--portal",
368                "0x1111111111111111111111111111111111111111",
369                "--amount",
370                "1000000",
371                "--account",
372                "zone-test",
373                "--password-file",
374                "/tmp/password",
375                "--approve",
376            ],
377            vec![
378                "tempo",
379                "zone",
380                "withdraw",
381                "--zone-id",
382                "42",
383                "--zone-chain-id",
384                "1337",
385                "--amount",
386                "1000000",
387                "--account",
388                "zone-test",
389                "--password-file",
390                "/tmp/password",
391                "--approve",
392            ],
393        ] {
394            let tempo = TempoArgs::try_parse_from(args).unwrap();
395            assert!(matches!(tempo.command, TempoSubcommand::Zone(_)));
396        }
397    }
398
399    #[test]
400    fn callback_limits_match_zone_protocol() {
401        assert!(validate_callback(&Bytes::from(vec![0; 1024]), 10_000_000).is_ok());
402        assert!(validate_callback(&Bytes::from(vec![0; 1025]), 10_000_000).is_err());
403        assert!(validate_callback(&Bytes::new(), 10_000_001).is_err());
404        assert!(validate_callback(&Bytes::from_static(&[1]), 0).is_err());
405    }
406
407    #[test]
408    fn parses_settlement_wait() {
409        assert!(
410            TempoArgs::try_parse_from([
411                "tempo",
412                "zone",
413                "withdraw",
414                "--zone-id",
415                "42",
416                "--zone-chain-id",
417                "1337",
418                "--amount",
419                "1",
420                "--wait-l1",
421            ])
422            .is_ok()
423        );
424    }
425}