clementine_core/
citrea.rs

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