// 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 poolperiod::PoolPeriod; use rocket::{get, State}; use serde_json::Value; use crate::{ db::{ cauldron::pool::{get_pool_period_snapshot, get_pool_period_snapshot_by_pool_ids}, DB, }, rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult}, rpc::response::{cached_ok, CACHE_AGGREGATE}, timeutil::time_now, }; use serde_json::json; pub mod apyaggregator; pub mod poolperiod; #[derive(Clone, Debug)] pub struct PoolSnapshot { pub pool_id: String, pub timestamp: u64, pub sats: u64, pub token_amount: u64, } impl PoolSnapshot { #[allow(dead_code)] // used in unit tests pub fn dummy(timestamp: u64, sats: u64, token_amount: u64) -> Self { Self { pool_id: "dummy".to_string(), timestamp, sats, token_amount, } } } /// Fetch apy for a token and/or an account within a given time interval. All variables are optional. /// A query with no variables will return the AAPY based on all users and all tokens aggregated. /// /// Status: Stable /// /// - token: The 32 byte token ID /// - pkh: Public key hash for a single wallet account /// - pool: One or more pool IDs to scope the APY calculation to specific pools. /// Use this for per-user APY: first resolve pool IDs via `/pool/active?pkh=`, /// then pass them here. Supports multiple wallets (collect IDs from each PKH first). /// **Cannot be combined with token or pkh** — returns 400 if mixed. /// - start: Unix timestamp for period start (default 30 days) /// - end: Unix timestamp for period end (default NOW) /// /// **Response Example:** /// /// ```json /// {"apy":"10.00","pools":100} /// ``` /// #[get("/pool/aggregated_apy?&&&&")] pub async fn aggregate_apy( token: Option<&str>, pkh: Option<&str>, pool: Vec<&str>, start: Option, end: Option, db: &State, ) -> CachedApiResult { let end = end.unwrap_or(time_now()); let start = start.unwrap_or(end - (3600 * 24 * 30)); // 30 days if !pool.is_empty() && (token.is_some() || pkh.is_some()) { return Err(bad_request( ApiErrorCode::InvalidParameters, "pool cannot be combined with token or pkh; use pool alone to filter by specific pools", )); } if end < start { return Err(bad_request( ApiErrorCode::InvalidTimeRange, "end time cannot be less than start time", )); } if start < 0 { return Err(bad_request( ApiErrorCode::InvalidTimeRange, "start time cannot be negative", )); } let pool_snapshots = if !pool.is_empty() { let ids: Vec = pool.into_iter().map(|s| s.to_string()).collect(); get_pool_period_snapshot_by_pool_ids(&db.cauldron_r, &ids, start, end) .await .map_err(db_error)? } else { get_pool_period_snapshot(&db.cauldron_r, token, pkh, start, end) .await .map_err(db_error)? }; let pools: anyhow::Result> = pool_snapshots .into_iter() .map(|(start, end)| PoolPeriod::new(start, end)) .collect(); let pools = pools.map_err(db_error)?; let pools_count = pools.len(); let apy = apyaggregator::APYAggregator::aggregate_apy(pools.into_iter(), Some(start as u64)) .map_err(db_error)?; Ok(cached_ok( json!({ "apy": apy.to_string(), "pools": pools_count, }), CACHE_AGGREGATE, )) }