clementine_core/
citrea.rs

1//! # Citrea Related Utilities
2
3use crate::config::protocol::ProtocolParamset;
4use crate::config::protocol::ProtocolParamsetExt;
5use crate::database::DatabaseTransaction;
6use crate::{citrea::BRIDGE_CONTRACT::DepositReplaced, database::Database};
7use alloy::{
8    eips::{BlockId, BlockNumberOrTag},
9    network::EthereumWallet,
10    primitives::{keccak256, U256},
11    providers::{
12        fillers::{
13            BlobGasFiller, ChainIdFiller, FillProvider, GasFiller, JoinFill, NonceFiller,
14            WalletFiller,
15        },
16        Provider, ProviderBuilder, RootProvider,
17    },
18    rpc::types::{EIP1186AccountProofResponse, Filter, Log},
19    signers::{local::PrivateKeySigner, Signer},
20    sol,
21    sol_types::SolEvent,
22    transports::http::reqwest::Url,
23};
24use bitcoin::{hashes::Hash, OutPoint, Txid, XOnlyPublicKey};
25use bridge_circuit_host::receipt_from_inner;
26use circuits_lib::bridge_circuit::{
27    lc_proof::check_method_id,
28    structs::{LightClientProof, StorageProof},
29};
30use citrea_sov_rollup_interface::zk::light_client_proof::output::LightClientCircuitOutput;
31use clementine_errors::BridgeError;
32use eyre::Context;
33use jsonrpsee::http_client::{HttpClient, HttpClientBuilder};
34use jsonrpsee::proc_macros::rpc;
35use risc0_zkvm::{InnerReceipt, Receipt};
36use std::{fmt::Debug, time::Duration};
37use tonic::async_trait;
38
39#[cfg(test)]
40pub const SATS_TO_WEI_MULTIPLIER: u64 = 10_000_000_000;
41pub const BRIDGE_CONTRACT_ADDRESS: &str = "0x3100000000000000000000000000000000000002";
42
43#[cfg(test)]
44pub const LIGHT_CLIENT_ADDRESS: &str = "0x3100000000000000000000000000000000000001";
45
46const UTXOS_STORAGE_INDEX: [u8; 32] =
47    hex_literal::hex!("0000000000000000000000000000000000000000000000000000000000000007");
48const DEPOSIT_STORAGE_INDEX: [u8; 32] =
49    hex_literal::hex!("0000000000000000000000000000000000000000000000000000000000000008");
50
51// Codegen from ABI file to interact with the contract.
52sol!(
53    #[allow(missing_docs)]
54    #[sol(rpc)]
55    #[derive(Debug)]
56    BRIDGE_CONTRACT,
57    "../scripts/Bridge.json"
58);
59
60#[async_trait]
61pub trait CitreaClientT: Send + Sync + Debug + Clone + 'static {
62    /// # Parameters
63    ///
64    /// - `citrea_rpc_url`: URL of the Citrea RPC.
65    /// - `light_client_prover_url`: URL of the Citrea light client prover RPC.
66    /// - `chain_id`: Citrea's EVM chain id.
67    /// - `secret_key`: EVM secret key of the EVM user. If not given, random
68    ///   secret key is used (wallet is not required). This is given mostly for
69    ///   testing purposes.
70    async fn new(
71        citrea_rpc_url: String,
72        light_client_prover_url: String,
73        chain_id: u32,
74        secret_key: Option<PrivateKeySigner>,
75        timeout: Option<Duration>,
76    ) -> Result<Self, BridgeError>;
77
78    /// Returns deposit move txids, starting from the last deposit index.
79    ///
80    /// # Parameters
81    ///
82    /// - `last_deposit_idx`: Last deposit index. None if no deposit
83    /// - `to_height`: End block height (inclusive)
84    async fn collect_deposit_move_txids(
85        &self,
86        last_deposit_idx: Option<u32>,
87        to_height: u64,
88    ) -> Result<Vec<(u64, Txid)>, BridgeError>;
89
90    /// Returns withdrawal utxos, starting from the last withdrawal index.
91    ///
92    /// # Parameters
93    ///
94    /// - `last_withdrawal_idx`: Last withdrawal index. None if no withdrawal
95    /// - `to_height`: End block height (inclusive)
96    async fn collect_withdrawal_utxos(
97        &self,
98        last_withdrawal_idx: Option<u32>,
99        to_height: u64,
100    ) -> Result<Vec<(u64, OutPoint)>, BridgeError>;
101
102    /// Returns the light client proof and its L2 height for the given L1 block
103    /// height.
104    ///
105    /// # Returns
106    ///
107    /// A tuple, wrapped around a [`Some`] if present:
108    ///
109    /// - [`u64`]: Last L2 block height.
110    /// - [`ProtocolParamset`]: Protocol paramset.
111    ///
112    /// If not present, [`None`] is returned.
113    async fn get_light_client_proof(
114        &self,
115        l1_height: u64,
116        paramset: &'static ProtocolParamset,
117    ) -> Result<Option<(LightClientProof, Receipt, u64)>, BridgeError>;
118
119    /// Returns the L2 block height range for the given L1 block height.
120    ///
121    /// # Parameters
122    ///
123    /// - `block_height`: L1 block height.
124    /// - `timeout`: Timeout duration.
125    ///
126    /// # Returns
127    ///
128    /// A tuple of:
129    ///
130    /// - [`u64`]: Start of the L2 block height (not inclusive)
131    /// - [`u64`]: End of the L2 block height (inclusive)
132    async fn get_citrea_l2_height_range(
133        &self,
134        block_height: u64,
135        timeout: Duration,
136        paramset: &'static ProtocolParamset,
137    ) -> Result<(u64, u64), BridgeError>;
138
139    /// Returns the replacement deposit move txids for the given range of blocks.
140    ///
141    /// # Parameters
142    ///
143    /// - `from_height`: Start block height (inclusive)
144    /// - `to_height`: End block height (inclusive)
145    ///
146    /// # Returns
147    ///
148    /// A vector of tuples, each containing:
149    ///
150    /// - [`Txid`]: The original move txid.
151    /// - [`Txid`]: The replacement move txid.
152    async fn get_replacement_deposit_move_txids(
153        &self,
154        from_height: u64,
155        to_height: u64,
156    ) -> Result<Vec<(u32, Txid)>, BridgeError>;
157
158    async fn check_nofn_correctness(
159        &self,
160        nofn_xonly_pk: XOnlyPublicKey,
161    ) -> Result<(), BridgeError>;
162
163    async fn get_storage_proof(
164        &self,
165        l2_height: u64,
166        deposit_index: u32,
167    ) -> Result<StorageProof, BridgeError>;
168
169    async fn fetch_validate_and_store_lcp(
170        &self,
171        payout_block_height: u64,
172        deposit_index: u32,
173        db: &Database,
174        dbtx: Option<DatabaseTransaction<'_>>,
175        paramset: &'static ProtocolParamset,
176    ) -> Result<Receipt, BridgeError>;
177}
178
179/// Citrea client is responsible for interacting with the Citrea EVM and Citrea
180/// RPC.
181#[derive(Clone, Debug)]
182pub struct CitreaClient {
183    pub client: HttpClient,
184    pub light_client_prover_client: HttpClient,
185    #[cfg(test)]
186    pub wallet_address: alloy::primitives::Address,
187    pub contract: CitreaContract,
188}
189
190impl CitreaClient {
191    /// Returns all logs for the given filter and block range while considering
192    /// about the 1000 block limit.
193    async fn get_logs(
194        &self,
195        filter: Filter,
196        from_height: u64,
197        to_height: u64,
198    ) -> Result<Vec<Log>, BridgeError> {
199        let mut logs = vec![];
200
201        let mut from_height = from_height;
202        while from_height <= to_height {
203            // Block num is 999 because limits are inclusive.
204            let to_height = std::cmp::min(from_height + 999, to_height);
205            tracing::debug!("Fetching logs from {} to {}", from_height, to_height);
206
207            // Update filter with the new range.
208            let filter = filter.clone();
209            let filter = filter.from_block(BlockNumberOrTag::Number(from_height));
210            let filter = filter.to_block(BlockNumberOrTag::Number(to_height));
211
212            let logs_chunk = self
213                .contract
214                .provider()
215                .get_logs(&filter)
216                .await
217                .wrap_err("Failed to get logs")?;
218            logs.extend(logs_chunk);
219
220            from_height = to_height + 1;
221        }
222
223        Ok(logs)
224    }
225}
226
227#[async_trait]
228impl CitreaClientT for CitreaClient {
229    /// Fetches the storage proof for a given deposit index and transaction ID.
230    ///
231    /// This function interacts with an Citrea RPC endpoint to retrieve a storage proof,
232    /// which includes proof details for both the UTXO and the deposit index.
233    ///
234    /// # Arguments
235    /// * `l2_height` - A `u64` representing the L2 block height.
236    /// * `deposit_index` - A `u32` representing the deposit index.
237    ///
238    /// # Returns
239    /// Returns a `StorageProof` struct containing serialized storage proofs for the UTXO and deposit index.
240    async fn get_storage_proof(
241        &self,
242        l2_height: u64,
243        deposit_index: u32,
244    ) -> Result<StorageProof, BridgeError> {
245        let ind = deposit_index;
246        let tx_index: u32 = ind * 2;
247
248        let storage_address_wd_utxo_bytes = keccak256(UTXOS_STORAGE_INDEX);
249        let storage_address_wd_utxo: U256 = U256::from_be_bytes(
250            <[u8; 32]>::try_from(&storage_address_wd_utxo_bytes[..])
251                .wrap_err("Storage address wd utxo bytes slice with incorrect length")?,
252        );
253
254        // Storage key address calculation UTXO
255        let storage_key_wd_utxo: U256 = storage_address_wd_utxo + U256::from(tx_index);
256        let storage_key_wd_utxo_hex =
257            format!("0x{}", hex::encode(storage_key_wd_utxo.to_be_bytes::<32>()));
258
259        // Storage key address calculation Vout
260        let storage_key_vout: U256 = storage_address_wd_utxo + U256::from(tx_index + 1);
261        let storage_key_vout_hex =
262            format!("0x{}", hex::encode(storage_key_vout.to_be_bytes::<32>()));
263
264        // Storage key address calculation Deposit
265        let storage_address_deposit_bytes = keccak256(DEPOSIT_STORAGE_INDEX);
266        let storage_address_deposit: U256 = U256::from_be_bytes(
267            <[u8; 32]>::try_from(&storage_address_deposit_bytes[..])
268                .wrap_err("Storage address deposit bytes slice with incorrect length")?,
269        );
270
271        let storage_key_deposit: U256 = storage_address_deposit + U256::from(deposit_index);
272        let storage_key_deposit_hex = hex::encode(storage_key_deposit.to_be_bytes::<32>());
273        let storage_key_deposit_hex = format!("0x{storage_key_deposit_hex}");
274
275        let response: serde_json::Value = self
276            .client
277            .get_proof(
278                BRIDGE_CONTRACT_ADDRESS,
279                vec![
280                    storage_key_wd_utxo_hex,
281                    storage_key_vout_hex,
282                    storage_key_deposit_hex,
283                ],
284                format!("0x{l2_height:x}"),
285            )
286            .await
287            .wrap_err("Failed to get storage proof from rpc")?;
288
289        let response: EIP1186AccountProofResponse = serde_json::from_value(response)
290            .wrap_err("Failed to deserialize EIP1186AccountProofResponse")?;
291
292        // It does not seem possible to get a storage proof with less than 3 items. But still
293        // we check it to avoid panics.
294        if response.storage_proof.len() < 3 {
295            return Err(eyre::eyre!(
296                "Expected at least 3 storage proofs, got {}",
297                response.storage_proof.len()
298            )
299            .into());
300        }
301
302        let serialized_utxo = serde_json::to_string(&response.storage_proof[0])
303            .wrap_err("Failed to serialize storage proof utxo")?;
304
305        let serialized_vout = serde_json::to_string(&response.storage_proof[1])
306            .wrap_err("Failed to serialize storage proof vout")?;
307
308        let serialized_deposit = serde_json::to_string(&response.storage_proof[2])
309            .wrap_err("Failed to serialize storage proof deposit")?;
310
311        Ok(StorageProof {
312            storage_proof_utxo: serialized_utxo,
313            storage_proof_vout: serialized_vout,
314            storage_proof_deposit_txid: serialized_deposit,
315            index: ind,
316        })
317    }
318
319    async fn fetch_validate_and_store_lcp(
320        &self,
321        payout_block_height: u64,
322        deposit_index: u32,
323        db: &Database,
324        mut dbtx: Option<DatabaseTransaction<'_>>,
325        paramset: &'static ProtocolParamset,
326    ) -> Result<Receipt, BridgeError> {
327        let saved_data = db
328            .get_lcp_for_assert(dbtx.as_deref_mut(), deposit_index)
329            .await?;
330        if let Some(lcp) = saved_data {
331            // if already saved, do nothing
332            return Ok(lcp);
333        };
334
335        let lcp_result = self
336            .get_light_client_proof(payout_block_height, paramset)
337            .await?;
338        let (_lcp, lcp_receipt, _l2_height) = match lcp_result {
339            Some(lcp) => lcp,
340            None => {
341                return Err(eyre::eyre!(
342                    "Light client proof could not be fetched found for block height {}",
343                    payout_block_height
344                )
345                .into())
346            }
347        };
348
349        // save the LCP for assert
350        db.insert_lcp_for_assert(dbtx, deposit_index, lcp_receipt.clone())
351            .await?;
352
353        Ok(lcp_receipt)
354    }
355
356    async fn new(
357        citrea_rpc_url: String,
358        light_client_prover_url: String,
359        chain_id: u32,
360        secret_key: Option<PrivateKeySigner>,
361        timeout: Option<Duration>,
362    ) -> Result<Self, BridgeError> {
363        let citrea_rpc_url = Url::parse(&citrea_rpc_url).wrap_err("Can't parse Citrea RPC URL")?;
364        let light_client_prover_url =
365            Url::parse(&light_client_prover_url).wrap_err("Can't parse Citrea LCP RPC URL")?;
366        let secret_key = secret_key.unwrap_or(PrivateKeySigner::random());
367
368        let key = secret_key.with_chain_id(Some(chain_id.into()));
369
370        #[cfg(test)]
371        let wallet_address = key.address();
372
373        tracing::info!("Wallet address: {}", key.address());
374
375        let provider = ProviderBuilder::new()
376            .wallet(EthereumWallet::from(key))
377            .on_http(citrea_rpc_url.clone());
378
379        tracing::info!("Provider created");
380
381        let contract = BRIDGE_CONTRACT::new(
382            BRIDGE_CONTRACT_ADDRESS
383                .parse()
384                .expect("Correct contract address"),
385            provider,
386        );
387
388        tracing::info!("Contract created");
389
390        let client = HttpClientBuilder::default()
391            .request_timeout(timeout.unwrap_or(Duration::from_secs(60)))
392            .build(citrea_rpc_url)
393            .wrap_err("Failed to create Citrea RPC client")?;
394
395        tracing::info!("Citrea RPC client created");
396
397        let light_client_prover_client = HttpClientBuilder::default()
398            .request_timeout(timeout.unwrap_or(Duration::from_secs(60)))
399            .build(light_client_prover_url)
400            .wrap_err("Failed to create Citrea LCP RPC client")?;
401
402        tracing::info!("Citrea LCP RPC client created");
403
404        Ok(CitreaClient {
405            client,
406            light_client_prover_client,
407            #[cfg(test)]
408            wallet_address,
409            contract,
410        })
411    }
412
413    async fn collect_deposit_move_txids(
414        &self,
415        last_deposit_idx: Option<u32>,
416        to_height: u64,
417    ) -> Result<Vec<(u64, Txid)>, BridgeError> {
418        let mut move_txids = vec![];
419
420        let mut start_idx = match last_deposit_idx {
421            Some(idx) => idx + 1,
422            None => 0,
423        };
424
425        loop {
426            let deposit_txid = self
427                .contract
428                .depositTxIds(U256::from(start_idx))
429                .block(BlockId::Number(BlockNumberOrTag::Number(to_height)))
430                .call()
431                .await;
432            match deposit_txid {
433                Err(e) if e.to_string().contains("execution reverted") => {
434                    tracing::trace!("Deposit txid not found for index, error: {:?}", e);
435                    break;
436                }
437                Err(e) => return Err(e.into()),
438                Ok(_) => {}
439            }
440            tracing::info!("Deposit txid found for index: {:?}", deposit_txid);
441
442            let deposit_txid = deposit_txid.expect("Failed to get deposit txid");
443            let move_txid = Txid::from_slice(deposit_txid._0.as_ref())
444                .wrap_err("Failed to convert move txid to Txid")?;
445            move_txids.push((start_idx as u64, move_txid));
446            start_idx += 1;
447        }
448        Ok(move_txids)
449    }
450
451    async fn collect_withdrawal_utxos(
452        &self,
453        last_withdrawal_idx: Option<u32>,
454        to_height: u64,
455    ) -> Result<Vec<(u64, OutPoint)>, BridgeError> {
456        let mut utxos = vec![];
457
458        let mut start_idx = match last_withdrawal_idx {
459            Some(idx) => idx + 1,
460            None => 0,
461        };
462
463        loop {
464            let withdrawal_utxo = self
465                .contract
466                .withdrawalUTXOs(U256::from(start_idx))
467                .block(BlockId::Number(BlockNumberOrTag::Number(to_height)))
468                .call()
469                .await;
470            match withdrawal_utxo {
471                Err(e) if e.to_string().contains("execution reverted") => {
472                    tracing::trace!("Withdrawal utxo not found for index, error: {:?}", e);
473                    break;
474                }
475                Err(e) => return Err(e.into()),
476                Ok(_) => {}
477            }
478            let withdrawal_utxo = withdrawal_utxo.expect("Failed to get withdrawal UTXO");
479            let txid = withdrawal_utxo.txId.0;
480            let txid =
481                Txid::from_slice(txid.as_ref()).wrap_err("Failed to convert txid to Txid")?;
482            let vout = withdrawal_utxo.outputId.0;
483            let vout = u32::from_le_bytes(vout);
484            let utxo = OutPoint { txid, vout };
485            utxos.push((start_idx as u64, utxo));
486            start_idx += 1;
487        }
488        Ok(utxos)
489    }
490
491    async fn get_light_client_proof(
492        &self,
493        l1_height: u64,
494        paramset: &'static ProtocolParamset,
495    ) -> Result<Option<(LightClientProof, Receipt, u64)>, BridgeError> {
496        let proof_result = self
497            .light_client_prover_client
498            .get_light_client_proof_by_l1_height(l1_height)
499            .await
500            .wrap_err("Failed to get light client proof")?;
501        tracing::debug!(
502            "Light client proof result {}: {:?}",
503            l1_height,
504            proof_result
505        );
506
507        let ret = if let Some(proof_result) = proof_result {
508            let decoded: InnerReceipt = bincode::deserialize(&proof_result.proof)
509                .wrap_err("Failed to deserialize light client proof from citrea lcp")?;
510            let receipt = receipt_from_inner(decoded)
511                .wrap_err("Failed to create receipt from light client proof")?;
512
513            let l2_height = u64::try_from(proof_result.light_client_proof_output.last_l2_height)
514                .wrap_err("Failed to convert l2 height to u64")?;
515
516            let lc_image_id = paramset.get_lcp_image_id()?;
517
518            let proof_output: LightClientCircuitOutput = borsh::from_slice(&receipt.journal.bytes)
519                .wrap_err("Failed to deserialize light client circuit output")?;
520
521            if !paramset.is_regtest() {
522                receipt
523                    .verify(lc_image_id)
524                    .map_err(|_| eyre::eyre!("Light client proof verification failed"))?;
525
526                if !check_method_id(&proof_output, lc_image_id) {
527                    return Err(eyre::eyre!(
528                    "Current light client proof method ID does not match the expected LC image ID"
529                )
530                    .into());
531                }
532            }
533
534            Some((
535                LightClientProof {
536                    lc_journal: receipt.journal.bytes.clone(),
537                },
538                receipt,
539                l2_height,
540            ))
541        } else {
542            None
543        };
544
545        Ok(ret)
546    }
547
548    async fn get_citrea_l2_height_range(
549        &self,
550        block_height: u64,
551        timeout: Duration,
552        paramset: &'static ProtocolParamset,
553    ) -> Result<(u64, u64), BridgeError> {
554        let start = std::time::Instant::now();
555        let proof_current = loop {
556            if let Some(proof) = self.get_light_client_proof(block_height, paramset).await? {
557                break proof;
558            }
559
560            if start.elapsed() > timeout {
561                return Err(eyre::eyre!(
562                    "Light client proof not found for block height {} after {} seconds",
563                    block_height,
564                    timeout.as_secs()
565                )
566                .into());
567            }
568
569            tokio::time::sleep(Duration::from_secs(1)).await;
570        };
571
572        let proof_previous = self
573            .get_light_client_proof(block_height - 1, paramset)
574            .await?
575            .ok_or(eyre::eyre!(
576                "Light client proof not found for block height: {}",
577                block_height - 1
578            ))?;
579
580        let l2_height_end: u64 = proof_current.2;
581        let l2_height_start: u64 = proof_previous.2;
582
583        Ok((l2_height_start, l2_height_end))
584    }
585
586    async fn get_replacement_deposit_move_txids(
587        &self,
588        from_height: u64,
589        to_height: u64,
590    ) -> Result<Vec<(u32, Txid)>, BridgeError> {
591        let mut replacement_move_txids = vec![];
592
593        // get logs
594        let filter = self.contract.event_filter::<DepositReplaced>().filter;
595        let logs = self.get_logs(filter, from_height, to_height).await?;
596
597        for log in logs {
598            let replacement_raw_data = &log.data().data;
599
600            let idx = DepositReplaced::abi_decode_data(replacement_raw_data, false)
601                .wrap_err("Failed to decode replacement deposit data")?
602                .0;
603            let new_move_txid = DepositReplaced::abi_decode_data(replacement_raw_data, false)
604                .wrap_err("Failed to decode replacement deposit data")?
605                .2;
606
607            let idx = u32::try_from(idx).wrap_err("Failed to convert idx to u32")?;
608            let new_move_txid = Txid::from_slice(new_move_txid.as_ref())
609                .wrap_err("Failed to convert new move txid to Txid")?;
610
611            replacement_move_txids.push((idx, new_move_txid));
612        }
613
614        Ok(replacement_move_txids)
615    }
616
617    async fn check_nofn_correctness(
618        &self,
619        nofn_xonly_pk: XOnlyPublicKey,
620    ) -> Result<(), BridgeError> {
621        if std::env::var("DISABLE_NOFN_CHECK").is_ok() {
622            return Ok(());
623        }
624
625        let contract_nofn_xonly_pk = self
626            .contract
627            .getAggregatedKey()
628            .call()
629            .await
630            .wrap_err("Failed to get script prefix")?
631            ._0;
632
633        let contract_nofn_xonly_pk = XOnlyPublicKey::from_slice(contract_nofn_xonly_pk.as_ref())
634            .wrap_err("Failed to convert citrea contract script nofn bytes to xonly pk")?;
635        if contract_nofn_xonly_pk != nofn_xonly_pk {
636            return Err(eyre::eyre!("Nofn of deposit does not match with citrea contract").into());
637        }
638        Ok(())
639    }
640}
641
642#[rpc(client, namespace = "lightClientProver")]
643trait LightClientProverRpc {
644    /// Generate state transition data for the given L1 block height, and return the data as a borsh serialized hex string.
645    #[method(name = "getLightClientProofByL1Height")]
646    async fn get_light_client_proof_by_l1_height(
647        &self,
648        l1_height: u64,
649    ) -> RpcResult<Option<citrea_sov_rollup_interface::rpc::LightClientProofResponse>>;
650}
651
652#[rpc(client, namespace = "eth")]
653pub trait CitreaRpc {
654    #[method(name = "getProof")]
655    async fn get_proof(
656        &self,
657        address: &str,
658        storage_keys: Vec<String>,
659        block: String,
660    ) -> RpcResult<serde_json::Value>;
661}
662
663// Ugly typedefs.
664type CitreaContract = BRIDGE_CONTRACT::BRIDGE_CONTRACTInstance<
665    (),
666    FillProvider<
667        JoinFill<
668            JoinFill<
669                alloy::providers::Identity,
670                JoinFill<GasFiller, JoinFill<BlobGasFiller, JoinFill<NonceFiller, ChainIdFiller>>>,
671            >,
672            WalletFiller<EthereumWallet>,
673        >,
674        RootProvider,
675    >,
676>;