298 lines
11 KiB
Rust
298 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);
|
|||
|
|
}
|
|||
|
|
}
|