2026-01-21 12:34:59 +01:00
// Copyright (C) 2024-2026 Whiterun LLC
2025-09-05 13:53:10 +00: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
2026-02-01 13:23:57 +01:00
use anyhow ::Result ;
use bitcoincash ::TokenID ;
2026-02-17 17:47:43 +01:00
use sqlx ::{ Row , SqlitePool } ;
2025-09-05 13:53:10 +00:00
2026-02-01 13:23:57 +01:00
use crate ::db ::blob ::display_hex_to_blob ;
2025-09-05 13:53:10 +00:00
pub enum CachedSort {
ScoreDesc ,
ScoreAsc ,
NameAsc ,
NameDesc ,
SymbolAsc ,
SymbolDesc ,
TvlDesc ,
TvlAsc ,
VolumeDesc ,
VolumeAsc ,
Change24hDesc ,
Change24hAsc ,
Change7dDesc ,
Change7dAsc ,
PriceUsdDesc ,
PriceUsdAsc ,
Change24hUsdDesc ,
Change24hUsdAsc ,
Change7dUsdDesc ,
Change7dUsdAsc ,
Apy30dDesc ,
Apy30dAsc ,
2026-07-30 12:24:29 +00:00
FirstPoolTsDesc ,
FirstPoolTsAsc ,
2025-09-05 13:53:10 +00:00
}
pub fn order_clause ( sort : CachedSort ) -> & 'static str {
match sort {
2025-09-11 07:36:06 +00:00
CachedSort ::ScoreDesc = > {
" ORDER BY (NULLIF(score_rank,0) IS NULL) ASC, \
NULLIF ( score_rank , 0 ) ASC , \
score DESC , trade_volume DESC , token_id ASC "
}
CachedSort ::ScoreAsc = > {
" ORDER BY (NULLIF(score_rank,0) IS NULL) ASC, \
NULLIF ( score_rank , 0 ) DESC , \
score ASC , trade_volume ASC , token_id ASC "
}
2025-09-05 13:53:10 +00:00
CachedSort ::TvlDesc = > " ORDER BY tvl_sats DESC, token_id ASC " ,
2025-09-11 07:36:06 +00:00
CachedSort ::TvlAsc = > " ORDER BY tvl_sats ASC, token_id ASC " ,
2025-09-05 13:53:10 +00:00
CachedSort ::VolumeDesc = > " ORDER BY trade_volume DESC, token_id ASC " ,
2025-09-11 07:36:06 +00:00
CachedSort ::VolumeAsc = > " ORDER BY trade_volume ASC, token_id ASC " ,
CachedSort ::NameAsc = > {
" ORDER BY display_name IS NULL, display_name COLLATE NOCASE ASC, token_id ASC "
}
CachedSort ::NameDesc = > {
" ORDER BY display_name IS NULL, display_name COLLATE NOCASE DESC, token_id ASC "
}
CachedSort ::SymbolAsc = > {
" ORDER BY display_symbol IS NULL, display_symbol COLLATE NOCASE ASC, token_id ASC "
}
CachedSort ::SymbolDesc = > {
" ORDER BY display_symbol IS NULL, display_symbol COLLATE NOCASE DESC, token_id ASC "
}
CachedSort ::Change24hDesc = > {
" ORDER BY change_24h_bp IS NULL, change_24h_bp DESC, token_id ASC "
}
CachedSort ::Change24hAsc = > {
" ORDER BY change_24h_bp IS NULL, change_24h_bp ASC, token_id ASC "
}
CachedSort ::Change7dDesc = > {
" ORDER BY change_7d_bp IS NULL, change_7d_bp DESC, token_id ASC "
}
CachedSort ::Change7dAsc = > {
" ORDER BY change_7d_bp IS NULL, change_7d_bp ASC, token_id ASC "
}
2025-09-05 13:53:10 +00:00
CachedSort ::PriceUsdDesc = > " ORDER BY price_now_usd DESC, token_id ASC " ,
CachedSort ::PriceUsdAsc = > " ORDER BY price_now_usd ASC, token_id ASC " ,
2025-09-11 07:36:06 +00:00
CachedSort ::Change24hUsdDesc = > {
" ORDER BY change_24h_usd_bp IS NULL, change_24h_usd_bp DESC, token_id ASC "
}
CachedSort ::Change24hUsdAsc = > {
" ORDER BY change_24h_usd_bp IS NULL, change_24h_usd_bp ASC, token_id ASC "
}
CachedSort ::Change7dUsdDesc = > {
" ORDER BY change_7d_usd_bp IS NULL, change_7d_usd_bp DESC, token_id ASC "
}
CachedSort ::Change7dUsdAsc = > {
" ORDER BY change_7d_usd_bp IS NULL, change_7d_usd_bp ASC, token_id ASC "
}
CachedSort ::Apy30dDesc = > " ORDER BY apy_30d_bp IS NULL, apy_30d_bp DESC, token_id ASC " ,
CachedSort ::Apy30dAsc = > " ORDER BY apy_30d_bp IS NULL, apy_30d_bp ASC, token_id ASC " ,
2026-07-30 12:24:29 +00:00
// NULLIF(...,0): 0 is the "not backfilled yet" sentinel — sort it with the NULLs,
// last, so un-backfilled tokens never pollute "newest" results.
CachedSort ::FirstPoolTsDesc = > {
" ORDER BY NULLIF(first_pool_ts,0) IS NULL, NULLIF(first_pool_ts,0) DESC, token_id ASC "
}
CachedSort ::FirstPoolTsAsc = > {
" ORDER BY NULLIF(first_pool_ts,0) IS NULL, NULLIF(first_pool_ts,0) ASC, token_id ASC "
}
2025-09-05 13:53:10 +00:00
}
}
2025-09-11 07:36:06 +00:00
/// Create the *current* index set only (no drops).
2026-02-17 17:47:43 +01:00
pub async fn create_cached_token_metrics_indexes ( pool : & SqlitePool ) -> Result < ( ) > {
sqlx ::query (
2025-09-05 13:53:10 +00:00
r #"
2025-09-11 07:36:06 +00:00
CREATE INDEX IF NOT EXISTS idx_ctm_score
ON cached_token_metrics ( score , trade_volume , token_id ) ;
" #,
2026-02-17 17:47:43 +01:00
)
. execute ( pool )
. await ? ;
sqlx ::query (
" CREATE INDEX IF NOT EXISTS idx_ctm_tvl ON cached_token_metrics(tvl_sats, token_id); " ,
)
. execute ( pool )
. await ? ;
sqlx ::query ( " CREATE INDEX IF NOT EXISTS idx_ctm_volume ON cached_token_metrics(trade_volume, token_id); " )
. execute ( pool ) . await ? ;
sqlx ::query ( " CREATE INDEX IF NOT EXISTS idx_ctm_price_usd ON cached_token_metrics(price_now_usd, token_id); " )
. execute ( pool ) . await ? ;
sqlx ::query (
" CREATE INDEX IF NOT EXISTS idx_ctm_apy ON cached_token_metrics(apy_30d_bp, token_id); " ,
)
. execute ( pool )
. await ? ;
sqlx ::query ( " CREATE INDEX IF NOT EXISTS idx_ctm_name_ord ON cached_token_metrics((display_name IS NULL), display_name COLLATE NOCASE, token_id); " )
. execute ( pool ) . await ? ;
sqlx ::query ( " CREATE INDEX IF NOT EXISTS idx_ctm_symbol_ord ON cached_token_metrics((display_symbol IS NULL), display_symbol COLLATE NOCASE, token_id); " )
. execute ( pool ) . await ? ;
sqlx ::query ( " CREATE INDEX IF NOT EXISTS idx_ctm_ch24_ord ON cached_token_metrics((change_24h_bp IS NULL), change_24h_bp, token_id); " )
. execute ( pool ) . await ? ;
sqlx ::query ( " CREATE INDEX IF NOT EXISTS idx_ctm_ch7d_ord ON cached_token_metrics((change_7d_bp IS NULL), change_7d_bp, token_id); " )
. execute ( pool ) . await ? ;
sqlx ::query ( " CREATE INDEX IF NOT EXISTS idx_ctm_ch24_usd_ord ON cached_token_metrics((change_24h_usd_bp IS NULL), change_24h_usd_bp, token_id); " )
. execute ( pool ) . await ? ;
sqlx ::query ( " CREATE INDEX IF NOT EXISTS idx_ctm_ch7d_usd_ord ON cached_token_metrics((change_7d_usd_bp IS NULL), change_7d_usd_bp, token_id); " )
. execute ( pool ) . await ? ;
sqlx ::query ( " CREATE INDEX IF NOT EXISTS idx_ctm_score_rank ON cached_token_metrics(score_rank, token_id); " )
. execute ( pool ) . await ? ;
2026-07-30 12:24:29 +00:00
sqlx ::query ( " CREATE INDEX IF NOT EXISTS idx_ctm_first_pool_ts_ord ON cached_token_metrics((NULLIF(first_pool_ts,0) IS NULL), NULLIF(first_pool_ts,0), token_id); " )
. execute ( pool ) . await ? ;
2026-02-17 17:47:43 +01:00
sqlx ::query ( " ANALYZE; " ) . execute ( pool ) . await ? ;
sqlx ::query ( " PRAGMA optimize; " ) . execute ( pool ) . await ? ;
2025-09-11 07:36:06 +00:00
Ok ( ( ) )
}
/// Indexes that speed up the aggregation path (tx → phe → pool).
2026-02-17 17:47:43 +01:00
pub async fn create_aggregation_path_indexes ( pool : & SqlitePool ) -> Result < ( ) > {
sqlx ::query ( " CREATE INDEX IF NOT EXISTS idx_tx_effective_ts ON tx(effective_timestamp, txid); " )
. execute ( pool )
. await ? ;
sqlx ::query ( " CREATE INDEX IF NOT EXISTS idx_phe_txid ON pool_history_entry(txid); " )
. execute ( pool )
. await ? ;
sqlx ::query ( " CREATE INDEX IF NOT EXISTS idx_phe_pool_txid ON pool_history_entry(pool, txid); " )
. execute ( pool )
. await ? ;
sqlx ::query ( " CREATE INDEX IF NOT EXISTS idx_phe_pool_timestamp ON pool_history_entry(pool, effective_timestamp); " )
. execute ( pool ) . await ? ;
sqlx ::query (
" CREATE INDEX IF NOT EXISTS idx_pool_creation_utxo ON pool(creation_utxo, token_id); " ,
)
. execute ( pool )
. await ? ;
sqlx ::query ( " CREATE INDEX IF NOT EXISTS idx_pool_token_id ON pool(token_id, creation_utxo); " )
. execute ( pool )
. await ? ;
sqlx ::query ( " CREATE INDEX IF NOT EXISTS idx_volume_query_optimization ON pool_history_entry(txid, pool, sats_delta, token_delta); " )
. execute ( pool ) . await ? ;
sqlx ::query ( " CREATE INDEX IF NOT EXISTS idx_tx_effective_timestamp_txid ON tx(effective_timestamp, txid); " )
. execute ( pool ) . await ? ;
sqlx ::query ( " ANALYZE; " ) . execute ( pool ) . await ? ;
sqlx ::query ( " PRAGMA optimize; " ) . execute ( pool ) . await ? ;
2025-09-05 13:53:10 +00:00
Ok ( ( ) )
}
2026-02-17 17:47:43 +01:00
pub async fn create_cached_token_metrics_table ( pool : & SqlitePool ) -> Result < ( ) > {
sqlx ::query (
2025-09-05 13:53:10 +00:00
r #"
CREATE TABLE IF NOT EXISTS cached_token_metrics (
Convert hash columns from TEXT to BLOB storage
Migrate all 32-byte hash fields from TEXT (64-char hex) to BLOB
(32 bytes) for ~50% storage savings on hash columns.
Changes:
- Add src/db/blob.rs with ToBlob/FromBlob traits for hash types
- Update all table schemas to use BLOB for hash columns
- Replace .to_hex() with .to_blob() for DB inserts
- Replace ::from_hex() with ::from_blob() for DB reads
- Use SQL hex() function where API responses need hex strings
- Bump DB_VERSION from 4 to 5
Affected tables: tx, pool, pool_history_entry, utxo_spending,
utxo_funding, user_action, auth_chain_entry, bcmr_data,
bcmr_failure, bcmr_well_known, crc20, crc20_candidates,
delphi_entry, cached_token_metrics
Fixes #1
2026-01-21 13:25:35 +01:00
token_id BLOB PRIMARY KEY ,
2025-09-11 07:36:06 +00:00
trade_volume INTEGER NOT NULL DEFAULT 0 ,
tvl_sats INTEGER NOT NULL DEFAULT 0 ,
tvl_tokens INTEGER NOT NULL DEFAULT 0 ,
score INTEGER NOT NULL DEFAULT 0 ,
score_rank INTEGER NOT NULL DEFAULT 0 ,
display_name TEXT ,
display_symbol TEXT ,
price_now REAL NOT NULL DEFAULT 0 ,
price_24h REAL ,
price_7d REAL ,
change_24h_bp INTEGER ,
change_7d_bp INTEGER ,
price_now_usd REAL NOT NULL DEFAULT 0 ,
price_24h_usd REAL ,
price_7d_usd REAL ,
change_24h_usd_bp INTEGER ,
change_7d_usd_bp INTEGER ,
apy_30d_bp INTEGER ,
last_trade_ts INTEGER ,
updated_at INTEGER NOT NULL
2025-09-05 13:53:10 +00:00
) ;
" #,
2026-02-17 17:47:43 +01:00
)
. execute ( pool )
. await ? ;
add_column_if_missing ( pool , " cached_token_metrics " , " first_pool_ts " , " INTEGER " ) . await ? ;
add_column_if_missing ( pool , " cached_token_metrics " , " bcmr_json " , " TEXT " ) . await ? ;
add_column_if_missing ( pool , " cached_token_metrics " , " bcmr_well_known_json " , " TEXT " ) . await ? ;
2025-09-05 13:53:10 +00:00
Ok ( ( ) )
}
2025-09-17 11:11:21 +02:00
2026-02-17 17:47:43 +01:00
async fn add_column_if_missing (
pool : & SqlitePool ,
table : & str ,
column : & str ,
decl : & str ,
) -> Result < ( ) > {
let rows = sqlx ::query ( & format! ( " PRAGMA table_info( {table} ); " ) )
. fetch_all ( pool )
. await ? ;
2025-09-17 11:11:21 +02:00
let mut exists = false ;
2026-02-17 17:47:43 +01:00
for row in rows {
let name : String = row . get ( 1 ) ;
2025-09-17 11:11:21 +02:00
if name . eq_ignore_ascii_case ( column ) {
exists = true ;
break ;
}
}
if ! exists {
2026-02-17 17:47:43 +01:00
sqlx ::query ( & format! ( " ALTER TABLE {table} ADD COLUMN {column} {decl} ; " ) )
. execute ( pool )
. await ? ;
2025-09-17 11:11:21 +02:00
}
Ok ( ( ) )
}
2026-02-17 17:47:43 +01:00
async fn table_has_column ( pool : & SqlitePool , table : & str , column : & str ) -> anyhow ::Result < bool > {
let rows = sqlx ::query ( & format! ( " PRAGMA table_info( {table} ); " ) )
. fetch_all ( pool )
. await ? ;
for row in rows {
let name : String = row . get ( 1 ) ;
2025-09-17 12:08:03 +02:00
if name . eq_ignore_ascii_case ( column ) {
return Ok ( true ) ;
}
2025-09-17 11:52:22 +02:00
}
Ok ( false )
}
/// First pool info: (creation_utxo, txid, first_ts, first_height)
pub type FirstPoolInfo = ( String , String , i64 , Option < i64 > ) ;
2025-09-17 11:27:05 +02:00
/// Only set `first_pool_ts` when it is currently NULL or 0.
2026-02-17 17:47:43 +01:00
pub async fn cache_first_pool_ts_if_empty (
pool : & SqlitePool ,
token_id : & str ,
ts : i64 ,
) -> Result < ( ) > {
2026-02-01 13:23:57 +01:00
let token_blob = display_hex_to_blob ::< TokenID > ( token_id ) ? ;
Convert hash columns from TEXT to BLOB storage
Migrate all 32-byte hash fields from TEXT (64-char hex) to BLOB
(32 bytes) for ~50% storage savings on hash columns.
Changes:
- Add src/db/blob.rs with ToBlob/FromBlob traits for hash types
- Update all table schemas to use BLOB for hash columns
- Replace .to_hex() with .to_blob() for DB inserts
- Replace ::from_hex() with ::from_blob() for DB reads
- Use SQL hex() function where API responses need hex strings
- Bump DB_VERSION from 4 to 5
Affected tables: tx, pool, pool_history_entry, utxo_spending,
utxo_funding, user_action, auth_chain_entry, bcmr_data,
bcmr_failure, bcmr_well_known, crc20, crc20_candidates,
delphi_entry, cached_token_metrics
Fixes #1
2026-01-21 13:25:35 +01:00
2026-02-17 17:47:43 +01:00
sqlx ::query (
2025-09-17 11:11:21 +02:00
r #"
INSERT INTO cached_token_metrics ( token_id , updated_at )
VALUES ( ? 1 , strftime ( ' % s ',' now ' ) )
ON CONFLICT ( token_id ) DO NOTHING ;
" #,
2026-02-17 17:47:43 +01:00
)
. bind ( & token_blob )
. execute ( pool )
. await ? ;
2025-09-17 11:11:21 +02:00
2026-02-17 17:47:43 +01:00
sqlx ::query (
2025-09-17 11:11:21 +02:00
r #"
UPDATE cached_token_metrics
SET first_pool_ts = ? 2
WHERE token_id = ? 1 AND ( first_pool_ts IS NULL OR first_pool_ts = 0 ) ;
" #,
2026-02-17 17:47:43 +01:00
)
. bind ( & token_blob )
. bind ( ts )
. execute ( pool )
. await ? ;
2025-09-17 11:11:21 +02:00
Ok ( ( ) )
}
2025-09-17 11:52:22 +02:00
/// Returns (creation_utxo, txid, first_ts, first_height) for the first-ever pool of `token_id`.
2026-02-17 17:47:43 +01:00
pub async fn db_first_pool_creation_row (
pool : & SqlitePool ,
2025-09-17 11:11:21 +02:00
token_id : & str ,
2025-09-17 12:05:24 +02:00
) -> anyhow ::Result < Option < FirstPoolInfo > > {
2026-02-17 17:47:43 +01:00
let has_tx_height = table_has_column ( pool , " tx " , " block_height " ) . await ? ;
let has_blockhash = table_has_column ( pool , " tx " , " blockhash " ) . await ? ;
let has_headers_key = table_has_column ( pool , " headers " , " key " ) . await ? ;
let has_headers_ht = table_has_column ( pool , " headers " , " height " ) . await ? ;
2025-09-17 12:05:24 +02:00
let sql = if has_tx_height {
r #"
SELECT p . creation_utxo , uf . txid , t . effective_timestamp , t . block_height AS block_height
FROM pool AS p
JOIN utxo_funding AS uf ON uf . new_utxo_hash = p . creation_utxo
JOIN tx AS t ON t . txid = uf . txid
WHERE p . token_id = ?
ORDER BY
2026-02-17 17:47:43 +01:00
( t . block_height IS NULL ) ASC ,
t . block_height ASC ,
2025-09-17 12:05:24 +02:00
t . effective_timestamp ASC ,
uf . txid ASC ,
p . creation_utxo ASC
LIMIT 1 ;
" #
} else if has_blockhash & & has_headers_key & & has_headers_ht {
2025-09-17 11:52:22 +02:00
r #"
2025-09-17 12:05:24 +02:00
SELECT p . creation_utxo , uf . txid , t . effective_timestamp , h . height AS block_height
2025-09-17 11:52:22 +02:00
FROM pool AS p
JOIN utxo_funding AS uf ON uf . new_utxo_hash = p . creation_utxo
JOIN tx AS t ON t . txid = uf . txid
2025-09-17 12:05:24 +02:00
LEFT JOIN headers AS h ON h . key = t . blockhash
2025-09-17 11:52:22 +02:00
WHERE p . token_id = ?
ORDER BY
2026-02-17 17:47:43 +01:00
( t . blockhash IS NULL ) ASC ,
( h . height IS NULL ) ASC ,
2025-09-17 12:05:24 +02:00
h . height ASC ,
2025-09-17 11:52:22 +02:00
t . effective_timestamp ASC ,
uf . txid ASC ,
p . creation_utxo ASC
LIMIT 1 ;
" #
} else {
r #"
SELECT p . creation_utxo , uf . txid , t . effective_timestamp , NULL AS block_height
2025-09-17 11:11:21 +02:00
FROM pool AS p
JOIN utxo_funding AS uf ON uf . new_utxo_hash = p . creation_utxo
2025-09-17 11:52:22 +02:00
JOIN tx AS t ON t . txid = uf . txid
2025-09-17 11:11:21 +02:00
WHERE p . token_id = ?
2025-09-17 11:52:22 +02:00
ORDER BY
t . effective_timestamp ASC ,
uf . txid ASC ,
p . creation_utxo ASC
2025-09-17 11:11:21 +02:00
LIMIT 1 ;
2025-09-17 11:52:22 +02:00
" #
} ;
2026-02-01 13:23:57 +01:00
let token_blob = display_hex_to_blob ::< TokenID > ( token_id ) ? ;
2025-09-17 11:11:21 +02:00
2026-02-17 17:47:43 +01:00
let row = sqlx ::query ( sql )
. bind ( & token_blob )
. fetch_optional ( pool )
. await ? ;
if let Some ( row ) = row {
2026-02-01 13:23:57 +01:00
use crate ::db ::blob ::blob_to_display_hex ;
use bitcoincash ::Txid ;
use riftenlabs_defi ::chainutil ::OutPointHash ;
2026-02-17 17:47:43 +01:00
let creation_utxo_blob : Vec < u8 > = row . get ( 0 ) ;
let txid_blob : Vec < u8 > = row . get ( 1 ) ;
2026-02-01 13:23:57 +01:00
let creation_utxo = blob_to_display_hex ::< OutPointHash > ( & creation_utxo_blob ) ? ;
let txid = blob_to_display_hex ::< Txid > ( & txid_blob ) ? ;
2026-02-17 17:47:43 +01:00
let ts : i64 = row . get ( 2 ) ;
let height : Option < i64 > = row . get ( 3 ) ;
2025-09-17 11:52:22 +02:00
Ok ( Some ( ( creation_utxo , txid , ts , height ) ) )
2025-09-17 11:11:21 +02:00
} else {
Ok ( None )
}
}