diff --git a/Cargo.lock b/Cargo.lock index 7162c884..e1291abf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -938,6 +938,7 @@ dependencies = [ "const-crypto", "expect-test", "generic-array-struct", + "inf1-test-utils", "proptest", "sanctum-fee-ratio", "sanctum-u64-ratio", diff --git a/controller/core/Cargo.toml b/controller/core/Cargo.toml index f1c35791..c9aefd3b 100644 --- a/controller/core/Cargo.toml +++ b/controller/core/Cargo.toml @@ -13,4 +13,5 @@ sanctum-u64-ratio = { workspace = true } [dev-dependencies] borsh = { workspace = true, features = ["derive", "std"] } expect-test = { workspace = true } +inf1-test-utils = { workspace = true } proptest = { workspace = true, features = ["std"] } diff --git a/controller/core/src/accounts/pool_state/mod.rs b/controller/core/src/accounts/pool_state/mod.rs new file mode 100644 index 00000000..7ce595f7 --- /dev/null +++ b/controller/core/src/accounts/pool_state/mod.rs @@ -0,0 +1,5 @@ +mod v1; +mod v2; + +pub use v1::*; +pub use v2::*; diff --git a/controller/core/src/accounts/pool_state.rs b/controller/core/src/accounts/pool_state/v1.rs similarity index 100% rename from controller/core/src/accounts/pool_state.rs rename to controller/core/src/accounts/pool_state/v1.rs diff --git a/controller/core/src/accounts/pool_state/v2.rs b/controller/core/src/accounts/pool_state/v2.rs new file mode 100644 index 00000000..d0eb3e84 --- /dev/null +++ b/controller/core/src/accounts/pool_state/v2.rs @@ -0,0 +1,239 @@ +use core::mem::{align_of, size_of}; + +use generic_array_struct::generic_array_struct; + +use crate::{ + accounts::pool_state::PoolState, + internal_utils::{impl_cast_from_acc_data, impl_cast_to_acc_data}, + typedefs::{fee_nanos::FeeNanos, rps::Rps}, +}; + +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PoolStateV2 { + pub total_sol_value: u64, + + // combined from `trading_protocol_fee_bps` + // and `lp_protocol_fee_bps` in v1 + pub protocol_fee_nanos: u32, + + pub version: u8, + pub is_disabled: u8, + pub is_rebalancing: u8, + pub padding: [u8; 1], + pub admin: [u8; 32], + pub rebalance_authority: [u8; 32], + pub protocol_fee_beneficiary: [u8; 32], + pub pricing_program: [u8; 32], + pub lp_token_mint: [u8; 32], + + // new fields added over V1 + pub rps_authority: [u8; 32], + pub rps: u64, + pub withheld_lamports: u64, + pub protocol_fee_lamports: u64, + pub last_release_slot: u64, +} +impl_cast_from_acc_data!(PoolStateV2); +impl_cast_to_acc_data!(PoolStateV2); + +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PoolStateV2Packed { + total_sol_value: [u8; 8], + protocol_fee_nanos: [u8; 4], + version: u8, + is_disabled: u8, + is_rebalancing: u8, + padding: [u8; 1], + admin: [u8; 32], + rebalance_authority: [u8; 32], + protocol_fee_beneficiary: [u8; 32], + pricing_program: [u8; 32], + lp_token_mint: [u8; 32], + rps_authority: [u8; 32], + rps: [u8; 8], + withheld_lamports: [u8; 8], + protocol_fee_lamports: [u8; 8], + last_release_slot: [u8; 8], +} +impl_cast_from_acc_data!(PoolStateV2Packed, packed); +impl_cast_to_acc_data!(PoolStateV2Packed, packed); + +impl PoolStateV2Packed { + #[inline] + pub const fn into_pool_state_v2(self) -> PoolStateV2 { + let Self { + total_sol_value, + protocol_fee_nanos, + version, + is_disabled, + is_rebalancing, + padding, + admin, + rebalance_authority, + protocol_fee_beneficiary, + pricing_program, + lp_token_mint, + withheld_lamports, + protocol_fee_lamports, + last_release_slot, + rps, + rps_authority, + } = self; + PoolStateV2 { + total_sol_value: u64::from_le_bytes(total_sol_value), + protocol_fee_nanos: u32::from_le_bytes(protocol_fee_nanos), + version, + is_disabled, + is_rebalancing, + padding, + admin, + rebalance_authority, + protocol_fee_beneficiary, + pricing_program, + lp_token_mint, + withheld_lamports: u64::from_le_bytes(withheld_lamports), + protocol_fee_lamports: u64::from_le_bytes(protocol_fee_lamports), + last_release_slot: u64::from_le_bytes(last_release_slot), + rps: u64::from_le_bytes(rps), + rps_authority, + } + } + + /// # Safety + /// - `self` must be pointing to mem that has same align as `PoolState`. + /// This is true onchain for a PoolState account since account data + /// is always 8-byte aligned onchain. + #[inline] + pub const unsafe fn as_pool_state_v2(&self) -> &PoolStateV2 { + &*(self as *const Self).cast() + } + + /// # Safety + /// - same rules as [`Self::as_pool_state`] apply + #[inline] + pub const unsafe fn as_pool_state_v2_mut(&mut self) -> &mut PoolStateV2 { + &mut *(self as *mut Self).cast() + } +} + +impl From for PoolStateV2 { + #[inline] + fn from(value: PoolStateV2Packed) -> Self { + value.into_pool_state_v2() + } +} + +// field type aggregations +// NB: v1's are in test-utils but for v2, we move it into core since they may be +// generally useful, and also so that they can be used for unit tests + +#[generic_array_struct(builder pub)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PoolStateV2Addrs { + pub admin: T, + pub rebalance_authority: T, + pub protocol_fee_beneficiary: T, + pub pricing_program: T, + pub lp_token_mint: T, + pub rps_authority: T, +} + +#[generic_array_struct(builder pub)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PoolStateV2U64s { + pub total_sol_value: T, + pub withheld_lamports: T, + pub protocol_fee_lamports: T, + pub last_release_slot: T, + // rps excluded due to its different type + // despite same repr +} + +#[generic_array_struct(builder pub)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PoolStateV2U8Bools { + pub is_disabled: T, + pub is_rebalancing: T, +} + +// TODO: if we were disciplined about packing all fields of the same type +// at the same region and didnt care about backward compatibility, then +// we could just use this type as the account data repr and woudlnt need +// conversion functions +/// Field-Type aggregations +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PoolStateV2FT { + pub addrs: PoolStateV2Addrs, + pub u64s: PoolStateV2U64s, + pub u8_bools: PoolStateV2U8Bools, + pub protocol_fee_nanos: W, + pub rps: X, +} + +pub type PoolStateV2FTVals = PoolStateV2FT<[u8; 32], u64, u8, FeeNanos, Rps>; + +impl PoolStateV2FTVals { + #[inline] + pub const fn into_pool_state_v2(self) -> PoolStateV2 { + let Self { + addrs, + u64s, + u8_bools, + protocol_fee_nanos, + rps, + } = self; + PoolStateV2 { + total_sol_value: *u64s.total_sol_value(), + protocol_fee_nanos: protocol_fee_nanos.get(), + version: 2u8, + is_disabled: *u8_bools.is_disabled(), + is_rebalancing: *u8_bools.is_rebalancing(), + padding: [0u8], + admin: *addrs.admin(), + rebalance_authority: *addrs.rebalance_authority(), + protocol_fee_beneficiary: *addrs.protocol_fee_beneficiary(), + pricing_program: *addrs.pricing_program(), + lp_token_mint: *addrs.lp_token_mint(), + rps_authority: *addrs.rps_authority(), + rps: *rps.as_inner().as_raw(), + withheld_lamports: *u64s.withheld_lamports(), + protocol_fee_lamports: *u64s.protocol_fee_lamports(), + last_release_slot: *u64s.last_release_slot(), + } + } +} + +impl From for PoolStateV2 { + #[inline] + fn from(value: PoolStateV2FTVals) -> Self { + value.into_pool_state_v2() + } +} + +const _ASSERT_PACKED_UNPACKED_SIZES_EQ: () = + assert!(size_of::() == size_of::()); + +const _ASSERT_SAME_ALIGN_AS_V1: () = assert!(align_of::() == align_of::()); + +/// Check we didn't mess up existing fields from v1 +/// `assert_offset_unchanged` +macro_rules! aou { + ($ASSERTION:ident, $field:ident) => { + const $ASSERTION: () = assert!( + core::mem::offset_of!(PoolStateV2, $field) == core::mem::offset_of!(PoolState, $field) + ); + }; +} + +aou!(_TOTAL_SOL_VALUE, total_sol_value); +aou!(_VERSION, version); +aou!(_IS_DISABLED, is_disabled); +aou!(_IS_REBALANCING, is_rebalancing); +aou!(_PADDING, padding); +aou!(_ADMIN, admin); +aou!(_REBALANCE_AUTH, rebalance_authority); +aou!(_PROTOCOL_FEE_BENEFICIARY, protocol_fee_beneficiary); +aou!(_PRICING_PROGRAM, pricing_program); +aou!(_LP_TOKEN_MINT, lp_token_mint); diff --git a/controller/core/src/lib.rs b/controller/core/src/lib.rs index 04fb1f7c..14a38799 100644 --- a/controller/core/src/lib.rs +++ b/controller/core/src/lib.rs @@ -8,6 +8,6 @@ pub mod instructions; pub mod keys; pub mod pda; pub mod typedefs; -pub mod yield_release; +pub mod yields; keys::id_str!(ID_STR, ID, "5ocnV1qiCgaQR8Jb8xWnVbApfaygJ8tNoZfgPwsgx9kx"); diff --git a/controller/core/src/typedefs/fee_nanos.rs b/controller/core/src/typedefs/fee_nanos.rs new file mode 100644 index 00000000..d9ce3865 --- /dev/null +++ b/controller/core/src/typedefs/fee_nanos.rs @@ -0,0 +1,102 @@ +use core::{error::Error, fmt::Display, ops::Deref}; + +use sanctum_fee_ratio::Fee; +use sanctum_u64_ratio::{Ceil, Ratio}; + +pub const NANOS_DENOM: u32 = 1_000_000_000; + +/// 100% +pub const MAX_FEE_NANOS: u32 = NANOS_DENOM; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[repr(transparent)] +pub struct FeeNanos(u32); + +impl FeeNanos { + /// 0% + pub const ZERO: Self = Self(0); + + /// 100% + pub const MAX: Self = Self(MAX_FEE_NANOS); + + #[inline] + pub const fn new(n: u32) -> Result { + if n > MAX_FEE_NANOS { + Err(FeeNanosTooLargeErr { actual: n }) + } else { + Ok(Self(n)) + } + } + + #[inline] + pub const fn get(&self) -> u32 { + self.0 + } + + #[inline] + pub const fn into_fee(self) -> F { + // safety: n <= d checked at construction (::new()) + unsafe { + F::new_unchecked(Ratio { + n: self.0, + d: NANOS_DENOM, + }) + } + } +} + +type F = Fee>>; + +impl Deref for FeeNanos { + type Target = u32; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct FeeNanosTooLargeErr { + pub actual: u32, +} + +impl Display for FeeNanosTooLargeErr { + #[inline] + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let Self { actual } = self; + f.write_fmt(format_args!("fee nanos {actual} > {MAX_FEE_NANOS} (max)")) + } +} + +impl Error for FeeNanosTooLargeErr {} + +#[cfg(test)] +pub mod test_utils { + use proptest::prelude::*; + + use super::*; + + pub fn any_fee_nanos_strat() -> impl Strategy { + (0..=MAX_FEE_NANOS) + .prop_map(FeeNanos::new) + .prop_map(Result::unwrap) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_range_sc() { + const FAIL: u32 = NANOS_DENOM + 1; + const SUCC: u32 = NANOS_DENOM; + + assert_eq!( + FeeNanos::new(FAIL), + Err(FeeNanosTooLargeErr { actual: FAIL }) + ); + assert!(FeeNanos::new(SUCC).is_ok()); + } +} diff --git a/controller/core/src/typedefs/mod.rs b/controller/core/src/typedefs/mod.rs index ea4d41ef..498b2a76 100644 --- a/controller/core/src/typedefs/mod.rs +++ b/controller/core/src/typedefs/mod.rs @@ -1,4 +1,6 @@ +pub mod fee_nanos; pub mod lst_state; pub mod rps; +pub mod snap; pub mod u8bool; pub mod uq0_63; diff --git a/controller/core/src/typedefs/snap.rs b/controller/core/src/typedefs/snap.rs new file mode 100644 index 00000000..f1a1987e --- /dev/null +++ b/controller/core/src/typedefs/snap.rs @@ -0,0 +1,18 @@ +use generic_array_struct::generic_array_struct; + +/// A state snapshot across time +#[generic_array_struct(builder pub)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Snap { + pub old: T, + pub new: T, +} + +impl Snap { + #[inline] + pub const fn memset(v: T) -> Self { + Self([v; SNAP_LEN]) + } +} + +pub type SnapU64 = Snap; diff --git a/controller/core/src/typedefs/uq0_63.rs b/controller/core/src/typedefs/uq0_63.rs index 054cf969..5545b62b 100644 --- a/controller/core/src/typedefs/uq0_63.rs +++ b/controller/core/src/typedefs/uq0_63.rs @@ -183,13 +183,13 @@ mod tests { 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) + /// - UQ0_63. 1-bit, so 2^-63 + /// - f64 for range 0.0-1.0, around 2^-54 (around 2^9 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, + d: 10_000_000, }; const fn f64_approx(UQ0_63(a): UQ0_63) -> f64 { @@ -240,16 +240,26 @@ mod tests { proptest! { #[test] - fn exp_pt(base in any_uq0_63_strat(), exp: u64) { + fn exp_pt( + base in any_uq0_63_strat(), + // use smaller range to include boundary cases more often + // larger exps are less interesting since its likely they just go to 0 + exp in 0..=u16::MAX as 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}"); + if exp == 0 { + // x^0 == 1 + prop_assert_eq!(us, UQ0_63::ONE); + } else if base == UQ0_63::ZERO || base == UQ0_63::ONE || exp == 1 { + // 0^+ve = 0, 1^+ve = 1, x^1 = x + prop_assert_eq!(us, base); + } else { + // x^+ve should be < x since base < 1.0 + prop_assert!(us < base, "{us} >= {base}"); } - let approx_f64 = f64_approx(us).powf(exp as f64); + let approx_f64 = f64_approx(base).powf(exp as f64); let approx_uq0_63 = uq0_63_approx(approx_f64); // small error from f64 result @@ -257,8 +267,9 @@ mod tests { // 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_f64, approx_uq0_63.0 ); @@ -269,7 +280,7 @@ mod tests { }; prop_assert!(diff_r < EPSILON_RATIO_DIFF, "diff_r: {diff_r}"); - // exponent of anything < 1.0 eventually reaches 0 + // pow of anything < 1.0 eventually reaches 0 if base != UQ0_63::ONE { prop_assert_eq!(base.pow(u64::MAX), UQ0_63::ZERO); } @@ -278,10 +289,34 @@ mod tests { 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), + 1..=LIM => (0..exp.saturating_sub(1)).fold(base, |res, _| res * base), _will_take_too_long_to_run => return Ok(()) }; - prop_assert_eq!(naive_mul_res, us); + // result will not be exactly eq bec each mult has rounding + // and the 2 procedures mult differently + let diff_r = Ratio { + n: naive_mul_res.0.abs_diff(us.0), + d: min(naive_mul_res.0, us.0), + }; + prop_assert!(diff_r < EPSILON_RATIO_DIFF, "naive_mul diff_r: {diff_r}"); + } + } + + // separate test from exp_pt bec strat doesnt seem to select boundary values + // TODO: investigate. This doesnt seem like correct proptest behaviour + proptest! { + #[test] + fn pow_zero_is_one(base in any_uq0_63_strat()) { + prop_assert_eq!(base.pow(0), UQ0_63::ONE); + } + } + + // separate test from exp_pt bec strat doesnt seem to select boundary values + // TODO: investigate. This doesnt seem like correct proptest behaviour + proptest! { + #[test] + fn one_pow_is_one(exp: u64) { + prop_assert_eq!(UQ0_63::ONE.pow(exp), UQ0_63::ONE); } } diff --git a/controller/core/src/yields/mod.rs b/controller/core/src/yields/mod.rs new file mode 100644 index 00000000..386b1900 --- /dev/null +++ b/controller/core/src/yields/mod.rs @@ -0,0 +1,4 @@ +//! The subsystem controlling the deferred release of yield over time + +pub mod release; +pub mod update; diff --git a/controller/core/src/yield_release.rs b/controller/core/src/yields/release.rs similarity index 50% rename from controller/core/src/yield_release.rs rename to controller/core/src/yields/release.rs index 9022dd9d..cb41f722 100644 --- a/controller/core/src/yield_release.rs +++ b/controller/core/src/yields/release.rs @@ -1,33 +1,50 @@ //! The subsystem controlling the deferred release of yield over time -use sanctum_fee_ratio::{AftFee, BefFee}; +use generic_array_struct::generic_array_struct; +use sanctum_fee_ratio::BefFee; use sanctum_u64_ratio::Ceil; -use crate::typedefs::rps::Rps; +use crate::typedefs::{fee_nanos::FeeNanos, rps::Rps}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ReleaseYield { pub slots_elapsed: u64, pub withheld_lamports: u64, pub rps: Rps, + pub protocol_fee_nanos: FeeNanos, } +#[generic_array_struct(builder pub)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct YRel { + pub released: T, + pub to_protocol: T, + pub new_withheld: T, +} + +impl YRel { + #[inline] + pub const fn memset(v: T) -> Self { + Self([v; Y_REL_LEN]) + } +} + +/// invariant: self.sum() = old_withheld_lamports +pub type YRelLamports = YRel; + 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 + /// If `new_withheld == old_withheld` then `pool_state.last_release_slot` should not be updated. /// /// Returns `None` on ratio error (overflow) #[inline] - pub const fn calc(&self) -> AftFee { + pub const fn calc(&self) -> YRelLamports { let Self { slots_elapsed, withheld_lamports, rps, + protocol_fee_nanos, } = self; let rem_ratio = rps.as_inner().one_minus().pow(*slots_elapsed).into_ratio(); @@ -42,9 +59,20 @@ impl ReleaseYield { }; // unwrap-safety: new_withheld_lamports is never > withheld_lamports // since its either 0 or * ratio where ratio <= 1.0 - BefFee(*withheld_lamports) + let bef_pf = BefFee(*withheld_lamports) .with_rem(new_withheld_lamports) - .unwrap() + .unwrap(); + let released_bef_pf = bef_pf.fee(); + // unwrap-safety: ratio.apply should never overflow since fee ratios <= 1.0 + let aft_pf = protocol_fee_nanos + .into_fee() + .apply(released_bef_pf) + .unwrap(); + + YRelLamports::memset(0) + .const_with_new_withheld(new_withheld_lamports) + .const_with_released(aft_pf.rem()) + .const_with_to_protocol(aft_pf.fee()) } } @@ -53,43 +81,64 @@ mod tests { use expect_test::expect; use proptest::prelude::*; - use crate::typedefs::{rps::test_utils::any_rps_strat, uq0_63::UQ0_63}; + use crate::typedefs::{ + fee_nanos::test_utils::any_fee_nanos_strat, rps::test_utils::any_rps_strat, uq0_63::UQ0_63, + }; use super::*; - fn into_ry((slots_elapsed, withheld_lamports, rps): (u64, u64, Rps)) -> ReleaseYield { + fn into_ry( + (slots_elapsed, withheld_lamports, rps, protocol_fee_nanos): (u64, u64, Rps, FeeNanos), + ) -> ReleaseYield { ReleaseYield { slots_elapsed, withheld_lamports, rps, + protocol_fee_nanos, } } fn any_release_yield_strat() -> impl Strategy { - (any::(), any::(), any_rps_strat()).prop_map(into_ry) + ( + any::(), + any::(), + any_rps_strat(), + any_fee_nanos_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(); + // shouldnt panic + let res = ry.calc(); + + // sum invariant + prop_assert_eq!( + res.0.into_iter().map(u128::from).sum::(), + ry.withheld_lamports.into() + ); } } fn zero_slots_elapsed_strat() -> impl Strategy { - (Just(0), any::(), any_rps_strat()).prop_map(into_ry) + ( + Just(0), + any::(), + any_rps_strat(), + any_fee_nanos_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); + let ryc = ry.calc(); + prop_assert_eq!(*ryc.to_protocol(), 0); + prop_assert_eq!(*ryc.released(), 0); + prop_assert_eq!(*ryc.new_withheld(), ry.withheld_lamports); } } @@ -98,6 +147,7 @@ mod tests { any::(), any::(), Just(Rps::new(UQ0_63::ONE).unwrap()), + any_fee_nanos_strat(), ) .prop_map(into_ry) } @@ -107,13 +157,8 @@ mod tests { 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) + 0 => prop_assert_eq!(*res.new_withheld(), ry.withheld_lamports), + _rest => prop_assert_eq!(*res.new_withheld(), 0) }; } } @@ -127,6 +172,7 @@ mod tests { 0..=ry.slots_elapsed, Just(ry.withheld_lamports), Just(ry.rps), + Just(ry.protocol_fee_nanos), ) .prop_map(into_ry), ) @@ -139,12 +185,16 @@ mod tests { (ry_lg, ry_sm) in release_yield_split_strat() ) { let lg = ry_lg.calc(); + let aft_first = ry_sm.calc(); let sm = ReleaseYield { slots_elapsed: ry_lg.slots_elapsed - ry_sm.slots_elapsed, - withheld_lamports: ry_sm.calc().rem(), + withheld_lamports: *aft_first.new_withheld(), rps: ry_sm.rps, + protocol_fee_nanos: ry_lg.protocol_fee_nanos, }.calc(); - prop_assert_eq!(lg.rem(), sm.rem()); + prop_assert_eq!(lg.new_withheld(), sm.new_withheld()); + prop_assert_eq!(*lg.released(), aft_first.released() + sm.released()); + prop_assert_eq!(*lg.to_protocol(), aft_first.to_protocol() + sm.to_protocol()); } } @@ -152,31 +202,21 @@ mod tests { fn rand_rps_sc() { let ryc = ReleaseYield { slots_elapsed: 1, - withheld_lamports: 1_000_000_000, + withheld_lamports: 2_000_000_000, // this is around 1 / 1_000_000_000 rps: Rps::new(UQ0_63::new(9_223_372_037).unwrap()).unwrap(), + protocol_fee_nanos: FeeNanos::new(1_000_000).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)); + expect![[r#" + YRel( + [ + 1, + 1, + 1999999998, + ], + ) + "#]] + .assert_debug_eq(&ryc); } } diff --git a/controller/core/src/yields/update.rs b/controller/core/src/yields/update.rs new file mode 100644 index 00000000..517ca7b6 --- /dev/null +++ b/controller/core/src/yields/update.rs @@ -0,0 +1,299 @@ +use generic_array_struct::generic_array_struct; + +use crate::{accounts::pool_state::PoolStateV2, typedefs::snap::SnapU64}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct UpdateYield { + pub pool_total_sol_value: SnapU64, + pub old_lamport_fields: YieldLamportFieldsVal, +} + +impl UpdateYield { + /// # Returns + /// + /// `None` on overflow on protocol fee calculation + #[inline] + pub const fn calc(&self) -> YieldLamportFieldUpdates { + let (vals, dir) = if *self.pool_total_sol_value.old() <= *self.pool_total_sol_value.new() { + // unchecked-arith: no overflow, bounds checked above + let change = *self.pool_total_sol_value.new() - *self.pool_total_sol_value.old(); + ( + YieldLamportFieldsVal::memset(0).const_with_withheld(change), + UpdateDir::Inc, + ) + } else { + // unchecked-arith: no overflow, bounds checked above + let change = *self.pool_total_sol_value.old() - *self.pool_total_sol_value.new(); + let shortfall = change.saturating_sub(*self.old_lamport_fields.withheld()); + let withheld = change.saturating_sub(shortfall); + ( + YieldLamportFieldsVal::memset(0) + .const_with_withheld(withheld) + .const_with_protocol_fee(shortfall), + UpdateDir::Dec, + ) + }; + YieldLamportFieldUpdates { vals, dir } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum UpdateDir { + /// increment + Inc, + + /// decrement + Dec, +} + +#[generic_array_struct(builder pub)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct YieldLamportFields { + /// `pool_state.withheld_lamports` + pub withheld: T, + + /// `pool_state.protocol_fee_lamports` + pub protocol_fee: T, +} + +impl YieldLamportFields { + #[inline] + pub const fn memset(v: T) -> Self { + Self([v; YIELD_LAMPORT_FIELDS_LEN]) + } +} + +pub type YieldLamportFieldsVal = YieldLamportFields; + +impl YieldLamportFieldsVal { + #[inline] + pub const fn snap( + PoolStateV2 { + withheld_lamports, + protocol_fee_lamports, + .. + }: &PoolStateV2, + ) -> Self { + YieldLamportFieldsVal::memset(0) + .const_with_protocol_fee(*protocol_fee_lamports) + .const_with_withheld(*withheld_lamports) + } +} + +// dont derive Copy even tho we can. Same motivation as iterators +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct YieldLamportFieldUpdates { + pub vals: YieldLamportFieldsVal, + pub dir: UpdateDir, +} + +impl YieldLamportFieldUpdates { + /// Consumes `self` + /// + /// # Returns + /// new values of `YieldLamportFieldsVal` + /// + /// `None` if increment overflows + #[inline] + pub fn exec(self, mut old: YieldLamportFieldsVal) -> Option { + let Self { vals, dir } = self; + vals.0 + .into_iter() + .zip(old.0.each_mut()) + .try_for_each(|(v, r)| { + let new = match dir { + UpdateDir::Dec => r.saturating_sub(v), + UpdateDir::Inc => r.checked_add(v)?, + }; + *r = new; + Some(()) + })?; + Some(old) + } +} + +#[cfg(test)] +mod tests { + use core::array; + + use inf1_test_utils::bals_from_supply; + use proptest::prelude::*; + + use crate::{ + accounts::pool_state::{ + NewPoolStateV2U64sBuilder, PoolStateV2Addrs, PoolStateV2FTVals, PoolStateV2U8Bools, + }, + typedefs::{ + fee_nanos::test_utils::any_fee_nanos_strat, rps::test_utils::any_rps_strat, + snap::NewSnapBuilder, + }, + }; + + use super::*; + + fn any_update_yield_strat() -> impl Strategy { + ( + any::() + // this enforces the invariant that + // old_total_sol_value >= old_protocol_fee_lamports + old_withheld_lamports + .prop_flat_map(|old_tsv| { + ([any::(); 2], bals_from_supply(old_tsv), Just(old_tsv)) + }) + .prop_map( + |( + [new_tsv, last_release_slot], + ([withheld_lamports, protocol_fee_lamports], _rem), + old_tsv, + )| { + ( + old_tsv, + NewPoolStateV2U64sBuilder::start() + .with_last_release_slot(last_release_slot) + .with_protocol_fee_lamports(protocol_fee_lamports) + .with_total_sol_value(new_tsv) + .with_withheld_lamports(withheld_lamports) + .build(), + ) + }, + ), + array::from_fn(|_| any::<[u8; 32]>()), + array::from_fn(|_| any::()), + any_fee_nanos_strat(), + any_rps_strat(), + ) + .prop_map( + |((old_tsv, u64s), addrs, u8_bools, protocol_fee_nanos, rps)| { + ( + old_tsv, + PoolStateV2FTVals { + addrs: PoolStateV2Addrs(addrs), + u64s, + u8_bools: PoolStateV2U8Bools(u8_bools), + protocol_fee_nanos, + rps, + } + .into_pool_state_v2(), + ) + }, + ) + } + + proptest! { + #[test] + fn update_yield_pt( + (old_total_sol_value, mut ps) in any_update_yield_strat(), + ) { + prop_assert!( + old_total_sol_value >= ps.protocol_fee_lamports + ps.withheld_lamports + ); + + let uy = UpdateYield { + pool_total_sol_value: NewSnapBuilder::start() + .with_new(ps.total_sol_value) + .with_old(old_total_sol_value) + .build(), + old_lamport_fields: NewYieldLamportFieldsBuilder::start() + .with_protocol_fee(ps.protocol_fee_lamports) + .with_withheld(ps.withheld_lamports) + .build(), + }; + let u = uy.calc(); + + let YieldLamportFieldUpdates { vals, dir } = u; + + let old_vals = NewYieldLamportFieldsBuilder::start() + .with_withheld(ps.withheld_lamports) + .with_protocol_fee(ps.protocol_fee_lamports) + .build(); + + let exec_res = u.exec(YieldLamportFieldsVal::snap(&ps)); + + let mut itr = old_vals.0.into_iter().zip(vals.0); + + let PoolStateV2 { withheld_lamports, protocol_fee_lamports, .. } = &mut ps; + + let ps_refs = NewYieldLamportFieldsBuilder::start() + .with_withheld(withheld_lamports) + .with_protocol_fee(protocol_fee_lamports) + .build(); + + match exec_res { + None => { + match dir { + UpdateDir::Dec => panic!("decrement should never panic"), + UpdateDir::Inc => assert!(itr.any(|(old, c)| old.checked_add(c).is_none())) + } + // tests below assume update was successful + return Ok(()); + } + Some(new_vals) => { + itr.zip(new_vals.0).zip(ps_refs.0).for_each( + |(((old, c), new), ps_ref)| { + match dir { + UpdateDir::Inc => assert_eq!(new, old + c), + UpdateDir::Dec => assert_eq!(new, old.saturating_sub(c)), + } + *ps_ref = new; + } + ); + } + } + + let [old_lp_sv, new_lp_sv] = [ + [*old_vals.protocol_fee(), *old_vals.withheld(), old_total_sol_value], + [ps.protocol_fee_lamports, ps.withheld_lamports, ps.total_sol_value], + ].map(|[p, w, t]| t - p - w); + + // sol value due to LPers should not change on profit events + if let UpdateDir::Inc = dir { + prop_assert_eq!(new_lp_sv, old_lp_sv, "{} != {}", old_lp_sv, new_lp_sv); + } + + if let UpdateDir::Dec = dir { + let [loss, lp_loss, withheld_loss, pf_loss] = [ + [old_total_sol_value, ps.total_sol_value], + [old_lp_sv, new_lp_sv], + [*old_vals.withheld(), ps.withheld_lamports], + [*old_vals.protocol_fee(), ps.protocol_fee_lamports] + ].map(|[o, n]| o - n); + + // sol value due to LPers should decrease by at most same amount on loss events. + if *old_vals.withheld() + *old_vals.protocol_fee() == 0 { + // strict-eq if no softening + prop_assert_eq!(loss, lp_loss, "{} != {}", lp_loss, loss); + + } else { + // less if softened by accumulated withheld and protocol fees + prop_assert!(loss > lp_loss, "{} > {}", lp_loss, loss); + } + + // accumulated withheld and protocol fee lamports should have in total + // decreased at most equal to loss + let non_lp_loss = withheld_loss + pf_loss; + if ps.withheld_lamports + ps.protocol_fee_lamports == 0 { + prop_assert!(loss >= non_lp_loss, "{} > {}", non_lp_loss, loss); + } else { + // strict-eq if no saturation + prop_assert_eq!(loss, non_lp_loss, "{} != {}", non_lp_loss, loss); + } + + if pf_loss > 0 && *old_vals.withheld() > 0 { + prop_assert!( + withheld_loss > 0, + "withheld should be decreased from first before protocol fee" + ); + } + } + + // after update_yield, total_sol_value must remain + // >= protocol_fee_lamports + withheld_lamports, + // assuming invariant holds before the update + prop_assert!( + ps.total_sol_value >= ps.protocol_fee_lamports + ps.withheld_lamports, + "{} {} {}\n{} {} {}", + old_vals.protocol_fee(), old_vals.withheld(), old_total_sol_value, + ps.protocol_fee_lamports, ps.withheld_lamports, ps.total_sol_value, + ); + } + } +} diff --git a/docs/v2/README.md b/docs/v2/README.md index c077fcac..daa95443 100644 --- a/docs/v2/README.md +++ b/docs/v2/README.md @@ -63,7 +63,9 @@ For [all instructions that have write access to the `PoolState`](#migration-plan - calc `slots_elapsed = sysvar.clock.slot - pool_state.last_release_slot` - 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` +- have `lamports_released` = decrease in withheld_lamports + - apply protocol fees to `lamports_released` and increment `pool_state.protocol_fee_lamports` by the fee amount +- update `pool_state.last_release_slot = sysvar.clock.slot` if nonzero `lamports_released` - if `pool_state.withheld_lamports` changed, self-CPI `LogSigned` to log data about how much yield was released ###### Rounding @@ -89,13 +91,15 @@ For instructions that involve running at least 1 SyncSolValue procedure, apart f Right before the end of the instruction, it will run a `update_yield` subroutine which: -- Compare `pool.total_sol_value` at the start of the instruction with that at the end of the instruction +- Compare `pool_state.total_sol_value` at the start of the instruction with that at the end of the instruction - If theres an increase (yield was observed) - - Divide the increase according to `pool_state.protocol_fee_nanos` - - Increment `pool_state.protocol_fee_lamports` by protocol fee share - - Increment `pool_state.withheld_lamports` by non-protocol fee share + - Increment `pool_state.withheld_lamports` by same amount - If theres a decrease (loss was observed) - - decrement `pool_state.withheld_lamports` by the equivalent value (saturating). This has the effect of using any previously accumulated yield to soften the loss + - Decrement by same amount, saturating from the following quantities + - `pool_state.withheld_lamports` + - `pool_state.protocol_fee_lamports` + - This has the effect of using any previously accumulated yield and protocol fees to soften the loss + - This also maintains the invariant that `pool_state.total_sol_value >= pool_state.withheld_lamports + pool_state.protocol_fee_lamports`, ensuring that LPers are never insolvent - In both cases, self-CPI `LogSigned` to log data about how much yield/loss was observed. `AddLiquidity` and `RemoveLiquidity` instructions require special-case handling because they modify both `pool.total_sol_value` and INF mint supply, so yields and losses need to be counted using the differences between the ratio of the 2 before and after. @@ -178,6 +182,18 @@ Same as v1, with following changes: - The SOL values of each LST entry will be updated by incrementing/decrementing according to the SOL value of the input/output amounts - To continue to correctly enforce the no-loss-of-sol-value invariant correctly, the assertion will be `input_sol_value >= output_sol_value` instead of on changes to the pool total SOL value before and after +##### SwapExactOutV2 + +Same as [SwapExactInV2](#swapexactinv2), but + +- discriminant = 24 +- `max_amount_in` instead of `min_amount_out` +- `amount` is amount of dst tokens to receive +- the core part goes like this instead: + - out_sol_value = LstToSol(amount).max + - in_sol_value = PriceExactOut(amount, out_sol_value) + - amount_in = SolToLst(in_sol_value).max + ##### WithdrawProtocolFeesV2 ###### Data @@ -219,6 +235,24 @@ Set `pool_state.rps` to a new value. | pool_state | The pool's state singleton PDA | W | N | | rps_auth | The pool's rps auth | R | Y | +##### SetRpsAuth + +Set the pool's RPS authority to a new value. + +###### Data + +| Name | Value | Type | +| ------------ | ----- | ---- | +| discriminant | 27 | u8 | + +###### Accounts + +| Account | Description | Read/Write (R/W) | Signer (Y/N) | +| ------------ | ------------------------------------------- | ---------------- | ------------ | +| pool_state | The pool's state singleton PDA | W | N | +| signer | Either the pool's current rps auth or admin | R | Y | +| new_rps_auth | New rps auth to set to | R | N | + ##### LogSigned No-op instruction for self-CPI for logging/indexing purposes diff --git a/test-utils/README.md b/test-utils/README.md index 0daabfa7..8db65901 100644 --- a/test-utils/README.md +++ b/test-utils/README.md @@ -5,4 +5,4 @@ Common test utils. Includes things like - common mollusk & `solana-*` interop - `proptest Strategy`s for generating accounts -This library can be included in any library in here's `dev-dependencies` and used in integration tests, but may not be included under `dependencies`, else circular dependency. +This library can be included in any library in here's `dev-dependencies` and used in both integration and unit tests, but may not be included under `dependencies`, else circular dependency.