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

297 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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