333 lines
11 KiB
Rust
333 lines
11 KiB
Rust
// 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<String>, Option<String>);
|
|
type TokenVolumeInfo = (String, Option<String>, Option<String>, 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<Vec<TokenBasicInfo>> {
|
|
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<TokenBasicInfo> = Vec::new();
|
|
|
|
if is_full_hex_token_id {
|
|
let token_blob = display_hex_to_blob::<TokenID>(search_query)?;
|
|
let rows = sqlx::query(sql)
|
|
.bind(token_blob)
|
|
.fetch_all(bcmr_pool)
|
|
.await?;
|
|
for row in rows {
|
|
let token_blob: Vec<u8> = row.get(0);
|
|
let token_id = blob_to_display_hex::<TokenID>(&token_blob)?;
|
|
let name: Option<String> = row.get(1);
|
|
let ticker: Option<String> = 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<u8> = row.get(0);
|
|
let token_id = blob_to_display_hex::<TokenID>(&token_blob)?;
|
|
let name: Option<String> = row.get(1);
|
|
let ticker: Option<String> = row.get(2);
|
|
bcmr_data.push((token_id, name, ticker));
|
|
}
|
|
}
|
|
|
|
Ok(bcmr_data)
|
|
}
|
|
|
|
async fn search_crc20(crc20_pool: &SqlitePool, search_query: &str) -> Result<Vec<TokenBasicInfo>> {
|
|
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<TokenBasicInfo> = Vec::new();
|
|
|
|
if is_full_hex_token_id {
|
|
let token_blob = display_hex_to_blob::<TokenID>(search_query)?;
|
|
let rows = sqlx::query(sql)
|
|
.bind(token_blob)
|
|
.fetch_all(crc20_pool)
|
|
.await?;
|
|
for row in rows {
|
|
let token_blob: Vec<u8> = row.get(0);
|
|
let token_id = blob_to_display_hex::<TokenID>(&token_blob)?;
|
|
let name: Option<String> = row.get(1);
|
|
let ticker: Option<String> = 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<u8> = row.get(0);
|
|
let token_id = blob_to_display_hex::<TokenID>(&token_blob)?;
|
|
let name: Option<String> = row.get(1);
|
|
let ticker: Option<String> = row.get(2);
|
|
crc20_data.push((token_id, name, ticker));
|
|
}
|
|
}
|
|
|
|
Ok(crc20_data)
|
|
}
|
|
|
|
async fn token_volume(
|
|
cauldron_pool: &SqlitePool,
|
|
tokens: Vec<TokenBasicInfo>,
|
|
) -> Result<Vec<TokenVolumeInfo>> {
|
|
let mut result = Vec::with_capacity(tokens.len());
|
|
|
|
for (token_id, name, ticker) in tokens {
|
|
let token_blob = display_hex_to_blob::<TokenID>(&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<Vec<TokenVolumeInfo>> {
|
|
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<Vec<TokenListItemCached>> {
|
|
let order_sql = order_clause(sort);
|
|
|
|
enum Filter {
|
|
None,
|
|
Id(Vec<u8>),
|
|
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::<TokenID>(&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<u8> = row.get("token_id");
|
|
let token_id = blob_to_display_hex::<TokenID>(&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<f64> = row.get("price_24h");
|
|
let price_7d: Option<f64> = row.get("price_7d");
|
|
let change_24h_bp: Option<i64> = row.get("change_24h_bp");
|
|
let change_7d_bp: Option<i64> = row.get("change_7d_bp");
|
|
let display_name: Option<String> = row.get("display_name");
|
|
let display_symbol: Option<String> = row.get("display_symbol");
|
|
let price_now_usd: f64 = row.get("price_now_usd");
|
|
let price_24h_usd: Option<f64> = row.get("price_24h_usd");
|
|
let price_7d_usd: Option<f64> = row.get("price_7d_usd");
|
|
let change_24h_usd_bp: Option<i64> = row.get("change_24h_usd_bp");
|
|
let change_7d_usd_bp: Option<i64> = row.get("change_7d_usd_bp");
|
|
let apy_30d_bp: Option<i64> = row.get("apy_30d_bp");
|
|
let bcmr_json_str: Option<String> = row.get("bcmr_json");
|
|
let bcmr_wk_json_str: Option<String> = row.get("bcmr_well_known_json");
|
|
let bcmr: Option<ParsedBCMR> = bcmr_json_str.and_then(|s| serde_json::from_str(&s).ok());
|
|
let bcmr_well_known: Vec<ParsedBCMR> = 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;
|