diff --git a/crates/core/component/funding/Cargo.toml b/crates/core/component/funding/Cargo.toml index fec6e3d9d7..286b1afe15 100644 --- a/crates/core/component/funding/Cargo.toml +++ b/crates/core/component/funding/Cargo.toml @@ -14,6 +14,7 @@ component = [ "penumbra-sdk-proto/cnidarium", "penumbra-sdk-community-pool/component", "penumbra-sdk-distributions/component", + "penumbra-sdk-governance/component", "penumbra-sdk-sct/component", "penumbra-sdk-shielded-pool/component", "penumbra-sdk-stake/component", diff --git a/crates/core/component/funding/src/action_handler/liquidity_tournament/mod.rs b/crates/core/component/funding/src/action_handler/liquidity_tournament/mod.rs index 0e5bb91054..500882f0f0 100644 --- a/crates/core/component/funding/src/action_handler/liquidity_tournament/mod.rs +++ b/crates/core/component/funding/src/action_handler/liquidity_tournament/mod.rs @@ -1,15 +1,25 @@ -use anyhow::Context as _; +use anyhow::{anyhow, Context as _}; use async_trait::async_trait; -use cnidarium::StateWrite; -use penumbra_sdk_asset::asset::Denom; +use cnidarium::{StateRead, StateWrite}; +use cnidarium_component::ActionHandler; +use penumbra_sdk_asset::{asset::Denom, Value}; +use penumbra_sdk_governance::StateReadExt as _; +use penumbra_sdk_num::Amount; use penumbra_sdk_proof_params::DELEGATOR_VOTE_PROOF_VERIFICATION_KEY; -use penumbra_sdk_txhash::TransactionContext; +use penumbra_sdk_sct::component::clock::EpochRead as _; +use penumbra_sdk_sct::epoch::Epoch; +use penumbra_sdk_stake::component::validator_handler::ValidatorDataRead as _; +use penumbra_sdk_tct::Position; +use penumbra_sdk_txhash::{TransactionContext, TransactionId}; +use crate::component::liquidity_tournament::{ + nullifier::{NullifierRead as _, NullifierWrite as _}, + votes::StateWriteExt as _, +}; use crate::liquidity_tournament::{ proof::LiquidityTournamentVoteProofPublic, ActionLiquidityTournamentVote, LiquidityTournamentVoteBody, LIQUIDITY_TOURNAMENT_VOTE_DENOM_MAX_BYTES, }; -use cnidarium_component::ActionHandler; fn is_valid_denom(denom: &Denom) -> anyhow::Result<()> { anyhow::ensure!( @@ -26,6 +36,37 @@ fn is_valid_denom(denom: &Denom) -> anyhow::Result<()> { Ok(()) } +// Check that the start position is early enough to vote in the current epoch. +async fn start_position_good_for_epoch(epoch: Epoch, start: Position) -> anyhow::Result<()> { + anyhow::ensure!( + epoch.index > u64::from(start.epoch()), + "position {start:?} is not before epoch {epoch:?}" + ); + Ok(()) +} + +/// Fetch the unbonded equivalent of some purported delegation token. +/// +/// Will fail if (either): +/// - the token is not for a known validator, +/// - the validator does not have any rate data. +async fn unbonded_amount(state: impl StateRead, value: Value) -> anyhow::Result { + let validator = state.validator_by_delegation_asset(value.asset_id).await?; + let rate = state + .get_validator_rate(&validator) + .await? + .ok_or_else(|| anyhow!("{} has no rate data", &validator))?; + Ok(rate.unbonded_amount(value.amount)) +} + +// This isolates the logic for how we should handle out of bounds amounts. +fn voting_power(amount: Amount) -> u64 { + amount + .value() + .try_into() + .expect("someone acquired {amount:?} > u64::MAX worth of delegation tokens!") +} + #[async_trait] impl ActionHandler for ActionLiquidityTournamentVote { type CheckStatelessContext = TransactionContext; @@ -70,7 +111,37 @@ impl ActionHandler for ActionLiquidityTournamentVote { Ok(()) } - async fn check_and_execute(&self, _state: S) -> anyhow::Result<()> { - todo!() + async fn check_and_execute(&self, mut state: S) -> anyhow::Result<()> { + // 1. Check that the start position can vote in this round. + let current_epoch = state + .get_current_epoch() + .await + .expect("failed to fetch current epoch"); + start_position_good_for_epoch(current_epoch, self.body.start_position).await?; + // 2. We can tally, as long as the nullifier hasn't been used yet (this round). + let nullifier = self.body.nullifier; + let nullifier_exists = state.get_lqt_spent_nullifier(nullifier).await.is_some(); + anyhow::ensure!( + !nullifier_exists, + "nullifier {} already voted in epoch {}", + self.body.nullifier, + current_epoch.index + ); + state.put_lqt_spent_nullifier(current_epoch.index, nullifier, TransactionId([0u8; 32])); + // 3. Ok, actually tally. + let power = voting_power(unbonded_amount(&state, self.body.value).await?); + let incentivized = self + .body + .incentivized_id() + .ok_or_else(|| anyhow!("{:?} is not a base denom", self.body.incentivized))?; + state + .tally( + current_epoch.index, + incentivized, + power, + &self.body.rewards_recipient, + ) + .await?; + Ok(()) } } diff --git a/crates/core/component/funding/src/component/liquidity_tournament/mod.rs b/crates/core/component/funding/src/component/liquidity_tournament/mod.rs index 1d68566db0..3cc2e3c317 100644 --- a/crates/core/component/funding/src/component/liquidity_tournament/mod.rs +++ b/crates/core/component/funding/src/component/liquidity_tournament/mod.rs @@ -1 +1,2 @@ pub mod nullifier; +pub mod votes; diff --git a/crates/core/component/funding/src/component/liquidity_tournament/nullifier/mod.rs b/crates/core/component/funding/src/component/liquidity_tournament/nullifier/mod.rs index 59a867eb38..d01fb440e5 100644 --- a/crates/core/component/funding/src/component/liquidity_tournament/nullifier/mod.rs +++ b/crates/core/component/funding/src/component/liquidity_tournament/nullifier/mod.rs @@ -6,7 +6,6 @@ use cnidarium::{StateRead, StateWrite}; use penumbra_sdk_proto::{StateReadProto, StateWriteProto}; use penumbra_sdk_sct::{component::clock::EpochRead, Nullifier}; -#[allow(dead_code)] #[async_trait] pub trait NullifierRead: StateRead { /// Returns the `TransactionId` if the nullifier has been spent; otherwise, returns None. @@ -34,7 +33,6 @@ pub trait NullifierRead: StateRead { impl NullifierRead for T {} -#[allow(dead_code)] #[async_trait] pub trait NullifierWrite: StateWrite { /// Sets the LQT nullifier in the NV storage. diff --git a/crates/core/component/funding/src/component/liquidity_tournament/votes/mod.rs b/crates/core/component/funding/src/component/liquidity_tournament/votes/mod.rs new file mode 100644 index 0000000000..b79df79c64 --- /dev/null +++ b/crates/core/component/funding/src/component/liquidity_tournament/votes/mod.rs @@ -0,0 +1,26 @@ +use async_trait::async_trait; +use cnidarium::StateWrite; +use penumbra_sdk_asset::asset; +use penumbra_sdk_keys::Address; + +use crate::component::state_key; + +#[async_trait] +pub trait StateWriteExt: StateWrite { + // Keeping this as returning a result to not have to touch other code if it changes to return an error. + async fn tally( + &mut self, + epoch: u64, + asset: asset::Id, + power: u64, + voter: &Address, + ) -> anyhow::Result<()> { + self.nonverifiable_put_raw( + state_key::lqt::v1::votes::receipt(epoch, asset, power, voter).to_vec(), + Vec::default(), + ); + Ok(()) + } +} + +impl StateWriteExt for T {} diff --git a/crates/core/component/funding/src/component/state_key.rs b/crates/core/component/funding/src/component/state_key.rs index 7a31d41ff3..58713c361f 100644 --- a/crates/core/component/funding/src/component/state_key.rs +++ b/crates/core/component/funding/src/component/state_key.rs @@ -12,5 +12,59 @@ pub mod lqt { format!("funding/lqt/v1/nullifier/{epoch_index:020}/lookup/{nullifier}") } } + + pub mod votes { + use penumbra_sdk_asset::asset; + use penumbra_sdk_keys::{address::ADDRESS_LEN_BYTES, Address}; + + const PART0: &'static str = "funding/lqt/v1/votes/"; + const EPOCH_LEN: usize = 20; + const PART1: &'static str = "/by_asset/"; + const PREFIX_LEN: usize = PART0.len() + EPOCH_LEN + PART1.len(); + + /// A prefix for accessing the votes in a given epoch, c.f. [`power_asset_address`]; + pub(crate) fn prefix(epoch_index: u64) -> [u8; PREFIX_LEN] { + let mut bytes = [0u8; PREFIX_LEN]; + + let rest = &mut bytes; + let (bytes_part0, rest) = rest.split_at_mut(PART0.len()); + let (bytes_epoch_index, bytes_part1) = rest.split_at_mut(EPOCH_LEN); + + bytes_part0.copy_from_slice(PART0.as_bytes()); + bytes_epoch_index + .copy_from_slice(format!("{epoch_index:0w$}", w = EPOCH_LEN).as_bytes()); + bytes_part1.copy_from_slice(PART1.as_bytes()); + + bytes + } + + const ASSET_LEN: usize = 32; + const POWER_LEN: usize = 8; + const RECEIPT_LEN: usize = PREFIX_LEN + ASSET_LEN + POWER_LEN + ADDRESS_LEN_BYTES; + + /// When present, indicates that an address voted for a particular asset, with a given power. + /// + /// To get the values ordered by descending voting power, use [`prefix`]; + pub(crate) fn receipt( + epoch_index: u64, + asset: asset::Id, + power: u64, + voter: &Address, + ) -> [u8; RECEIPT_LEN] { + let mut bytes = [0u8; RECEIPT_LEN]; + + let rest = &mut bytes; + let (bytes_prefix, rest) = rest.split_at_mut(PREFIX_LEN); + let (bytes_asset, rest) = rest.split_at_mut(ASSET_LEN); + let (bytes_power, bytes_voter) = rest.split_at_mut(POWER_LEN); + + bytes_prefix.copy_from_slice(&prefix(epoch_index)); + bytes_asset.copy_from_slice(&asset.to_bytes()); + bytes_power.copy_from_slice(&((!power).to_be_bytes())); + bytes_voter.copy_from_slice(&voter.to_vec()); + + bytes + } + } } } diff --git a/crates/core/component/funding/src/liquidity_tournament/action/mod.rs b/crates/core/component/funding/src/liquidity_tournament/action/mod.rs index 86bf11b355..34a95c2409 100644 --- a/crates/core/component/funding/src/liquidity_tournament/action/mod.rs +++ b/crates/core/component/funding/src/liquidity_tournament/action/mod.rs @@ -1,6 +1,9 @@ use anyhow::{anyhow, Context}; use decaf377_rdsa::{Signature, SpendAuth, VerificationKey}; -use penumbra_sdk_asset::{asset::Denom, balance, Value}; +use penumbra_sdk_asset::{ + asset::{self, Denom, REGISTRY}, + balance, Value, +}; use penumbra_sdk_keys::Address; use penumbra_sdk_proto::{core::component::funding::v1 as pb, DomainType}; use penumbra_sdk_sct::Nullifier; @@ -34,6 +37,17 @@ pub struct LiquidityTournamentVoteBody { pub rk: VerificationKey, } +impl LiquidityTournamentVoteBody { + /// Get the asset id that should be incentivized. + /// + /// This will return None if the denom is not a base denom. + pub fn incentivized_id(&self) -> Option { + REGISTRY + .parse_denom(&self.incentivized.denom) + .map(|x| x.id()) + } +} + impl DomainType for LiquidityTournamentVoteBody { type Proto = pb::LiquidityTournamentVoteBody; }