Skip to main content

cast/cmd/tempo/zone/
l1.rs

1//! Parent-chain selection and correlated withdrawal settlement.
2
3use 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/// L1 connection options, deliberately separate from the authenticated zone RPC.
14#[derive(Debug, Parser)]
15pub(super) struct L1Args {
16    /// Override the public mainnet or Moderato RPC inferred from the zone chain ID.
17    #[arg(long, env = "L1_RPC_URL")]
18    l1_rpc_url: Option<String>,
19    /// Zone portal on Tempo L1. Required for --wait-l1 and Earn callback builders.
20    #[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
53// Chain ID allocation mirrored from zones/crates/primitives/src/constants.rs at a1c15e9f.
54fn 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            // Bound each query for public RPC providers and advance through historical ranges.
111            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}