// Copyright (C) 2025-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 rusqlite::Connection; use anyhow::Result; use crate::timeutil::time_now; #[repr(u64)] #[derive(Copy, Clone, Debug, PartialEq)] pub(crate) enum OptionalPoolFields { Owner = 1, Txid = 1 << 2, TxPos = 1 << 3, TokenId = 1 << 4, PoolId = 1 << 5, Timestamp = 1 << 6, } #[derive(Default)] pub(crate) struct PoolFilters { pub timestamp_lt: Option, pub timestamp_lte: Option, pub timestamp_gt: Option, pub timestamp_gte: Option, pub token_id: Option, pub owner: Option, } impl PoolFilters { pub fn new() -> Self { Self::default() } #[allow(dead_code)] pub fn lt(mut self, timestamp: u64) -> Self { self.timestamp_lt = Some(timestamp); self } pub fn lte(mut self, timestamp: u64) -> Self { self.timestamp_lte = Some(timestamp); self } #[allow(dead_code)] pub fn gt(mut self, timestamp: u64) -> Self { self.timestamp_gt = Some(timestamp); self } #[allow(dead_code)] pub fn gte(mut self, timestamp: u64) -> Self { self.timestamp_gte = Some(timestamp); self } pub fn token_id(mut self, token_id: &str) -> Self { self.token_id = Some(token_id.to_string()); self } #[allow(dead_code)] pub fn owner(mut self, owner: &str) -> Self { self.owner = Some(owner.to_string()); self } } #[derive(Default)] pub(crate) struct OptionalFields { pub owner: Option, pub txid: Option, pub tx_pos: Option, pub token_id: Option, pub pool_id: Option, pub timestamp: Option, } pub(crate) trait PoolVisitor { fn optional_fields_wanted(&self) -> u64; fn visit(&mut self, sats: u64, tokens: u64, optional_fields: OptionalFields) -> Result; } fn time_filters( filters: &PoolFilters, param_index: usize, ) -> (String, Vec>) { let now = time_now() as u64; let mut clauses: Vec = vec!["1=1".to_string()]; let mut params: Vec = Vec::new(); let mut next_idx: usize = param_index; if filters.timestamp_lt.map(|t| t <= now).unwrap_or(false) { clauses.push(format!("effective_timestamp < ?{}", next_idx)); params.push(rusqlite::types::ToSqlOutput::Owned( (filters.timestamp_lt.unwrap() as i64).into(), )); next_idx += 1; } if filters.timestamp_lte.map(|t| t <= now).unwrap_or(false) { clauses.push(format!("effective_timestamp <= ?{}", next_idx)); params.push(rusqlite::types::ToSqlOutput::Owned( (filters.timestamp_lte.unwrap() as i64).into(), )); next_idx += 1; } if let Some(gt) = filters.timestamp_gt { clauses.push(format!("effective_timestamp > ?{}", next_idx)); params.push(rusqlite::types::ToSqlOutput::Owned((gt as i64).into())); next_idx += 1; } if let Some(gte) = filters.timestamp_gte { clauses.push(format!("effective_timestamp >= ?{}", next_idx)); params.push(rusqlite::types::ToSqlOutput::Owned((gte as i64).into())); } let phe_sql = format!(" WHERE {} ", clauses.join(" AND ")); (phe_sql, params) } pub(crate) fn db_visit_pool_entries( conn: &Connection, visitor: &mut T, filters: PoolFilters, ) -> Result<()> { let (phe_timestamp_filter, time_params) = time_filters(&filters, 2); // Start at ?2, ?1 for withdrawn let mut sql_filters = Vec::new(); let mut params: Vec = time_params; let mut param_index = 2 + params.len(); let phe_tokenid_filter = if let Some(token_id) = &filters.token_id { params.push(rusqlite::types::ToSqlOutput::Owned( token_id.to_owned().into(), )); let this_param_index = param_index; param_index += 1; format!("AND token_id = ?{}", this_param_index) } else { "".to_string() }; if let Some(owner) = &filters.owner { sql_filters.push(format!("p.owner_pkh = ?{}", param_index)); params.push(rusqlite::types::ToSqlOutput::Owned(owner.to_owned().into())); } let sql_filters_str = if sql_filters.is_empty() { "".to_string() } else { format!("AND {}", sql_filters.join(" AND ")) }; let query: String = " SELECT p.owner_pkh, phe.sats, phe.token_amount, phe.txid, phe.tx_pos, p.token_id, p.creation_utxo, phe.effective_timestamp FROM pool p JOIN pool_history_entry phe ON p.creation_utxo = phe.pool JOIN ( SELECT pool, MAX(sequence) AS max_sequence FROM pool_history_entry {phe_timestamp_filter} {phe_tokenid_filter} GROUP BY pool ) max_phe ON phe.pool = max_phe.pool AND phe.sequence = max_phe.max_sequence WHERE (p.withdrawn_in_utxo IS NULL OR ( SELECT effective_timestamp FROM utxo_spending us JOIN tx t ON us.txid = t.txid WHERE us.spent_utxo_hash = p.withdrawn_in_utxo ) >= ?1) {filters} " .replace("{filters}", &sql_filters_str) .replace("{phe_timestamp_filter}", &phe_timestamp_filter) .replace("{phe_tokenid_filter}", &phe_tokenid_filter); // allow to include pools that were withdrawn AFTER a time filter. let withdraw_time_filter = filters .timestamp_lt .min(filters.timestamp_lte) .unwrap_or(i64::MAX as u64); let mut final_params = vec![rusqlite::types::ToSqlOutput::Owned( (withdraw_time_filter as i64).into(), )]; final_params.extend(params); let mut stmt = conn.prepare(&query)?; let mut rows = stmt.query(rusqlite::params_from_iter(final_params))?; let wanted = visitor.optional_fields_wanted(); while let Some(row) = rows.next()? { let sats: u64 = row.get(1)?; let tokens: u64 = row.get(2)?; let mut optional: OptionalFields = OptionalFields::default(); if (wanted & OptionalPoolFields::Owner as u64) != 0 { optional.owner = Some(row.get(0)?); } if (wanted & OptionalPoolFields::Txid as u64) != 0 { optional.txid = Some(row.get(3)?); } if (wanted & OptionalPoolFields::TxPos as u64) != 0 { optional.tx_pos = Some(row.get(4)?); } if (wanted & OptionalPoolFields::TokenId as u64) != 0 { optional.token_id = Some(row.get(5)?); } if (wanted & OptionalPoolFields::PoolId as u64) != 0 { optional.pool_id = Some(row.get(6)?); } if (wanted & OptionalPoolFields::Timestamp as u64) != 0 { optional.timestamp = Some(row.get(7)?); } if !visitor.visit(sats, tokens, optional)? { break; } } Ok(()) }