// Copyright (C) 2024-2026 Whiterun LLC // // 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::Result; use bitcoin_hashes::hex::FromHex; use bitcoincash::TokenID; use sqlx::{Row, SqlitePool}; type TokenBasicInfo = (String, Option, Option); type TokenVolumeInfo = (String, Option, Option, u64); use crate::bcmr::parsedbcmr::ParsedBCMR; use crate::db::blob::{blob_to_display_hex, display_hex_to_blob}; use crate::db::cauldron::tokenlist::db_utils::order_clause; use crate::db::cauldron::tokenlist::db_utils::CachedSort; use crate::db::cauldron::tokenlist::list_cached::TokenListItemCached; async fn search_bcmr(bcmr_pool: &SqlitePool, search_query: &str) -> Result> { let is_full_hex_token_id = TokenID::from_hex(search_query).is_ok(); let sql = if is_full_hex_token_id { "SELECT token_id, name, symbol FROM ( SELECT a.token_id, b.name, b.symbol, a.height, ROW_NUMBER() OVER (PARTITION BY a.token_id ORDER BY a.height DESC) AS rn FROM auth_chain_entry a LEFT JOIN bcmr_data b ON a.utxo = b.utxo ) subquery WHERE rn = 1 AND (token_id = ?1);" } else { "SELECT token_id, name, symbol FROM ( SELECT a.token_id, b.name, b.symbol, a.height, ROW_NUMBER() OVER (PARTITION BY a.token_id ORDER BY a.height DESC) AS rn FROM auth_chain_entry a LEFT JOIN bcmr_data b ON a.utxo = b.utxo WHERE a.bcmr_data IS NOT NULL ) subquery WHERE rn = 1 AND (name LIKE ?1 OR symbol LIKE ?1);" }; let mut bcmr_data: Vec = Vec::new(); if is_full_hex_token_id { let token_blob = display_hex_to_blob::(search_query)?; let rows = sqlx::query(sql) .bind(token_blob) .fetch_all(bcmr_pool) .await?; for row in rows { let token_blob: Vec = row.get(0); let token_id = blob_to_display_hex::(&token_blob)?; let name: Option = row.get(1); let ticker: Option = row.get(2); bcmr_data.push((token_id, name, ticker)); } } else { let search_pattern = format!("%{search_query}%"); let rows = sqlx::query(sql) .bind(search_pattern) .fetch_all(bcmr_pool) .await?; for row in rows { let token_blob: Vec = row.get(0); let token_id = blob_to_display_hex::(&token_blob)?; let name: Option = row.get(1); let ticker: Option = row.get(2); bcmr_data.push((token_id, name, ticker)); } } Ok(bcmr_data) } async fn search_crc20(crc20_pool: &SqlitePool, search_query: &str) -> Result> { let is_full_hex_token_id = TokenID::from_hex(search_query).is_ok(); let sql = if is_full_hex_token_id { "SELECT token_id, name, symbol FROM crc20 WHERE token_id = ?1;" } else { "SELECT token_id, name, symbol FROM crc20 WHERE name LIKE ?1 OR symbol LIKE ?1;" }; let mut crc20_data: Vec = Vec::new(); if is_full_hex_token_id { let token_blob = display_hex_to_blob::(search_query)?; let rows = sqlx::query(sql) .bind(token_blob) .fetch_all(crc20_pool) .await?; for row in rows { let token_blob: Vec = row.get(0); let token_id = blob_to_display_hex::(&token_blob)?; let name: Option = row.get(1); let ticker: Option = row.get(2); crc20_data.push((token_id, name, ticker)); } } else { let search_pattern = format!("%{search_query}%"); let rows = sqlx::query(sql) .bind(search_pattern) .fetch_all(crc20_pool) .await?; for row in rows { let token_blob: Vec = row.get(0); let token_id = blob_to_display_hex::(&token_blob)?; let name: Option = row.get(1); let ticker: Option = row.get(2); crc20_data.push((token_id, name, ticker)); } } Ok(crc20_data) } async fn token_volume( cauldron_pool: &SqlitePool, tokens: Vec, ) -> Result> { let mut result = Vec::with_capacity(tokens.len()); for (token_id, name, ticker) in tokens { let token_blob = display_hex_to_blob::(&token_id)?; let row: (i64,) = sqlx::query_as( "WITH TradeData AS ( SELECT p.token_id, ABS(phe.sats_delta) as trade_volume FROM pool_history_entry phe JOIN pool p ON phe.pool = p.creation_utxo JOIN tx ON phe.txid = tx.txid WHERE tx.effective_timestamp >= (strftime('%s', 'now') - 2592000) AND p.token_id = ? ) SELECT COALESCE(SUM(trade_volume), 0) as total_trade_volume FROM TradeData;", ) .bind(&token_blob) .fetch_one(cauldron_pool) .await?; result.push((token_id, name, ticker, row.0 as u64)); } Ok(result) } pub async fn search_tokens_by_volume( cauldron_pool: &SqlitePool, bcmr_pool: &SqlitePool, crc20_pool: &SqlitePool, search_query: &str, ) -> Result> { let mut combined_tokens = [ search_bcmr(bcmr_pool, search_query).await?, search_crc20(crc20_pool, search_query).await?, ] .concat(); combined_tokens.sort_by_key(|a| a.0.to_lowercase()); combined_tokens.dedup_by(|a, b| { if a.0.eq_ignore_ascii_case(&b.0) { if b.1.is_none() && a.1.is_some() { b.1 = a.1.take(); b.2 = a.2.take(); } true } else { false } }); let mut result = token_volume(cauldron_pool, combined_tokens).await?; result.sort_by_key(|b| std::cmp::Reverse(b.3)); Ok(result) } /// Search the cached list by token_id (exact), or display_name/symbol (LIKE), /// then sort by *whatever* using CachedSort. /// /// - `query`: /// - empty/whitespace => returns everything /// - full 32-byte hex => exact token_id match /// - otherwise => display_name LIKE or display_symbol LIKE /// - `filter_zero_tvl`: skip rows with tvl_sats=0 (usually you want `true`) pub async fn db_search_tokens_cached( cauldron_pool: &SqlitePool, q: &str, sort: CachedSort, limit: usize, offset: usize, ) -> Result> { let order_sql = order_clause(sort); enum Filter { None, Id(Vec), Pat(String), } let mut where_sql = String::new(); let filter = { let q = q.trim(); if q.is_empty() { Filter::None } else if TokenID::from_hex(q).is_ok() { where_sql.push_str("WHERE token_id = ?"); Filter::Id(display_hex_to_blob::(&q.to_lowercase())?) } else { where_sql.push_str( "WHERE (display_name LIKE ? COLLATE NOCASE OR display_symbol LIKE ? COLLATE NOCASE)" ); Filter::Pat(format!("%{}%", q)) } }; if matches!(filter, Filter::None | Filter::Pat(_)) { if where_sql.is_empty() { where_sql.push_str("WHERE tvl_sats > 0"); } else { where_sql.push_str(" AND tvl_sats > 0"); } } let sql = format!( "SELECT token_id, trade_volume, tvl_sats, tvl_tokens, score, price_now, price_24h, price_7d, change_24h_bp, change_7d_bp, display_name, display_symbol, price_now_usd, price_24h_usd, price_7d_usd, change_24h_usd_bp, change_7d_usd_bp, apy_30d_bp, score_rank, bcmr_json, bcmr_well_known_json FROM cached_token_metrics {where_sql} {order_sql} LIMIT ? OFFSET ?" ); let rows = match &filter { Filter::None => { sqlx::query(&sql) .bind(limit as i64) .bind(offset as i64) .fetch_all(cauldron_pool) .await? } Filter::Id(blob) => { sqlx::query(&sql) .bind(blob) .bind(limit as i64) .bind(offset as i64) .fetch_all(cauldron_pool) .await? } Filter::Pat(pat) => { sqlx::query(&sql) .bind(pat) .bind(pat) .bind(limit as i64) .bind(offset as i64) .fetch_all(cauldron_pool) .await? } }; let mut out = Vec::with_capacity(limit); for row in rows { let token_blob: Vec = row.get("token_id"); let token_id = blob_to_display_hex::(&token_blob)?; let trade_volume: i64 = row.get("trade_volume"); let tvl_sats: i64 = row.get("tvl_sats"); let tvl_tokens: i64 = row.get("tvl_tokens"); let score: i64 = row.get("score"); let score_rank: i64 = row.get("score_rank"); let price_now: f64 = row.get("price_now"); let price_24h: Option = row.get("price_24h"); let price_7d: Option = row.get("price_7d"); let change_24h_bp: Option = row.get("change_24h_bp"); let change_7d_bp: Option = row.get("change_7d_bp"); let display_name: Option = row.get("display_name"); let display_symbol: Option = row.get("display_symbol"); let price_now_usd: f64 = row.get("price_now_usd"); let price_24h_usd: Option = row.get("price_24h_usd"); let price_7d_usd: Option = row.get("price_7d_usd"); let change_24h_usd_bp: Option = row.get("change_24h_usd_bp"); let change_7d_usd_bp: Option = row.get("change_7d_usd_bp"); let apy_30d_bp: Option = row.get("apy_30d_bp"); let bcmr_json_str: Option = row.get("bcmr_json"); let bcmr_wk_json_str: Option = row.get("bcmr_well_known_json"); let bcmr: Option = bcmr_json_str.and_then(|s| serde_json::from_str(&s).ok()); let bcmr_well_known: Vec = bcmr_wk_json_str .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); out.push(TokenListItemCached { token_id, trade_volume: trade_volume as u64, tvl_sats: tvl_sats as u64, tvl_tokens: tvl_tokens as u64, bcmr, bcmr_well_known, trade_count: 0, score, score_rank, price_now, price_24h, price_7d, change_24h_bp, change_7d_bp, display_name, display_symbol, price_now_usd, price_24h_usd, price_7d_usd, change_24h_usd_bp, change_7d_usd_bp, apy_30d_bp, }); } Ok(out) } #[cfg(test)] mod test;