riftenlabs-indexer/src/rpc/pool.rs
Dagur Valberg Johannsson 58fd9595d7
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-22 08:30:17 +01:00

355 lines
10 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 crate::{
cashaddr::utiladdr::p2pkh_hex_to_addr,
db::{
cauldron::{
pool::{db_pool_get_details, db_pool_history, db_pool_id_from_utxo},
poolvisitor::{
db_visit_pool_entries, OptionalFields, OptionalPoolFields, PoolFilters, PoolVisitor,
},
},
DB,
},
def::PoolID,
rpc::err::{bad_request, db_error, not_found, ApiErrorCode, CachedApiResult},
rpc::response::{cached_ok, CACHE_AGGREGATE, CACHE_NONE},
timeutil::time_now,
};
use anyhow::Result;
use bitcoin_hashes::hex::{FromHex, ToHex};
use bitcoincash::Txid;
use riftenlabs_defi::chainutil::compute_outpoint_hash;
use rocket::{get, State};
use rusqlite::{params, Connection};
use serde_json::{json, Value};
#[derive(serde::Serialize)]
struct PoolYield {
token_id: String,
txid: String,
tx_pos: i64,
sats: i64,
tokens: i64,
pool_yield: f64,
apy: f64,
}
fn pools_by_apy(connection: &Connection) -> Result<Vec<PoolYield>> {
let sql = "
WITH OriginalData AS (
SELECT
p.creation_utxo,
uf.sats AS original_sats,
uf.token_amount AS original_token_amount,
uf.token_id as token_id,
tx.effective_timestamp AS original_timestamp
FROM
pool p
JOIN utxo_funding uf ON p.creation_utxo = uf.new_utxo_hash
JOIN tx ON uf.txid = tx.txid
WHERE
p.withdrawn_in_utxo IS NULL
AND uf.sats >= 10000
),
LatestData AS (
SELECT
phe.pool,
uf.sats AS latest_sats,
uf.token_amount AS latest_token_amount,
tx.effective_timestamp AS latest_timestamp,
uf.new_utxo_txid,
uf.new_utxo_n
FROM
pool_history_entry phe
JOIN utxo_funding uf ON phe.utxo = uf.new_utxo_hash
LEFT JOIN utxo_spending us ON uf.new_utxo_hash = us.spent_utxo_hash
JOIN tx ON uf.txid = tx.txid
WHERE
phe.pool IN (SELECT creation_utxo FROM OriginalData)
AND us.spent_utxo_hash IS NULL
ORDER BY
tx.effective_timestamp DESC
)
SELECT
od.original_sats,
od.original_token_amount,
od.original_timestamp,
ld.latest_sats,
ld.latest_token_amount,
od.token_id,
ld.new_utxo_txid,
ld.new_utxo_n
FROM
OriginalData od
JOIN
LatestData ld ON od.creation_utxo = ld.pool;
";
let mut statement = connection.prepare(sql)?;
let current_timestamp = time_now();
let pool_rows = statement.query_map(params![], |row| {
let original_sats: i64 = row.get(0)?;
let original_tokens: i64 = row.get(1)?;
let original_timestamp: i64 = row.get(2)?;
let latest_sats: i64 = row.get(3)?;
let latest_token_amount: i64 = row.get(4)?;
let token_id = row.get(5)?;
let txid = row.get(6)?;
let tx_pos = row.get(7)?;
assert!(current_timestamp >= original_timestamp);
let original_k_sr = f64::sqrt((original_sats * original_tokens) as f64);
let latest_k_sr = f64::sqrt((latest_sats * latest_token_amount) as f64);
let pool_yield = ((latest_k_sr - original_k_sr) / original_k_sr) * 100.;
let days_elapsed = (current_timestamp - original_timestamp) as f64 / 86400.0;
let apy: f64 = if days_elapsed > 0.0 {
let years_elapsed = 365.25 / days_elapsed;
(((pool_yield / 100.0) + 1.0).powf(years_elapsed) - 1.0) * 100.0
} else {
0.0
};
Ok(PoolYield {
token_id,
txid,
tx_pos,
sats: latest_sats,
tokens: latest_token_amount,
pool_yield,
apy,
})
})?;
let mut pools = Vec::new();
for pool_row in pool_rows {
let pool_data = pool_row?;
pools.push(pool_data);
}
// Sort pools by highest APY first
pools.sort_by(|a, b| {
b.apy
.partial_cmp(&a.apy)
.unwrap_or(std::cmp::Ordering::Equal)
});
pools.truncate(1000);
Ok(pools)
}
/// Status: Deprecated
#[get("/pool/list_by_apy")]
pub fn list_pools_by_apy(conn: &State<DB>) -> CachedApiResult<Value> {
let db = conn.cauldron_r.get().map_err(db_error)?;
let pools: Vec<PoolYield> = pools_by_apy(&db).map_err(db_error)?;
Ok(cached_ok(
json!({
"pools": json!(pools)
}),
CACHE_AGGREGATE,
))
}
#[derive(Serialize)]
struct ActivePool {
owner_pkh: String,
owner_p2pkh_addr: String,
token_id: String,
sats: u64,
tokens: u64,
txid: String,
tx_pos: u32,
pool_id: String,
}
#[derive(Default)]
struct ActivePoolList {
active: Vec<ActivePool>,
}
impl PoolVisitor for ActivePoolList {
fn optional_fields_wanted(&self) -> u64 {
OptionalPoolFields::Owner as u64
| OptionalPoolFields::Txid as u64
| OptionalPoolFields::TxPos as u64
| OptionalPoolFields::TokenId as u64
| OptionalPoolFields::PoolId as u64
}
fn visit(&mut self, sats: u64, tokens: u64, optional_fields: OptionalFields) -> Result<bool> {
let owner_pkh = optional_fields.owner.unwrap().to_lowercase();
let owner_p2pkh_addr = p2pkh_hex_to_addr(&owner_pkh)?;
self.active.push(ActivePool {
owner_pkh,
owner_p2pkh_addr,
token_id: optional_fields.token_id.unwrap().to_lowercase(),
sats,
tokens,
txid: optional_fields.txid.unwrap().to_lowercase(),
tx_pos: optional_fields.tx_pos.unwrap(),
pool_id: optional_fields.pool_id.unwrap().to_lowercase(),
});
Ok(true)
}
}
/// Get list of active pools for given token and/or user.
/// Either user or token ID must be provided.
///
/// - user: 20 byte hash of a users pkh
/// - token: byte token ID.
///
/// Status: Stable
///
/// - token: Token ID or symbol
/// - pkh: Public key hash
///
/// **Response Example:**
/// ```json
/// {
/// "active": [
/// {
/// "owner_p2pkh_addr": "bitcoincash:zqmvqqsd6w08e4nvy8er0hzn6wzxvxj40u7tlk8wl3",
/// "owner_pkh": "36c0020dd39e7cd66c21f237dc53d384661a557f",
/// "sats": 776661580,
/// "token_id": "b79bfc8246b5fc4707e7c7dedcb6619ef1ab91f494a790c20b0f4c422ed95b92",
/// "tokens": 16,
/// "tx_pos": 0,
/// "txid": "94a933a0fa55093a0965eb867f1b9cac2bb07488ced4825bc31f86c9371f76aa"
/// }
/// ]
/// }
/// ```
#[get("/pool/active?<token>&<pkh>")]
pub fn list_active_pools(
token: Option<&str>,
pkh: Option<&str>,
conn: &State<DB>,
) -> CachedApiResult<Value> {
let db = conn.cauldron_r.get().map_err(db_error)?;
if token.is_none() && pkh.is_none() {
return Err(bad_request(
ApiErrorCode::MissingParameters,
"Provide token or pkh",
));
}
let mut active_pool_list = ActivePoolList::default();
db_visit_pool_entries(
&db,
&mut active_pool_list,
PoolFilters {
token_id: token.map(|s| s.to_string()),
owner: pkh.map(|s| s.to_string()),
timestamp_lt: None,
timestamp_lte: None,
timestamp_gt: None,
timestamp_gte: None,
},
)
.map_err(db_error)?;
Ok(cached_ok(
json!({
"active": active_pool_list.active,
}),
CACHE_NONE,
))
}
#[get("/pool/history/<pool_id>?<start>")]
pub fn pool_history(pool_id: &str, start: Option<u64>, conn: &State<DB>) -> CachedApiResult<Value> {
let db = conn.cauldron_r.get().map_err(db_error)?;
let start = start.unwrap_or(time_now() as u64 - (30 * 3600 * 24) /* 30 days ago */);
let pool_id = PoolID::from_hex(pool_id).map_err(|e| {
bad_request(
ApiErrorCode::InvalidPoolId,
&format!("Invalid pool ID: {e}"),
)
})?;
let (token_id, owner_pkh) = db_pool_get_details(&db, &pool_id).map_err(|e| {
let err_msg = e.to_string();
if err_msg.contains("not found") || err_msg.contains("no rows") {
not_found(ApiErrorCode::PoolNotFound, &format!("Pool not found: {e}"))
} else {
db_error(e)
}
})?;
let history = db_pool_history(&db, &pool_id, start).map_err(db_error)?;
// No end parameter exists, so this always queries to "now" - don't cache
Ok(cached_ok(
json!({
"history": history,
"token_id": token_id,
"owner_pkh": owner_pkh,
}),
CACHE_NONE,
))
}
/// Get pool ID from a UTXO specified by transaction ID and input position.
///
/// - txid: Transaction ID (hex string)
/// - input_pos: Input position (vout) in the transaction
///
/// **Response Example:**
/// ```json
/// {
/// "pool_id": "a1b2c3d4e5f6..."
/// }
/// ```
#[get("/pool/id_from_utxo?<txid>&<n>")]
pub fn pool_id_from_utxo(txid: &str, n: u32, conn: &State<DB>) -> CachedApiResult<Value> {
let db = conn.cauldron_r.get().map_err(db_error)?;
// Parse txid from hex
let txid = Txid::from_hex(txid)
.map_err(|e| bad_request(ApiErrorCode::InvalidTxid, &format!("Invalid txid: {e}")))?;
// Compute outpoint hash from txid and input position
let utxo_hash = compute_outpoint_hash(&txid, n);
// Query pool_history_entry table
let pool_id = db_pool_id_from_utxo(&db, &utxo_hash).map_err(db_error)?;
match pool_id {
Some(pool_id) => Ok(cached_ok(
json!({
"pool_id": pool_id
}),
CACHE_AGGREGATE,
)),
None => Err(not_found(
ApiErrorCode::PoolNotFound,
&format!(
"No pool found for UTXO: txid={}, input_pos={}",
txid.to_hex(),
n
),
)),
}
}