1use 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
24const MAX_CALLBACK_GAS_LIMIT: u64 = 10_000_000;
26const MAX_CALLBACK_DATA_SIZE: usize = 1024;
27
28#[derive(Debug, Parser)]
30pub struct ZoneArgs {
31 #[command(subcommand)]
32 command: ZoneSubcommand,
33}
34
35#[derive(Debug, Parser)]
36enum ZoneSubcommand {
37 Deposit(DepositArgs),
41 Withdraw(WithdrawArgs),
45 Earn(earn::EarnArgs),
47}
48
49#[derive(Debug, Parser)]
50struct TransferArgs {
51 #[arg(long, default_value_t = PATH_USD_ADDRESS)]
53 token: Address,
54 #[arg(long)]
56 amount: u128,
57 #[arg(long)]
59 to: Option<Address>,
60 #[arg(long, default_value_t = B256::ZERO)]
62 memo: B256,
63 #[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 #[arg(long, env = "L1_PORTAL_ADDRESS")]
76 portal: Address,
77 #[arg(long)]
79 refund_recipient: Option<Address>,
80 #[command(flatten)]
81 transfer: TransferArgs,
82}
83
84#[derive(Debug, Parser)]
85struct WithdrawArgs {
86 #[arg(long, env = "ZONE_ID")]
88 zone_id: u32,
89 #[arg(long, env = "ZONE_CHAIN_ID")]
91 zone_chain_id: u64,
92 #[arg(long, default_value_t = 0)]
94 callback_gas_limit: u64,
95 #[arg(long)]
98 fallback_recipient: Option<Address>,
99 #[arg(long, default_value = "0x")]
101 callback_data: Bytes,
102 #[arg(long, default_value = "0x")]
104 reveal_to: Bytes,
105 #[arg(long)]
108 wait_l1: bool,
109 #[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 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 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}