1use std::{sync::LazyLock, time::Duration};
8
9use bitcoin::Amount;
10use bitcoincore_rpc::RpcApi;
11use eyre::Context;
12use metrics::Gauge;
13use tokio::time::error::Elapsed;
14use tonic::async_trait;
15
16use crate::{
17 database::Database,
18 extended_bitcoin_rpc::ExtendedBitcoinRpc,
19 utils::{timed_request_base, NamedEntity},
20};
21use clementine_errors::BridgeError;
22use metrics_derive::Metrics;
23
24const L1_SYNC_STATUS_SUB_REQUEST_METRICS_TIMEOUT: Duration = Duration::from_secs(45);
25
26#[derive(Metrics)]
27#[metrics(scope = "l1_sync_status")]
28pub struct L1SyncStatusMetrics {
30 #[metric(describe = "The current balance of the wallet in Bitcoin (BTC)")]
31 pub wallet_balance_btc: Gauge,
32 #[metric(describe = "The block height of the chain as seen by Bitcoin Core RPC")]
33 pub rpc_tip_height: Gauge,
34 #[metric(describe = "The block height of the Bitcoin Syncer")]
35 pub btc_syncer_synced_height: Gauge,
36 #[metric(describe = "The block height of the latest header chain proof")]
37 pub hcp_last_proven_height: Gauge,
38 #[metric(describe = "The block height processed by the Transaction Sender")]
39 pub tx_sender_synced_height: Gauge,
40 #[metric(describe = "The finalized block height as seen by the FinalizedBlockFetcher task")]
41 pub finalized_synced_height: Gauge,
42 #[metric(describe = "The next block height to process for the State Manager")]
43 pub state_manager_next_height: Gauge,
44 #[metric(describe = "The current Bitcoin fee rate in sat/vB")]
45 pub bitcoin_fee_rate_sat_vb: Gauge,
46}
47
48#[derive(Metrics)]
49#[metrics(dynamic = true)]
50pub struct EntityL1SyncStatusMetrics {
58 #[metric(describe = "The current balance of the wallet of the entity in Bitcoin (BTC)")]
59 pub wallet_balance_btc: Gauge,
60 #[metric(
61 describe = "The block height of the chain as seen by Bitcoin Core RPC for the entity"
62 )]
63 pub rpc_tip_height: Gauge,
64 #[metric(describe = "The block height of the Bitcoin Syncer for the entity")]
65 pub btc_syncer_synced_height: Gauge,
66 #[metric(describe = "The block height of the latest header chain proof for the entity")]
67 pub hcp_last_proven_height: Gauge,
68 #[metric(describe = "The block height processed by the Transaction Sender for the entity")]
69 pub tx_sender_synced_height: Gauge,
70 #[metric(
71 describe = "The finalized block height as seen by the FinalizedBlockFetcher task for the entity"
72 )]
73 pub finalized_synced_height: Gauge,
74 #[metric(describe = "The next block height to process for the State Manager for the entity")]
75 pub state_manager_next_height: Gauge,
76
77 #[metric(describe = "The current Bitcoin fee rate in sat/vB for the entity")]
78 pub bitcoin_fee_rate_sat_vb: Gauge,
79
80 #[metric(describe = "The number of error responses from the entity status endpoint")]
81 pub entity_status_error_count: metrics::Counter,
82
83 #[metric(describe = "The number of stopped tasks for the entity")]
84 pub stopped_tasks_count: Gauge,
85}
86
87pub static L1_SYNC_STATUS: LazyLock<L1SyncStatusMetrics> = LazyLock::new(|| {
89 L1SyncStatusMetrics::describe();
90 L1SyncStatusMetrics::default()
91});
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct L1SyncStatus {
96 pub wallet_balance: Option<Amount>,
97 pub rpc_tip_height: Option<u32>,
98 pub btc_syncer_synced_height: Option<u32>,
99 pub hcp_last_proven_height: Option<u32>,
100 pub tx_sender_synced_height: Option<u32>,
101 pub finalized_synced_height: Option<u32>,
102 pub state_manager_next_height: Option<u32>,
103 pub bitcoin_fee_rate_sat_vb: Option<u64>,
104}
105
106pub async fn get_wallet_balance(rpc: &ExtendedBitcoinRpc) -> Result<Amount, BridgeError> {
108 let balance = rpc
109 .get_balance(None, None)
110 .await
111 .wrap_err("Failed to get wallet balance")?;
112
113 Ok(balance)
114}
115
116pub async fn get_rpc_tip_height(rpc: &ExtendedBitcoinRpc) -> Result<u32, BridgeError> {
118 let height = rpc.get_current_chain_height().await?;
119 Ok(height)
120}
121
122pub async fn get_btc_syncer_consumer_last_processed_block_height(
125 db: &Database,
126 consumer_handle: &str,
127) -> Result<Option<u32>, BridgeError> {
128 db.get_last_processed_event_block_height(None, consumer_handle)
129 .await
130}
131
132pub async fn get_btc_syncer_synced_height(db: &Database) -> Result<Option<u32>, BridgeError> {
135 let height = db.get_max_height(None).await?;
136 Ok(height)
137}
138
139pub async fn get_hcp_last_proven_height(db: &Database) -> Result<Option<u32>, BridgeError> {
141 let latest_proven_block_height = db
142 .get_latest_proven_block_info(None)
143 .await?
144 .map(|(_, _, height)| height as u32);
145 Ok(latest_proven_block_height)
146}
147
148pub async fn get_state_manager_next_height(
151 db: &Database,
152 owner_type: &str,
153) -> Result<Option<u32>, BridgeError> {
154 #[cfg(feature = "automation")]
155 {
156 let next_height = db
157 .get_next_height_to_process(None, owner_type)
158 .await?
159 .map(|x| x as u32);
160 Ok(next_height)
161 }
162 #[cfg(not(feature = "automation"))]
163 {
164 Ok(None)
165 }
166}
167
168pub async fn get_bitcoin_fee_rate(
170 rpc: &ExtendedBitcoinRpc,
171 config: &crate::config::BridgeConfig,
172) -> Result<u64, BridgeError> {
173 let fee_rate = rpc
174 .get_fee_rate(
175 config.protocol_paramset.network,
176 &config.mempool_api_host,
177 &config.mempool_api_endpoint,
178 config.tx_sender_limits.mempool_fee_rate_multiplier,
179 config.tx_sender_limits.mempool_fee_rate_offset_sat_kvb,
180 config.tx_sender_limits.fee_rate_hard_cap,
181 )
182 .await
183 .wrap_err("Failed to get fee rate")?;
184
185 Ok(fee_rate.to_sat_per_vb_ceil())
187}
188
189#[async_trait]
190pub trait L1SyncStatusProvider: NamedEntity {
192 async fn get_l1_status(
193 db: &Database,
194 rpc: &ExtendedBitcoinRpc,
195 config: &crate::config::BridgeConfig,
196 ) -> Result<L1SyncStatus, BridgeError>;
197}
198
199#[inline(always)]
200fn log_errs_and_ok<A, T: NamedEntity>(
201 result: Result<Result<A, BridgeError>, Elapsed>,
202 action: &str,
203) -> Option<A> {
204 result
205 .inspect_err(|_| {
206 tracing::error!(
207 "[L1SyncStatus({})] Timed out while {action}",
208 T::ENTITY_NAME
209 )
210 })
211 .ok()
212 .transpose()
213 .inspect_err(|e| {
214 tracing::error!("[L1SyncStatus({})] Error {action}: {:?}", T::ENTITY_NAME, e)
215 })
216 .ok()
217 .flatten()
218}
219
220#[async_trait]
221impl<T: NamedEntity> L1SyncStatusProvider for T {
222 async fn get_l1_status(
223 db: &Database,
224 rpc: &ExtendedBitcoinRpc,
225 config: &crate::config::BridgeConfig,
226 ) -> Result<L1SyncStatus, BridgeError> {
227 let wallet_balance = log_errs_and_ok::<_, T>(
228 timed_request_base(
229 L1_SYNC_STATUS_SUB_REQUEST_METRICS_TIMEOUT,
230 "get_wallet_balance",
231 get_wallet_balance(rpc),
232 )
233 .await,
234 "getting wallet balance",
235 );
236
237 let rpc_tip_height = log_errs_and_ok::<_, T>(
238 timed_request_base(
239 L1_SYNC_STATUS_SUB_REQUEST_METRICS_TIMEOUT,
240 "get_rpc_tip_height",
241 get_rpc_tip_height(rpc),
242 )
243 .await,
244 "getting rpc tip height",
245 );
246
247 #[cfg(feature = "automation")]
248 let finalized_synced_height = log_errs_and_ok::<_, T>(
249 timed_request_base(
250 L1_SYNC_STATUS_SUB_REQUEST_METRICS_TIMEOUT,
251 "get_finalized_synced_height",
252 get_btc_syncer_consumer_last_processed_block_height(
253 db,
254 T::FINALIZED_BLOCK_CONSUMER_ID_AUTOMATION,
255 ),
256 )
257 .await,
258 "getting finalized synced height",
259 )
260 .flatten();
261
262 #[cfg(not(feature = "automation"))]
263 let finalized_synced_height = log_errs_and_ok::<_, T>(
264 timed_request_base(
265 L1_SYNC_STATUS_SUB_REQUEST_METRICS_TIMEOUT,
266 "get_finalized_synced_height",
267 get_btc_syncer_consumer_last_processed_block_height(
268 db,
269 T::FINALIZED_BLOCK_CONSUMER_ID_NO_AUTOMATION,
270 ),
271 )
272 .await,
273 "getting finalized synced height",
274 )
275 .flatten();
276
277 let btc_syncer_synced_height = log_errs_and_ok::<_, T>(
278 timed_request_base(
279 L1_SYNC_STATUS_SUB_REQUEST_METRICS_TIMEOUT,
280 "get_btc_syncer_synced_height",
281 get_btc_syncer_synced_height(db),
282 )
283 .await,
284 "getting btc syncer synced height",
285 )
286 .flatten();
287
288 let hcp_last_proven_height = log_errs_and_ok::<_, T>(
289 timed_request_base(
290 L1_SYNC_STATUS_SUB_REQUEST_METRICS_TIMEOUT,
291 "get_hcp_last_proven_height",
292 get_hcp_last_proven_height(db),
293 )
294 .await,
295 "getting hcp last proven height",
296 )
297 .flatten();
298 let state_manager_next_height = log_errs_and_ok::<_, T>(
299 timed_request_base(
300 L1_SYNC_STATUS_SUB_REQUEST_METRICS_TIMEOUT,
301 "get_state_manager_next_height",
302 get_state_manager_next_height(db, T::ENTITY_NAME),
303 )
304 .await,
305 "getting state manager next height",
306 )
307 .flatten();
308
309 let bitcoin_fee_rate_sat_vb = log_errs_and_ok::<_, T>(
310 timed_request_base(
311 L1_SYNC_STATUS_SUB_REQUEST_METRICS_TIMEOUT,
312 "get_bitcoin_fee_rate",
313 get_bitcoin_fee_rate(rpc, config),
314 )
315 .await,
316 "getting bitcoin fee rate",
317 );
318
319 Ok(L1SyncStatus {
320 wallet_balance,
321 rpc_tip_height,
322 btc_syncer_synced_height,
323 hcp_last_proven_height,
324 tx_sender_synced_height: None,
325 finalized_synced_height,
326 state_manager_next_height,
327 bitcoin_fee_rate_sat_vb,
328 })
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use bitcoincore_rpc::RpcApi;
335
336 #[cfg(not(feature = "automation"))]
337 use crate::rpc::clementine::EntityType;
338 use crate::{
339 rpc::clementine::{Empty, GetEntityStatusesRequest},
340 test::common::{
341 citrea::MockCitreaClient, create_actors, create_regtest_rpc,
342 create_test_config_with_thread_name,
343 },
344 };
345 use std::time::Duration;
346
347 #[tokio::test]
348 async fn test_get_sync_status_should_not_fail() {
349 let mut config = create_test_config_with_thread_name().await;
350 let regtest = create_regtest_rpc(&mut config).await;
351 config.bitcoin_rpc_url += "/wallet/test-wallet";
352 regtest.rpc().unload_wallet("admin".into()).await.unwrap();
354
355 regtest
357 .rpc()
358 .create_wallet("test-wallet", None, None, None, None)
359 .await
360 .unwrap();
361
362 let addr = regtest.rpc().get_new_address(None, None).await.unwrap();
363 regtest
364 .rpc()
365 .generate_to_address(201, addr.assume_checked_ref())
366 .await
367 .unwrap();
368
369 let actors = create_actors::<MockCitreaClient>(&config).await;
370
371 regtest
373 .rpc()
374 .unload_wallet(Some("test-wallet"))
375 .await
376 .unwrap();
377
378 let res = actors
380 .get_verifier_client_by_index(0)
381 .get_current_status(Empty {})
382 .await;
383
384 assert!(res.is_ok(), "Expected Ok(_) but got {res:?}");
386
387 assert_eq!(res.unwrap().into_inner().wallet_balance, None);
389 }
390
391 #[tokio::test]
392 async fn test_get_sync_status() {
393 let mut config = create_test_config_with_thread_name().await;
394 let _regtest = create_regtest_rpc(&mut config).await;
395 let actors = create_actors::<MockCitreaClient>(&config).await;
396 let mut aggregator = actors.get_aggregator();
397 tokio::time::sleep(Duration::from_secs(40)).await;
399 let entity_statuses = aggregator
400 .get_entity_statuses(tonic::Request::new(GetEntityStatusesRequest {
401 restart_tasks: false,
402 }))
403 .await
404 .unwrap()
405 .into_inner();
406
407 for entity in entity_statuses.entity_statuses {
408 let status = entity.status_result.unwrap();
409 match status {
410 crate::rpc::clementine::entity_status_with_id::StatusResult::Status(status) => {
411 tracing::info!("Status: {:#?}", status);
412 #[cfg(feature = "automation")]
413 {
414 assert!(status.automation);
415 assert!(status.tx_sender_synced_height.is_none());
417 assert!(
418 status
419 .finalized_synced_height
420 .expect("finalized_synced_height is None")
421 > 0
422 );
423 assert!(
424 status
425 .hcp_last_proven_height
426 .expect("hcp_last_proven_height is None")
427 > 0
428 );
429 assert!(status.rpc_tip_height.expect("rpc_tip_height is None") > 0);
430 assert!(
431 status
432 .bitcoin_syncer_synced_height
433 .expect("bitcoin_syncer_synced_height is None")
434 > 0
435 );
436 assert!(
437 status
438 .state_manager_next_height
439 .expect("state_manager_next_height is None")
440 > 0
441 );
442 assert!(status.wallet_balance.is_some());
443 assert!(
444 status
445 .btc_fee_rate_sat_vb
446 .expect("btc_fee_rate_sat_vb is None")
447 > 0
448 );
449 }
450 #[cfg(not(feature = "automation"))]
451 {
452 let entity_type: EntityType =
453 entity.entity_id.unwrap().kind.try_into().unwrap();
454 assert!(!status.automation);
456 assert!(status.tx_sender_synced_height.is_none());
457 if entity_type == EntityType::Verifier {
458 assert!(
459 status
460 .finalized_synced_height
461 .expect("finalized_synced_height is None")
462 > 0
463 );
464 } else {
465 assert!(status.finalized_synced_height.is_none());
467 }
468 assert!(status.hcp_last_proven_height.is_none());
469 assert!(status.rpc_tip_height.expect("rpc_tip_height is None") > 0);
470 assert!(
471 status
472 .bitcoin_syncer_synced_height
473 .expect("bitcoin_syncer_synced_height is None")
474 > 0
475 );
476 assert!(status.state_manager_next_height.is_none());
477 assert!(status.wallet_balance.is_some());
478 assert!(
479 status
480 .btc_fee_rate_sat_vb
481 .expect("bitcoin_fee_rate_sat_vb is None")
482 > 0
483 );
484 }
485 }
486 crate::rpc::clementine::entity_status_with_id::StatusResult::Err(error) => {
487 let error_msg = &error.error;
488 panic!("Couldn't get entity status: {error_msg}");
489 }
490 }
491 }
492 }
493}