// 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> { 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) -> CachedApiResult { let db = conn.cauldron_r.get().map_err(db_error)?; let pools: Vec = 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, } 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 { 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?&")] pub fn list_active_pools( token: Option<&str>, pkh: Option<&str>, conn: &State, ) -> CachedApiResult { 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/?")] pub fn pool_history(pool_id: &str, start: Option, conn: &State) -> CachedApiResult { 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?&")] pub fn pool_id_from_utxo(txid: &str, n: u32, conn: &State) -> CachedApiResult { 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 ), )), } }