From f20af27c338b7172bb48a91a739cf141af180b33 Mon Sep 17 00:00:00 2001 From: Lucas Meier Date: Tue, 28 Jan 2025 16:10:24 -0800 Subject: [PATCH] Implement parameters for liquidity tournament control (in funding) Closes #5013. I also added a little utility type for representing percentages. Not strictly necessary, but I think it's a good move towards type safety. The motivation here was all of the annoyance of dealing with keeping track of what u64s were bps or bps^2 or yadda yadda yadda in other parts of the codebase. --- crates/core/app/src/params/change.rs | 10 +- crates/core/component/funding/src/params.rs | 73 ++++++- crates/core/num/src/lib.rs | 2 + crates/core/num/src/percentage/mod.rs | 36 ++++ .../gen/penumbra.core.component.funding.v1.rs | 46 +++- ...enumbra.core.component.funding.v1.serde.rs | 200 +++++++++++++++++- .../proto/src/gen/proto_descriptor.bin.no_lfs | Bin 646628 -> 647837 bytes .../core/component/funding/v1/funding.proto | 26 ++- 8 files changed, 377 insertions(+), 16 deletions(-) create mode 100644 crates/core/num/src/percentage/mod.rs diff --git a/crates/core/app/src/params/change.rs b/crates/core/app/src/params/change.rs index d58b5c4b05..f2b45a2451 100644 --- a/crates/core/app/src/params/change.rs +++ b/crates/core/app/src/params/change.rs @@ -75,7 +75,10 @@ impl AppParameters { fixed_gas_prices: _, fixed_alt_gas_prices: _, }, - funding_params: FundingParameters {}, + funding_params: + FundingParameters { + liquidity_tournament: _, + }, governance_params: GovernanceParameters { proposal_voting_blocks: _, @@ -171,7 +174,10 @@ impl AppParameters { fixed_gas_prices: _, fixed_alt_gas_prices: _, }, - funding_params: FundingParameters {}, + funding_params: + FundingParameters { + liquidity_tournament: _, + }, governance_params: GovernanceParameters { proposal_voting_blocks, diff --git a/crates/core/component/funding/src/params.rs b/crates/core/component/funding/src/params.rs index ee5784528a..bdfc4da068 100644 --- a/crates/core/component/funding/src/params.rs +++ b/crates/core/component/funding/src/params.rs @@ -1,10 +1,60 @@ +use penumbra_sdk_num::Percentage; use penumbra_sdk_proto::core::component::funding::v1 as pb; use penumbra_sdk_proto::DomainType; use serde::{Deserialize, Serialize}; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LiquidityTournamentParameters { + // The fraction of gauge votes that an asset must clear to receive rewards. + pub gauge_threshold: Percentage, + // The maximum number of liquidity positions that can receive rewards. + pub max_positions: u64, + // The maximum number of delegators that can receive rewards. + pub max_delegators: u64, + // The share of rewards that go to delegators, instead of positions. + pub delegator_share: Percentage, +} + +impl Default for LiquidityTournamentParameters { + fn default() -> Self { + Self { + gauge_threshold: Percentage::from_percent(100), + max_positions: 0, + max_delegators: 0, + delegator_share: Percentage::zero(), + } + } +} + +impl TryFrom for LiquidityTournamentParameters { + type Error = anyhow::Error; + + fn try_from(proto: pb::funding_parameters::LiquidityTournament) -> Result { + Ok(Self { + gauge_threshold: Percentage::from_percent(proto.gauge_threshold_percent), + max_positions: proto.max_positions, + max_delegators: proto.max_delegators, + delegator_share: Percentage::from_percent(proto.delegator_share_percent), + }) + } +} + +impl From for pb::funding_parameters::LiquidityTournament { + fn from(value: LiquidityTournamentParameters) -> Self { + Self { + gauge_threshold_percent: value.gauge_threshold.to_percent(), + max_positions: value.max_positions, + max_delegators: value.max_delegators, + delegator_share_percent: value.delegator_share.to_percent(), + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(try_from = "pb::FundingParameters", into = "pb::FundingParameters")] -pub struct FundingParameters {} +pub struct FundingParameters { + pub liquidity_tournament: LiquidityTournamentParameters, +} impl DomainType for FundingParameters { type Proto = pb::FundingParameters; @@ -13,19 +63,30 @@ impl DomainType for FundingParameters { impl TryFrom for FundingParameters { type Error = anyhow::Error; - fn try_from(_params: pb::FundingParameters) -> anyhow::Result { - Ok(FundingParameters {}) + fn try_from(proto: pb::FundingParameters) -> anyhow::Result { + Ok(FundingParameters { + // Explicitly consider missing parameters to *be* the default parameters, for upgrades. + liquidity_tournament: proto + .liquidity_tournament + .map(LiquidityTournamentParameters::try_from) + .transpose()? + .unwrap_or_default(), + }) } } impl From for pb::FundingParameters { - fn from(_params: FundingParameters) -> Self { - pb::FundingParameters {} + fn from(params: FundingParameters) -> Self { + pb::FundingParameters { + liquidity_tournament: Some(params.liquidity_tournament.into()), + } } } impl Default for FundingParameters { fn default() -> Self { - Self {} + Self { + liquidity_tournament: LiquidityTournamentParameters::default(), + } } } diff --git a/crates/core/num/src/lib.rs b/crates/core/num/src/lib.rs index 082246ee6a..bba62be82a 100644 --- a/crates/core/num/src/lib.rs +++ b/crates/core/num/src/lib.rs @@ -2,5 +2,7 @@ #![cfg_attr(docsrs, feature(doc_auto_cfg))] mod amount; pub mod fixpoint; +mod percentage; pub use amount::{Amount, AmountVar}; +pub use percentage::Percentage; diff --git a/crates/core/num/src/percentage/mod.rs b/crates/core/num/src/percentage/mod.rs new file mode 100644 index 0000000000..6c85804da1 --- /dev/null +++ b/crates/core/num/src/percentage/mod.rs @@ -0,0 +1,36 @@ +/// Represents a percentage value. +/// +/// Useful for more robust typesafety, versus just passing around a `u64` which +/// is merely *understood* to only contain values in [0, 100]. +/// +/// Defaults to 0%. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Percentage(u64); + +impl Percentage { + /// 0% + pub const fn zero() -> Self { + Self(0) + } + + /// Convert this value into a `u64` in [0, 100]; + pub const fn to_percent(self) -> u64 { + self.0 + } + + /// Given an arbitrary `u64`, produce a percentage, *saturating* at 100. + pub fn from_percent(p: u64) -> Self { + Self(u64::min(p.into(), 100)) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_percentage_operations() { + assert_eq!(Percentage::from_percent(101), Percentage::from_percent(100)); + assert_eq!(Percentage::from_percent(48).to_percent(), 48); + } +} diff --git a/crates/proto/src/gen/penumbra.core.component.funding.v1.rs b/crates/proto/src/gen/penumbra.core.component.funding.v1.rs index d72e5db496..c5d098a8bd 100644 --- a/crates/proto/src/gen/penumbra.core.component.funding.v1.rs +++ b/crates/proto/src/gen/penumbra.core.component.funding.v1.rs @@ -1,7 +1,51 @@ // This file is @generated by prost-build. /// Funding component configuration data. #[derive(Clone, Copy, PartialEq, ::prost::Message)] -pub struct FundingParameters {} +pub struct FundingParameters { + /// The parameters governing the funding of the liquidity tournament. + #[prost(message, optional, tag = "1")] + pub liquidity_tournament: ::core::option::Option< + funding_parameters::LiquidityTournament, + >, +} +/// Nested message and enum types in `FundingParameters`. +pub mod funding_parameters { + #[derive(Clone, Copy, PartialEq, ::prost::Message)] + pub struct LiquidityTournament { + /// The fraction of gauge votes that an asset must pass to get any rewards. + /// + /// Takes a value in \[0, 100\]. + #[prost(uint64, tag = "1")] + pub gauge_threshold_percent: u64, + /// The maximum number of liquidity positions that can receive rewards. + /// + /// This avoids potential DoS vectors with processing a large number of small positions. + #[prost(uint64, tag = "2")] + pub max_positions: u64, + /// The maximum number of delegators that can be rewarded. + /// + /// Also avoids potential DoS vectors + #[prost(uint64, tag = "3")] + pub max_delegators: u64, + /// The share of rewards which will go to delegators, opposed with positions. + /// + /// Takes a value in \[0, 100\]. + #[prost(uint64, tag = "4")] + pub delegator_share_percent: u64, + } + impl ::prost::Name for LiquidityTournament { + const NAME: &'static str = "LiquidityTournament"; + const PACKAGE: &'static str = "penumbra.core.component.funding.v1"; + fn full_name() -> ::prost::alloc::string::String { + "penumbra.core.component.funding.v1.FundingParameters.LiquidityTournament" + .into() + } + fn type_url() -> ::prost::alloc::string::String { + "/penumbra.core.component.funding.v1.FundingParameters.LiquidityTournament" + .into() + } + } +} impl ::prost::Name for FundingParameters { const NAME: &'static str = "FundingParameters"; const PACKAGE: &'static str = "penumbra.core.component.funding.v1"; diff --git a/crates/proto/src/gen/penumbra.core.component.funding.v1.serde.rs b/crates/proto/src/gen/penumbra.core.component.funding.v1.serde.rs index 827556590e..ec4c17bc14 100644 --- a/crates/proto/src/gen/penumbra.core.component.funding.v1.serde.rs +++ b/crates/proto/src/gen/penumbra.core.component.funding.v1.serde.rs @@ -140,8 +140,14 @@ impl serde::Serialize for FundingParameters { S: serde::Serializer, { use serde::ser::SerializeStruct; - let len = 0; - let struct_ser = serializer.serialize_struct("penumbra.core.component.funding.v1.FundingParameters", len)?; + let mut len = 0; + if self.liquidity_tournament.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("penumbra.core.component.funding.v1.FundingParameters", len)?; + if let Some(v) = self.liquidity_tournament.as_ref() { + struct_ser.serialize_field("liquidityTournament", v)?; + } struct_ser.end() } } @@ -152,10 +158,13 @@ impl<'de> serde::Deserialize<'de> for FundingParameters { D: serde::Deserializer<'de>, { const FIELDS: &[&str] = &[ + "liquidity_tournament", + "liquidityTournament", ]; #[allow(clippy::enum_variant_names)] enum GeneratedField { + LiquidityTournament, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -177,7 +186,10 @@ impl<'de> serde::Deserialize<'de> for FundingParameters { where E: serde::de::Error, { - Ok(GeneratedField::__SkipField__) + match value { + "liquidityTournament" | "liquidity_tournament" => Ok(GeneratedField::LiquidityTournament), + _ => Ok(GeneratedField::__SkipField__), + } } } deserializer.deserialize_identifier(GeneratedVisitor) @@ -195,16 +207,194 @@ impl<'de> serde::Deserialize<'de> for FundingParameters { where V: serde::de::MapAccess<'de>, { - while map_.next_key::()?.is_some() { - let _ = map_.next_value::()?; + let mut liquidity_tournament__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::LiquidityTournament => { + if liquidity_tournament__.is_some() { + return Err(serde::de::Error::duplicate_field("liquidityTournament")); + } + liquidity_tournament__ = map_.next_value()?; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } } Ok(FundingParameters { + liquidity_tournament: liquidity_tournament__, }) } } deserializer.deserialize_struct("penumbra.core.component.funding.v1.FundingParameters", FIELDS, GeneratedVisitor) } } +impl serde::Serialize for funding_parameters::LiquidityTournament { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.gauge_threshold_percent != 0 { + len += 1; + } + if self.max_positions != 0 { + len += 1; + } + if self.max_delegators != 0 { + len += 1; + } + if self.delegator_share_percent != 0 { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("penumbra.core.component.funding.v1.FundingParameters.LiquidityTournament", len)?; + if self.gauge_threshold_percent != 0 { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("gaugeThresholdPercent", ToString::to_string(&self.gauge_threshold_percent).as_str())?; + } + if self.max_positions != 0 { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("maxPositions", ToString::to_string(&self.max_positions).as_str())?; + } + if self.max_delegators != 0 { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("maxDelegators", ToString::to_string(&self.max_delegators).as_str())?; + } + if self.delegator_share_percent != 0 { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("delegatorSharePercent", ToString::to_string(&self.delegator_share_percent).as_str())?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for funding_parameters::LiquidityTournament { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "gauge_threshold_percent", + "gaugeThresholdPercent", + "max_positions", + "maxPositions", + "max_delegators", + "maxDelegators", + "delegator_share_percent", + "delegatorSharePercent", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + GaugeThresholdPercent, + MaxPositions, + MaxDelegators, + DelegatorSharePercent, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "gaugeThresholdPercent" | "gauge_threshold_percent" => Ok(GeneratedField::GaugeThresholdPercent), + "maxPositions" | "max_positions" => Ok(GeneratedField::MaxPositions), + "maxDelegators" | "max_delegators" => Ok(GeneratedField::MaxDelegators), + "delegatorSharePercent" | "delegator_share_percent" => Ok(GeneratedField::DelegatorSharePercent), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = funding_parameters::LiquidityTournament; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct penumbra.core.component.funding.v1.FundingParameters.LiquidityTournament") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut gauge_threshold_percent__ = None; + let mut max_positions__ = None; + let mut max_delegators__ = None; + let mut delegator_share_percent__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::GaugeThresholdPercent => { + if gauge_threshold_percent__.is_some() { + return Err(serde::de::Error::duplicate_field("gaugeThresholdPercent")); + } + gauge_threshold_percent__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::MaxPositions => { + if max_positions__.is_some() { + return Err(serde::de::Error::duplicate_field("maxPositions")); + } + max_positions__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::MaxDelegators => { + if max_delegators__.is_some() { + return Err(serde::de::Error::duplicate_field("maxDelegators")); + } + max_delegators__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::DelegatorSharePercent => { + if delegator_share_percent__.is_some() { + return Err(serde::de::Error::duplicate_field("delegatorSharePercent")); + } + delegator_share_percent__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(funding_parameters::LiquidityTournament { + gauge_threshold_percent: gauge_threshold_percent__.unwrap_or_default(), + max_positions: max_positions__.unwrap_or_default(), + max_delegators: max_delegators__.unwrap_or_default(), + delegator_share_percent: delegator_share_percent__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("penumbra.core.component.funding.v1.FundingParameters.LiquidityTournament", FIELDS, GeneratedVisitor) + } +} impl serde::Serialize for GenesisContent { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result diff --git a/crates/proto/src/gen/proto_descriptor.bin.no_lfs b/crates/proto/src/gen/proto_descriptor.bin.no_lfs index 0a7803f0131bf9119dd419a2b23c9fd94d7afaf5..54eeb4fe0216e6b34656789922e3da451d94cfda 100644 GIT binary patch delta 1452 zcmaKs-D(?06vuaG*0Mb7M9E6FzMGRz$9AMRHL)F+211iSAOYhjp-_UEXvf+mt6gP3 zRZB2-i}<;c6(dg=Y0_hKQO*xrTEuXTFO^c zP%r8q--&+I$k4HVR z6I#9qEYC4_2Ey-RX!3%#J(svUnRn~ZX`E%p`uxkCffv}J?YTjPAdj{^i^i?YW}Iio zuMEbfajgqGZ&8@umIZwm2>tkV^~_6A)W%`#0>D4M28g|b;TC6#K>Y1U*B%SlBPgX-Es zZ5KM0fL)(=Wo6*)Lec^ldZ7p)w0H>I1s((msdeB1ycb|bT_PB2bZN{L( zpI{7!hePf}0&Ew)Sh)mOR#qON_c)tAe0NGdJfV?G*UudJDeChV*e_}divEJutbM1( z|NTbG@r}PpX_Rnv5zH4|VGqUsC@dQxhMsK(Fu*F@(B=-@^zOq@bmdrJ&kijZ_+D28 z0d5=;JKXOHaHIZX;maNbeeO6Ioz8m#Uq$@F>IAUB~t%%TAP>jGxOCK2@Jz$`n3S zZ?)M~{MV?4c1k%N-_>a~-rt~&Tc`5#Oh*D$5&&69Vo+c{AjM2!J}8~Z6y^g2zmEBc zp`BCO-(I8F3+M8{epWFRk*OBrd)Mg8l|_YU6o+%~xrw=_($@UxEownTjp!f>jTTAbU>(K92Pjy?!49q6I|STZF9bR{ ziK|dV96Rd^xcLY^fG43DesDg{?|f%E4}L#_Ny<|G2ICO^Y}Auaaj0KH=z1=$T#N@d zcZ1uj-mrJyf9MZKv+EP6Gx=qrfX8OseP2l!EgYKT|7cAXta!Vopm6@HZgOA zG%7lYpxu>r11l&54O;3J(t^sfVQM_rBf2=T)%Z`{r0g9lQ6pOM%v1eH#;rSm1*f`Vx diff --git a/proto/penumbra/penumbra/core/component/funding/v1/funding.proto b/proto/penumbra/penumbra/core/component/funding/v1/funding.proto index 6c3f3b2261..ab5a5aae72 100644 --- a/proto/penumbra/penumbra/core/component/funding/v1/funding.proto +++ b/proto/penumbra/penumbra/core/component/funding/v1/funding.proto @@ -4,7 +4,29 @@ package penumbra.core.component.funding.v1; import "penumbra/core/num/v1/num.proto"; // Funding component configuration data. -message FundingParameters {} +message FundingParameters { + message LiquidityTournament { + // The fraction of gauge votes that an asset must pass to get any rewards. + // + // Takes a value in [0, 100]. + uint64 gauge_threshold_percent = 1; + // The maximum number of liquidity positions that can receive rewards. + // + // This avoids potential DoS vectors with processing a large number of small positions. + uint64 max_positions = 2; + // The maximum number of delegators that can be rewarded. + // + // Also avoids potential DoS vectors + uint64 max_delegators = 3; + // The share of rewards which will go to delegators, opposed with positions. + // + // Takes a value in [0, 100]. + uint64 delegator_share_percent = 4; + } + + // The parameters governing the funding of the liquidity tournament. + LiquidityTournament liquidity_tournament = 1; +} // Genesis data for the funding component. message GenesisContent { @@ -22,4 +44,4 @@ message EventFundingStreamReward { uint64 epoch_index = 2; // The amount of the reward, in staking tokens. num.v1.Amount reward_amount = 3; -} \ No newline at end of file +}