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