riftenlabs-indexer/src/main.rs

631 lines
22 KiB
Rust
Raw Normal View History

2026-01-21 12:34:59 +01:00
// Copyright (C) 2024-2026 Whiterun LLC
2024-02-05 16:32:42 +01:00
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use anyhow::{bail, Result};
use bcmr::wellknowndowloader::WellKnownDownloader;
2026-01-05 15:40:01 +01:00
use bitcoincash::{consensus::deserialize, Block, BlockHash, Network};
2024-10-21 12:48:47 +02:00
use crc20::crc20fetcher::CRC20Fetcher;
use db::cauldron::tokenlist::db_utils::create_cached_token_metrics_table;
2026-01-05 15:40:01 +01:00
use db::DB;
use electrum::electrum_get_tip;
use electrum_client_netagnostic::{Client, ElectrumApi, Param};
use log::{error, info, warn};
use rocket::{launch, routes};
2024-01-19 14:38:31 +01:00
use rocket_cors::{AllowedHeaders, AllowedOrigins};
2026-03-25 11:38:10 +00:00
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
2023-11-29 12:25:04 +01:00
use std::{
backtrace::Backtrace,
2026-01-05 15:40:01 +01:00
panic, process,
sync::{Arc, Mutex},
2026-01-05 15:40:01 +01:00
time::Duration,
2023-11-29 12:25:04 +01:00
};
2024-02-14 11:11:16 +01:00
use stderrlog::LogLevelNum;
2023-11-29 12:25:04 +01:00
2026-03-25 11:38:10 +00:00
/// Tracks how far the OHLCV pre-aggregation table has been populated.
/// `materialized_end` is the exclusive upper bound: `ohlcv_1h` contains
/// data for all complete 1-hour buckets whose `bucket_ts + 3600 ≤ materialized_end`.
/// Value 0 means nothing has been materialised yet.
pub struct OhlcvState {
pub materialized_end: AtomicI64,
}
/// State tracking for Initial Block Download (IBD).
/// Used to return 503 errors while the indexer is catching up.
pub struct IbdState {
/// Set to true once the initial sync completes
pub initial_sync_complete: AtomicBool,
/// Current block height being indexed
pub current_height: AtomicU64,
/// Target chain tip height
pub target_height: AtomicU64,
/// If true, serve requests even during IBD (for debugging)
pub serve_during_ibd: bool,
}
use crate::bcmr::bcmrdownloader::BCMRDownloader;
use crate::db::cauldron::header::load_all_headers;
2025-09-05 13:53:10 +00:00
use crate::db::cauldron::tokenlist::metrics_cache::spawn_token_metrics_updater;
2026-02-10 14:48:45 +01:00
use crate::db::init::{initialize_databases, ReadSlots};
use crate::index::{index_blocks, update_mempool};
2023-11-29 12:25:04 +01:00
#[macro_use]
extern crate configure_me;
include_config!();
2024-02-14 11:11:16 +01:00
// The block where first cauldron contract was deployed. (Block 799870)
#[allow(dead_code)]
2024-02-14 11:11:16 +01:00
const RIFTEN_LABS_GENESIS_BLOCK: &str =
"000000000000000000ed24c811077f7268a21ecf25cb437655aaba33d8ff4997";
// Start parsing for BCMR data from this height
const CASHTOKEN_ACTIVATION_HEIGHT: &str =
"000000000000000002b678c471841c3e404ec7ae9ca9c32026fe27eb6e3a1ed1";
2026-01-05 15:40:01 +01:00
// Chipnet genesis
const CHIPNET_START_BLOCK: &str =
"000000001dd410c49a788668ce26751718cc797474d3152a5fc073dd44fd9f7b";
2023-11-29 12:25:04 +01:00
// Last indexed block height.
const KEY_LAST_INDEXED: &str = "last_indexed";
mod bcmr;
2024-04-03 21:40:33 +02:00
mod cashaddr;
2024-02-14 11:11:16 +01:00
mod chain;
2024-10-21 12:48:47 +02:00
mod crc20;
2023-11-29 12:25:04 +01:00
mod db;
2024-11-13 11:16:24 +01:00
mod def;
2024-02-14 11:11:16 +01:00
mod electrum;
mod index;
2024-03-04 16:40:50 +01:00
mod rpc;
mod signal;
2024-04-03 09:49:44 +02:00
mod timeutil;
2024-10-31 10:55:29 +00:00
mod utiltest;
2024-10-21 12:48:47 +02:00
mod utiltoken;
mod utiltx;
2023-11-29 12:25:04 +01:00
fn set_panic_hook() {
panic::set_hook(Box::new(|panic_info| {
2024-03-04 16:40:50 +01:00
error!("A thread panicked, terminating the program.");
if let Some(error) = panic_info.payload().downcast_ref::<anyhow::Error>() {
2025-07-16 09:31:14 +02:00
error!("Panic occurred: {error:?}");
2024-03-04 16:40:50 +01:00
error!("Anyhow backtrace:\n{}", error.backtrace());
let mut source = error.source();
while let Some(cause) = source {
2025-07-16 09:31:14 +02:00
error!("Caused by: {cause:?}");
2024-03-04 16:40:50 +01:00
source = cause.source();
}
} else if let Some(message) = panic_info.payload().downcast_ref::<&str>() {
2025-07-16 09:31:14 +02:00
error!("Panic occurred: {message}");
2024-10-21 12:48:47 +02:00
} else if let Some(message) = panic_info.payload().downcast_ref::<String>() {
2025-07-16 09:31:14 +02:00
error!("Panic occurred: {message}");
2024-02-14 11:11:16 +01:00
} else {
2025-07-16 09:31:14 +02:00
error!("Panic info: {panic_info:?}");
2023-11-29 12:25:04 +01:00
}
2024-03-04 16:40:50 +01:00
let backtrace = Backtrace::capture();
2025-07-16 09:31:14 +02:00
error!("Backtrace (if RUST_BACKTRACE=1):\n{backtrace}");
2023-11-29 12:25:04 +01:00
process::exit(1);
}));
}
async fn start_program(
config: Config,
) -> Result<(
DB,
BCMRDownloader,
WellKnownDownloader,
CRC20Fetcher,
Arc<IbdState>,
2026-03-25 11:38:10 +00:00
Arc<AtomicBool>, // indexing_in_progress
)> {
2026-01-05 15:40:01 +01:00
let network = match config.network.to_lowercase().as_str() {
"mainnet" => Network::Bitcoin,
"chipnet" => Network::Chipnet,
_ => bail!(
"Invalid network '{}'. Must be 'mainnet' or 'chipnet'",
config.network
),
};
2026-01-05 15:40:01 +01:00
info!("Using network: {:?}", network);
2023-11-29 12:25:04 +01:00
2026-01-05 15:40:01 +01:00
let rostrum_addr = if config.rostrum_addr.is_empty() {
match network {
Network::Chipnet => "127.0.0.1:64001".to_string(),
_ => "127.0.0.1:50001".to_string(),
}
} else {
config.rostrum_addr
};
2024-10-21 12:48:47 +02:00
2026-02-03 09:37:24 +01:00
// Normalize address: if no scheme, default to tcp:// for backwards compatibility.
// Supported schemes: tcp://, ssl://, ws://, wss://
let electrum_url = {
let s = rostrum_addr.trim();
if s.starts_with("tcp://")
|| s.starts_with("ssl://")
|| s.starts_with("ws://")
|| s.starts_with("wss://")
{
s.to_string()
} else {
format!("tcp://{}", s)
}
};
2026-01-05 15:40:01 +01:00
let network_str = match network {
Network::Bitcoin => "mainnet",
Network::Chipnet => "chipnet",
_ => "mainnet",
};
2026-02-10 14:48:45 +01:00
let db = initialize_databases(
network_str,
ReadSlots {
cauldron: config.cauldron_read_slots,
bcmr: config.bcmr_read_slots,
crc20: config.crc20_read_slots,
oracle: config.oracle_read_slots,
moria: config.moria_read_slots,
2026-02-10 14:48:45 +01:00
},
)
.await?;
let indexing_in_progress = Arc::new(AtomicBool::new(false));
let ibd_state = Arc::new(IbdState {
initial_sync_complete: AtomicBool::new(false),
current_height: AtomicU64::new(0),
target_height: AtomicU64::new(0),
serve_during_ibd: config.serve_during_ibd,
});
2026-02-03 09:37:24 +01:00
let client = Arc::new(Mutex::new(match Client::new(&electrum_url) {
Ok(server) => server,
Err(e) => {
error!(
"Failed to connect to {}: {}. See --help for setting a different server.",
electrum_url, e
);
bail!(e)
}
}));
2024-02-14 11:11:16 +01:00
let genesis = match client
2024-02-14 11:11:16 +01:00
.lock()
.unwrap()
.raw_call("blockchain.block.get", vec![Param::U32(0)])
{
Ok(g) => g,
Err(e) => {
error!(
"Failed to fetch genesis block from {}: {}. See --help for setting a different server.",
electrum_url, e
);
bail!(e)
}
};
2024-02-14 11:11:16 +01:00
let genesis: Block = deserialize(&hex::decode(genesis.as_str().unwrap()).unwrap()).unwrap();
let chain = Arc::new(Mutex::new(chain::Chain::new(genesis.header)));
db::cauldron::pool::initialize_seq(&db.cauldron_r).await;
2024-02-14 11:11:16 +01:00
info!("Loading block headers...");
let all_headers = load_all_headers(&db.cauldron_r).await.unwrap();
2024-02-14 11:11:16 +01:00
info!("Initializing {} headers...", all_headers.len());
chain.lock().unwrap().load(all_headers).unwrap();
info!("Headers loaded.");
let db_cpy = db.clone();
2024-10-21 12:48:47 +02:00
let mut crc20fetcher = CRC20Fetcher::new();
crc20fetcher.start(
db.crc20_w.clone(),
db.bcmr_r.clone(),
client.clone(),
indexing_in_progress.clone(),
)?;
2023-11-29 12:25:04 +01:00
db::oracle::clear_mempool(&db.oracle_w).await.unwrap();
db::moria::clear_mempool(&db.moria_w).await.unwrap();
let indexing_in_progress_clone = indexing_in_progress.clone();
let ibd_state_clone = ibd_state.clone();
let start_height = config.start_height;
tokio::spawn(async move {
let db = db_cpy;
2024-02-14 11:11:16 +01:00
{
let client_lock = client.lock().unwrap();
if let Ok(tip_info) = electrum_get_tip(&client_lock) {
let tip_height = chain.lock().unwrap().height();
ibd_state_clone
.target_height
.store(tip_height.max(tip_info.1), Ordering::Relaxed);
}
}
// Initial full index
let mut tip: BlockHash = loop {
if signal::shutdown_requested() {
info!("Shutdown requested, exiting indexing task during initial index");
return;
}
indexing_in_progress_clone.store(true, Ordering::Relaxed);
2026-01-05 15:40:01 +01:00
break match index_blocks(
chain.clone(),
db.clone(),
client.clone(),
true,
Some(network),
Some(ibd_state_clone.clone()),
start_height,
)
.await
{
Ok(tip) => tip,
Err(e) => {
if e.to_string().contains("database is locked") {
2025-07-16 09:31:14 +02:00
warn!("initial index error, trying again: {e}");
continue;
}
panic!("Initial index failed: {}\n {}", e, e.backtrace());
}
};
2024-02-14 11:11:16 +01:00
};
indexing_in_progress_clone.store(false, Ordering::Relaxed);
info!("Initial block download complete");
2026-03-25 11:38:10 +00:00
// Run ANALYZE before signalling initial_sync_complete so the ohlcv post-IBD
// backfill (which waits for that flag) doesn't race with this write.
info!("Running ANALYZE on cauldron database...");
if let Err(e) = sqlx::query("ANALYZE;").execute(&db.cauldron_w).await {
warn!("ANALYZE failed: {e}");
} else {
info!("ANALYZE complete");
}
2026-03-25 11:38:10 +00:00
ibd_state_clone
.initial_sync_complete
.store(true, Ordering::Relaxed);
// Follow chain
2023-11-29 12:25:04 +01:00
loop {
if signal::shutdown_requested() {
info!("Shutdown requested, exiting indexing task");
return;
}
let new_tip_result = {
let client_lock = client.lock().unwrap();
electrum_get_tip(&client_lock).map(|t| t.0.block_hash())
};
let new_tip = match new_tip_result {
Ok(t) => t,
Err(e) => {
warn!("Failed to get chain tip from electrum: {e}");
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
}
};
2023-11-29 12:25:04 +01:00
if new_tip != tip {
indexing_in_progress_clone.store(true, Ordering::Relaxed);
2026-01-05 15:40:01 +01:00
tip = match index_blocks(
chain.clone(),
db.clone(),
client.clone(),
true,
Some(network),
None,
0, // start_height only matters for initial sync
)
.await
{
Ok(t) => t,
Err(e) => {
warn!("Indexing block failed: {} {}", e, e.backtrace());
tip
}
};
indexing_in_progress_clone.store(false, Ordering::Relaxed);
2023-11-29 12:25:04 +01:00
}
// Avoid overlapping writer while indexer is on
if !indexing_in_progress_clone.load(Ordering::Relaxed) {
if let Err(e) = update_mempool(&db, client.clone()).await {
error!("Failed to update mempool: {e}");
}
2024-02-16 09:11:40 +01:00
}
tokio::time::sleep(Duration::from_secs(5)).await;
2023-11-29 12:25:04 +01:00
}
});
let mut bcmrdownloader = BCMRDownloader::new(db.bcmr_w.clone());
bcmrdownloader.start()?;
let mut wellknowndownloader = WellKnownDownloader::new(db.bcmr_w.clone());
wellknowndownloader.start()?;
spawn_token_metrics_updater(db.clone(), indexing_in_progress.clone());
Ok((
db,
bcmrdownloader,
wellknowndownloader,
crc20fetcher,
ibd_state,
2026-03-25 11:38:10 +00:00
indexing_in_progress,
))
2024-02-14 11:11:16 +01:00
}
#[launch]
async fn launch() -> _ {
2024-02-14 11:11:16 +01:00
stderrlog::new()
.verbosity(LogLevelNum::Info)
2024-02-14 11:11:16 +01:00
.init()
.unwrap();
set_panic_hook();
let config = {
let (config, _extra) =
Config::including_optional_config_files(std::iter::empty::<std::ffi::OsString>())
.unwrap_or_exit();
config
};
2026-03-25 11:38:10 +00:00
let (
dbpool,
bcmrdownloader,
wellknowndownloader,
crc20fetcher,
ibd_state,
indexing_in_progress,
) = match start_program(config).await {
Ok(db) => db,
Err(e) => {
let backtrace = Backtrace::capture();
error!("Backtrace (if RUST_BACKTRACE=1):\n{backtrace}");
error!("Error: {e}");
panic!("Failed at program startup")
}
};
2024-01-19 14:38:31 +01:00
let allowed_origins = AllowedOrigins::all();
let cors = rocket_cors::CorsOptions {
allowed_origins,
allowed_methods: vec![rocket::http::Method::Get]
.into_iter()
.map(From::from)
.collect(),
allowed_headers: AllowedHeaders::some(&["Authorization", "Accept"]),
allow_credentials: true,
..Default::default()
}
.to_cors()
.unwrap();
create_cached_token_metrics_table(&dbpool.cauldron_w)
.await
.expect("ensure cached_token_metrics exists");
2025-09-05 13:53:10 +00:00
2026-03-25 11:38:10 +00:00
// Ensure the OHLCV pre-aggregation table exists (safe on both new and existing DBs).
db::cauldron::ohlcv::create_table(&dbpool.cauldron_w).await;
// Bootstrap OhlcvState from whatever is already in the table (survives restarts).
let max_bucket_ts = db::cauldron::ohlcv::get_max_bucket_ts(&dbpool.cauldron_r)
.await
.unwrap_or(None);
let initial_ohlcv_end = max_bucket_ts.map(|ts| ts + 3600).unwrap_or(0);
let ohlcv_state = Arc::new(OhlcvState {
materialized_end: AtomicI64::new(initial_ohlcv_end),
});
// Synchronous post-IBD backfill: run the full ohlcv_1h materialisation before
// allowing metrics_cache and other background writers to start. We reuse the
// indexing_in_progress flag so metrics_cache backs off during this window.
{
const BACKFILL_BATCH_SECS: i64 = 24 * 3600;
const BACKFILL_SAFETY_SECS: i64 = 3 * 3600;
// Wait for IBD to finish — ohlcv_1h data is only useful for confirmed blocks.
while !ibd_state.initial_sync_complete.load(Ordering::Relaxed) {
tokio::time::sleep(Duration::from_secs(1)).await;
}
// Gate metrics_cache so it doesn't compete for cauldron_w during backfill.
indexing_in_progress.store(true, Ordering::Relaxed);
info!("ohlcv: starting post-IBD full backfill");
let now = crate::timeutil::time_now();
let cutoff = (now - BACKFILL_SAFETY_SECS) / 3600 * 3600;
let since_opt = match max_bucket_ts {
Some(ts) => Some(ts + 3600),
None => match db::cauldron::ohlcv::get_min_trade_bucket_ts(&dbpool.cauldron_r).await {
Ok(v) => v,
Err(e) => {
warn!("ohlcv backfill: could not read min trade ts: {e}");
None
}
},
};
if since_opt.is_none() {
info!("ohlcv backfill: no confirmed trades found, skipping");
}
if let Some(mut batch_start) = since_opt {
while batch_start < cutoff {
let batch_end = (batch_start + BACKFILL_BATCH_SECS).min(cutoff);
match db::cauldron::ohlcv::rebuild_range(
&dbpool.cauldron_r,
&dbpool.cauldron_w,
batch_start,
batch_end,
)
.await
{
Ok(n) => {
info!("ohlcv backfill: {n} buckets [{batch_start}, {batch_end})");
ohlcv_state
.materialized_end
.store(batch_end, Ordering::Relaxed);
}
Err(e) => {
warn!("ohlcv backfill failed at [{batch_start}, {batch_end}): {e}");
break;
}
}
batch_start = batch_end;
// Brief yield so new block writes are not starved.
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
info!("ohlcv: post-IBD backfill complete");
indexing_in_progress.store(false, Ordering::Relaxed);
}
// Background task: incrementally materialise new 1-hour OHLCV buckets as blocks arrive.
// The full historical backfill above already ran; this task only handles the tail.
// Only processes buckets older than 3 hours (well beyond BCH reorg depth).
{
let ohlcv_write = dbpool.cauldron_w.clone();
let ohlcv_read = dbpool.cauldron_r.clone();
let ohlcv_state_bg = ohlcv_state.clone();
tokio::spawn(async move {
// Batch size: 1 day per SQL call to keep each write short.
const BATCH_SECS: i64 = 24 * 3600;
// Safety margin: only materialise buckets older than this many seconds.
const SAFETY_SECS: i64 = 3 * 3600;
loop {
let now = crate::timeutil::time_now();
// Floor to 1-hour boundary, 3 hours ago.
let cutoff = (now - SAFETY_SECS) / 3600 * 3600;
let since = match db::cauldron::ohlcv::get_max_bucket_ts(&ohlcv_read).await {
Ok(Some(max_ts)) => max_ts + 3600,
Ok(None) => {
// Table is empty: start from the first confirmed trade rather than
// scanning from Unix epoch 0 through thousands of empty batches.
match db::cauldron::ohlcv::get_min_trade_bucket_ts(&ohlcv_read).await {
Ok(Some(min_ts)) => min_ts,
Ok(None) => {
// No confirmed trades yet; wait before retrying.
tokio::time::sleep(Duration::from_secs(60)).await;
continue;
}
Err(e) => {
error!("ohlcv rebuild (min trade ts): {e}");
tokio::time::sleep(Duration::from_secs(60)).await;
continue;
}
}
}
Err(e) => {
error!("ohlcv rebuild: {e}");
tokio::time::sleep(Duration::from_secs(60)).await;
continue;
}
};
let mut batch_start = since;
while batch_start < cutoff {
let batch_end = (batch_start + BATCH_SECS).min(cutoff);
match db::cauldron::ohlcv::rebuild_range(
&ohlcv_read,
&ohlcv_write,
batch_start,
batch_end,
)
.await
{
Ok(n) => {
info!("ohlcv: materialised {n} buckets [{batch_start}, {batch_end})");
ohlcv_state_bg
.materialized_end
.store(batch_end, Ordering::Relaxed);
}
Err(e) => {
error!("ohlcv rebuild failed: {e}");
break;
}
}
batch_start = batch_end;
// Yield between batches so block indexing writes can proceed.
tokio::time::sleep(Duration::from_millis(200)).await;
}
tokio::time::sleep(Duration::from_secs(600)).await;
}
});
}
2024-01-05 14:21:50 +01:00
rocket::build()
.attach(signal::ShutdownFairing)
.attach(signal::IbdCheckFairing)
2024-03-14 12:14:13 +01:00
.manage(dbpool)
.manage(ibd_state)
2026-03-25 11:38:10 +00:00
.manage(ohlcv_state)
2026-04-24 05:19:43 +00:00
// give rocket ownership of downloaders/fetchers to ensure threads aren't dropped
.manage(bcmrdownloader)
.manage(wellknowndownloader)
2024-10-21 12:48:47 +02:00
.manage(crc20fetcher)
2024-03-04 16:40:50 +01:00
.mount(
"/cauldron/",
2024-03-18 12:34:46 +01:00
routes![
2024-04-03 09:49:44 +02:00
rpc::tvl::deprecated_tvl,
rpc::tvl::valuelocked_token,
rpc::tvl::valuelocked_all,
rpc::volume::volume_all,
rpc::volume::volume_token,
2024-10-31 10:55:29 +00:00
rpc::tokens::search_by_volume,
2025-09-05 13:53:10 +00:00
rpc::tokens::search_cached,
rpc::tokens::list_cached,
rpc::tokens::list_cached_by_ids,
2024-04-03 11:39:49 +02:00
rpc::price::price_history,
2025-06-17 13:22:46 +00:00
rpc::candlesticks::price_candlesticks,
2024-04-03 11:39:49 +02:00
rpc::price::price_current,
2024-10-14 11:18:50 +00:00
rpc::price::price_at,
2024-04-03 21:40:33 +02:00
rpc::pool::list_active_pools,
2024-11-13 11:16:24 +01:00
rpc::pool::pool_history,
rpc::pool::pool_id_from_utxo,
rpc::apy::aggregate_apy,
2024-04-03 11:39:49 +02:00
rpc::contract::contract_count_token,
rpc::contract::contract_count_all,
rpc::contract::contract_volume,
2024-09-25 11:56:55 +02:00
rpc::user::unique_addresses,
rpc::tx::tx_latest,
2025-09-17 11:11:21 +02:00
rpc::tokens::first_pool_creation,
2024-03-18 12:34:46 +01:00
],
2024-03-04 16:40:50 +01:00
)
.mount(
"/bcmr",
routes![rpc::bcmr::token_bcmr, rpc::bcmr::token_bcmr_all],
)
.mount(
"/oracle",
routes![
rpc::oracle::oracle_get_closest,
2025-06-17 14:04:26 +00:00
rpc::oracle::oracle_get_range,
2026-04-24 05:19:43 +00:00
rpc::oracle::oracle_get_history,
rpc::oracle::oracle_cash_closest,
rpc::oracle::oracle_cash_history,
],
)
.mount(
"/moria",
routes![
rpc::moria::loan_history,
rpc::moria::global_history,
rpc::moria::active_loans,
rpc::moria::moria_stats,
],
)
2026-02-10 15:36:04 +01:00
.mount("/", routes![rpc::health::health])
2024-01-19 14:38:31 +01:00
.attach(cors)
2023-11-29 12:25:04 +01:00
}