riftenlabs-indexer/src/db/cauldron/fees.rs

298 lines
11 KiB
Rust
Raw Normal View History

// 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);
}
}