# Store the LP fee per history entry; bound and cache
This commit is contained in:
parent
7f424c79d0
commit
b5e8c45487
6 changed files with 784 additions and 14 deletions
|
|
@ -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
297
src/db/cauldron/fees.rs
Normal 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.2999–0.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
18
src/main.rs
18
src/main.rs
|
|
@ -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,
|
||||
|
|
|
|||
140
src/rpc/pool.rs
140
src/rpc/pool.rs
|
|
@ -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,
|
||||
))
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue