163 lines
5.2 KiB
Rust
163 lines
5.2 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 poolperiod::PoolPeriod;
|
|
use rocket::{get, State};
|
|
use serde_json::Value;
|
|
|
|
use crate::{
|
|
db::{
|
|
blob::display_hex_to_blob,
|
|
cauldron::pool::{
|
|
get_injections_between, get_pool_period_snapshot, get_pool_period_snapshot_by_pool_ids,
|
|
},
|
|
DB,
|
|
},
|
|
def::PoolID,
|
|
rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult},
|
|
rpc::response::{cached_ok, CACHE_AGGREGATE},
|
|
rpc::validate::{parse_pkh, parse_token_id},
|
|
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=<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?<token>&<pkh>&<pool>&<start>&<end>")]
|
|
pub async fn aggregate_apy(
|
|
token: Option<&str>,
|
|
pkh: Option<&str>,
|
|
pool: Vec<&str>,
|
|
start: Option<i64>,
|
|
end: Option<i64>,
|
|
db: &State<DB>,
|
|
) -> CachedApiResult<Value> {
|
|
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<String> = 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 {
|
|
let token = token.map(parse_token_id).transpose()?;
|
|
let pkh = pkh.map(parse_pkh).transpose()?;
|
|
|
|
get_pool_period_snapshot(&db.cauldron_r, token.as_deref(), pkh.as_deref(), start, end)
|
|
.await
|
|
.map_err(db_error)?
|
|
};
|
|
|
|
// Fetch injection events (capital additions) for all pools in the result.
|
|
// Each pool period is split into sub-periods at injection boundaries so the
|
|
// APY calculation only sees real fee growth, never injected capital.
|
|
let pool_id_blobs: Vec<Vec<u8>> = pool_snapshots
|
|
.iter()
|
|
.filter_map(|(s, _)| display_hex_to_blob::<PoolID>(&s.pool_id).ok())
|
|
.collect();
|
|
|
|
let min_start_ts = pool_snapshots
|
|
.iter()
|
|
.map(|(s, _)| s.timestamp as i64)
|
|
.min()
|
|
.unwrap_or(start);
|
|
|
|
let injections_map = get_injections_between(&db.cauldron_r, &pool_id_blobs, min_start_ts, end)
|
|
.await
|
|
.map_err(db_error)?;
|
|
|
|
let pools_count = pool_snapshots.len();
|
|
let pools: anyhow::Result<Vec<PoolPeriod>> = pool_snapshots
|
|
.into_iter()
|
|
.flat_map(|(start_snap, end_snap)| {
|
|
let injections = injections_map
|
|
.get(&start_snap.pool_id)
|
|
.map(|entries| {
|
|
entries
|
|
.iter()
|
|
.filter(|r| {
|
|
r.timestamp > start_snap.timestamp && r.timestamp < end_snap.timestamp
|
|
})
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
poolperiod::split_at_injections(start_snap, end_snap, injections)
|
|
})
|
|
.collect();
|
|
|
|
let pools = pools.map_err(db_error)?;
|
|
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,
|
|
))
|
|
}
|