// 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 bitcoin_hashes::hex::{FromHex, ToHex}; use bitcoincash::TokenID; use rocket::{get, State}; use serde_json::json; use serde_json::Value; use crate::db::cauldron::tokenlist::db_utils::{ cache_first_pool_ts_if_empty, db_first_pool_creation_row, CachedSort, }; use crate::db::cauldron::tokenlist::list_cached::{ db_list_tokens_cached, db_list_tokens_cached_by_ids, TokenListItemCached, }; use crate::db::search::db_search_tokens_cached; use crate::db::DB; use super::err::{bad_request, db_error, not_found, ApiErrorCode, CachedApiResult}; use super::response::{cached_ok, CACHE_AGGREGATE, CACHE_IMMUTABLE}; /// Search tokens by name or symbol, sorted by trade volume. /// /// Status: Unstable /// /// Queries BCMR and CRC20 registries for matching tokens, then joins with live /// trade volume data. /// /// - search_query: Token name or symbol to search for /// /// **Response:** A direct JSON array, sorted by `trade_volume` descending. /// /// **Response Example:** /// ```json /// [ /// { /// "token_id": "b79bfc8246b5fc4707e7c7dedcb6619ef1ab91f494a790c20b0f4c422ed95b92", /// "name": "ExampleToken", /// "ticker": "EXT", /// "trade_volume": 1459676788 /// } /// ] /// ``` #[get("/tokens/search_by_volume?")] pub async fn search_by_volume(search_query: &str, db: &State) -> CachedApiResult> { use rayon::prelude::*; let list: Vec<(String, Option, Option, u64)> = crate::db::search::search_tokens_by_volume( &db.cauldron_r, &db.bcmr_r, &db.crc20_r, search_query, ) .await .map_err(db_error)?; let result: Vec = list .into_par_iter() .map(|(token_id, name, ticker, trade_volume)| { json!({ "token_id": token_id, "name": name, "ticker": ticker, "trade_volume": trade_volume }) }) .collect(); Ok(cached_ok(result, CACHE_AGGREGATE)) } /// Search tokens with optional filtering and sorting. /// /// - q: Search query string /// - limit: Maximum number of results (default: 250) /// - offset: Pagination offset (default: 0) /// - by: Sort field (`name`, `symbol`, `tvl`, `volume`) /// - order: Sort direction (`asc`, `desc`) /// /// **Response:** A direct JSON array. /// /// **Response Example:** /// ```json /// [ /// { /// "token_id": "b79bfc8246b5fc4707e7c7dedcb6619ef1ab91f494a790c20b0f4c422ed95b92", /// "name": "ExampleToken", /// "symbol": "EXT", /// "decimals": 8 /// } /// ] /// ``` #[get("/tokens/search_cached?&&&&")] pub async fn search_cached( db: &State, q: Option, limit: Option, offset: Option, by: Option, order: Option, ) -> CachedApiResult { let sort = parse_cached_sort(by, order); let limit = limit.unwrap_or(250); let offset = offset.unwrap_or(0); let query = q.unwrap_or_default(); let items: Vec = db_search_tokens_cached(&db.cauldron_r, &query, sort, limit, offset) .await .map_err(db_error)?; Ok(cached_ok(json!(items), CACHE_AGGREGATE)) } fn parse_cached_sort(by: Option, order: Option) -> CachedSort { let desc = matches!(order.as_deref(), Some("desc") | Some("DESC")); match by.as_deref() { Some("name") => { if desc { CachedSort::NameDesc } else { CachedSort::NameAsc } } Some("symbol") => { if desc { CachedSort::SymbolDesc } else { CachedSort::SymbolAsc } } Some("tvl") => { if desc { CachedSort::TvlDesc } else { CachedSort::TvlAsc } } Some("volume") => { if desc { CachedSort::VolumeDesc } else { CachedSort::VolumeAsc } } Some("change_24h_bp") => { if desc { CachedSort::Change24hDesc } else { CachedSort::Change24hAsc } } Some("change_7d_bp") => { if desc { CachedSort::Change7dDesc } else { CachedSort::Change7dAsc } } // USD sorts Some("price_usd") => { if desc { CachedSort::PriceUsdDesc } else { CachedSort::PriceUsdAsc } } Some("change_24h_usd_bp") => { if desc { CachedSort::Change24hUsdDesc } else { CachedSort::Change24hUsdAsc } } Some("change_7d_usd_bp") => { if desc { CachedSort::Change7dUsdDesc } else { CachedSort::Change7dUsdAsc } } // APY (30d) sorts — accept a few aliases Some("apy") | Some("apy_30d") | Some("apy_30d_bp") => { if desc { CachedSort::Apy30dDesc } else { CachedSort::Apy30dAsc } } // default _ => { if desc { CachedSort::ScoreDesc } else { CachedSort::ScoreAsc } } } } /// List all tokens with optional pagination and sorting. /// /// Similar to `/tokens/search_cached` but without search filtering — returns all tokens /// sorted by the specified field. /// /// - limit: Maximum number of results (default: 250) /// - offset: Pagination offset (default: 0) /// - by: Sort field (`name`, `symbol`, `tvl`, `volume`, `score`) /// - order: Sort direction (`asc`, `desc`) /// /// **Response:** A direct JSON array. #[get("/tokens/list_cached?&&&")] pub async fn list_cached( db: &State, limit: Option, offset: Option, by: Option, order: Option, ) -> CachedApiResult { let limit = limit.unwrap_or(250); let offset = offset.unwrap_or(0); let sort = parse_cached_sort(by, order); let items: Vec = db_list_tokens_cached(&db.cauldron_r, limit, offset, sort) .await .map_err(db_error)?; Ok(cached_ok(json!(items), CACHE_AGGREGATE)) } /// List specific tokens by their IDs. /// /// Status: Unstable /// /// Fetch token metadata for multiple tokens in a single request. /// /// - ids: Comma-separated list of token IDs (hex strings) /// - by: Sort field for results (`name`, `symbol`, `tvl`, `volume`, `score`) /// - order: Sort direction (`asc`, `desc`) /// /// **Response:** A direct JSON array. /// /// **Example:** /// `/tokens/list_cached_by_ids?ids=b79bfc8246b5fc4707e7c7dedcb6619ef1ab91f494a790c20b0f4c422ed95b92,abc123...` #[get("/tokens/list_cached_by_ids?&&")] pub async fn list_cached_by_ids( db: &State, ids: &str, by: Option, order: Option, ) -> CachedApiResult { // split, trim, and normalize (lowercase is typical for hex IDs in DB) let token_ids: Vec = ids .split(',') .map(|s| s.trim().to_lowercase()) .filter(|s| !s.is_empty()) .collect(); if token_ids.is_empty() { return Ok(cached_ok(json!([]), CACHE_AGGREGATE)); } let sort = parse_cached_sort(by, order); let items = db_list_tokens_cached_by_ids(&db.cauldron_r, &token_ids, sort) .await .map_err(db_error)?; Ok(cached_ok(json!(items), CACHE_AGGREGATE)) } /// Get the first pool creation event for a given token. /// /// - token: The 32 byte token ID /// /// Returns 404 if no pools have ever been created for the token. /// /// **Response Example:** /// ```json /// { /// "token": "b79bfc8246b5fc4707e7c7dedcb6619ef1ab91f494a790c20b0f4c422ed95b92", /// "creation_utxo": "94a933a0fa55093a0965eb867f1b9cac2bb07488ced4825bc31f86c9371f76aa:0", /// "txid": "94a933a0fa55093a0965eb867f1b9cac2bb07488ced4825bc31f86c9371f76aa", /// "timestamp": 1709468902, /// "block_height": 880000 /// } /// ``` #[get("/token//first_pool")] pub async fn first_pool_creation(token: &str, dbp: &State) -> CachedApiResult { let token = TokenID::from_hex(token).map_err(|e| { bad_request( ApiErrorCode::InvalidTokenId, &format!("Invalid token id: {e}"), ) })?; let token_hex = token.to_hex(); match db_first_pool_creation_row(&dbp.cauldron_r, &token_hex).await { Ok(Some((creation_utxo, txid, timestamp, block_height))) => { // Opportunistically cache the timestamp (non-blocking) let cauldron_w = dbp.cauldron_w.clone(); let token_hex_clone = token_hex.clone(); tokio::spawn(async move { if let Err(e) = cache_first_pool_ts_if_empty(&cauldron_w, &token_hex_clone, timestamp).await { log::warn!("Failed to cache first_pool_ts for {token_hex_clone}: {e}"); } }); Ok(cached_ok( json!({ "token": token_hex, "creation_utxo": creation_utxo, "txid": txid, "timestamp": timestamp, "block_height": block_height }), CACHE_IMMUTABLE, )) } Ok(None) => Err(not_found(ApiErrorCode::PoolNotFound, "No pools for token")), Err(e) => Err(db_error(e)), } }