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
355 lines
10 KiB
Rust
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
|
|
),
|
|
)),
|
|
}
|
|
}
|