diff --git a/Cargo.lock b/Cargo.lock index 90be29bdc2..8cb88723e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5244,6 +5244,7 @@ dependencies = [ "penumbra-sdk-tct", "penumbra-sdk-txhash", "proptest", + "rand", "serde", "tendermint 0.40.1", "tonic", diff --git a/crates/bin/pcli/src/command/tx.rs b/crates/bin/pcli/src/command/tx.rs index 6ae6950881..0a71282326 100644 --- a/crates/bin/pcli/src/command/tx.rs +++ b/crates/bin/pcli/src/command/tx.rs @@ -22,6 +22,7 @@ use ibc_types::core::{ client::Height as IbcHeight, }; use ibc_types::lightclients::tendermint::client_state::ClientState as TendermintClientState; +use lqt_vote::LqtVoteCmd; use rand_core::OsRng; use regex::Regex; @@ -82,6 +83,7 @@ use clap::Parser; mod auction; mod liquidity_position; +mod lqt_vote; mod proposal; mod replicate; @@ -311,6 +313,8 @@ pub enum TxCmd { /// The transaction to be broadcast transaction: PathBuf, }, + #[clap(display_order = 700)] + LqtVote(LqtVoteCmd), } /// Vote on a governance proposal. @@ -368,6 +372,7 @@ impl TxCmd { TxCmd::Auction(_) => false, TxCmd::Broadcast { .. } => false, TxCmd::RegisterForwardingAccount { .. } => false, + TxCmd::LqtVote(cmd) => cmd.offline(), } } @@ -1553,6 +1558,7 @@ impl TxCmd { println!("Noble response: {:?}", r); } + TxCmd::LqtVote(cmd) => cmd.exec(app, gas_prices).await?, } Ok(()) diff --git a/crates/bin/pcli/src/command/tx/lqt_vote.rs b/crates/bin/pcli/src/command/tx/lqt_vote.rs new file mode 100644 index 0000000000..0a06f18c87 --- /dev/null +++ b/crates/bin/pcli/src/command/tx/lqt_vote.rs @@ -0,0 +1,128 @@ +use std::collections::BTreeMap; + +use anyhow::anyhow; +use penumbra_sdk_asset::{ + asset::{self, REGISTRY}, + Value, +}; +use penumbra_sdk_fee::{FeeTier, GasPrices}; +use penumbra_sdk_keys::Address; +use penumbra_sdk_num::Amount; +use penumbra_sdk_proto::core::component::sct::v1::{ + query_service_client::QueryServiceClient as SctQueryServiceClient, EpochByHeightRequest, +}; +use penumbra_sdk_sct::epoch::Epoch; +use penumbra_sdk_view::{Planner, ViewClient}; +use rand_core::OsRng; + +use crate::App; + +async fn fetch_epoch(app: &mut App) -> anyhow::Result { + let mut sct_client = SctQueryServiceClient::new(app.pd_channel().await?); + let latest_sync_height = app.view().status().await?.full_sync_height; + let epoch = sct_client + .epoch_by_height(EpochByHeightRequest { + height: latest_sync_height, + }) + .await? + .into_inner() + .epoch + .expect("epoch must be available") + .into(); + Ok(epoch) +} + +/// Vote in the current round of the liquidity tournament. +/// +/// This will plan a transaction which directs all available voting power to a single asset. +#[derive(Debug, clap::Parser)] +pub struct LqtVoteCmd { + /// The denom string for the asset being voted for. + vote: String, + /// If provided, make the rewards recipient a particular address instead. + /// + /// This can also be an integer, indicating an ephemeral address of a sub-account. + #[clap(short, long)] + rewards_recipient: Option, + /// The selected fee tier. + #[clap(short, long, default_value_t)] + fee_tier: FeeTier, +} + +impl LqtVoteCmd { + pub fn offline(&self) -> bool { + false + } + + fn rewards_addr(&self, app: &App) -> anyhow::Result
{ + let to_parse = match &self.rewards_recipient { + None => { + return Ok(app + .config + .full_viewing_key + .ephemeral_address(OsRng, Default::default()) + .0) + } + Some(x) => x, + }; + let maybe_index: Option = to_parse.parse().ok(); + if let Some(i) = maybe_index { + return Ok(app + .config + .full_viewing_key + .ephemeral_address(OsRng, i.into()) + .0); + } + to_parse + .parse() + .map_err(|_| anyhow!("failed to parse address '{}'", to_parse)) + } + + pub async fn exec(&self, app: &mut App, gas_prices: GasPrices) -> anyhow::Result<()> { + let vote_meta = REGISTRY + .parse_denom(&self.vote) + .ok_or_else(|| anyhow!("failed to parse denom: '{}'", &self.vote))?; + let vote_denom = vote_meta.base_denom(); + + let epoch = fetch_epoch(app).await?; + let voting_notes = app.view().lqt_voting_notes(epoch.index, None).await?; + + let mut planner = Planner::new(OsRng); + + planner + .set_gas_prices(gas_prices) + .set_fee_tier(self.fee_tier); + + // First, tell the planner to make all the necessary votes. + planner.lqt_vote( + u16::try_from(epoch.index)?, + vote_denom, + self.rewards_addr(app)?, + &voting_notes, + ); + // We also want to go ahead and do the consolidation thing, + // to reduce the number of votes we need in the next epoch. + // To do so, we need to spend all of these notes, and produce one output per + // delegator token. + let mut totals: BTreeMap = Default::default(); + for note in voting_notes { + let value = note.note.value(); + planner.spend(note.note, note.position); + *totals.entry(value.asset_id).or_insert(Amount::zero()) += value.amount; + } + let change_addr = app + .config + .full_viewing_key + .ephemeral_address(OsRng, Default::default()) + .0; + planner.change_address(change_addr.clone()); + for (asset_id, amount) in totals { + planner.output(Value { asset_id, amount }, change_addr.clone()); + } + + let plan = planner.plan(app.view(), Default::default()).await?; + app.build_and_submit_transaction(plan).await?; + + Ok(()) + } +} diff --git a/crates/bin/pcli/src/terminal.rs b/crates/bin/pcli/src/terminal.rs index 69dea541d0..df1d36a167 100644 --- a/crates/bin/pcli/src/terminal.rs +++ b/crates/bin/pcli/src/terminal.rs @@ -160,6 +160,7 @@ fn pretty_print_transaction_plan( ActionPlan::ActionDutchAuctionEnd(_) => None, ActionPlan::ActionDutchAuctionWithdraw(_) => None, ActionPlan::IbcAction(_) => todo!(), + ActionPlan::ActionLiquidityTournamentVote(_) => None, } } diff --git a/crates/core/app/src/action_handler/actions.rs b/crates/core/app/src/action_handler/actions.rs index 93f33355e0..7f84627105 100644 --- a/crates/core/app/src/action_handler/actions.rs +++ b/crates/core/app/src/action_handler/actions.rs @@ -56,7 +56,7 @@ impl AppActionHandler for Action { Action::ActionDutchAuctionSchedule(action) => action.check_stateless(()).await, Action::ActionDutchAuctionEnd(action) => action.check_stateless(()).await, Action::ActionDutchAuctionWithdraw(action) => action.check_stateless(()).await, - Action::ActionLiquidityTournamentVote(_action) => todo!(), + Action::ActionLiquidityTournamentVote(action) => action.check_stateless(context).await, } } @@ -98,7 +98,7 @@ impl AppActionHandler for Action { Action::ActionDutchAuctionSchedule(action) => action.check_historical(state).await, Action::ActionDutchAuctionEnd(action) => action.check_historical(state).await, Action::ActionDutchAuctionWithdraw(action) => action.check_historical(state).await, - Action::ActionLiquidityTournamentVote(_action) => todo!(), + Action::ActionLiquidityTournamentVote(action) => action.check_historical(state).await, } } @@ -140,7 +140,7 @@ impl AppActionHandler for Action { Action::ActionDutchAuctionSchedule(action) => action.check_and_execute(state).await, Action::ActionDutchAuctionEnd(action) => action.check_and_execute(state).await, Action::ActionDutchAuctionWithdraw(action) => action.check_and_execute(state).await, - Action::ActionLiquidityTournamentVote(_action) => todo!(), + Action::ActionLiquidityTournamentVote(action) => action.check_and_execute(state).await, } } } diff --git a/crates/core/app/src/action_handler/actions/submit.rs b/crates/core/app/src/action_handler/actions/submit.rs index f6abb3b636..a0ded0339c 100644 --- a/crates/core/app/src/action_handler/actions/submit.rs +++ b/crates/core/app/src/action_handler/actions/submit.rs @@ -93,6 +93,7 @@ impl AppActionHandler for ProposalSubmit { anyhow::bail!("invalid action in Community Pool spend proposal (not allowed to manipulate proposals from within proposals)") } ValidatorDefinition(_) + | ActionLiquidityTournamentVote(_) | IbcAction(_) | ValidatorVote(_) | PositionOpen(_) @@ -356,6 +357,7 @@ async fn build_community_pool_transaction( effect_hash: Some(effect_hash), spend_auths: Default::default(), delegator_vote_auths: Default::default(), + lqt_vote_auths: Default::default(), }, ) } diff --git a/crates/core/component/funding/Cargo.toml b/crates/core/component/funding/Cargo.toml index b4ffbc35e3..b103d62511 100644 --- a/crates/core/component/funding/Cargo.toml +++ b/crates/core/component/funding/Cargo.toml @@ -60,6 +60,7 @@ penumbra-sdk-shielded-pool = {workspace = true, default-features = false} penumbra-sdk-stake = {workspace = true, default-features = false} penumbra-sdk-tct = {workspace = true, default-features = false} penumbra-sdk-txhash = {workspace = true, default-features = false} +rand = {workspace = true} serde = {workspace = true, features = ["derive"]} tendermint = {workspace = true} tracing = {workspace = true} diff --git a/crates/core/component/funding/src/liquidity_tournament/mod.rs b/crates/core/component/funding/src/liquidity_tournament/mod.rs index 5a660f69a1..214a6a0af3 100644 --- a/crates/core/component/funding/src/liquidity_tournament/mod.rs +++ b/crates/core/component/funding/src/liquidity_tournament/mod.rs @@ -1,8 +1,10 @@ mod action; +mod plan; mod view; pub mod proof; pub use action::{ActionLiquidityTournamentVote, LiquidityTournamentVoteBody}; +pub use plan::ActionLiquidityTournamentVotePlan; pub use view::ActionLiquidityTournamentVoteView; /// The maximum number of allowable bytes in the denom string. diff --git a/crates/core/component/funding/src/liquidity_tournament/plan/mod.rs b/crates/core/component/funding/src/liquidity_tournament/plan/mod.rs new file mode 100644 index 0000000000..a73d172905 --- /dev/null +++ b/crates/core/component/funding/src/liquidity_tournament/plan/mod.rs @@ -0,0 +1,204 @@ +use anyhow::{anyhow, Context}; +use decaf377::{Fq, Fr}; +use decaf377_rdsa::{Signature, SpendAuth}; +use penumbra_sdk_asset::asset::Denom; +use penumbra_sdk_keys::{Address, FullViewingKey}; +use penumbra_sdk_proof_params::DELEGATOR_VOTE_PROOF_PROVING_KEY; +use penumbra_sdk_proto::{core::component::funding::v1 as pb, DomainType}; +use penumbra_sdk_sct::Nullifier; +use penumbra_sdk_shielded_pool::note::Note; +use penumbra_sdk_tct::{self as tct}; +use rand::{CryptoRng, RngCore}; +use serde::{Deserialize, Serialize}; +use std::convert::{From, TryFrom}; + +use super::{ + proof::{ + LiquidityTournamentVoteProof, LiquidityTournamentVoteProofPrivate, + LiquidityTournamentVoteProofPublic, + }, + ActionLiquidityTournamentVote, LiquidityTournamentVoteBody, +}; + +/// A plan to vote in the liquidity tournament. +/// +/// This structure represents the planned vote before it is actually executed, +/// containing the necessary information and blinding factors for the voting proof. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde( + try_from = "pb::ActionLiquidityTournamentVotePlan", + into = "pb::ActionLiquidityTournamentVotePlan" +)] +pub struct ActionLiquidityTournamentVotePlan { + /// The asset the user wants to vote for. + pub incentivized: Denom, + /// The address the user wants potential rewards to go to. + pub rewards_recipient: Address, + /// The note containing the staked note used for voting. + pub staked_note: Note, + /// The position of the staked note. + pub staked_note_position: tct::Position, + /// The start position of the tournament. + pub start_position: tct::Position, + /// Randomizer for proof of spend capability. + pub randomizer: Fr, + /// The first blinding factor used for generating the ZK proof. + pub proof_blinding_r: Fq, + /// The second blinding factor used for generating the ZK proof. + pub proof_blinding_s: Fq, +} + +impl ActionLiquidityTournamentVotePlan { + /// Create a new [`ActionLiquidityTournamentVotePlan`] that votes using the given positioned [`Note`]. + #[allow(clippy::too_many_arguments)] + pub fn new( + rng: &mut R, + incentivized: Denom, + rewards_recipient: Address, + staked_note: Note, + staked_note_position: tct::Position, + start_position: tct::Position, + ) -> ActionLiquidityTournamentVotePlan { + ActionLiquidityTournamentVotePlan { + incentivized, + rewards_recipient, + staked_note, + staked_note_position, + start_position, + randomizer: Fr::rand(rng), + proof_blinding_r: Fq::rand(rng), + proof_blinding_s: Fq::rand(rng), + } + } + + pub fn to_body(&self, fvk: &FullViewingKey) -> LiquidityTournamentVoteBody { + let commitment = self.staked_note.commit(); + + let nk = fvk.nullifier_key(); + let nullifier = Nullifier::derive(nk, self.staked_note_position, &commitment); + let ak = fvk.spend_verification_key(); + let rk = ak.randomize(&self.randomizer); + let value = self.staked_note.value(); + + LiquidityTournamentVoteBody { + incentivized: self.incentivized.clone(), + rewards_recipient: self.rewards_recipient.clone(), + start_position: self.start_position, + value, + nullifier, + rk, + } + } + + /// Convert this plan into an action. + /// + /// * `fvk`: [`FullViewingKey`], in order to derive keys. + /// * `auth_sig`: [`Signature`], as the signature for the transaction. + /// * `auth_path`: [`tct::Proof`], witnessing the inclusion of the spent note. + pub fn to_action( + self, + fvk: &FullViewingKey, + auth_sig: Signature, + auth_path: tct::Proof, + ) -> ActionLiquidityTournamentVote { + let commitment = self.staked_note.commit(); + + let nk = fvk.nullifier_key(); + let nullifier = Nullifier::derive(nk, self.staked_note_position, &commitment); + let ak = fvk.spend_verification_key(); + let rk = ak.randomize(&self.randomizer); + let value = self.staked_note.value(); + + let public = LiquidityTournamentVoteProofPublic { + anchor: auth_path.root(), + value, + nullifier, + rk, + start_position: self.start_position, + }; + let private = LiquidityTournamentVoteProofPrivate { + state_commitment_proof: auth_path, + note: self.staked_note.clone(), + spend_auth_randomizer: self.randomizer, + ak: *ak, + nk: *nk, + }; + let proof = LiquidityTournamentVoteProof::prove( + self.proof_blinding_r, + self.proof_blinding_s, + &DELEGATOR_VOTE_PROOF_PROVING_KEY, + public, + private, + ) + .expect("can generate ZK LQT voting proof"); + + ActionLiquidityTournamentVote { + body: self.to_body(fvk), + auth_sig, + proof, + } + } +} + +impl DomainType for ActionLiquidityTournamentVotePlan { + type Proto = pb::ActionLiquidityTournamentVotePlan; +} + +impl TryFrom for ActionLiquidityTournamentVotePlan { + type Error = anyhow::Error; + + fn try_from(proto: pb::ActionLiquidityTournamentVotePlan) -> Result { + let proof_blinding_r_bytes: [u8; 32] = proto + .proof_blinding_r + .try_into() + .map_err(|_| anyhow::anyhow!("malformed r in `DelegatorVotePlan`"))?; + let proof_blinding_s_bytes: [u8; 32] = proto + .proof_blinding_s + .try_into() + .map_err(|_| anyhow::anyhow!("malformed s in `DelegatorVotePlan`"))?; + Result::<_, Self::Error>::Ok(Self { + incentivized: proto + .incentivized + .ok_or_else(|| anyhow!("missing `incentivized`"))? + .try_into()?, + rewards_recipient: proto + .rewards_recipient + .ok_or_else(|| anyhow!("missing `rewards_recipient`"))? + .try_into()?, + staked_note: proto + .staked_note + .ok_or_else(|| anyhow!("missing `staked_note`"))? + .try_into()?, + staked_note_position: proto.staked_note_position.into(), + start_position: proto.start_position.into(), + randomizer: Fr::from_bytes_checked( + proto + .randomizer + .as_slice() + .try_into() + .map_err(|_| anyhow::anyhow!("invalid randomizer"))?, + ) + .map_err(|_| anyhow!("randomizer malformed"))?, + proof_blinding_r: Fq::from_bytes_checked(&proof_blinding_r_bytes) + .map_err(|_| anyhow!("proof_blinding_r malformed"))?, + proof_blinding_s: Fq::from_bytes_checked(&proof_blinding_s_bytes) + .map_err(|_| anyhow!("proof_blinding_s malformed"))?, + }) + .with_context(|| format!("while parsing {}", std::any::type_name::())) + } +} + +impl From for pb::ActionLiquidityTournamentVotePlan { + fn from(value: ActionLiquidityTournamentVotePlan) -> Self { + Self { + incentivized: Some(value.incentivized.into()), + rewards_recipient: Some(value.rewards_recipient.into()), + staked_note: Some(value.staked_note.into()), + staked_note_position: value.staked_note_position.into(), + start_position: value.start_position.into(), + randomizer: value.randomizer.to_bytes().to_vec(), + proof_blinding_r: value.proof_blinding_r.to_bytes().to_vec(), + proof_blinding_s: value.proof_blinding_s.to_bytes().to_vec(), + } + } +} diff --git a/crates/core/transaction/src/auth_data.rs b/crates/core/transaction/src/auth_data.rs index 9a6b00a856..3f45e83dbf 100644 --- a/crates/core/transaction/src/auth_data.rs +++ b/crates/core/transaction/src/auth_data.rs @@ -15,6 +15,9 @@ pub struct AuthorizationData { /// The required delegator vote authorization signatures, returned in the same order as the /// DelegatorVote actions in the original request. pub delegator_vote_auths: Vec>, + /// The required LQT vote authorization signatures, returned in the same order as the + /// actions in the original request + pub lqt_vote_auths: Vec>, } impl DomainType for AuthorizationData { @@ -31,6 +34,7 @@ impl From for pb::AuthorizationData { .into_iter() .map(Into::into) .collect(), + lqt_vote_auths: msg.lqt_vote_auths.into_iter().map(Into::into).collect(), } } } @@ -50,6 +54,11 @@ impl TryFrom for AuthorizationData { .into_iter() .map(TryInto::try_into) .collect::>()?, + lqt_vote_auths: value + .lqt_vote_auths + .into_iter() + .map(TryInto::try_into) + .collect::>()?, }) } } diff --git a/crates/core/transaction/src/gas.rs b/crates/core/transaction/src/gas.rs index af236bb012..8880144249 100644 --- a/crates/core/transaction/src/gas.rs +++ b/crates/core/transaction/src/gas.rs @@ -371,6 +371,7 @@ impl GasCost for ActionPlan { ActionPlan::CommunityPoolOutput(d) => d.gas_cost(), ActionPlan::CommunityPoolDeposit(dd) => dd.gas_cost(), ActionPlan::Ics20Withdrawal(w) => w.gas_cost(), + ActionPlan::ActionLiquidityTournamentVote(_) => liquidity_tournament_vote_gas_cost(), } } } diff --git a/crates/core/transaction/src/plan.rs b/crates/core/transaction/src/plan.rs index 17280905b6..0f450e2cd5 100644 --- a/crates/core/transaction/src/plan.rs +++ b/crates/core/transaction/src/plan.rs @@ -10,6 +10,7 @@ use penumbra_sdk_dex::{ swap::SwapPlan, swap_claim::SwapClaimPlan, }; +use penumbra_sdk_funding::liquidity_tournament::ActionLiquidityTournamentVotePlan; use penumbra_sdk_governance::{ DelegatorVotePlan, ProposalDepositClaim, ProposalSubmit, ProposalWithdraw, ValidatorVote, }; @@ -213,6 +214,16 @@ impl TransactionPlan { }) } + pub fn lqt_vote_plans(&self) -> impl Iterator { + self.actions.iter().filter_map(|action| { + if let ActionPlan::ActionLiquidityTournamentVote(v) = action { + Some(v) + } else { + None + } + }) + } + pub fn validator_votes(&self) -> impl Iterator { self.actions.iter().filter_map(|action| { if let ActionPlan::ValidatorVote(v) = action { diff --git a/crates/core/transaction/src/plan/action.rs b/crates/core/transaction/src/plan/action.rs index 8ef121ce0d..c0acf69ac0 100644 --- a/crates/core/transaction/src/plan/action.rs +++ b/crates/core/transaction/src/plan/action.rs @@ -8,6 +8,7 @@ use penumbra_sdk_auction::auction::dutch::actions::ActionDutchAuctionEnd; use penumbra_sdk_auction::auction::dutch::actions::ActionDutchAuctionSchedule; use penumbra_sdk_auction::auction::dutch::actions::ActionDutchAuctionWithdrawPlan; use penumbra_sdk_community_pool::{CommunityPoolDeposit, CommunityPoolOutput, CommunityPoolSpend}; +use penumbra_sdk_funding::liquidity_tournament::ActionLiquidityTournamentVotePlan; use penumbra_sdk_txhash::{EffectHash, EffectingData}; use penumbra_sdk_dex::{ @@ -82,6 +83,8 @@ pub enum ActionPlan { ActionDutchAuctionSchedule(ActionDutchAuctionSchedule), ActionDutchAuctionEnd(ActionDutchAuctionEnd), ActionDutchAuctionWithdraw(ActionDutchAuctionWithdrawPlan), + + ActionLiquidityTournamentVote(ActionLiquidityTournamentVotePlan), } impl ActionPlan { @@ -165,6 +168,18 @@ impl ActionPlan { ActionDutchAuctionWithdraw(plan) => { Action::ActionDutchAuctionWithdraw(plan.to_action()) } + ActionLiquidityTournamentVote(plan) => { + let note_commitment = plan.staked_note.commit(); + let auth_path = witness_data + .state_commitment_proofs + .get(¬e_commitment) + .context(format!("could not get proof for {note_commitment:?}"))?; + Action::ActionLiquidityTournamentVote(plan.to_action( + fvk, + [0; 64].into(), + auth_path.clone(), + )) + } }) } @@ -195,6 +210,7 @@ impl ActionPlan { ActionPlan::ActionDutchAuctionSchedule(_) => 53, ActionPlan::ActionDutchAuctionEnd(_) => 54, ActionPlan::ActionDutchAuctionWithdraw(_) => 55, + ActionPlan::ActionLiquidityTournamentVote(_) => 70, } } @@ -225,7 +241,10 @@ impl ActionPlan { ActionDutchAuctionWithdraw(action) => action.balance(), // None of these contribute to transaction balance: - IbcAction(_) | ValidatorDefinition(_) | ValidatorVote(_) => Balance::default(), + IbcAction(_) + | ValidatorDefinition(_) + | ValidatorVote(_) + | ActionLiquidityTournamentVote(_) => Balance::default(), } } @@ -257,6 +276,7 @@ impl ActionPlan { ActionDutchAuctionSchedule(_) => Fr::zero(), ActionDutchAuctionEnd(_) => Fr::zero(), ActionDutchAuctionWithdraw(_) => Fr::zero(), + ActionLiquidityTournamentVote(_) => Fr::zero(), } } @@ -289,6 +309,7 @@ impl ActionPlan { ActionDutchAuctionSchedule(plan) => plan.effect_hash(), ActionDutchAuctionEnd(plan) => plan.effect_hash(), ActionDutchAuctionWithdraw(plan) => plan.to_action().effect_hash(), + ActionLiquidityTournamentVote(plan) => plan.to_body(fvk).effect_hash(), } } } @@ -439,6 +460,12 @@ impl From for ActionPlan { } } +impl From for ActionPlan { + fn from(inner: ActionLiquidityTournamentVotePlan) -> ActionPlan { + ActionPlan::ActionLiquidityTournamentVote(inner) + } +} + impl DomainType for ActionPlan { type Proto = pb_t::ActionPlan; } @@ -532,6 +559,11 @@ impl From for pb_t::ActionPlan { inner.into(), )), }, + ActionPlan::ActionLiquidityTournamentVote(inner) => pb_t::ActionPlan { + action: Some( + pb_t::action_plan::Action::ActionLiquidityTournamentVotePlan(inner.into()), + ), + }, } } } @@ -617,6 +649,9 @@ impl TryFrom for ActionPlan { pb_t::action_plan::Action::Ics20Withdrawal(inner) => { Ok(ActionPlan::Ics20Withdrawal(inner.try_into()?)) } + pb_t::action_plan::Action::ActionLiquidityTournamentVotePlan(inner) => { + Ok(ActionPlan::ActionLiquidityTournamentVote(inner.try_into()?)) + } } } } diff --git a/crates/core/transaction/src/plan/auth.rs b/crates/core/transaction/src/plan/auth.rs index 831d34f075..0d61dcafc5 100644 --- a/crates/core/transaction/src/plan/auth.rs +++ b/crates/core/transaction/src/plan/auth.rs @@ -17,6 +17,7 @@ impl TransactionPlan { let effect_hash = self.effect_hash(sk.full_viewing_key())?; let mut spend_auths = Vec::new(); let mut delegator_vote_auths = Vec::new(); + let mut lqt_vote_auths = Vec::new(); for spend_plan in self.spend_plans() { let rsk = sk.spend_auth_key().randomize(&spend_plan.randomizer); @@ -30,10 +31,16 @@ impl TransactionPlan { let auth_sig = rsk.sign(&mut rng, effect_hash.as_ref()); delegator_vote_auths.push(auth_sig); } + for lqt_vote_plan in self.lqt_vote_plans() { + let rsk = sk.spend_auth_key().randomize(&lqt_vote_plan.randomizer); + let auth_sig = rsk.sign(&mut rng, effect_hash.as_ref()); + lqt_vote_auths.push(auth_sig); + } Ok(AuthorizationData { effect_hash: Some(effect_hash), spend_auths, delegator_vote_auths, + lqt_vote_auths, }) } } diff --git a/crates/core/transaction/src/plan/build.rs b/crates/core/transaction/src/plan/build.rs index 1eb831a162..76a7e76055 100644 --- a/crates/core/transaction/src/plan/build.rs +++ b/crates/core/transaction/src/plan/build.rs @@ -100,6 +100,22 @@ impl TransactionPlan { delegator_vote.auth_sig = auth_sig; } + for (lqt_vote, auth_sig) in transaction + .transaction_body + .actions + .iter_mut() + .filter_map(|action| { + if let Action::ActionLiquidityTournamentVote(s) = action { + Some(s) + } else { + None + } + }) + .zip(auth_data.lqt_vote_auths.clone().into_iter()) + { + lqt_vote.auth_sig = auth_sig; + } + // Compute the binding signature and assemble the transaction. let binding_signing_key = rdsa::SigningKey::from(synthetic_blinding_factor); let auth_hash = transaction.transaction_body.auth_hash(); diff --git a/crates/core/transaction/src/view.rs b/crates/core/transaction/src/view.rs index 14ca9e95b6..74d46b1cc6 100644 --- a/crates/core/transaction/src/view.rs +++ b/crates/core/transaction/src/view.rs @@ -710,6 +710,7 @@ mod test { ActionPlan::ActionDutchAuctionEnd(_) => None, ActionPlan::ActionDutchAuctionWithdraw(_) => None, ActionPlan::IbcAction(_) => todo!(), + ActionPlan::ActionLiquidityTournamentVote(_) => todo!(), } } diff --git a/crates/custody/src/threshold/sign.rs b/crates/custody/src/threshold/sign.rs index fe0ac495c5..a52c7f3f02 100644 --- a/crates/custody/src/threshold/sign.rs +++ b/crates/custody/src/threshold/sign.rs @@ -321,7 +321,9 @@ impl DomainType for FollowerRound2 { fn required_signatures(request: &SigningRequest) -> usize { match request { SigningRequest::TransactionPlan(plan) => { - plan.spend_plans().count() + plan.delegator_vote_plans().count() + plan.spend_plans().count() + + plan.delegator_vote_plans().count() + + plan.lqt_vote_plans().count() } SigningRequest::ValidatorDefinition(_) => 1, SigningRequest::ValidatorVote(_) => 1, @@ -339,6 +341,7 @@ pub fn no_signature_response( effect_hash: Some(plan.effect_hash(fvk)?), spend_auths: Vec::new(), delegator_vote_auths: Vec::new(), + lqt_vote_auths: Vec::new(), }))) } _ => Ok(None), @@ -484,6 +487,7 @@ pub fn coordinator_round3( .spend_plans() .map(|x| x.randomizer) .chain(plan.delegator_vote_plans().map(|x| x.randomizer)) + .chain(plan.lqt_vote_plans().map(|x| x.randomizer)) .zip(share_maps.iter()) .zip(state.signing_packages.iter()) .map(|((randomizer, share_map), signing_package)| { @@ -495,7 +499,8 @@ pub fn coordinator_round3( ) }) .collect::, _>>()?; - let delegator_vote_auths = spend_auths.split_off(plan.spend_plans().count()); + let mut delegator_vote_auths = spend_auths.split_off(plan.spend_plans().count()); + let lqt_vote_auths = delegator_vote_auths.split_off(plan.spend_plans().count()); Ok(SigningResponse::Transaction(AuthorizationData { effect_hash: { let ToBeSigned::EffectHash(effect_hash) = state.to_be_signed else { @@ -505,6 +510,7 @@ pub fn coordinator_round3( }, spend_auths, delegator_vote_auths, + lqt_vote_auths, })) } SigningRequest::ValidatorDefinition(_) => { 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 b6e8305d01..f72c027953 100644 --- a/crates/proto/src/gen/penumbra.core.component.funding.v1.rs +++ b/crates/proto/src/gen/penumbra.core.component.funding.v1.rs @@ -206,6 +206,44 @@ impl ::prost::Name for LiquidityTournamentVoteBody { "/penumbra.core.component.funding.v1.LiquidityTournamentVoteBody".into() } } +/// The plan associated with a `ActionLiquidityTournamentVote`. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ActionLiquidityTournamentVotePlan { + /// The asset the user wants to vote for. + #[prost(message, optional, tag = "1")] + pub incentivized: ::core::option::Option, + /// Where to send any rewards for participating in the tournament. + #[prost(message, optional, tag = "2")] + pub rewards_recipient: ::core::option::Option< + super::super::super::keys::v1::Address, + >, + /// The note containing the staked note used for voting. + #[prost(message, optional, tag = "3")] + pub staked_note: ::core::option::Option, + /// The position of the staked note. + #[prost(uint64, tag = "4")] + pub staked_note_position: u64, + /// The start position of the tournament. + #[prost(uint64, tag = "5")] + pub start_position: u64, + /// Randomizer for proof of spend capability. + #[prost(bytes = "vec", tag = "6")] + pub randomizer: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "7")] + pub proof_blinding_r: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "8")] + pub proof_blinding_s: ::prost::alloc::vec::Vec, +} +impl ::prost::Name for ActionLiquidityTournamentVotePlan { + const NAME: &'static str = "ActionLiquidityTournamentVotePlan"; + const PACKAGE: &'static str = "penumbra.core.component.funding.v1"; + fn full_name() -> ::prost::alloc::string::String { + "penumbra.core.component.funding.v1.ActionLiquidityTournamentVotePlan".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/penumbra.core.component.funding.v1.ActionLiquidityTournamentVotePlan".into() + } +} /// A proof of the validity of a liquidity vote, wrt private state. #[derive(Clone, PartialEq, ::prost::Message)] pub struct ZkLiquidityTournamentVoteProof { 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 2412560c5d..d3c0965779 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 @@ -128,6 +128,246 @@ impl<'de> serde::Deserialize<'de> for ActionLiquidityTournamentVote { deserializer.deserialize_struct("penumbra.core.component.funding.v1.ActionLiquidityTournamentVote", FIELDS, GeneratedVisitor) } } +impl serde::Serialize for ActionLiquidityTournamentVotePlan { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.incentivized.is_some() { + len += 1; + } + if self.rewards_recipient.is_some() { + len += 1; + } + if self.staked_note.is_some() { + len += 1; + } + if self.staked_note_position != 0 { + len += 1; + } + if self.start_position != 0 { + len += 1; + } + if !self.randomizer.is_empty() { + len += 1; + } + if !self.proof_blinding_r.is_empty() { + len += 1; + } + if !self.proof_blinding_s.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("penumbra.core.component.funding.v1.ActionLiquidityTournamentVotePlan", len)?; + if let Some(v) = self.incentivized.as_ref() { + struct_ser.serialize_field("incentivized", v)?; + } + if let Some(v) = self.rewards_recipient.as_ref() { + struct_ser.serialize_field("rewardsRecipient", v)?; + } + if let Some(v) = self.staked_note.as_ref() { + struct_ser.serialize_field("stakedNote", v)?; + } + if self.staked_note_position != 0 { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("stakedNotePosition", ToString::to_string(&self.staked_note_position).as_str())?; + } + if self.start_position != 0 { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("startPosition", ToString::to_string(&self.start_position).as_str())?; + } + if !self.randomizer.is_empty() { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("randomizer", pbjson::private::base64::encode(&self.randomizer).as_str())?; + } + if !self.proof_blinding_r.is_empty() { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("proofBlindingR", pbjson::private::base64::encode(&self.proof_blinding_r).as_str())?; + } + if !self.proof_blinding_s.is_empty() { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("proofBlindingS", pbjson::private::base64::encode(&self.proof_blinding_s).as_str())?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for ActionLiquidityTournamentVotePlan { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "incentivized", + "rewards_recipient", + "rewardsRecipient", + "staked_note", + "stakedNote", + "staked_note_position", + "stakedNotePosition", + "start_position", + "startPosition", + "randomizer", + "proof_blinding_r", + "proofBlindingR", + "proof_blinding_s", + "proofBlindingS", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Incentivized, + RewardsRecipient, + StakedNote, + StakedNotePosition, + StartPosition, + Randomizer, + ProofBlindingR, + ProofBlindingS, + __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 { + "incentivized" => Ok(GeneratedField::Incentivized), + "rewardsRecipient" | "rewards_recipient" => Ok(GeneratedField::RewardsRecipient), + "stakedNote" | "staked_note" => Ok(GeneratedField::StakedNote), + "stakedNotePosition" | "staked_note_position" => Ok(GeneratedField::StakedNotePosition), + "startPosition" | "start_position" => Ok(GeneratedField::StartPosition), + "randomizer" => Ok(GeneratedField::Randomizer), + "proofBlindingR" | "proof_blinding_r" => Ok(GeneratedField::ProofBlindingR), + "proofBlindingS" | "proof_blinding_s" => Ok(GeneratedField::ProofBlindingS), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = ActionLiquidityTournamentVotePlan; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct penumbra.core.component.funding.v1.ActionLiquidityTournamentVotePlan") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut incentivized__ = None; + let mut rewards_recipient__ = None; + let mut staked_note__ = None; + let mut staked_note_position__ = None; + let mut start_position__ = None; + let mut randomizer__ = None; + let mut proof_blinding_r__ = None; + let mut proof_blinding_s__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Incentivized => { + if incentivized__.is_some() { + return Err(serde::de::Error::duplicate_field("incentivized")); + } + incentivized__ = map_.next_value()?; + } + GeneratedField::RewardsRecipient => { + if rewards_recipient__.is_some() { + return Err(serde::de::Error::duplicate_field("rewardsRecipient")); + } + rewards_recipient__ = map_.next_value()?; + } + GeneratedField::StakedNote => { + if staked_note__.is_some() { + return Err(serde::de::Error::duplicate_field("stakedNote")); + } + staked_note__ = map_.next_value()?; + } + GeneratedField::StakedNotePosition => { + if staked_note_position__.is_some() { + return Err(serde::de::Error::duplicate_field("stakedNotePosition")); + } + staked_note_position__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::StartPosition => { + if start_position__.is_some() { + return Err(serde::de::Error::duplicate_field("startPosition")); + } + start_position__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::Randomizer => { + if randomizer__.is_some() { + return Err(serde::de::Error::duplicate_field("randomizer")); + } + randomizer__ = + Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0) + ; + } + GeneratedField::ProofBlindingR => { + if proof_blinding_r__.is_some() { + return Err(serde::de::Error::duplicate_field("proofBlindingR")); + } + proof_blinding_r__ = + Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0) + ; + } + GeneratedField::ProofBlindingS => { + if proof_blinding_s__.is_some() { + return Err(serde::de::Error::duplicate_field("proofBlindingS")); + } + proof_blinding_s__ = + Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0) + ; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(ActionLiquidityTournamentVotePlan { + incentivized: incentivized__, + rewards_recipient: rewards_recipient__, + staked_note: staked_note__, + staked_note_position: staked_note_position__.unwrap_or_default(), + start_position: start_position__.unwrap_or_default(), + randomizer: randomizer__.unwrap_or_default(), + proof_blinding_r: proof_blinding_r__.unwrap_or_default(), + proof_blinding_s: proof_blinding_s__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("penumbra.core.component.funding.v1.ActionLiquidityTournamentVotePlan", FIELDS, GeneratedVisitor) + } +} impl serde::Serialize for ActionLiquidityTournamentVoteView { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result diff --git a/crates/proto/src/gen/penumbra.core.transaction.v1.rs b/crates/proto/src/gen/penumbra.core.transaction.v1.rs index ed60879050..540d60ecac 100644 --- a/crates/proto/src/gen/penumbra.core.transaction.v1.rs +++ b/crates/proto/src/gen/penumbra.core.transaction.v1.rs @@ -587,6 +587,11 @@ pub struct AuthorizationData { pub delegator_vote_auths: ::prost::alloc::vec::Vec< super::super::super::crypto::decaf377_rdsa::v1::SpendAuthSignature, >, + /// The required LQT vote authorizations, in the same order as the original request. + #[prost(message, repeated, tag = "4")] + pub lqt_vote_auths: ::prost::alloc::vec::Vec< + super::super::super::crypto::decaf377_rdsa::v1::SpendAuthSignature, + >, } impl ::prost::Name for AuthorizationData { const NAME: &'static str = "AuthorizationData"; @@ -674,7 +679,7 @@ impl ::prost::Name for DetectionDataPlan { pub struct ActionPlan { #[prost( oneof = "action_plan::Action", - tags = "1, 2, 3, 4, 16, 17, 18, 19, 20, 21, 22, 200, 30, 31, 32, 34, 40, 41, 42, 50, 51, 52, 53, 54, 55" + tags = "1, 2, 3, 4, 16, 17, 18, 19, 20, 21, 22, 200, 30, 31, 32, 34, 40, 41, 42, 50, 51, 52, 53, 54, 55, 70" )] pub action: ::core::option::Option, } @@ -762,6 +767,11 @@ pub mod action_plan { ActionDutchAuctionWithdraw( super::super::super::component::auction::v1::ActionDutchAuctionWithdrawPlan, ), + /// Funding + #[prost(message, tag = "70")] + ActionLiquidityTournamentVotePlan( + super::super::super::component::funding::v1::ActionLiquidityTournamentVotePlan, + ), } } impl ::prost::Name for ActionPlan { diff --git a/crates/proto/src/gen/penumbra.core.transaction.v1.serde.rs b/crates/proto/src/gen/penumbra.core.transaction.v1.serde.rs index 1b09283f15..cb0c1f5840 100644 --- a/crates/proto/src/gen/penumbra.core.transaction.v1.serde.rs +++ b/crates/proto/src/gen/penumbra.core.transaction.v1.serde.rs @@ -533,6 +533,9 @@ impl serde::Serialize for ActionPlan { action_plan::Action::ActionDutchAuctionWithdraw(v) => { struct_ser.serialize_field("actionDutchAuctionWithdraw", v)?; } + action_plan::Action::ActionLiquidityTournamentVotePlan(v) => { + struct_ser.serialize_field("actionLiquidityTournamentVotePlan", v)?; + } } } struct_ser.end() @@ -590,6 +593,8 @@ impl<'de> serde::Deserialize<'de> for ActionPlan { "actionDutchAuctionEnd", "action_dutch_auction_withdraw", "actionDutchAuctionWithdraw", + "action_liquidity_tournament_vote_plan", + "actionLiquidityTournamentVotePlan", ]; #[allow(clippy::enum_variant_names)] @@ -619,6 +624,7 @@ impl<'de> serde::Deserialize<'de> for ActionPlan { ActionDutchAuctionSchedule, ActionDutchAuctionEnd, ActionDutchAuctionWithdraw, + ActionLiquidityTournamentVotePlan, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -666,6 +672,7 @@ impl<'de> serde::Deserialize<'de> for ActionPlan { "actionDutchAuctionSchedule" | "action_dutch_auction_schedule" => Ok(GeneratedField::ActionDutchAuctionSchedule), "actionDutchAuctionEnd" | "action_dutch_auction_end" => Ok(GeneratedField::ActionDutchAuctionEnd), "actionDutchAuctionWithdraw" | "action_dutch_auction_withdraw" => Ok(GeneratedField::ActionDutchAuctionWithdraw), + "actionLiquidityTournamentVotePlan" | "action_liquidity_tournament_vote_plan" => Ok(GeneratedField::ActionLiquidityTournamentVotePlan), _ => Ok(GeneratedField::__SkipField__), } } @@ -861,6 +868,13 @@ impl<'de> serde::Deserialize<'de> for ActionPlan { return Err(serde::de::Error::duplicate_field("actionDutchAuctionWithdraw")); } action__ = map_.next_value::<::std::option::Option<_>>()?.map(action_plan::Action::ActionDutchAuctionWithdraw) +; + } + GeneratedField::ActionLiquidityTournamentVotePlan => { + if action__.is_some() { + return Err(serde::de::Error::duplicate_field("actionLiquidityTournamentVotePlan")); + } + action__ = map_.next_value::<::std::option::Option<_>>()?.map(action_plan::Action::ActionLiquidityTournamentVotePlan) ; } GeneratedField::__SkipField__ => { @@ -1339,6 +1353,9 @@ impl serde::Serialize for AuthorizationData { if !self.delegator_vote_auths.is_empty() { len += 1; } + if !self.lqt_vote_auths.is_empty() { + len += 1; + } let mut struct_ser = serializer.serialize_struct("penumbra.core.transaction.v1.AuthorizationData", len)?; if let Some(v) = self.effect_hash.as_ref() { struct_ser.serialize_field("effectHash", v)?; @@ -1349,6 +1366,9 @@ impl serde::Serialize for AuthorizationData { if !self.delegator_vote_auths.is_empty() { struct_ser.serialize_field("delegatorVoteAuths", &self.delegator_vote_auths)?; } + if !self.lqt_vote_auths.is_empty() { + struct_ser.serialize_field("lqtVoteAuths", &self.lqt_vote_auths)?; + } struct_ser.end() } } @@ -1365,6 +1385,8 @@ impl<'de> serde::Deserialize<'de> for AuthorizationData { "spendAuths", "delegator_vote_auths", "delegatorVoteAuths", + "lqt_vote_auths", + "lqtVoteAuths", ]; #[allow(clippy::enum_variant_names)] @@ -1372,6 +1394,7 @@ impl<'de> serde::Deserialize<'de> for AuthorizationData { EffectHash, SpendAuths, DelegatorVoteAuths, + LqtVoteAuths, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -1397,6 +1420,7 @@ impl<'de> serde::Deserialize<'de> for AuthorizationData { "effectHash" | "effect_hash" => Ok(GeneratedField::EffectHash), "spendAuths" | "spend_auths" => Ok(GeneratedField::SpendAuths), "delegatorVoteAuths" | "delegator_vote_auths" => Ok(GeneratedField::DelegatorVoteAuths), + "lqtVoteAuths" | "lqt_vote_auths" => Ok(GeneratedField::LqtVoteAuths), _ => Ok(GeneratedField::__SkipField__), } } @@ -1419,6 +1443,7 @@ impl<'de> serde::Deserialize<'de> for AuthorizationData { let mut effect_hash__ = None; let mut spend_auths__ = None; let mut delegator_vote_auths__ = None; + let mut lqt_vote_auths__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::EffectHash => { @@ -1439,6 +1464,12 @@ impl<'de> serde::Deserialize<'de> for AuthorizationData { } delegator_vote_auths__ = Some(map_.next_value()?); } + GeneratedField::LqtVoteAuths => { + if lqt_vote_auths__.is_some() { + return Err(serde::de::Error::duplicate_field("lqtVoteAuths")); + } + lqt_vote_auths__ = Some(map_.next_value()?); + } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } @@ -1448,6 +1479,7 @@ impl<'de> serde::Deserialize<'de> for AuthorizationData { effect_hash: effect_hash__, spend_auths: spend_auths__.unwrap_or_default(), delegator_vote_auths: delegator_vote_auths__.unwrap_or_default(), + lqt_vote_auths: lqt_vote_auths__.unwrap_or_default(), }) } } diff --git a/crates/proto/src/gen/proto_descriptor.bin.no_lfs b/crates/proto/src/gen/proto_descriptor.bin.no_lfs index 677b237ed3..41b2afc543 100644 Binary files a/crates/proto/src/gen/proto_descriptor.bin.no_lfs and b/crates/proto/src/gen/proto_descriptor.bin.no_lfs differ diff --git a/crates/view/src/client.rs b/crates/view/src/client.rs index 1359c4ff60..1292553f2d 100644 --- a/crates/view/src/client.rs +++ b/crates/view/src/client.rs @@ -338,6 +338,13 @@ pub trait ViewClient { fn unclaimed_swaps( &mut self, ) -> Pin>> + Send + 'static>>; + + /// Get all of the notes that can be used for voting + fn lqt_voting_notes( + &mut self, + epoch: u64, + filter: Option, + ) -> Pin>> + Send + 'static>>; } // We need to tell `async_trait` not to add a `Send` bound to the boxed @@ -1051,4 +1058,33 @@ where } .boxed() } + + fn lqt_voting_notes( + &mut self, + epoch: u64, + filter: Option, + ) -> Pin>> + Send + 'static>> { + let mut client = self.clone(); + async move { + let request = tonic::Request::new(pb::LqtVotingNotesRequest { + epoch_index: epoch, + account_filter: filter.map(|x| x.into()), + }); + let response = client.lqt_voting_notes(request).await?; + let pb_notes: Vec = + response.into_inner().try_collect().await?; + + pb_notes + .into_iter() + .map(|note_rsp| { + let note_record = note_rsp + .note_record + .ok_or_else(|| anyhow::anyhow!("empty LqtVotingNotesResponse message"))? + .try_into()?; + Ok(note_record) + }) + .collect() + } + .boxed() + } } diff --git a/crates/view/src/planner.rs b/crates/view/src/planner.rs index a0c3f95dd6..6c07421937 100644 --- a/crates/view/src/planner.rs +++ b/crates/view/src/planner.rs @@ -5,6 +5,7 @@ use std::{ }; use anyhow::{Context, Result}; +use penumbra_sdk_funding::liquidity_tournament::ActionLiquidityTournamentVotePlan; use penumbra_sdk_sct::epoch::Epoch; use rand::{CryptoRng, RngCore}; use rand_core::OsRng; @@ -12,7 +13,10 @@ use tracing::instrument; use crate::{SpendableNoteRecord, ViewClient}; use anyhow::anyhow; -use penumbra_sdk_asset::{asset, Value}; +use penumbra_sdk_asset::{ + asset::{self, Denom}, + Value, +}; use penumbra_sdk_auction::auction::dutch::DutchAuctionDescription; use penumbra_sdk_auction::auction::dutch::{actions::ActionDutchAuctionWithdrawPlan, DutchAuction}; use penumbra_sdk_auction::auction::{ @@ -477,6 +481,29 @@ impl Planner { self } + #[instrument(skip(self))] + pub fn lqt_vote( + &mut self, + epoch_index: u16, + incentivized: Denom, + rewards_recipient: Address, + notes: &[SpendableNoteRecord], + ) -> &mut Self { + let start_position = tct::Position::from((epoch_index, 0, 0)); + for note in notes { + self.action_list + .push(ActionLiquidityTournamentVotePlan::new( + &mut self.rng, + incentivized.clone(), + rewards_recipient.clone(), + note.note.clone(), + note.position, + start_position, + )); + } + self + } + /// Prioritize notes to spend to release value of a specific transaction. /// /// Various logic is possible for note selection. Currently, this method diff --git a/crates/view/src/service.rs b/crates/view/src/service.rs index 204037bc73..a307b55197 100644 --- a/crates/view/src/service.rs +++ b/crates/view/src/service.rs @@ -57,13 +57,13 @@ use penumbra_sdk_proto::{ }, DomainType, }; -use penumbra_sdk_stake::rate::RateData; +use penumbra_sdk_stake::{rate::RateData, IdentityKey}; use penumbra_sdk_tct::{Proof, StateCommitment}; use penumbra_sdk_transaction::{ AuthorizationData, Transaction, TransactionPerspective, TransactionPlan, WitnessData, }; -use crate::{worker::Worker, Planner, Storage}; +use crate::{worker::Worker, Planner, SpendableNoteRecord, Storage}; /// A [`futures::Stream`] of broadcast transaction responses. /// @@ -1886,9 +1886,36 @@ impl ViewService for ViewServer { #[instrument(skip_all, level = "trace")] async fn lqt_voting_notes( &self, - _request: tonic::Request, + request: tonic::Request, ) -> Result, tonic::Status> { - unimplemented!("lqt_voting_notes currently only implemented on web") + async fn inner( + this: &ViewServer, + epoch: u64, + filter: Option, + ) -> anyhow::Result> { + let (_, start_height) = this.storage.get_epoch(epoch).await?; + let start_height = + start_height.ok_or_else(|| anyhow!("missing height for epoch {epoch}"))?; + let notes = this.storage.notes_for_voting(filter, start_height).await?; + Ok(notes) + } + + let request = request.into_inner(); + let epoch = request.epoch_index; + let filter = request + .account_filter + .map(|x| AddressIndex::try_from(x)) + .transpose() + .map_err(|_| tonic::Status::invalid_argument("invalid account filter"))?; + let notes = inner(self, epoch, filter).await.map_err(|e| { + tonic::Status::internal(format!("error fetching voting notes: {:#}", e)) + })?; + let stream = tokio_stream::iter(notes.into_iter().map(|(note, _)| { + Result::<_, tonic::Status>::Ok(pb::LqtVotingNotesResponse { + note_record: Some(note.into()), + }) + })); + Ok(tonic::Response::new(stream.boxed())) } } diff --git a/crates/view/src/storage.rs b/crates/view/src/storage.rs index 0d88e650da..d8ae66903a 100644 --- a/crates/view/src/storage.rs +++ b/crates/view/src/storage.rs @@ -37,7 +37,7 @@ use penumbra_sdk_proto::{ use penumbra_sdk_sct::{CommitmentSource, Nullifier}; use penumbra_sdk_shielded_pool::{fmd, note, Note, Rseed}; use penumbra_sdk_stake::{DelegationToken, IdentityKey}; -use penumbra_sdk_tct as tct; +use penumbra_sdk_tct::{self as tct, builder::epoch::Root}; use penumbra_sdk_transaction::Transaction; use sct::TreeStore; use tct::StateCommitment; @@ -207,7 +207,7 @@ impl Storage { // Connect to the database (or create it) let pool = Self::connect(storage_path)?; - spawn_blocking(move || { + let out = spawn_blocking(move || { // In one database transaction, populate everything let mut conn = pool.get()?; let tx = conn.transaction()?; @@ -244,7 +244,7 @@ impl Storage { tx.commit()?; drop(conn); - Ok(Storage { + anyhow::Ok(Storage { pool, uncommitted_height: Arc::new(Mutex::new(None)), scanned_notes_tx: broadcast::channel(128).0, @@ -252,7 +252,11 @@ impl Storage { scanned_swaps_tx: broadcast::channel(128).0, }) }) - .await? + .await??; + + out.update_epoch(0, None, Some(0)).await?; + + Ok(out) } /// Loads asset metadata from a JSON file and use to update the database. @@ -1785,4 +1789,61 @@ impl Storage { }) .await? } + + /// Update information about an epoch. + pub async fn update_epoch( + &self, + epoch: u64, + root: Option, + start_height: Option, + ) -> anyhow::Result<()> { + let pool = self.pool.clone(); + + spawn_blocking(move || { + pool.get()? + .execute( + r#" + INSERT INTO epochs(epoch_index, root, start_height) + VALUES (?1, ?2, ?3) + ON CONFLICT(epoch_index) + DO UPDATE SET + root = COALESCE(?2, root), + start_height = COALESCE(?3, start_height) + "#, + (epoch, root.map(|x| x.encode_to_vec()), start_height), + ) + .map_err(anyhow::Error::from) + }) + .await??; + + Ok(()) + } + + /// Fetch information about the current epoch. + /// + /// This will return the root of the epoch, if present, + /// and the start height of the epoch, if present. + pub async fn get_epoch(&self, epoch: u64) -> anyhow::Result<(Option, Option)> { + let pool = self.pool.clone(); + + spawn_blocking(move || { + pool.get()? + .query_row_and_then( + r#" + SELECT root, start_height + FROM epochs + WHERE epoch_index = ?1 + "#, + (epoch,), + |row| { + let root_raw: Option> = row.get("root")?; + let start_height: Option = row.get("start_height")?; + let root = root_raw.map(|x| Root::decode(x.as_slice())).transpose()?; + anyhow::Ok((root, start_height)) + }, + ) + .map_err(anyhow::Error::from) + }) + .await? + } } diff --git a/crates/view/src/storage/schema.sql b/crates/view/src/storage/schema.sql index 41c52ba820..85f1a95fb2 100644 --- a/crates/view/src/storage/schema.sql +++ b/crates/view/src/storage/schema.sql @@ -140,3 +140,9 @@ CREATE TABLE auctions ( auction_state BIGINT NOT NULL, note_commitment BLOB ); + +CREATE TABLE epochs ( + epoch_index BIGINT PRIMARY KEY, + root BLOB, + start_height BIGINT +); diff --git a/crates/view/src/worker.rs b/crates/view/src/worker.rs index 8ca7bbf5ca..37cab01993 100644 --- a/crates/view/src/worker.rs +++ b/crates/view/src/worker.rs @@ -236,6 +236,17 @@ impl Worker { // Lock the SCT only while processing this block. let mut sct_guard = self.sct.write().await; + if let Some(root) = block.epoch_root { + // We now know the root for this epoch. + self.storage + .update_epoch(block.epoch_index, Some(root), None) + .await?; + // And also where the next epoch starts, since this block is the last. + self.storage + .update_epoch(block.epoch_index + 1, None, Some(block.height + 1)) + .await?; + } + if !block.requires_scanning() { // Optimization: if the block is empty, seal the in-memory SCT, // and skip touching the database: diff --git a/proto/penumbra/penumbra/core/component/funding/v1/funding.proto b/proto/penumbra/penumbra/core/component/funding/v1/funding.proto index 47792def4e..9367d74e15 100644 --- a/proto/penumbra/penumbra/core/component/funding/v1/funding.proto +++ b/proto/penumbra/penumbra/core/component/funding/v1/funding.proto @@ -105,6 +105,24 @@ message LiquidityTournamentVoteBody { crypto.decaf377_rdsa.v1.SpendVerificationKey rk = 6; } +// The plan associated with a `ActionLiquidityTournamentVote`. +message ActionLiquidityTournamentVotePlan { + // The asset the user wants to vote for. + asset.v1.Denom incentivized = 1; + // Where to send any rewards for participating in the tournament. + keys.v1.Address rewards_recipient = 2; + // The note containing the staked note used for voting. + penumbra.core.component.shielded_pool.v1.Note staked_note = 3; + // The position of the staked note. + uint64 staked_note_position = 4; + // The start position of the tournament. + uint64 start_position = 5; + // Randomizer for proof of spend capability. + bytes randomizer = 6; + bytes proof_blinding_r = 7; + bytes proof_blinding_s = 8; +} + // A proof of the validity of a liquidity vote, wrt private state. message ZKLiquidityTournamentVoteProof { bytes inner = 1; diff --git a/proto/penumbra/penumbra/core/transaction/v1/transaction.proto b/proto/penumbra/penumbra/core/transaction/v1/transaction.proto index 9a4a195dc0..8598863518 100644 --- a/proto/penumbra/penumbra/core/transaction/v1/transaction.proto +++ b/proto/penumbra/penumbra/core/transaction/v1/transaction.proto @@ -262,6 +262,8 @@ message AuthorizationData { // The required delegator vote authorizations, returned in the same order as the // DelegatorVote actions in the original request. repeated crypto.decaf377_rdsa.v1.SpendAuthSignature delegator_vote_auths = 3; + // The required LQT vote authorizations, in the same order as the original request. + repeated crypto.decaf377_rdsa.v1.SpendAuthSignature lqt_vote_auths = 4; } // The data required for proving when building a transaction from a plan. @@ -341,6 +343,9 @@ message ActionPlan { component.auction.v1.ActionDutchAuctionSchedule action_dutch_auction_schedule = 53; component.auction.v1.ActionDutchAuctionEnd action_dutch_auction_end = 54; component.auction.v1.ActionDutchAuctionWithdrawPlan action_dutch_auction_withdraw = 55; + + // Funding + component.funding.v1.ActionLiquidityTournamentVotePlan action_liquidity_tournament_vote_plan = 70; } }