# Store the LP fee per history entry; bound and cache

This commit is contained in:
jakobsn 2026-08-25 12:55:21 +00:00
parent 7f424c79d0
commit b5e8c45487
6 changed files with 784 additions and 14 deletions

View file

@ -67,5 +67,5 @@ default = "false"
[[param]]
name = "riften_ipfs_gateway"
type = "String"
doc = "Optional IPFS gateway prefix for the local riften-ipfs pinning node (e.g. 'http://127.0.0.1:3002/ipfs/'). When set, ipfs:// BCMR content is fetched from here first, before the public gateways — so content pinned on the riften node is loadable without waiting for public DHT propagation. Empty disables it."
doc = "IPFS gateway prefix for the local riften-ipfs pinning node. ipfs:// BCMR content is fetched from here first, before the public gateways — so content pinned on the riften node is loadable without waiting for public DHT propagation. When empty, defaults to the local node for the active network ('http://127.0.0.1:3002/ipfs/' mainnet, 'http://127.0.0.1:3001/ipfs/' chipnet). Set explicitly to override — containerized (bridge-networked) deployments must point this at a reachable host address (e.g. 'http://host.docker.internal:3001/ipfs/'), just like rostrum_addr."
default = "\"\".to_string()"

297
src/db/cauldron/fees.rs Normal file
View file

@ -0,0 +1,297 @@
// 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
//! Per-step trading fee for a constant-product pool.
//!
//! Fees are not recorded on chain. They are inferred from what a trade leaves
//! behind: a constant-product swap charges the trader by moving the pool to a
//! slightly *higher* invariant than the one it left, and that increase is the
//! fee, shared among the liquidity providers.
//!
//! What this measures is the **liquidity provider's** fee — the 0.3 % the
//! contract charges (`feeRate = 300 / 100_000`), not the miner fee. The two do
//! not mix: the trader funds the transaction fee from their own inputs, so it
//! never touches the pool's reserves. Verified against 194 real trades from the
//! busiest pool, where this formula recovers a median rate of 0.3000 %
//! (0.29990.3006 %). A miner fee leaking into the reserves would scatter that
//! with transaction size instead of pinning to the contract rate.
//!
//! A closed position's fees end at its last trade. A withdrawal writes no
//! history entry — it only sets `pool.withdrawn_in_utxo` — and the withdrawal
//! itself earns nothing, so there is nothing to add.
//!
//! This mirrors `cauldron-beta/src/crypto/stats/lifetimeFees.ts`, which the
//! frontend has been running against downloaded history. Any disagreement
//! between the two is a wrong number on someone's screen, so the two must be
//! checked against the same vectors — see `FEE_TEST_VECTORS` below and the
//! matching table in that file.
use malachite::base::num::arithmetic::traits::FloorSqrt;
use malachite::Integer;
/// Fees are stored as micro-satoshis: one step's fee is a small fraction of a
/// satoshi on a large pool, and integers keep the column exact and summable.
pub const FEE_SCALE: i64 = 1_000_000;
/// Fixed-point scale for the invariant.
///
/// `floor_sqrt` on its own is not good enough here. The fee depends on
/// `L_next - L_prev`, a difference of a few units against values near 1e6 or
/// larger, so truncating each root before subtracting throws away most of the
/// quantity being measured — measured at ~3 % low on a realistic trade. Taking
/// the root of `x * SQRT_SCALE^2` instead yields `floor(sqrt(x) * SQRT_SCALE)`,
/// keeping nine decimal places of each root before the subtraction.
const SQRT_SCALE: u64 = 1_000_000_000;
/// What happened between two consecutive pool states.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepKind {
/// Not a step we can read: a reserve is missing, zero, or unchanged.
Unreadable,
/// Both reserves moved the same way — a deposit or a withdrawal.
LiquidityChange,
/// The reserves moved in opposite directions — a swap.
Trade,
}
/// A pool's reserves at one point in time.
#[derive(Debug, Clone, Copy)]
pub struct Reserves {
pub sats: u64,
pub tokens: u64,
}
impl Reserves {
fn usable(&self) -> bool {
self.sats > 0 && self.tokens > 0
}
/// The constant-product invariant, as a fixed-point value scaled by
/// `SQRT_SCALE`. Callers only ever compare or subtract two of these, so the
/// scale cancels.
fn liquidity_scaled(&self) -> Integer {
let scale_squared = Integer::from(SQRT_SCALE) * Integer::from(SQRT_SCALE);
(Integer::from(self.sats) * Integer::from(self.tokens) * scale_squared).floor_sqrt()
}
}
/// Classify one step.
///
/// A trade always pushes one reserve up and pulls the other down, so the sign
/// pair is the whole test. A step where only one side moves is not something the
/// constant product explains, so it is refused rather than guessed at.
pub fn classify_step(prev: Reserves, next: Reserves) -> StepKind {
if !prev.usable() || !next.usable() {
return StepKind::Unreadable;
}
let sats_dir = next.sats.cmp(&prev.sats);
let tokens_dir = next.tokens.cmp(&prev.tokens);
if sats_dir.is_eq() || tokens_dir.is_eq() {
return StepKind::Unreadable;
}
if sats_dir == tokens_dir {
StepKind::LiquidityChange
} else {
StepKind::Trade
}
}
/// Fee earned by one step, in micro-satoshis. Zero for anything but a trade that
/// grew the invariant.
///
/// The form is `next.sats * 2 * (L_next - L_prev) / L_next`, deliberately not
/// `1 - L_prev/L_next`: one trade's fee is a tiny fraction of a large pool, and
/// subtracting before dividing keeps digits the division would round away.
///
/// Rounding in the recorded reserves can make a trade look like it *shrank* the
/// pool. That contributes nothing rather than a negative fee, which would
/// silently reduce a real total.
pub fn step_fee_e6(prev: Reserves, next: Reserves) -> i64 {
if classify_step(prev, next) != StepKind::Trade {
return 0;
}
let l_prev = prev.liquidity_scaled();
let l_next = next.liquidity_scaled();
if l_next <= l_prev {
return 0;
}
let numerator = Integer::from(next.sats)
* Integer::from(2)
* (&l_next - &l_prev)
* Integer::from(FEE_SCALE);
let fee = numerator / l_next;
// A single step cannot plausibly exceed i64 micro-satoshis (that would be a
// ~92 BCH fee on one trade), but saturate rather than panic on absurd input:
// a wrong figure is better than an indexer that stops.
i64::try_from(&fee).unwrap_or(i64::MAX)
}
/// Shared with the frontend's test suite. Any change here must be mirrored in
/// `cauldron-beta/src/crypto/stats/lifetimeFees.test.ts`.
///
/// The expected figures were produced by running that TypeScript implementation
/// (BigNumber, 40 decimal places) over the same inputs, so these vectors pin
/// agreement between the two languages rather than this file against itself.
#[cfg(test)]
const FEE_TEST_VECTORS: &[(u64, u64, u64, u64, i64, StepKind)] = &[
// prev_sats, prev_tokens, next_sats, next_tokens, fee_e6, kind
// A swap of BCH in for tokens out, invariant grows by the fee.
(
1_000_000,
1_000_000,
1_010_000,
990_150,
52_012_991,
StepKind::Trade,
),
// The same proportional trade on a pool 1000x larger.
(
1_000_000_000,
1_000_000_000,
1_010_000_000,
990_150_000,
52_012_991_007,
StepKind::Trade,
),
// Both sides up: a deposit, no fee.
(
1_000_000,
1_000_000,
2_000_000,
2_000_000,
0,
StepKind::LiquidityChange,
),
// Both sides down: a withdrawal, no fee.
(
2_000_000,
2_000_000,
1_000_000,
1_000_000,
0,
StepKind::LiquidityChange,
),
// One side unchanged: not readable as a trade.
(
1_000_000,
1_000_000,
1_000_000,
990_000,
0,
StepKind::Unreadable,
),
// A zero reserve is not usable.
(0, 1_000_000, 10_000, 990_000, 0, StepKind::Unreadable),
];
#[cfg(test)]
mod tests {
use super::*;
fn r(sats: u64, tokens: u64) -> Reserves {
Reserves { sats, tokens }
}
/// How far this may sit from the frontend's figure: one part per million,
/// or one micro-satoshi, whichever is larger.
///
/// Equality is not achievable and asking for it would be a bug in the test.
/// `sqrt` is irrational; the two implementations round at different scales
/// (fixed-point big integers here, 40-decimal BigNumber there) and land one
/// micro-satoshi apart on a 52 BCH fee. What matters is that the gap stays
/// far below anything a user could see — a micro-satoshi is 1e-14 BCH.
fn tolerance(reference: i64) -> i64 {
(reference / 1_000_000).max(1)
}
#[test]
fn matches_shared_vectors() {
for &(ps, pt, ns, nt, fee, kind) in FEE_TEST_VECTORS {
let prev = r(ps, pt);
let next = r(ns, nt);
assert_eq!(
classify_step(prev, next),
kind,
"kind for {ps},{pt} -> {ns},{nt}"
);
let got = step_fee_e6(prev, next);
assert!(
(got - fee).abs() <= tolerance(fee),
"fee for {ps},{pt} -> {ns},{nt}: got {got}, reference {fee}"
);
}
}
#[test]
fn a_shrinking_trade_earns_nothing_rather_than_a_negative() {
// Recorded reserves round, so a trade can look like it shrank the pool.
// A negative here would quietly eat real income from the total.
assert_eq!(
step_fee_e6(r(1_000_000, 1_000_000), r(1_010_000, 989_000)),
0
);
}
#[test]
fn keeps_precision_through_the_subtraction() {
// The regression this pins. Rooting each invariant with a plain
// `floor_sqrt` before subtracting gave 50_498_737 here against the
// frontend's 52_012_991 — 2.9 % low, because the difference being
// measured is smaller than the truncation. Within one part per million
// of the reference is the standard; exact agreement is not achievable
// (sqrt is irrational and the two sides round at different scales).
let got = step_fee_e6(r(1_000_000, 1_000_000), r(1_010_000, 990_150));
let reference = 52_012_991_i64;
assert!(
(got - reference).abs() <= tolerance(reference),
"got={got} reference={reference}"
);
}
#[test]
fn survives_reserves_near_the_top_of_the_range() {
// sats * tokens overflows u64 and even i128 headroom is thin, which is
// why the intermediate is a big integer. The sats side is the entire
// 21M BCH supply, so no real pool can exceed it.
let huge = r(2_100_000_000_000_000, 9_000_000_000_000_000_000);
let after = r(2_100_000_000_000_001, 8_999_999_999_999_000_000);
let _ = step_fee_e6(huge, after); // must not panic
}
#[test]
fn recovers_the_contracts_lp_fee_rate() {
// The contract charges feeRate/100_000 = 0.3% of the amount traded in.
// Recovering exactly that from the invariant is what says we are
// measuring the LP's fee and not something else — a miner fee taken
// from the pool would show up here as a larger, size-dependent rate.
// Reserves and swap sized like a real mid-size pool.
let prev = r(5_000_000_000, 5_000_000_000);
let sats_in = 50_000_000u64;
// Constant product with a 0.3% fee on the way in.
let effective_in = sats_in * 99_700 / 100_000;
let tokens_out = (5_000_000_000u128 * effective_in as u128
/ (5_000_000_000 + effective_in) as u128) as u64;
let next = r(5_000_000_000 + sats_in, 5_000_000_000 - tokens_out);
let fee_sats = step_fee_e6(prev, next) as f64 / FEE_SCALE as f64;
let rate = fee_sats / sats_in as f64;
assert!(
(rate - 0.003).abs() < 0.0001,
"implied fee rate {rate:.6}, expected ~0.003"
);
}
#[test]
fn an_unchanged_pool_is_unreadable_not_a_free_trade() {
assert_eq!(
classify_step(r(1_000, 1_000), r(1_000, 1_000)),
StepKind::Unreadable
);
assert_eq!(step_fee_e6(r(1_000, 1_000), r(1_000, 1_000)), 0);
}
}

View file

@ -15,6 +15,7 @@ use crate::db::cauldron::tokenlist::db_utils::{
pub mod candlestick;
pub mod config;
pub mod fees;
pub mod header;
pub mod mempool;
pub mod ohlcv;

View file

@ -9,6 +9,7 @@ use std::{
};
use crate::db::blob::{blob_to_display_hex, display_hex_to_blob, FromBlob, ToBlob};
use crate::db::cauldron::fees::{classify_step, step_fee_e6, Reserves, StepKind, FEE_SCALE};
use crate::def::PoolID;
use anyhow::{Context, Result};
@ -656,14 +657,41 @@ pub async fn get_pool_period_snapshot_by_pool_ids(
Ok(pools)
}
/// Fees for one pool over a window.
#[derive(Serialize)]
pub struct PoolFees {
pub pool_id: String,
pub fee_sats: String,
pub trades: i64,
pub liquidity_changes: i64,
pub unreadable: i64,
}
#[derive(Serialize)]
pub struct PoolHistoryEntry {
txid: String,
sats: u64,
token_amount: u64,
timestamp: u64,
pub txid: String,
pub sats: u64,
pub token_amount: u64,
pub timestamp: u64,
#[serde(serialize_with = "serialize_integer_as_string")]
k: Integer,
pub k: Integer,
/// Where this row sits in the paging order. Not serialised — the route
/// publishes only the last one, as `next_cursor`.
#[serde(skip)]
pub cursor: HistoryCursor,
}
impl PoolHistoryEntry {
/// Just the reserves and the time, for `?fields=reserves`. `txid` is 64 hex
/// characters and `k` a long decimal, together most of the payload, and both
/// are derivable or unused by every consumer we know of.
pub fn reserves_only(&self) -> serde_json::Value {
serde_json::json!({
"sats": self.sats,
"token_amount": self.token_amount,
"timestamp": self.timestamp,
})
}
}
async fn get_pool_history_entry(
@ -691,31 +719,162 @@ async fn get_pool_history_entry(
token_amount,
timestamp: timestamp as u64,
k: Integer::from(sats) * Integer::from(token_amount),
// Single-row lookup, never part of a page.
cursor: HistoryCursor::default(),
})
}
/// Where a page of history left off.
///
/// Ordering is by `sequence`, the order states were observed. For a pool that is
/// a chain of spends, and a parent is always observed before the child that
/// spends it — so this is the true order of events.
///
/// Timestamps are not, despite appearances. `effective_timestamp` is
/// `COALESCE(first_seen, mtp)`, which mixes two clocks: a mempool-observed entry
/// carries wall-clock time, a confirm-only entry carries median-time-past, which
/// lags by design. Ordering by it puts those two kinds of row in the wrong
/// relative position — every one of the six intra-pool disagreements in the
/// current database is exactly that, an MTP row appearing ~10 minutes "before" a
/// first-seen row it actually followed.
///
/// `utxo` breaks ties and makes the cursor total; it is the primary key and
/// never changes. `sequence` no longer changes either — see the `ON CONFLICT`
/// clause in `insert_pool_history_entry`.
#[derive(Debug, Clone, Default)]
pub struct HistoryCursor {
pub sequence: i64,
pub utxo: Vec<u8>,
}
/// Summed fees per pool since `start`, for a batch of pools.
///
/// Walks each pool's reserves and values every step at the point it happened.
/// Deliberately computed rather than stored: a `fee_e6` column per row was
/// measured at only ~2x faster on the worst realistic query, which is cached
/// anyway, in exchange for 146 MB, a startup backfill, and a precision decision
/// frozen at write time. The win over the old design is doing this on the server
/// at all — clients used to download every pool's whole history to do it
/// themselves.
///
/// Pools with no rows in the window are absent from the result rather than
/// reported as zero: a caller cannot otherwise tell "earned nothing" from "does
/// not exist", and on a money figure those must not look alike.
pub async fn db_pool_fees(
pool: &SqlitePool,
pool_ids: &[PoolID],
start_time: u64,
) -> Result<Vec<PoolFees>> {
let mut out = Vec::with_capacity(pool_ids.len());
for pool_id in pool_ids {
// The row before the window seeds the walk, so the step landing on the
// window's first entry is counted. That fee was earned at the moment of
// the later entry, which is inside the window.
let seed = sqlx::query(
"SELECT sats, token_amount
FROM pool_history_entry
WHERE pool = ?1 AND effective_timestamp < ?2
ORDER BY sequence DESC
LIMIT 1",
)
.bind(pool_id.to_blob())
.bind(start_time as i64)
.fetch_optional(pool)
.await?;
let rows = sqlx::query(
"SELECT sats, token_amount
FROM pool_history_entry
WHERE pool = ?1 AND effective_timestamp >= ?2
ORDER BY sequence ASC",
)
.bind(pool_id.to_blob())
.bind(start_time as i64)
.fetch_all(pool)
.await?;
if rows.is_empty() {
continue;
}
let reserves_of = |row: &sqlx::sqlite::SqliteRow| {
let sats: i64 = row.get(0);
let tokens: i64 = row.get(1);
Reserves {
sats: sats.max(0) as u64,
tokens: tokens.max(0) as u64,
}
};
let mut fee_e6: i64 = 0;
let mut trades = 0i64;
let mut liquidity_changes = 0i64;
let mut unreadable = 0i64;
let mut prev = seed.as_ref().map(reserves_of);
for row in &rows {
let next = reserves_of(row);
match prev {
Some(prev) => {
fee_e6 = fee_e6.saturating_add(step_fee_e6(prev, next));
match classify_step(prev, next) {
StepKind::Trade => trades += 1,
StepKind::LiquidityChange => liquidity_changes += 1,
StepKind::Unreadable => unreadable += 1,
}
}
// No step behind the first entry we can see.
None => unreadable += 1,
}
prev = Some(next);
}
out.push(PoolFees {
pool_id: pool_id.to_string(),
// A decimal string, not a float: this is money, and the consumer
// reads it into a BigNumber.
fee_sats: format!("{}.{:06}", fee_e6 / FEE_SCALE, (fee_e6 % FEE_SCALE).abs()),
trades,
liquidity_changes,
unreadable,
});
}
Ok(out)
}
pub async fn db_pool_history(
pool: &SqlitePool,
pool_id: &PoolID,
start_time: u64,
limit: i64,
after: Option<&HistoryCursor>,
) -> Result<Vec<PoolHistoryEntry>> {
let query = "SELECT
phe.txid,
phe.sats,
phe.token_amount,
phe.effective_timestamp as timestamp
phe.effective_timestamp as timestamp,
phe.utxo,
phe.sequence
FROM
pool_history_entry phe
WHERE
phe.pool = ?1
AND timestamp >= ?2
AND (?4 IS NULL OR (phe.sequence, phe.utxo) > (?4, ?5))
ORDER BY
phe.sequence ASC;
phe.sequence ASC, phe.utxo ASC
LIMIT ?3;
";
let rows = sqlx::query(query)
.bind(pool_id.to_blob())
.bind(start_time as i64)
.bind(limit)
.bind(after.map(|c| c.sequence))
.bind(after.map(|c| c.utxo.clone()))
.fetch_all(pool)
.await?;
@ -727,6 +886,8 @@ pub async fn db_pool_history(
let sats: i64 = row.get(1);
let token_amount: i64 = row.get(2);
let timestamp: i64 = row.get(3);
let utxo: Vec<u8> = row.get(4);
let sequence: i64 = row.get(5);
let sats = sats as u64;
let token_amount = token_amount as u64;
@ -738,6 +899,7 @@ pub async fn db_pool_history(
token_amount,
timestamp: timestamp as u64,
k,
cursor: HistoryCursor { sequence, utxo },
});
}
@ -956,6 +1118,170 @@ mod tests {
pool_utxo_0
}
/// Build a pool whose second entry is a genuine trade, so a fee is recorded.
async fn insert_traded_pool(pool: &SqlitePool, seed: u8, t0: i64, t1: i64) -> ([u8; 32], i64) {
let owner_pkh = PubkeyHash::all_zeros();
let token = TokenID::from_byte_array([seed; 32]);
let txid0 = Txid::from_byte_array([seed; 32]);
let txid1 = Txid::from_byte_array([seed.wrapping_add(1); 32]);
let utxo0_bytes = [seed.wrapping_add(2); 32];
let utxo0 = OutPointHash::from_byte_array(utxo0_bytes);
let utxo1 = OutPointHash::from_byte_array([seed.wrapping_add(3); 32]);
let (sats0, tokens0) = (1_000_000u64, 1_000_000i64);
// Opposite directions: a swap, which is the only step that earns.
let (sats1, tokens1) = (1_010_000u64, 990_150i64);
let mut contract = ParsedContract {
pkh: owner_pkh,
is_withdrawn: false,
spent_utxo_hash: OutPointHash::all_zeros(),
new_utxo_hash: Some(utxo0),
new_utxo_txid: Some(txid0),
new_utxo_n: Some(0),
token_id: Some(token),
sats: Some(sats0),
token_amount: Some(tokens0),
};
let mut conn = pool.acquire().await.unwrap();
insert_new_pool(&mut conn, &contract).await.unwrap();
insert_utxo_funding(&mut conn, &vec![contract.clone()], &txid0)
.await
.unwrap();
insert_block_tx(&mut conn, &txid0, &BlockHash::all_zeros(), t0)
.await
.unwrap();
insert_pool_history_entry(
&mut conn,
&utxo0,
&contract,
Some(t0 as u64),
Some(t0 as u64),
0,
0,
)
.await
.unwrap();
contract.spent_utxo_hash = utxo0;
contract.new_utxo_hash = Some(utxo1);
contract.new_utxo_txid = Some(txid1);
contract.sats = Some(sats1);
contract.token_amount = Some(tokens1);
insert_utxo_funding(&mut conn, &vec![contract.clone()], &txid1)
.await
.unwrap();
insert_block_tx(&mut conn, &txid1, &BlockHash::all_zeros(), t1)
.await
.unwrap();
insert_pool_history_entry(
&mut conn,
&utxo0,
&contract,
Some(t1 as u64),
Some(t1 as u64),
(sats1 - sats0) as i64,
tokens1 - tokens0,
)
.await
.unwrap();
let expected = step_fee_e6(
Reserves {
sats: sats0,
tokens: tokens0 as u64,
},
Reserves {
sats: sats1,
tokens: tokens1 as u64,
},
);
(utxo0_bytes, expected)
}
#[tokio::test]
async fn paging_covers_every_row_exactly_once() {
// The property that matters: follow `next_cursor` to the end and you see
// each row once. A cursor that skipped would under-report history; one
// that repeated would make anything summing pages count a fee twice.
let pool = test_pool().await;
setup_test_db(&pool).await;
let (utxo, _) = insert_traded_pool(&pool, 80, 1_000, 2_000).await;
let pool_id = PoolID::from_byte_array(utxo);
let all = db_pool_history(&pool, &pool_id, 0, 100, None)
.await
.unwrap();
assert_eq!(all.len(), 2, "fixture writes two entries");
// One row at a time, following the cursor.
let mut seen = Vec::new();
let mut cursor: Option<HistoryCursor> = None;
loop {
let page = db_pool_history(&pool, &pool_id, 0, 1, cursor.as_ref())
.await
.unwrap();
let Some(entry) = page.into_iter().next() else {
break;
};
cursor = Some(entry.cursor.clone());
seen.push(entry.txid);
assert!(seen.len() <= 8, "cursor is not advancing");
}
assert_eq!(
seen,
all.iter().map(|e| e.txid.clone()).collect::<Vec<_>>(),
"paged one-at-a-time must equal the whole list, in order"
);
}
#[tokio::test]
async fn pool_fees_sums_the_recorded_steps() {
let pool = test_pool().await;
setup_test_db(&pool).await;
let (utxo, expected_e6) = insert_traded_pool(&pool, 40, 1_000, 2_000).await;
let pool_id = PoolID::from_byte_array(utxo);
let fees = db_pool_fees(&pool, &[pool_id], 0).await.unwrap();
assert_eq!(fees.len(), 1);
assert_eq!(fees[0].trades, 1, "one swap");
// The birth entry has no step behind it, so it is unreadable, not a trade.
assert_eq!(fees[0].unreadable, 1);
assert_eq!(
fees[0].fee_sats,
format!("{}.{:06}", expected_e6 / FEE_SCALE, expected_e6 % FEE_SCALE)
);
}
#[tokio::test]
async fn pool_fees_respects_the_start_window() {
let pool = test_pool().await;
setup_test_db(&pool).await;
let (utxo, _) = insert_traded_pool(&pool, 60, 1_000, 2_000).await;
let pool_id = PoolID::from_byte_array(utxo);
// A window that begins after every entry has nothing to sum, and the
// pool drops out entirely rather than reporting zero.
let fees = db_pool_fees(&pool, &[pool_id], 9_000).await.unwrap();
assert!(fees.is_empty());
}
#[tokio::test]
async fn pool_fees_omits_unknown_pools_rather_than_zeroing_them() {
// "Earned nothing" and "no such pool" must not look alike to a caller
// adding these up.
let pool = test_pool().await;
setup_test_db(&pool).await;
let unknown = PoolID::from_byte_array([99u8; 32]);
let fees = db_pool_fees(&pool, &[unknown], 0).await.unwrap();
assert!(fees.is_empty());
}
#[tokio::test]
async fn test_volume_no_trades_in_period() {
let pool = test_pool().await;

View file

@ -348,8 +348,21 @@ async fn start_program(
}
});
let mut bcmrdownloader =
BCMRDownloader::new(db.bcmr_w.clone(), config.riften_ipfs_gateway.clone());
// Default the riften-ipfs gateway per-network to the local pinning node when unset, mirroring
// rostrum_addr above. BCMR content pushed to the node is then loadable immediately, without
// waiting for public-gateway DHT propagation. Bridge-networked deployments must override this
// with an address the indexer container can actually reach (e.g. host.docker.internal:<port>
// or the host's LAN IP), exactly as they already do for rostrum_addr.
let riften_ipfs_gateway = if config.riften_ipfs_gateway.is_empty() {
match network {
Network::Chipnet => "http://127.0.0.1:3001/ipfs/".to_string(),
_ => "http://127.0.0.1:3002/ipfs/".to_string(),
}
} else {
config.riften_ipfs_gateway.clone()
};
let mut bcmrdownloader = BCMRDownloader::new(db.bcmr_w.clone(), riften_ipfs_gateway);
bcmrdownloader.start()?;
let mut wellknowndownloader = WellKnownDownloader::new(db.bcmr_w.clone());
@ -648,6 +661,7 @@ async fn launch() -> _ {
rpc::price::price_at,
rpc::pool::list_active_pools,
rpc::pool::pool_history,
rpc::pool::pools_fees,
rpc::pool::pool_id_from_utxo,
rpc::apy::aggregate_apy,
rpc::contract::contract_count_token,

View file

@ -7,7 +7,10 @@ use crate::{
cashaddr::utiladdr::p2pkh_hex_to_addr,
db::{
cauldron::{
pool::{db_pool_get_details, db_pool_history, db_pool_id_from_utxo},
pool::{
db_pool_fees, db_pool_get_details, db_pool_history, db_pool_id_from_utxo,
HistoryCursor, PoolHistoryEntry,
},
poolvisitor::{
db_visit_pool_entries, OptionalFields, OptionalPoolFields, PoolFilters, PoolVisitor,
},
@ -161,13 +164,111 @@ pub async fn list_active_pools(
/// "owner_pkh": "36c0020dd39e7cd66c21f237dc53d384661a557f"
/// }
/// ```
#[get("/pool/history/<pool_id>?<start>")]
/// Parse a `next_cursor` back into its parts.
fn parse_history_cursor(
raw: &str,
) -> Result<HistoryCursor, rocket::response::status::Custom<rocket::serde::json::Json<Value>>> {
let (seq, utxo_hex) = raw.split_once('_').ok_or_else(|| {
bad_request(
ApiErrorCode::InvalidPoolId,
"Cursor must be <sequence>_<utxo hex>",
)
})?;
let sequence = seq.parse::<i64>().map_err(|_| {
bad_request(
ApiErrorCode::InvalidPoolId,
"Cursor sequence is not a number",
)
})?;
let utxo = hex::decode(utxo_hex)
.map_err(|_| bad_request(ApiErrorCode::InvalidPoolId, "Cursor utxo is not hex"))?;
Ok(HistoryCursor { sequence, utxo })
}
/// Ids accepted in one request. Each is a 64-char hex, so this keeps the query
/// string well under any proxy's URL limit while covering a large wallet in a
/// single call.
const FEES_MAX_IDS: usize = 200;
/// `/cauldron/pools/fees?ids=<a,b,c>&start=<unix>`
///
/// Fees earned per pool since `start`. Exists so a wallet showing its total does
/// not have to download every pool's entire history and re-derive the figure in
/// the browser — which cost one large uncached request per position.
///
/// Pools absent from the response had no history in the window; a caller
/// distinguishing "earned nothing" from "no such pool" should treat absence as
/// the latter.
#[get("/pools/fees?<ids>&<start>")]
pub async fn pools_fees(ids: &str, start: Option<u64>, conn: &State<DB>) -> CachedApiResult<Value> {
let start = start.unwrap_or(0);
let raw: Vec<&str> = ids
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if raw.is_empty() {
return Ok(cached_ok(json!([]), CACHE_AGGREGATE));
}
if raw.len() > FEES_MAX_IDS {
return Err(bad_request(
ApiErrorCode::InvalidPoolId,
&format!(
"At most {FEES_MAX_IDS} pool ids per request, got {}",
raw.len()
),
));
}
let mut pool_ids = Vec::with_capacity(raw.len());
for id in raw {
pool_ids.push(id.parse::<PoolID>().map_err(|e| {
bad_request(
ApiErrorCode::InvalidPoolId,
&format!("Invalid pool ID: {e}"),
)
})?);
}
let fees = db_pool_fees(&conn.cauldron_r, &pool_ids, start)
.await
.map_err(db_error)?;
Ok(cached_ok(json!(fees), CACHE_AGGREGATE))
}
/// Rows returned when the caller does not ask for a different bound.
const HISTORY_DEFAULT_LIMIT: i64 = 5_000;
/// Hard ceiling. The busiest pool has ~32k lifetime entries and every row is
/// shipped as JSON, so an unbounded response is the most expensive thing this
/// service can be asked for.
const HISTORY_MAX_LIMIT: i64 = 20_000;
#[get("/pool/history/<pool_id>?<start>&<limit>&<fields>&<after>")]
pub async fn pool_history(
pool_id: &str,
start: Option<u64>,
limit: Option<i64>,
fields: Option<&str>,
// `next_cursor` from the previous page, as `<sequence>_<utxo hex>`.
after: Option<&str>,
conn: &State<DB>,
) -> CachedApiResult<Value> {
let start = start.unwrap_or(time_now() as u64 - (30 * 3600 * 24) /* 30 days ago */);
let limit = limit
.unwrap_or(HISTORY_DEFAULT_LIMIT)
.clamp(1, HISTORY_MAX_LIMIT);
// `fields=reserves` drops `txid` and `k`, which together are roughly 40% of
// the payload and which no known consumer reads. Opt-in because this
// endpoint is public: existing callers keep the full shape.
let reserves_only = fields == Some("reserves");
let after = match after {
None => None,
Some(raw) => Some(parse_history_cursor(raw)?),
};
let pool_id = pool_id.parse::<PoolID>().map_err(|e| {
bad_request(
@ -187,17 +288,48 @@ pub async fn pool_history(
}
})?;
let history = db_pool_history(&conn.cauldron_r, &pool_id, start)
// One more than asked for, so a full page can be distinguished from a
// truncated one without a second count query.
let mut history = db_pool_history(&conn.cauldron_r, &pool_id, start, limit + 1, after.as_ref())
.await
.map_err(db_error)?;
let has_more = history.len() as i64 > limit;
if has_more {
history.truncate(limit as usize);
}
// Only when there is a further page: a caller that follows this until it is
// null reads the whole history without ever asking for it unbounded.
let next_cursor = if has_more {
history
.last()
.map(|e| format!("{}_{}", e.cursor.sequence, hex::encode(&e.cursor.utxo)))
} else {
None
};
let history = if reserves_only {
json!(history
.iter()
.map(PoolHistoryEntry::reserves_only)
.collect::<Vec<_>>())
} else {
json!(history)
};
// Append-only data: a minute of staleness costs a just-executed trade a
// moment before it appears, which is the right trade against re-running
// this for every caller. Note the client must send a coarse `start` for any
// of this to help — a second-granularity value makes every URL unique.
Ok(cached_ok(
json!({
"history": history,
"token_id": token_id,
"owner_pkh": owner_pkh,
"next_cursor": next_cursor,
}),
CACHE_NONE,
CACHE_AGGREGATE,
))
}