1use super::abi::{IZoneOutbox, IZonePortal, OUTBOX};
4use alloy_primitives::{Address, B256, keccak256};
5use alloy_provider::{Provider, RootProvider};
6use alloy_rpc_types::Filter;
7use alloy_sol_types::SolEvent;
8use clap::Parser;
9use eyre::{Result, ensure, eyre};
10use std::time::Duration;
11use tempo_alloy::TempoNetwork;
12
13#[derive(Debug, Parser)]
15pub(super) struct L1Args {
16 #[arg(long, env = "L1_RPC_URL")]
18 l1_rpc_url: Option<String>,
19 #[arg(long, env = "L1_PORTAL_ADDRESS")]
21 portal: Option<Address>,
22}
23
24impl L1Args {
25 pub(super) fn portal(&self) -> Result<Address> {
26 self.portal.ok_or_else(|| eyre!("--portal is required for L1 operations"))
27 }
28
29 pub(super) async fn provider(
30 &self,
31 zone_chain_id: u64,
32 zone_id: u32,
33 ) -> Result<RootProvider<TempoNetwork>> {
34 let portal = self.portal()?;
35 let parent = parent_chain(zone_chain_id, zone_id)?;
36 let url = match self.l1_rpc_url.as_deref() {
37 Some(url) => url,
38 None => public_rpc(parent)?,
39 };
40 let provider = RootProvider::<TempoNetwork>::new_http(url.parse()?);
41 ensure!(
42 provider.get_chain_id().await? == parent,
43 "L1 RPC chain ID does not match the zone's parent chain"
44 );
45 ensure!(
46 IZonePortal::new(portal, &provider).zoneId().call().await? == zone_id,
47 "--portal does not belong to --zone-id"
48 );
49 Ok(provider)
50 }
51}
52
53fn parent_chain(chain: u64, zone: u32) -> Result<u64> {
55 ensure!(zone != 0, "--zone-id must be nonzero");
56 let (parent, decoded_zone) = match chain {
57 421_700_000..1_424_310_000 => (4217, chain - 421_700_000),
58 1_424_310_000..2_147_483_648 => (42431, chain - 1_424_310_000),
59 _ if chain >= 1 << 32 => (chain >> 32, chain & 0xffff_ffff),
60 _ => return Err(eyre!("invalid zone chain ID: {chain}")),
61 };
62 ensure!(decoded_zone == u64::from(zone), "--zone-id does not match --zone-chain-id");
63 ensure!(parent <= (1 << 20) - 2, "invalid parent chain ID");
64 ensure!(chain < 1 << 32 || !matches!(parent, 4217 | 42431), "noncanonical zone chain ID");
65 Ok(parent)
66}
67
68fn public_rpc(parent: u64) -> Result<&'static str> {
69 match parent {
70 4217 => Ok("https://rpc.tempo.xyz"),
71 42431 => Ok("https://rpc.moderato.tempo.xyz"),
72 _ => Err(eyre!("no public RPC for parent chain {parent}; supply --l1-rpc-url")),
73 }
74}
75
76fn sender_tag(sender: Address, tx_hash: B256, nonce: u64) -> B256 {
77 let mut bytes = [0u8; 60];
78 bytes[..20].copy_from_slice(sender.as_slice());
79 bytes[20..52].copy_from_slice(tx_hash.as_slice());
80 bytes[52..].copy_from_slice(&nonce.to_be_bytes());
81 keccak256(bytes)
82}
83
84pub(super) async fn wait_for_withdrawal(
85 zone: &impl Provider<TempoNetwork>,
86 l1: &impl Provider<TempoNetwork>,
87 portal: Address,
88 mut from_block: u64,
89 zone_block: u64,
90 zone_hash: B256,
91) -> Result<B256> {
92 let logs = zone
93 .get_logs(
94 &Filter::new()
95 .address(OUTBOX)
96 .event_signature(IZoneOutbox::WithdrawalRequested::SIGNATURE_HASH)
97 .from_block(zone_block)
98 .to_block(zone_block),
99 )
100 .await?;
101 let requested = logs
102 .iter()
103 .filter(|log| log.transaction_hash == Some(zone_hash))
104 .find_map(|log| IZoneOutbox::WithdrawalRequested::decode_log(&log.inner).ok())
105 .ok_or_else(|| eyre!("withdrawal request event missing from {zone_hash}"))?;
106 let tag = sender_tag(requested.sender, zone_hash, requested.fallbackNonce);
107 loop {
108 let head = l1.get_block_number().await?;
109 if head >= from_block {
110 let end = head.min(from_block.saturating_add(999));
112 let logs = l1
113 .get_logs(
114 &Filter::new()
115 .address(portal)
116 .event_signature(IZonePortal::WithdrawalProcessed::SIGNATURE_HASH)
117 .topic1(requested.to.into_word())
118 .topic2(tag)
119 .from_block(from_block)
120 .to_block(end),
121 )
122 .await?;
123 for log in logs {
124 let event = IZonePortal::WithdrawalProcessed::decode_log(&log.inner)?;
125 if !log.removed
126 && event.senderTag == tag
127 && event.to == requested.to
128 && event.token == requested.token
129 && event.amount == requested.amount
130 {
131 let hash = log
132 .transaction_hash
133 .ok_or_else(|| eyre!("L1 event missing transaction hash"))?;
134 ensure!(
135 event.callbackSuccess,
136 "L1 delivery failed in {hash}; a refund was queued for the zone fallback recipient"
137 );
138 return Ok(hash);
139 }
140 }
141 from_block = end + 1;
142 if end < head {
143 continue;
144 }
145 }
146 tokio::time::sleep(Duration::from_secs(2)).await;
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use alloy_primitives::Bytes;
154 use alloy_provider::{ProviderBuilder, mock::Asserter};
155 use alloy_rpc_types::Log;
156
157 #[test]
158 fn parent_chain_and_public_defaults() {
159 assert_eq!(
160 public_rpc(parent_chain(421_700_007, 7).unwrap()).unwrap(),
161 "https://rpc.tempo.xyz"
162 );
163 assert_eq!(
164 public_rpc(parent_chain(1_424_310_003, 3).unwrap()).unwrap(),
165 "https://rpc.moderato.tempo.xyz"
166 );
167 assert_eq!(parent_chain((31337 << 32) | 2, 2).unwrap(), 31337);
168 assert!(public_rpc(31337).is_err());
169 for (chain, zone) in
170 [(421_700_007, 8), (1_424_310_000, 0), (1 << 31, 1), ((4217 << 32) | 1, 1)]
171 {
172 assert!(parent_chain(chain, zone).is_err());
173 }
174 }
175
176 #[test]
177 fn sender_tag_binds_transaction_and_nonce() {
178 let sender = Address::repeat_byte(0x11);
179 let hash = B256::repeat_byte(0x22);
180 let expected =
181 keccak256([sender.as_slice(), hash.as_slice(), &3u64.to_be_bytes()].concat());
182 assert_eq!(sender_tag(sender, hash, 3), expected);
183 assert_ne!(sender_tag(sender, hash, 3), sender_tag(sender, hash, 4));
184 assert_ne!(sender_tag(sender, hash, 3), sender_tag(sender, B256::ZERO, 3));
185 }
186 #[tokio::test]
187 async fn wait_checks_delivery_result_and_ignores_other_transactions() {
188 for success in [Some(true), Some(false), None] {
189 let zone_hash = B256::repeat_byte(1);
190 let l1_hash = B256::repeat_byte(2);
191 let portal = Address::repeat_byte(3);
192 let request = IZoneOutbox::WithdrawalRequested {
193 withdrawalIndex: 0,
194 sender: Address::repeat_byte(4),
195 token: Address::repeat_byte(5),
196 to: Address::repeat_byte(6),
197 amount: 7,
198 fee: 0,
199 memo: B256::ZERO,
200 gasLimit: 100_000,
201 fallbackNonce: 8,
202 data: Bytes::new(),
203 revealTo: Bytes::new(),
204 };
205 let request_log = Log {
206 inner: alloy_primitives::Log { address: OUTBOX, data: request.encode_log_data() },
207 transaction_hash: Some(zone_hash),
208 ..Default::default()
209 };
210 let mut unrelated = request_log.clone();
211 unrelated.transaction_hash = Some(B256::ZERO);
212 let zone_responses = Asserter::new();
213 zone_responses.push_success(&vec![unrelated, request_log]);
214 let zone = ProviderBuilder::new()
215 .network::<TempoNetwork>()
216 .connect_mocked_client(zone_responses);
217 let delivered = IZonePortal::WithdrawalProcessed {
218 to: request.to,
219 senderTag: sender_tag(request.sender, zone_hash, 8),
220 token: request.token,
221 amount: request.amount,
222 callbackSuccess: success.unwrap_or(true),
223 };
224 let delivered_log = Log {
225 inner: alloy_primitives::Log { address: portal, data: delivered.encode_log_data() },
226 transaction_hash: Some(l1_hash),
227 ..Default::default()
228 };
229 let l1_responses = Asserter::new();
230 l1_responses.push_success(&"0xa");
231 let mut removed = delivered_log.clone();
232 removed.removed = true;
233 let unrelated = IZonePortal::WithdrawalProcessed { senderTag: B256::ZERO, ..delivered };
234 let mut unrelated_log = delivered_log.clone();
235 unrelated_log.inner.data = unrelated.encode_log_data();
236 let mut logs = vec![removed, unrelated_log];
237 if success.is_some() {
238 logs.push(delivered_log);
239 }
240 l1_responses.push_success(&logs);
241 let l1 = ProviderBuilder::new()
242 .network::<TempoNetwork>()
243 .connect_mocked_client(l1_responses);
244 let result = tokio::time::timeout(
245 Duration::from_millis(100),
246 wait_for_withdrawal(&zone, &l1, portal, 10, 20, zone_hash),
247 )
248 .await;
249 match success {
250 Some(true) => assert_eq!(result.unwrap().unwrap(), l1_hash),
251 Some(false) => assert_eq!(
252 result.unwrap().unwrap_err().to_string(),
253 format!(
254 "L1 delivery failed in {l1_hash}; a refund was queued for the zone fallback recipient"
255 )
256 ),
257 None => assert!(
258 result.is_err(),
259 "unrelated or removed events must not complete the wait"
260 ),
261 }
262 }
263 }
264}