diff --git a/Cargo.lock b/Cargo.lock index f78dbb6bee..bd06634095 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5162,6 +5162,7 @@ dependencies = [ "rand_core 0.6.4", "regex", "serde", + "serde_json", "tap", "tendermint", "thiserror", diff --git a/crates/core/component/governance/Cargo.toml b/crates/core/component/governance/Cargo.toml index 48cb7b75e3..bb8b2ee1ae 100644 --- a/crates/core/component/governance/Cargo.toml +++ b/crates/core/component/governance/Cargo.toml @@ -71,6 +71,7 @@ rand_chacha = {workspace = true} rand_core = {workspace = true, features = ["getrandom"]} regex = {workspace = true} serde = {workspace = true, features = ["derive"]} +serde_json = {workspace = true} tap = {workspace = true} tendermint = {workspace = true} thiserror = {workspace = true} diff --git a/crates/core/component/governance/src/change.rs b/crates/core/component/governance/src/change.rs new file mode 100644 index 0000000000..0d11fd8c74 --- /dev/null +++ b/crates/core/component/governance/src/change.rs @@ -0,0 +1,248 @@ +use std::str::FromStr; + +use anyhow::Context; +use penumbra_proto::{core::component::governance::v1 as pb, DomainType}; +use serde::{Deserialize, Serialize}; + +/// An encoded parameter. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "pb::EncodedParameter", into = "pb::EncodedParameter")] +pub struct EncodedParameter { + pub component: String, + pub key: String, + pub value: String, +} + +impl DomainType for EncodedParameter { + type Proto = pb::EncodedParameter; +} + +impl TryFrom for EncodedParameter { + type Error = anyhow::Error; + fn try_from(value: pb::EncodedParameter) -> Result { + Ok(EncodedParameter { + component: value.component, + key: value.key, + value: value.value, + }) + } +} + +impl From for pb::EncodedParameter { + fn from(value: EncodedParameter) -> Self { + pb::EncodedParameter { + component: value.component, + key: value.key, + value: value.value, + } + } +} + +/// Generates a set of encoded parameters for the given object. +/// +/// This is useful for generating template changes. +pub fn encode_parameters(parameters: serde_json::Value) -> Vec { + let mut encoded_parameters = Vec::new(); + for (component, value) in parameters.as_object().into_iter().flatten() { + for (key, value) in value.as_object().into_iter().flatten() { + encoded_parameters.push(EncodedParameter { + component: component.to_string(), + key: key.to_string(), + value: value.to_string(), + }); + } + } + encoded_parameters +} + +/// Applies a set of changes to the app parameters. +/// +/// The app parameters are input as a [`serde_json::Value`] object, so that the +/// parameter change code does not need to know about the structure of the entire +/// application. +/// +/// If the changes can be successfully applied, the new app parameters are returned. +/// By taking ownership of the input `app_parameters`, we ensure that the caller cannot +/// access any partially-mutated app parameters. +pub fn apply_changes( + mut app_parameters: serde_json::Value, + changes: &[EncodedParameter], +) -> Result { + for change in changes { + let component = app_parameters + .get_mut(&change.component) + .ok_or_else(|| { + anyhow::anyhow!("component {} not found in app parameters", change.component) + })? + .as_object_mut() + .ok_or_else(|| { + anyhow::anyhow!( + "expected component {} to be an object in app parameters", + change.component + ) + })?; + + dbg!(&change); + + let new_value = serde_json::Value::from_str(&change.value) + .context("could not decode new value as JSON value")?; + + dbg!(&new_value); + dbg!(component.get(&change.key)); + + // We want to insert into the map to handle the case where the existing value + // is missing (e.g., it had a default value and so was not encoded) + component.insert(change.key.clone(), new_value); + } + Ok(app_parameters) +} + +#[cfg(test)] +mod tests { + use penumbra_num::Amount; + + use crate::params::GovernanceParameters; + + const SAMPLE_JSON_PARAMETERS: &'static str = r#" + { + "chainId": "penumbra-testnet-deimos-6-b295771a", + "sctParams": { + "epochDuration": "719" + }, + "communityPoolParams": { + "communityPoolSpendProposalsEnabled": true + }, + "governanceParams": { + "proposalVotingBlocks": "17280", + "proposalDepositAmount": { + "lo": "10000000" + }, + "proposalValidQuorum": "40/100", + "proposalPassThreshold": "50/100", + "proposalSlashThreshold": "80/100" + }, + "ibcParams": { + "ibcEnabled": true, + "inboundIcs20TransfersEnabled": true, + "outboundIcs20TransfersEnabled": true + }, + "stakeParams": { + "activeValidatorLimit": "80", + "baseRewardRate": "30000", + "slashingPenaltyMisbehavior": "10000000", + "slashingPenaltyDowntime": "10000", + "signedBlocksWindowLen": "10000", + "missedBlocksMaximum": "9500", + "minValidatorStake": { + "lo": "1000000" + }, + "unbondingDelay": "2158" + }, + "feeParams": { + "fixedGasPrices": {} + }, + "distributionsParams": { + "stakingIssuancePerBlock": "1" + }, + "fundingParams": {}, + "shieldedPoolParams": { + "fixedFmdParams": { + "asOfBlockHeight": "1" + } + }, + "dexParams": { + "isEnabled": true, + "fixedCandidates": [ + { + "inner": "KeqcLzNx9qSH5+lcJHBB9KNW+YPrBk5dKzvPMiypahA=" + }, + { + "inner": "reum7wQmk/owgvGMWMZn/6RFPV24zIKq3W6In/WwZgg=" + }, + { + "inner": "HW2Eq3UZVSBttoUwUi/MUtE7rr2UU7/UH500byp7OAc=" + }, + { + "inner": "nwPDkQq3OvLnBwGTD+nmv1Ifb2GEmFCgNHrU++9BsRE=" + }, + { + "inner": "ypUT1AOtjfwMOKMATACoD9RSvi8jY/YnYGi46CZ/6Q8=" + }, + { + "inner": "pmpygqUf4DL+z849rGPpudpdK/+FAv8qQ01U2C73kAw=" + }, + { + "inner": "o2gZdbhCH70Ry+7iBhkSeHC/PB1LZhgkn7LHC2kEhQc=" + } + ], + "maxHops": 4, + "maxPositionsPerPair": 10 + }, + "auctionParams": {} + } + "#; + + #[test] + fn dump_encoded_parameters() { + let parameters = serde_json::from_str(SAMPLE_JSON_PARAMETERS).unwrap(); + dbg!(¶meters); + let encoded_parameters = super::encode_parameters(parameters); + for encoded_parameter in encoded_parameters.iter() { + println!("{}", serde_json::to_string(&encoded_parameter).unwrap()); + } + } + + #[test] + fn apply_changes_to_gov_params() { + let old_parameters_raw: serde_json::Value = + serde_json::from_str(SAMPLE_JSON_PARAMETERS).unwrap(); + + // Make changes to the gov parameters specifically since they're + // local to this crate so we can also inspect the decoded parameters. + let changes = vec![ + super::EncodedParameter { + component: "governanceParams".to_string(), + key: "proposalVotingBlocks".to_string(), + value: r#""17281""#.to_string(), + }, + super::EncodedParameter { + component: "governanceParams".to_string(), + key: "proposalDepositAmount".to_string(), + value: r#"{"lo":"10000001"}"#.to_string(), + }, + ]; + let new_parameters_raw = + super::apply_changes(old_parameters_raw.clone(), &changes).unwrap(); + + println!( + "{}", + serde_json::to_string_pretty(&old_parameters_raw).unwrap() + ); + println!( + "{}", + serde_json::to_string_pretty(&new_parameters_raw).unwrap() + ); + + let old_gov_parameters_raw = old_parameters_raw["governanceParams"].clone(); + let new_gov_parameters_raw = new_parameters_raw["governanceParams"].clone(); + + let old_gov_parameters: GovernanceParameters = + serde_json::value::from_value(old_gov_parameters_raw).unwrap(); + let new_gov_parameters: GovernanceParameters = + serde_json::value::from_value(new_gov_parameters_raw).unwrap(); + + dbg!(&old_gov_parameters); + dbg!(&new_gov_parameters); + + assert_eq!(old_gov_parameters.proposal_voting_blocks, 17280); + assert_eq!( + old_gov_parameters.proposal_deposit_amount, + Amount::from(10_000_000u64) + ); + assert_eq!(new_gov_parameters.proposal_voting_blocks, 17281); + assert_eq!( + new_gov_parameters.proposal_deposit_amount, + Amount::from(10_000_001u64) + ); + } +} diff --git a/crates/core/component/governance/src/lib.rs b/crates/core/component/governance/src/lib.rs index 6f3c75c612..fbc981b9ea 100644 --- a/crates/core/component/governance/src/lib.rs +++ b/crates/core/component/governance/src/lib.rs @@ -57,3 +57,5 @@ pub use vote::Vote; pub mod genesis; pub mod params; + +pub mod change; diff --git a/crates/proto/src/gen/penumbra.core.component.governance.v1.rs b/crates/proto/src/gen/penumbra.core.component.governance.v1.rs index 59ede0ba17..c4d12de115 100644 --- a/crates/proto/src/gen/penumbra.core.component.governance.v1.rs +++ b/crates/proto/src/gen/penumbra.core.component.governance.v1.rs @@ -1003,8 +1003,33 @@ impl ::prost::Name for GenesisContent { ::prost::alloc::format!("penumbra.core.component.governance.v1.{}", Self::NAME) } } -/// Note: must be kept in sync with AppParameters. -/// Each field here is optional. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct EncodedParameter { + /// The component name in the `AppParameters`. + /// + /// This is the ProtoJSON-produced key in the `AppParameters` structure. + #[prost(string, tag = "1")] + pub component: ::prost::alloc::string::String, + /// The parameter key in the component parameters. + /// + /// This is the ProtoJSON-produced field name in the component's substructure. + #[prost(string, tag = "2")] + pub key: ::prost::alloc::string::String, + /// The parameter value. + /// + /// This is the ProtoJSON-encoded value of the parameter. + #[prost(string, tag = "3")] + pub value: ::prost::alloc::string::String, +} +impl ::prost::Name for EncodedParameter { + const NAME: &'static str = "EncodedParameter"; + const PACKAGE: &'static str = "penumbra.core.component.governance.v1"; + fn full_name() -> ::prost::alloc::string::String { + ::prost::alloc::format!("penumbra.core.component.governance.v1.{}", Self::NAME) + } +} +/// DEPRECATED #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ChangedAppParameters { @@ -1059,6 +1084,7 @@ impl ::prost::Name for ChangedAppParameters { ::prost::alloc::format!("penumbra.core.component.governance.v1.{}", Self::NAME) } } +/// DEPRECATED #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ChangedAppParametersSet { diff --git a/crates/proto/src/gen/penumbra.core.component.governance.v1.serde.rs b/crates/proto/src/gen/penumbra.core.component.governance.v1.serde.rs index a531d38884..2b274f0b8b 100644 --- a/crates/proto/src/gen/penumbra.core.component.governance.v1.serde.rs +++ b/crates/proto/src/gen/penumbra.core.component.governance.v1.serde.rs @@ -1812,6 +1812,135 @@ impl<'de> serde::Deserialize<'de> for delegator_vote_view::Visible { deserializer.deserialize_struct("penumbra.core.component.governance.v1.DelegatorVoteView.Visible", FIELDS, GeneratedVisitor) } } +impl serde::Serialize for EncodedParameter { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.component.is_empty() { + len += 1; + } + if !self.key.is_empty() { + len += 1; + } + if !self.value.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("penumbra.core.component.governance.v1.EncodedParameter", len)?; + if !self.component.is_empty() { + struct_ser.serialize_field("component", &self.component)?; + } + if !self.key.is_empty() { + struct_ser.serialize_field("key", &self.key)?; + } + if !self.value.is_empty() { + struct_ser.serialize_field("value", &self.value)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for EncodedParameter { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "component", + "key", + "value", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Component, + Key, + Value, + __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 { + "component" => Ok(GeneratedField::Component), + "key" => Ok(GeneratedField::Key), + "value" => Ok(GeneratedField::Value), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = EncodedParameter; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct penumbra.core.component.governance.v1.EncodedParameter") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut component__ = None; + let mut key__ = None; + let mut value__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Component => { + if component__.is_some() { + return Err(serde::de::Error::duplicate_field("component")); + } + component__ = Some(map_.next_value()?); + } + GeneratedField::Key => { + if key__.is_some() { + return Err(serde::de::Error::duplicate_field("key")); + } + key__ = Some(map_.next_value()?); + } + GeneratedField::Value => { + if value__.is_some() { + return Err(serde::de::Error::duplicate_field("value")); + } + value__ = Some(map_.next_value()?); + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(EncodedParameter { + component: component__.unwrap_or_default(), + key: key__.unwrap_or_default(), + value: value__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("penumbra.core.component.governance.v1.EncodedParameter", FIELDS, GeneratedVisitor) + } +} impl serde::Serialize for EventDelegatorVote { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result diff --git a/proto/penumbra/penumbra/core/component/governance/v1/governance.proto b/proto/penumbra/penumbra/core/component/governance/v1/governance.proto index f9ce96f9ea..4af392ba8e 100644 --- a/proto/penumbra/penumbra/core/component/governance/v1/governance.proto +++ b/proto/penumbra/penumbra/core/component/governance/v1/governance.proto @@ -430,9 +430,24 @@ message GenesisContent { GovernanceParameters governance_params = 1; } -// Note: must be kept in sync with AppParameters. -// Each field here is optional. +message EncodedParameter { + // The component name in the `AppParameters`. + // + // This is the ProtoJSON-produced key in the `AppParameters` structure. + string component = 1; + // The parameter key in the component parameters. + // + // This is the ProtoJSON-produced field name in the component's substructure. + string key = 2; + // The parameter value. + // + // This is the ProtoJSON-encoded value of the parameter. + string value = 3; +} + +// DEPRECATED message ChangedAppParameters { + option deprecated = true; // Sct module parameters. core.component.sct.v1.SctParameters sct_params = 1; // Community Pool module parameters. @@ -457,7 +472,9 @@ message ChangedAppParameters { core.component.auction.v1alpha1.AuctionParameters auction_params = 11; } +// DEPRECATED message ChangedAppParametersSet { + option deprecated = true; // The set of app parameters at the time the proposal was submitted. ChangedAppParameters old = 1; // The new set of parameters the proposal is trying to enact.