1use 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
51sol!(
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 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 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 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 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 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 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 async fn get_current_l2_block_height(&self) -> Result<u32, BridgeError>;
184}
185
186#[derive(Clone, Debug)]
189pub struct CitreaClient {
190 pub client: HttpClient,
191 pub light_client_prover_client: HttpClient,
192 #[cfg(test)]
193 pub wallet_address: alloy::primitives::Address,
194 pub contract: CitreaContract,
195}
196
197impl CitreaClient {
198 async fn get_logs(
201 &self,
202 filter: Filter,
203 from_height: u64,
204 to_height: u64,
205 ) -> Result<Vec<Log>, BridgeError> {
206 let mut logs = vec![];
207
208 let mut from_height = from_height;
209 while from_height <= to_height {
210 let to_height = std::cmp::min(from_height + 999, to_height);
212 tracing::debug!("Fetching logs from {} to {}", from_height, to_height);
213
214 let filter = filter.clone();
216 let filter = filter.from_block(BlockNumberOrTag::Number(from_height));
217 let filter = filter.to_block(BlockNumberOrTag::Number(to_height));
218
219 let logs_chunk = self
220 .contract
221 .provider()
222 .get_logs(&filter)
223 .await
224 .wrap_err("Failed to get logs")?;
225 logs.extend(logs_chunk);
226
227 from_height = to_height + 1;
228 }
229
230 Ok(logs)
231 }
232}
233
234#[async_trait]
235impl CitreaClientT for CitreaClient {
236 async fn get_storage_proof(
248 &self,
249 l2_height: u64,
250 deposit_index: u32,
251 ) -> Result<StorageProof, BridgeError> {
252 let ind = deposit_index;
253 let tx_index: u32 = ind * 2;
254
255 let storage_address_wd_utxo_bytes = keccak256(UTXOS_STORAGE_INDEX);
256 let storage_address_wd_utxo: U256 = U256::from_be_bytes(
257 <[u8; 32]>::try_from(&storage_address_wd_utxo_bytes[..])
258 .wrap_err("Storage address wd utxo bytes slice with incorrect length")?,
259 );
260
261 let storage_key_wd_utxo: U256 = storage_address_wd_utxo + U256::from(tx_index);
263 let storage_key_wd_utxo_hex =
264 format!("0x{}", hex::encode(storage_key_wd_utxo.to_be_bytes::<32>()));
265
266 let storage_key_vout: U256 = storage_address_wd_utxo + U256::from(tx_index + 1);
268 let storage_key_vout_hex =
269 format!("0x{}", hex::encode(storage_key_vout.to_be_bytes::<32>()));
270
271 let storage_address_deposit_bytes = keccak256(DEPOSIT_STORAGE_INDEX);
273 let storage_address_deposit: U256 = U256::from_be_bytes(
274 <[u8; 32]>::try_from(&storage_address_deposit_bytes[..])
275 .wrap_err("Storage address deposit bytes slice with incorrect length")?,
276 );
277
278 let storage_key_deposit: U256 = storage_address_deposit + U256::from(deposit_index);
279 let storage_key_deposit_hex = hex::encode(storage_key_deposit.to_be_bytes::<32>());
280 let storage_key_deposit_hex = format!("0x{storage_key_deposit_hex}");
281
282 let response: serde_json::Value = self
283 .client
284 .get_proof(
285 BRIDGE_CONTRACT_ADDRESS,
286 vec![
287 storage_key_wd_utxo_hex,
288 storage_key_vout_hex,
289 storage_key_deposit_hex,
290 ],
291 format!("0x{l2_height:x}"),
292 )
293 .await
294 .wrap_err("Failed to get storage proof from rpc")?;
295
296 let response: EIP1186AccountProofResponse = serde_json::from_value(response)
297 .wrap_err("Failed to deserialize EIP1186AccountProofResponse")?;
298
299 if response.storage_proof.len() < 3 {
302 return Err(eyre::eyre!(
303 "Expected at least 3 storage proofs, got {}",
304 response.storage_proof.len()
305 )
306 .into());
307 }
308
309 let serialized_utxo = serde_json::to_string(&response.storage_proof[0])
310 .wrap_err("Failed to serialize storage proof utxo")?;
311
312 let serialized_vout = serde_json::to_string(&response.storage_proof[1])
313 .wrap_err("Failed to serialize storage proof vout")?;
314
315 let serialized_deposit = serde_json::to_string(&response.storage_proof[2])
316 .wrap_err("Failed to serialize storage proof deposit")?;
317
318 Ok(StorageProof {
319 storage_proof_utxo: serialized_utxo,
320 storage_proof_vout: serialized_vout,
321 storage_proof_deposit_txid: serialized_deposit,
322 index: ind,
323 })
324 }
325
326 async fn fetch_validate_and_store_lcp(
327 &self,
328 payout_block_height: u64,
329 deposit_index: u32,
330 db: &Database,
331 mut dbtx: Option<DatabaseTransaction<'_>>,
332 paramset: &'static ProtocolParamset,
333 ) -> Result<Receipt, BridgeError> {
334 let saved_data = db
335 .get_lcp_for_assert(dbtx.as_deref_mut(), deposit_index)
336 .await?;
337 if let Some(lcp) = saved_data {
338 return Ok(lcp);
340 };
341
342 let lcp_result = self
343 .get_light_client_proof(payout_block_height, paramset)
344 .await?;
345 let (_lcp, lcp_receipt, _l2_height) = match lcp_result {
346 Some(lcp) => lcp,
347 None => {
348 return Err(eyre::eyre!(
349 "Light client proof could not be fetched found for block height {}",
350 payout_block_height
351 )
352 .into())
353 }
354 };
355
356 db.insert_lcp_for_assert(dbtx, deposit_index, lcp_receipt.clone())
358 .await?;
359
360 Ok(lcp_receipt)
361 }
362
363 async fn new(
364 citrea_rpc_url: String,
365 light_client_prover_url: String,
366 chain_id: u32,
367 secret_key: Option<PrivateKeySigner>,
368 timeout: Option<Duration>,
369 ) -> Result<Self, BridgeError> {
370 let citrea_rpc_url = Url::parse(&citrea_rpc_url).wrap_err("Can't parse Citrea RPC URL")?;
371 let light_client_prover_url =
372 Url::parse(&light_client_prover_url).wrap_err("Can't parse Citrea LCP RPC URL")?;
373 let secret_key = secret_key.unwrap_or(PrivateKeySigner::random());
374
375 let key = secret_key.with_chain_id(Some(chain_id.into()));
376
377 #[cfg(test)]
378 let wallet_address = key.address();
379
380 tracing::info!("Wallet address: {}", key.address());
381
382 let provider = ProviderBuilder::new()
383 .wallet(EthereumWallet::from(key))
384 .on_http(citrea_rpc_url.clone());
385
386 tracing::info!("Provider created");
387
388 let contract = BRIDGE_CONTRACT::new(
389 BRIDGE_CONTRACT_ADDRESS
390 .parse()
391 .expect("Correct contract address"),
392 provider,
393 );
394
395 tracing::info!("Contract created");
396
397 let client = HttpClientBuilder::default()
398 .request_timeout(timeout.unwrap_or(Duration::from_secs(60)))
399 .build(citrea_rpc_url)
400 .wrap_err("Failed to create Citrea RPC client")?;
401
402 tracing::info!("Citrea RPC client created");
403
404 let light_client_prover_client = HttpClientBuilder::default()
405 .request_timeout(timeout.unwrap_or(Duration::from_secs(60)))
406 .build(light_client_prover_url)
407 .wrap_err("Failed to create Citrea LCP RPC client")?;
408
409 tracing::info!("Citrea LCP RPC client created");
410
411 Ok(CitreaClient {
412 client,
413 light_client_prover_client,
414 #[cfg(test)]
415 wallet_address,
416 contract,
417 })
418 }
419
420 async fn collect_deposit_move_txids(
421 &self,
422 last_deposit_idx: Option<u32>,
423 to_height: u64,
424 ) -> Result<Vec<(u64, Txid)>, BridgeError> {
425 let mut move_txids = vec![];
426
427 let mut start_idx = match last_deposit_idx {
428 Some(idx) => idx + 1,
429 None => 0,
430 };
431
432 loop {
433 let deposit_txid = self
434 .contract
435 .depositTxIds(U256::from(start_idx))
436 .block(BlockId::Number(BlockNumberOrTag::Number(to_height)))
437 .call()
438 .await;
439 match deposit_txid {
440 Err(e) if e.to_string().contains("execution reverted") => {
441 tracing::trace!("Deposit txid not found for index, error: {:?}", e);
442 break;
443 }
444 Err(e) => return Err(e.into()),
445 Ok(_) => {}
446 }
447 tracing::info!("Deposit txid found for index: {:?}", deposit_txid);
448
449 let deposit_txid = deposit_txid.expect("Failed to get deposit txid");
450 let move_txid = Txid::from_slice(deposit_txid._0.as_ref())
451 .wrap_err("Failed to convert move txid to Txid")?;
452 move_txids.push((start_idx as u64, move_txid));
453 start_idx += 1;
454 }
455 Ok(move_txids)
456 }
457
458 async fn collect_withdrawal_utxos(
459 &self,
460 last_withdrawal_idx: Option<u32>,
461 to_height: u64,
462 ) -> Result<Vec<(u64, OutPoint)>, BridgeError> {
463 let mut utxos = vec![];
464
465 let mut start_idx = match last_withdrawal_idx {
466 Some(idx) => idx + 1,
467 None => 0,
468 };
469
470 loop {
471 let withdrawal_utxo = self
472 .contract
473 .withdrawalUTXOs(U256::from(start_idx))
474 .block(BlockId::Number(BlockNumberOrTag::Number(to_height)))
475 .call()
476 .await;
477 match withdrawal_utxo {
478 Err(e) if e.to_string().contains("execution reverted") => {
479 tracing::trace!("Withdrawal utxo not found for index, error: {:?}", e);
480 break;
481 }
482 Err(e) => return Err(e.into()),
483 Ok(_) => {}
484 }
485 let withdrawal_utxo = withdrawal_utxo.expect("Failed to get withdrawal UTXO");
486 let txid = withdrawal_utxo.txId.0;
487 let txid =
488 Txid::from_slice(txid.as_ref()).wrap_err("Failed to convert txid to Txid")?;
489 let vout = withdrawal_utxo.outputId.0;
490 let vout = u32::from_le_bytes(vout);
491 let utxo = OutPoint { txid, vout };
492 utxos.push((start_idx as u64, utxo));
493 start_idx += 1;
494 }
495 Ok(utxos)
496 }
497
498 async fn get_light_client_proof(
499 &self,
500 l1_height: u64,
501 paramset: &'static ProtocolParamset,
502 ) -> Result<Option<(LightClientProof, Receipt, u64)>, BridgeError> {
503 let proof_result = self
504 .light_client_prover_client
505 .get_light_client_proof_by_l1_height(l1_height)
506 .await
507 .wrap_err("Failed to get light client proof")?;
508 tracing::debug!(
509 "Light client proof result {}: {:?}",
510 l1_height,
511 proof_result
512 );
513
514 let ret = if let Some(proof_result) = proof_result {
515 let decoded: InnerReceipt = bincode::deserialize(&proof_result.proof)
516 .wrap_err("Failed to deserialize light client proof from citrea lcp")?;
517 let receipt = receipt_from_inner(decoded)
518 .wrap_err("Failed to create receipt from light client proof")?;
519
520 let l2_height = u64::try_from(proof_result.light_client_proof_output.last_l2_height)
521 .wrap_err("Failed to convert l2 height to u64")?;
522
523 let lc_image_id = paramset.get_lcp_image_id()?;
524
525 let proof_output: LightClientCircuitOutput = borsh::from_slice(&receipt.journal.bytes)
526 .wrap_err("Failed to deserialize light client circuit output")?;
527
528 if !paramset.is_regtest() {
529 receipt
530 .verify(lc_image_id)
531 .map_err(|_| eyre::eyre!("Light client proof verification failed"))?;
532
533 if !check_method_id(&proof_output, lc_image_id) {
534 return Err(eyre::eyre!(
535 "Current light client proof method ID does not match the expected LC image ID"
536 )
537 .into());
538 }
539 }
540
541 Some((
542 LightClientProof {
543 lc_journal: receipt.journal.bytes.clone(),
544 },
545 receipt,
546 l2_height,
547 ))
548 } else {
549 None
550 };
551
552 Ok(ret)
553 }
554
555 async fn get_citrea_l2_height_range(
556 &self,
557 block_height: u64,
558 timeout: Duration,
559 paramset: &'static ProtocolParamset,
560 ) -> Result<(u64, u64), BridgeError> {
561 let start = std::time::Instant::now();
562 let proof_current = loop {
563 if let Some(proof) = self.get_light_client_proof(block_height, paramset).await? {
564 break proof;
565 }
566
567 if start.elapsed() > timeout {
568 return Err(eyre::eyre!(
569 "Light client proof not found for block height {} after {} seconds",
570 block_height,
571 timeout.as_secs()
572 )
573 .into());
574 }
575
576 tokio::time::sleep(Duration::from_secs(1)).await;
577 };
578
579 let proof_previous = self
580 .get_light_client_proof(block_height - 1, paramset)
581 .await?
582 .ok_or(eyre::eyre!(
583 "Light client proof not found for block height: {}",
584 block_height - 1
585 ))?;
586
587 let l2_height_end: u64 = proof_current.2;
588 let l2_height_start: u64 = proof_previous.2;
589
590 Ok((l2_height_start, l2_height_end))
591 }
592
593 async fn get_replacement_deposit_move_txids(
594 &self,
595 from_height: u64,
596 to_height: u64,
597 ) -> Result<Vec<(u32, Txid)>, BridgeError> {
598 let mut replacement_move_txids = vec![];
599
600 let filter = self.contract.event_filter::<DepositReplaced>().filter;
602 let logs = self.get_logs(filter, from_height, to_height).await?;
603
604 for log in logs {
605 let replacement_raw_data = &log.data().data;
606
607 let idx = DepositReplaced::abi_decode_data(replacement_raw_data, false)
608 .wrap_err("Failed to decode replacement deposit data")?
609 .0;
610 let new_move_txid = DepositReplaced::abi_decode_data(replacement_raw_data, false)
611 .wrap_err("Failed to decode replacement deposit data")?
612 .2;
613
614 let idx = u32::try_from(idx).wrap_err("Failed to convert idx to u32")?;
615 let new_move_txid = Txid::from_slice(new_move_txid.as_ref())
616 .wrap_err("Failed to convert new move txid to Txid")?;
617
618 replacement_move_txids.push((idx, new_move_txid));
619 }
620
621 Ok(replacement_move_txids)
622 }
623
624 async fn check_nofn_correctness(
625 &self,
626 nofn_xonly_pk: XOnlyPublicKey,
627 ) -> Result<(), BridgeError> {
628 if std::env::var("DISABLE_NOFN_CHECK").is_ok() {
629 return Ok(());
630 }
631
632 let contract_nofn_xonly_pk = self
633 .contract
634 .getAggregatedKey()
635 .call()
636 .await
637 .wrap_err("Failed to get script prefix")?
638 ._0;
639
640 let contract_nofn_xonly_pk = XOnlyPublicKey::from_slice(contract_nofn_xonly_pk.as_ref())
641 .wrap_err("Failed to convert citrea contract script nofn bytes to xonly pk")?;
642 if contract_nofn_xonly_pk != nofn_xonly_pk {
643 return Err(eyre::eyre!("Nofn of deposit does not match with citrea contract").into());
644 }
645 Ok(())
646 }
647
648 async fn get_current_l2_block_height(&self) -> Result<u32, BridgeError> {
649 let block_number_u256 = self
652 .client
653 .block_number()
654 .await
655 .wrap_err("Failed to get L2 block height from Citrea RPC")?;
656
657 let block_number: u32 = block_number_u256
659 .try_into()
660 .map_err(|_| eyre::eyre!("L2 block height {} exceeds u32::MAX", block_number_u256))
661 .wrap_err("Failed to convert L2 block height to u32")?;
662 Ok(block_number)
663 }
664}
665
666#[rpc(client, namespace = "lightClientProver")]
667trait LightClientProverRpc {
668 #[method(name = "getLightClientProofByL1Height")]
670 async fn get_light_client_proof_by_l1_height(
671 &self,
672 l1_height: u64,
673 ) -> RpcResult<Option<citrea_sov_rollup_interface::rpc::LightClientProofResponse>>;
674}
675
676#[rpc(client, namespace = "eth")]
677pub trait CitreaRpc {
678 #[method(name = "getProof")]
679 async fn get_proof(
680 &self,
681 address: &str,
682 storage_keys: Vec<String>,
683 block: String,
684 ) -> RpcResult<serde_json::Value>;
685
686 #[method(name = "blockNumber")]
687 async fn block_number(&self) -> RpcResult<U256>;
688}
689
690type CitreaContract = BRIDGE_CONTRACT::BRIDGE_CONTRACTInstance<
692 (),
693 FillProvider<
694 JoinFill<
695 JoinFill<
696 alloy::providers::Identity,
697 JoinFill<GasFiller, JoinFill<BlobGasFiller, JoinFill<NonceFiller, ChainIdFiller>>>,
698 >,
699 WalletFiller<EthereumWallet>,
700 >,
701 RootProvider,
702 >,
703>;