297 lines
11 KiB
Rust
297 lines
11 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
|
||
|
||
//! 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);
|
||
}
|
||
}
|