diff --git a/Cargo.lock b/Cargo.lock index 8c62d7b9..7162c884 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -939,6 +939,8 @@ dependencies = [ "expect-test", "generic-array-struct", "proptest", + "sanctum-fee-ratio", + "sanctum-u64-ratio", ] [[package]] diff --git a/controller/core/Cargo.toml b/controller/core/Cargo.toml index 89dec094..f1c35791 100644 --- a/controller/core/Cargo.toml +++ b/controller/core/Cargo.toml @@ -7,6 +7,8 @@ version.workspace = true [dependencies] const-crypto = { workspace = true } generic-array-struct = { workspace = true } +sanctum-fee-ratio = { workspace = true } +sanctum-u64-ratio = { workspace = true } [dev-dependencies] borsh = { workspace = true, features = ["derive", "std"] } diff --git a/controller/core/src/lib.rs b/controller/core/src/lib.rs index 63ffcd01..04fb1f7c 100644 --- a/controller/core/src/lib.rs +++ b/controller/core/src/lib.rs @@ -8,5 +8,6 @@ pub mod instructions; pub mod keys; pub mod pda; pub mod typedefs; +pub mod yield_release; keys::id_str!(ID_STR, ID, "5ocnV1qiCgaQR8Jb8xWnVbApfaygJ8tNoZfgPwsgx9kx"); diff --git a/controller/core/src/typedefs/mod.rs b/controller/core/src/typedefs/mod.rs index dee4e31b..ea4d41ef 100644 --- a/controller/core/src/typedefs/mod.rs +++ b/controller/core/src/typedefs/mod.rs @@ -1,2 +1,4 @@ pub mod lst_state; +pub mod rps; pub mod u8bool; +pub mod uq0_63; diff --git a/controller/core/src/typedefs/rps.rs b/controller/core/src/typedefs/rps.rs new file mode 100644 index 00000000..e214ab4b --- /dev/null +++ b/controller/core/src/typedefs/rps.rs @@ -0,0 +1,85 @@ +use core::{error::Error, fmt::Display, ops::Deref}; + +use crate::typedefs::uq0_63::UQ0_63; + +const MIN_RPS_RAW: u64 = 9_223_372; + +/// Approx one pico (1 / 1_000_000_000_000) +pub const MIN_RPS: UQ0_63 = unsafe { UQ0_63::new_unchecked(MIN_RPS_RAW) }; + +/// Proportion of withheld_lamports to release per slot +#[repr(transparent)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Rps(UQ0_63); + +impl Rps { + pub const MIN: Self = Self(MIN_RPS); + + #[inline] + pub const fn new(raw: UQ0_63) -> Result { + // have to cmp raw values to use primitive const < operator + if *raw.as_raw() < *MIN_RPS.as_raw() { + Err(RpsTooSmallErr { actual: raw }) + } else { + Ok(Self(raw)) + } + } + + #[inline] + pub const fn as_inner(&self) -> &UQ0_63 { + &self.0 + } +} + +impl Deref for Rps { + type Target = UQ0_63; + + #[inline] + fn deref(&self) -> &Self::Target { + self.as_inner() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RpsTooSmallErr { + pub actual: UQ0_63, +} + +impl Display for RpsTooSmallErr { + #[inline] + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let Self { actual } = self; + f.write_fmt(format_args!("{actual} < {MIN_RPS} (min)")) + } +} + +impl Error for RpsTooSmallErr {} + +#[cfg(test)] +pub mod test_utils { + use proptest::prelude::*; + + use super::*; + + pub fn any_rps_strat() -> impl Strategy { + (MIN_RPS_RAW..=*UQ0_63::ONE.as_raw()) + .prop_map(UQ0_63::new) + .prop_map(Result::unwrap) + .prop_map(Rps::new) + .prop_map(Result::unwrap) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rps_new_sc() { + const FAIL: UQ0_63 = unsafe { UQ0_63::new_unchecked(MIN_RPS_RAW - 1) }; + const SUCC: UQ0_63 = unsafe { UQ0_63::new_unchecked(MIN_RPS_RAW) }; + + assert_eq!(Rps::new(FAIL), Err(RpsTooSmallErr { actual: FAIL })); + assert_eq!(Rps::new(SUCC), Ok(Rps(SUCC))); + } +} diff --git a/controller/core/src/typedefs/uq0_63.rs b/controller/core/src/typedefs/uq0_63.rs new file mode 100644 index 00000000..054cf969 --- /dev/null +++ b/controller/core/src/typedefs/uq0_63.rs @@ -0,0 +1,306 @@ +//! Binary fixed-point Q numbers +//! (https://en.wikipedia.org/wiki/Q_(number_format)) +//! +//! ## Why handroll our own? +//! - `fixed` crate has dependencies that we dont need +//! - we only need multiplication and exponentiation of unsigned ratios <= 1.0 +//! +//! ### TODO +//! Consider generalizing and separating this out into its own crate? + +use core::{error::Error, fmt::Display, ops::Mul}; + +use sanctum_u64_ratio::Ratio; + +/// 63-bit fraction only fixed-point number to represent a value between 0.0 and 1.0 +/// (denominator = 2^63) +#[repr(transparent)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct UQ0_63(u64); + +impl UQ0_63 { + #[inline] + pub const fn new(n: u64) -> Result { + if n > D { + Err(UQ0_63TooLargeErr { actual: n }) + } else { + Ok(Self(n)) + } + } + + /// # Safety + /// - n must be in range (<= 1 << 63) + #[inline] + pub const unsafe fn new_unchecked(n: u64) -> Self { + Self(n) + } + + #[inline] + pub const fn as_raw(&self) -> &u64 { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct UQ0_63TooLargeErr { + pub actual: u64, +} + +impl Display for UQ0_63TooLargeErr { + #[inline] + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let Self { actual } = self; + f.write_fmt(format_args!("{actual} > {D} (max)")) + } +} + +impl Error for UQ0_63TooLargeErr {} + +const Q: u8 = 63; +const Q_SUB_1: u8 = Q - 1; +const D: u64 = 1 << Q; + +#[inline] +pub const fn uq0_63_mul(UQ0_63(a): UQ0_63, UQ0_63(b): UQ0_63) -> UQ0_63 { + // == 0.5 + const ROUNDING_BIAS: u128 = 1 << Q_SUB_1; + + // where d = u64::MAX + // (n1/d) * (n2/d) = n1*n2/d^2 + // + // as-safety: u128 bitwidth > u64 bitwidth + // unchecked arith safety: u64*u64 never overflows u128 + let res = (a as u128) * (b as u128); + // round off 64th bit + // + // unchecked arith safety: res <= D * D + ROUNDING_BIAS < u128::MAX + let res = res + ROUNDING_BIAS; + // convert back to UQ0_63 by making denom = d + // n1*n2/d^2 = (n1*n2/d) / d + // so we need to divide res by d, + // and division by 2^Q is just >> Q + // + // as-safety: truncating conversion is what we want + // to achieve floor mul + UQ0_63((res >> Q) as u64) +} + +#[inline] +pub const fn uq0_63_into_ratio(a: UQ0_63) -> Ratio { + Ratio { n: a.0, d: D } +} + +#[inline] +pub const fn uq0_63_pow(mut base: UQ0_63, mut exp: u64) -> UQ0_63 { + // sq & mul + let mut res = UQ0_63::ONE; + while exp > 0 { + if exp % 2 == 1 { + res = uq0_63_mul(res, base); + } + base = uq0_63_mul(base, base); + exp /= 2; + } + res +} + +impl UQ0_63 { + pub const ZERO: Self = Self(0); + pub const ONE: Self = Self(D); + + /// Rounding is to closest bit + #[inline] + pub const fn const_mul(a: Self, b: Self) -> Self { + uq0_63_mul(a, b) + } + + /// Returns `1.0 - self` + #[inline] + pub const fn one_minus(self) -> Self { + // unchecked-arith safety: self.0 <= D + Self(D - self.0) + } + + #[inline] + pub const fn pow(self, exp: u64) -> Self { + uq0_63_pow(self, exp) + } + + #[inline] + pub const fn into_ratio(self) -> Ratio { + uq0_63_into_ratio(self) + } +} + +impl Mul for UQ0_63 { + type Output = Self; + + /// Rounding floors + #[inline] + fn mul(self, rhs: Self) -> Self::Output { + Self::const_mul(self, rhs) + } +} + +impl From for Ratio { + #[inline] + fn from(v: UQ0_63) -> Self { + v.into_ratio() + } +} + +impl Display for UQ0_63 { + #[inline] + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + self.into_ratio().fmt(f) + } +} + +#[cfg(test)] +pub mod test_utils { + use proptest::prelude::Strategy; + + use crate::typedefs::uq0_63::UQ0_63; + + use super::*; + + pub fn any_uq0_63_strat() -> impl Strategy { + (0..=D).prop_map(UQ0_63::new).prop_map(Result::unwrap) + } +} + +#[cfg(test)] +mod tests { + use core::cmp::min; + + use proptest::prelude::*; + use sanctum_u64_ratio::Ratio; + + use crate::typedefs::uq0_63::test_utils::any_uq0_63_strat; + + use super::*; + + const D_F64: f64 = D as f64; + + /// max error bounds for multiplication + /// - UQ0_63. 1-bit, so 2^-64 + /// - f64 for range 0.0-1.0, around 2^-54 (around 2^10 larger than UQ0_63 because fewer bits dedicated to fraction) + const MAX_MUL_DIFF_F64_VS_US: u64 = 2048; + + const EPSILON_RATIO_DIFF: Ratio = Ratio { + n: 1, + d: 1_000_000_000_000, + }; + + const fn f64_approx(UQ0_63(a): UQ0_63) -> f64 { + (a as f64) / D_F64 + } + + fn uq0_63_approx(a: f64) -> UQ0_63 { + if a > 1.0 { + panic!("a={a} > 1.0"); + } + UQ0_63((a * D_F64).floor() as u64) + } + + proptest! { + #[test] + fn mul_pt( + [a, b] in core::array::from_fn(|_| any_uq0_63_strat()) + ) { + let us = a * b; + + // a*b <= a and <= b since both are <= 1.0 + prop_assert!(us <= a, "{us} {a}"); + prop_assert!(us <= b, "{us} {b}"); + + let approx_f64 = [a, b].map(f64_approx).into_iter().reduce(core::ops::Mul::mul).unwrap(); + let approx_uq0_63 = uq0_63_approx(approx_f64); + + // small error from f64 result + let diff_u64 = us.0.abs_diff(approx_uq0_63.0); + prop_assert!( + diff_u64 <= MAX_MUL_DIFF_F64_VS_US, + "{}, {}", + us.0, + approx_uq0_63.0 + ); + + // diff should not exceed epsilon proportion of value + let diff_r = Ratio { + n: diff_u64, + d: min(us.0, approx_uq0_63.0), + }; + prop_assert!( + diff_r < EPSILON_RATIO_DIFF, + "diff_r: {diff_r}. us: {us}. f64: {approx_uq0_63}" + ); + } + } + + proptest! { + #[test] + fn exp_pt(base in any_uq0_63_strat(), exp: u64) { + let us = base.pow(exp); + + // (base)^+ve should be <= base since base <= 1.0 + // unless exp = 0 + if exp != 0 { + prop_assert!(us <= base, "{us} {base}"); + } + + let approx_f64 = f64_approx(us).powf(exp as f64); + let approx_uq0_63 = uq0_63_approx(approx_f64); + + // small error from f64 result + let diff_u64 = us.0.abs_diff(approx_uq0_63.0); + // same err bound as mul_pt since a.pow(2) = a * a + prop_assert!( + diff_u64 <= MAX_MUL_DIFF_F64_VS_US, + "{}, {}", + us.0, + approx_uq0_63.0 + ); + + // diff should not exceed epsilon proportion of value + let diff_r = Ratio { + n: diff_u64, + d: min(us.0, approx_uq0_63.0), + }; + prop_assert!(diff_r < EPSILON_RATIO_DIFF, "diff_r: {diff_r}"); + + // exponent of anything < 1.0 eventually reaches 0 + if base != UQ0_63::ONE { + prop_assert_eq!(base.pow(u64::MAX), UQ0_63::ZERO); + } + + // compare against naive multiplication implementation + const LIM: u64 = u16::MAX as u64; + let naive_mul_res = match exp { + 0 => UQ0_63::ONE, + 1..=LIM => (0..exp).fold(base, |res, _| res * base), + _will_take_too_long_to_run => return Ok(()) + }; + prop_assert_eq!(naive_mul_res, us); + } + } + + #[test] + fn into_ratio_sc() { + assert_eq!(UQ0_63(D / 2).into_ratio(), Ratio { n: 1, d: 2 }); + } + + #[test] + fn one_mul_one_eq_one() { + assert_eq!(UQ0_63::ONE * UQ0_63::ONE, UQ0_63::ONE); + } + + #[test] + fn uq0_63_new_sc() { + const FAIL: u64 = D + 1; + const SUCC: u64 = D; + + assert_eq!(UQ0_63::new(FAIL), Err(UQ0_63TooLargeErr { actual: FAIL })); + assert_eq!(UQ0_63::new(SUCC), Ok(UQ0_63(SUCC))); + } +} diff --git a/controller/core/src/yield_release.rs b/controller/core/src/yield_release.rs new file mode 100644 index 00000000..9022dd9d --- /dev/null +++ b/controller/core/src/yield_release.rs @@ -0,0 +1,182 @@ +//! The subsystem controlling the deferred release of yield over time + +use sanctum_fee_ratio::{AftFee, BefFee}; +use sanctum_u64_ratio::Ceil; + +use crate::typedefs::rps::Rps; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ReleaseYield { + pub slots_elapsed: u64, + pub withheld_lamports: u64, + pub rps: Rps, +} + +impl ReleaseYield { + /// # Returns + /// + /// [`AftFee`] where + /// - `.fee()` lamports to be released given slots_elapsed. + /// This can be 0 for small amounts of `slots_elapsed` and `rps`. + /// In those cases, `pool_state.last_release_slot` should not be updated. + /// - `.rem()` new withheld lamports after release + /// + /// Returns `None` on ratio error (overflow) + #[inline] + pub const fn calc(&self) -> AftFee { + let Self { + slots_elapsed, + withheld_lamports, + rps, + } = self; + + let rem_ratio = rps.as_inner().one_minus().pow(*slots_elapsed).into_ratio(); + let new_withheld_lamports = if rem_ratio.is_zero() { + 0 + } else { + // use `Ceil` to round in favour of withholding more yield than necessary + // unwrap-safety: .apply never panics because + // - ratio > 0 + // - ratio <= 1, so never overflows + Ceil(rem_ratio).apply(*withheld_lamports).unwrap() + }; + // unwrap-safety: new_withheld_lamports is never > withheld_lamports + // since its either 0 or * ratio where ratio <= 1.0 + BefFee(*withheld_lamports) + .with_rem(new_withheld_lamports) + .unwrap() + } +} + +#[cfg(test)] +mod tests { + use expect_test::expect; + use proptest::prelude::*; + + use crate::typedefs::{rps::test_utils::any_rps_strat, uq0_63::UQ0_63}; + + use super::*; + + fn into_ry((slots_elapsed, withheld_lamports, rps): (u64, u64, Rps)) -> ReleaseYield { + ReleaseYield { + slots_elapsed, + withheld_lamports, + rps, + } + } + + fn any_release_yield_strat() -> impl Strategy { + (any::(), any::(), any_rps_strat()).prop_map(into_ry) + } + + proptest! { + #[test] + fn release_yield_pt(ry in any_release_yield_strat()) { + // sanctum-fee-ratio tests guarantee many props e.g. + // - new_withheld_lamports + .fee() = withheld_lamports + // - .fee() (released yield) <= withheld_lamports + // - .rem() (new_withheld_lamports) <= withheld_lamports + // + // So just test that calc() never panics for all cases here + ry.calc(); + } + } + + fn zero_slots_elapsed_strat() -> impl Strategy { + (Just(0), any::(), any_rps_strat()).prop_map(into_ry) + } + + proptest! { + #[test] + fn zero_slots_elapsed_no_yields_released(ry in zero_slots_elapsed_strat()) { + prop_assert_eq!(ry.calc().fee(), 0); + } + } + + fn one_rps_strat() -> impl Strategy { + ( + any::(), + any::(), + Just(Rps::new(UQ0_63::ONE).unwrap()), + ) + .prop_map(into_ry) + } + + proptest! { + #[test] + fn one_rps_nonzero_slot_elapsed_release_all(ry in one_rps_strat()) { + let res = ry.calc(); + match ry.slots_elapsed { + // sanctum-fee-ratio guarantees .rem() == .bef_fee() + // (new_withheld = withheld) + 0 => prop_assert_eq!(res.fee(), 0), + + // sanctum-fee-ratio guarantees .fee() == .bef_fee() + // (released = withheld) + _rest => prop_assert_eq!(res.rem(), 0) + }; + } + } + + /// Returns (release_yield, release_yield with same params but slots_elapsed <= .0's) + fn release_yield_split_strat() -> impl Strategy { + any_release_yield_strat().prop_flat_map(|ry| { + ( + Just(ry), + ( + 0..=ry.slots_elapsed, + Just(ry.withheld_lamports), + Just(ry.rps), + ) + .prop_map(into_ry), + ) + }) + } + + proptest! { + #[test] + fn two_release_yields_in_seq_same_as_one_big_one( + (ry_lg, ry_sm) in release_yield_split_strat() + ) { + let lg = ry_lg.calc(); + let sm = ReleaseYield { + slots_elapsed: ry_lg.slots_elapsed - ry_sm.slots_elapsed, + withheld_lamports: ry_sm.calc().rem(), + rps: ry_sm.rps, + }.calc(); + prop_assert_eq!(lg.rem(), sm.rem()); + } + } + + #[test] + fn rand_rps_sc() { + let ryc = ReleaseYield { + slots_elapsed: 1, + withheld_lamports: 1_000_000_000, + // this is around 1 / 1_000_000_000 + rps: Rps::new(UQ0_63::new(9_223_372_037).unwrap()).unwrap(), + } + .calc(); + let _ = [ + ( + expect![[r#" + 999999999 + "#]], + ryc.rem(), + ), + ( + expect![[r#" + 1 + "#]], + ryc.fee(), + ), + ( + expect![[r#" + 1000000000 + "#]], + ryc.bef_fee(), + ), + ] + .map(|(e, a)| e.assert_debug_eq(&a)); + } +} diff --git a/docs/v2/README.md b/docs/v2/README.md index 569791cb..c077fcac 100644 --- a/docs/v2/README.md +++ b/docs/v2/README.md @@ -22,8 +22,9 @@ Add fields: - `withheld_lamports: u64`. Field that records accrued yield in units of lamports (SOL value) that have not yet been released to the pool - `last_release_slot: u64`. Slot where yield was last released, which happens on all instructions that have writable access to the pool -- `rps_picos: u64`. Proportion of current `withheld_lamports` that is released to the pool per slot, in terms of picos (1 / 10^12) +- `rps: u64`. Proportion of current `withheld_lamports` that is released to the pool per slot, in the [UQ0.63]() 63-bit decimal fixed-point format - `protocol_fee_lamports: u64`. Field that accumulates unclaimed protocol fees in units of lamports (SOL value) that have not yet been claimed by the protocol fee beneficiary +- `rps_auth: Address`. Authority allowed to set `rps` field. In general, where in the past `total_sol_value` was used, the semantically equivalent value should be `total_sol_value - withheld_lamports - protocol_fee_lamports` instead. @@ -61,10 +62,18 @@ If necessary, we will transfer SOL to the account to ensure that it has enough f For [all instructions that have write access to the `PoolState`](#migration-plan), immediately after verification, before running anything else, the instruction will run a `release_yield` subroutine which: - calc `slots_elapsed = sysvar.clock.slot - pool_state.last_release_slot` -- update `pool_state.withheld_lamports *= (1.0-rps_picos)^slots_elapsed` where `rps_picos` is `pool_state.rps_picos` converted to a rate between 0.0 and 1.0 +- update `pool_state.withheld_lamports *= (1.0-rps)^slots_elapsed` where `rps` is `pool_state.rps` converted to a rate between 0.0 and 1.0 - update `pool_state.last_release_slot = sysvar.clock.slot` - if `pool_state.withheld_lamports` changed, self-CPI `LogSigned` to log data about how much yield was released +###### Rounding + +Due to rounding, poorly timed calls to `release_yield` might result in more yield withheld than expected if parameters result in a single lamport requiring multiple slots to be released. + +To mitigate this, we only update `last_release_slot` if `release_yield` results in a nonzero lamport amount being released. + +An alternative is to store `withheld_lamports` with greater precision and round when required but we chose not to do this to avoid complexity. + ##### `update_yield` For instructions that involve running at least 1 SyncSolValue procedure, apart from `AddLiquidity` and `RemoveLiquidity`: @@ -98,7 +107,7 @@ Right before the end of the instruction, it will run a `update_yield` subroutine Basically works similarly to compound interest in lending programs. -let `y = pool_state.withheld_lamports`, `t = slots_elapsed`, `k = rps_picos` in terms of a rate between 0.0 and 1.0. +let `y = pool_state.withheld_lamports`, `t = slots_elapsed`, `k = rps` in terms of a rate between 0.0 and 1.0. We want to release `ky` lamports every slot and we're dealing with discrete units of time in terms of slots, which means `y_new = (1.0-k)y_old` after each slot. @@ -192,6 +201,24 @@ Same as v1, with following changes: - mints INF proportionally according to current accumulated `pool_state.protocol_fee_lamports` (should be equivalent to adding liquidity of equivalent SOL value) - reset `pool_state.protocol_fee_lamports` to 0 +##### SetRps + +Set `pool_state.rps` to a new value. + +###### Data + +| Name | Value | Type | +| ------------ | ----------------- | ------------ | +| discriminant | 26 | u8 | +| new_rps | New RPS to set to | UQ0.63 (u64) | + +###### Accounts + +| Account | Description | Read/Write (R/W) | Signer (Y/N) | +| ---------- | ------------------------------ | ---------------- | ------------ | +| pool_state | The pool's state singleton PDA | W | N | +| rps_auth | The pool's rps auth | R | Y | + ##### LogSigned No-op instruction for self-CPI for logging/indexing purposes