penumbra_sdk_app/action_handler/actions/
submit.rs1use std::str::FromStr;
2
3use anyhow::{Context, Result};
4use async_trait::async_trait;
5use decaf377::Fq;
6use decaf377_rdsa::{VerificationKey, VerificationKeyBytes};
7use ibc_types::core::client::ClientId;
8use once_cell::sync::Lazy;
9
10use cnidarium::StateWrite;
11use penumbra_sdk_asset::STAKING_TOKEN_DENOM;
12use penumbra_sdk_community_pool::component::StateReadExt as _;
13use penumbra_sdk_governance::{
14 component::{StateReadExt as _, StateWriteExt as _},
15 event,
16 proposal::{Proposal, ProposalPayload},
17 proposal_state::State as ProposalState,
18 ProposalNft, ProposalSubmit, VotingReceiptToken,
19};
20use penumbra_sdk_ibc::component::ClientStateReadExt;
21use penumbra_sdk_keys::keys::{FullViewingKey, NullifierKey};
22use penumbra_sdk_proto::{DomainType, StateWriteProto as _};
23use penumbra_sdk_sct::component::clock::EpochRead;
24use penumbra_sdk_sct::component::tree::SctRead;
25use penumbra_sdk_shielded_pool::component::AssetRegistry;
26use penumbra_sdk_transaction::{AuthorizationData, Transaction, TransactionPlan, WitnessData};
27
28use crate::app::StateReadExt;
29use crate::community_pool_ext::CommunityPoolStateWriteExt;
30use crate::{action_handler::AppActionHandler, params::change::ParameterChangeExt as _};
31
32pub const PROPOSAL_TITLE_LIMIT: usize = 80; pub const PROPOSAL_DESCRIPTION_LIMIT: usize = 10_000; #[async_trait]
46impl AppActionHandler for ProposalSubmit {
47 type CheckStatelessContext = ();
48 async fn check_stateless(&self, _context: ()) -> Result<()> {
49 let ProposalSubmit {
50 proposal,
51 deposit_amount: _, } = self;
53 let Proposal {
54 id: _, title,
56 description,
57 payload,
58 } = proposal;
59
60 if title.len() > PROPOSAL_TITLE_LIMIT {
61 anyhow::bail!("proposal title must fit within {PROPOSAL_TITLE_LIMIT} characters");
62 }
63
64 if description.len() > PROPOSAL_DESCRIPTION_LIMIT {
65 anyhow::bail!(
66 "proposal description must fit within {PROPOSAL_DESCRIPTION_LIMIT} characters"
67 );
68 }
69
70 use penumbra_sdk_governance::ProposalPayload::*;
71 match payload {
72 Signaling { commit: _ } => { }
73 Emergency { halt_chain: _ } => { }
74 ParameterChange(_change) => { }
75 CommunityPoolSpend { transaction_plan } => {
76 use penumbra_sdk_transaction::plan::ActionPlan::*;
79
80 let parsed_transaction_plan = TransactionPlan::decode(&transaction_plan[..])
81 .context("transaction plan was malformed")?;
82
83 for action in &parsed_transaction_plan.actions {
84 match action {
85 Spend(_) | Output(_) | Swap(_) | SwapClaim(_) | DelegatorVote(_)
86 | UndelegateClaim(_) => {
87 anyhow::bail!("invalid action in Community Pool spend proposal (would require proving)")
88 }
89 Delegate(_) | Undelegate(_) => {
90 anyhow::bail!("invalid action in Community Pool spend proposal (can't claim outputs of undelegation)")
91 }
92 ProposalSubmit(_) | ProposalWithdraw(_) | ProposalDepositClaim(_) => {
93 anyhow::bail!("invalid action in Community Pool spend proposal (not allowed to manipulate proposals from within proposals)")
94 }
95 ValidatorDefinition(_)
96 | ActionLiquidityTournamentVote(_)
97 | IbcAction(_)
98 | ValidatorVote(_)
99 | PositionOpen(_)
100 | PositionClose(_)
101 | PositionWithdraw(_)
102 | CommunityPoolSpend(_)
103 | CommunityPoolOutput(_)
104 | Ics20Withdrawal(_)
105 | CommunityPoolDeposit(_)
106 | ActionDutchAuctionSchedule(_)
107 | ActionDutchAuctionEnd(_)
108 | ActionDutchAuctionWithdraw(_) => {}
109 }
110 }
111 }
112 UpgradePlan { .. } => {}
113 FreezeIbcClient { client_id } => {
114 let _ = &ClientId::from_str(client_id)
115 .context("can't decode client id from IBC proposal")?;
116 }
117 UnfreezeIbcClient { client_id } => {
118 let _ = &ClientId::from_str(client_id)
119 .context("can't decode client id from IBC proposal")?;
120 }
121 }
122
123 Ok(())
124 }
125
126 async fn check_and_execute<S: StateWrite>(&self, mut state: S) -> Result<()> {
127 let ProposalSubmit {
132 deposit_amount,
133 proposal, } = self;
135
136 let governance_parameters = state.get_governance_params().await?;
138 if *deposit_amount != governance_parameters.proposal_deposit_amount {
139 anyhow::bail!(
140 "submitted proposal deposit of {}{} does not match required proposal deposit of {}{}",
141 deposit_amount,
142 *STAKING_TOKEN_DENOM,
143 governance_parameters.proposal_deposit_amount,
144 *STAKING_TOKEN_DENOM,
145 );
146 }
147
148 let next_proposal_id = state.next_proposal_id().await?;
150 if proposal.id != next_proposal_id {
151 anyhow::bail!(
152 "submitted proposal ID {} does not match expected proposal ID {}",
153 proposal.id,
154 next_proposal_id,
155 );
156 }
157
158 match &proposal.payload {
159 ProposalPayload::Signaling { .. } => { }
160 ProposalPayload::Emergency { .. } => { }
161 ProposalPayload::ParameterChange(change) => {
162 let current_parameters = state.get_app_params().await?;
166 change
167 .apply_changes(current_parameters)
168 .context("proposed parameter changes do not apply to current parameters")?;
169 }
170 ProposalPayload::CommunityPoolSpend { transaction_plan } => {
171 let community_pool_parameters = state.get_community_pool_params().await?;
173 anyhow::ensure!(
174 community_pool_parameters.community_pool_spend_proposals_enabled,
175 "Community Pool spend proposals are not enabled",
176 );
177
178 let parsed_transaction_plan = TransactionPlan::decode(&transaction_plan[..])
186 .context("transaction plan was malformed")?;
187 let tx = build_community_pool_transaction(parsed_transaction_plan.clone())
188 .await
189 .context("failed to build submitted Community Pool spend transaction plan")?;
190 tx.check_stateless(()).await.context(
191 "submitted Community Pool spend transaction failed stateless checks",
192 )?;
193 }
207 ProposalPayload::UpgradePlan { .. } => {
208 }
210 ProposalPayload::FreezeIbcClient { client_id } => {
211 let client_id = &ClientId::from_str(client_id)
215 .map_err(|e| tonic::Status::aborted(format!("invalid client id: {e}")))?;
216 let _ = state.get_client_state(client_id).await?;
217 }
218 ProposalPayload::UnfreezeIbcClient { client_id } => {
219 let client_id = &ClientId::from_str(client_id)
223 .map_err(|e| tonic::Status::aborted(format!("invalid client id: {e}")))?;
224 let _ = state.get_client_state(client_id).await?;
225 }
226 }
227
228 let ProposalSubmit {
231 proposal,
232 deposit_amount,
233 } = self;
234
235 if let ProposalPayload::CommunityPoolSpend { transaction_plan } = &proposal.payload {
238 let parsed_transaction_plan = TransactionPlan::decode(&transaction_plan[..])
241 .context("transaction plan was malformed")?;
242 let tx = build_community_pool_transaction(parsed_transaction_plan.clone())
243 .await
244 .context("failed to build submitted Community Pool spend transaction plan in execute step")?;
245
246 state.put_community_pool_transaction(proposal.id, tx);
248 }
249
250 let proposal_id = state
252 .new_proposal(proposal)
253 .await
254 .context("can create proposal")?;
255
256 state.put_deposit_amount(proposal_id, *deposit_amount);
258
259 state
261 .register_denom(&ProposalNft::deposit(proposal_id).denom())
262 .await;
263
264 state
266 .register_denom(&VotingReceiptToken::new(proposal_id).denom())
267 .await;
268
269 state.put_proposal_state(proposal_id, ProposalState::Voting);
271
272 let governance_params = state
275 .get_governance_params()
276 .await
277 .context("can get chain params")?;
278 let current_block = state
279 .get_block_height()
280 .await
281 .context("can get block height")?;
282 let voting_end = current_block + governance_params.proposal_voting_blocks;
283 state.put_proposal_voting_start(proposal_id, current_block);
284 state.put_proposal_voting_end(proposal_id, voting_end);
285
286 let Some(sct_position) = state.get_sct().await.position() else {
289 anyhow::bail!("state commitment tree is full");
290 };
291 let proposal_start_position = (sct_position.epoch(), sct_position.block(), 0).into();
294 state.put_proposal_voting_start_position(proposal_id, proposal_start_position);
295
296 state.mark_proposal_started();
299
300 state.record_proto(event::proposal_submit(self, current_block, voting_end));
301
302 tracing::debug!(proposal = %proposal_id, "created proposal");
303
304 Ok(())
305 }
306}
307
308static COMMUNITY_POOL_FULL_VIEWING_KEY: Lazy<FullViewingKey> = Lazy::new(|| {
313 let ak_personalization = b"Penumbra_CP_ak";
315 let nk_personalization = b"Penumbra_CP_nk";
316
317 let ak_hash_input =
319 b"This hash input is used to form the `ak` component of the Penumbra Community Pool's full viewing key.";
320 let nk_hash_input =
321 b"This hash input is used to form the `nk` component of the Penumbra Community Pool's full viewing key.";
322
323 let ak_hash = blake2b_simd::Params::new()
325 .personal(ak_personalization)
326 .hash(ak_hash_input);
327 let nk_hash = blake2b_simd::Params::new()
328 .personal(nk_personalization)
329 .hash(nk_hash_input);
330
331 let ak = VerificationKey::try_from(VerificationKeyBytes::from(
333 decaf377::Element::encode_to_curve(&Fq::from_le_bytes_mod_order(ak_hash.as_bytes()))
334 .vartime_compress()
335 .0,
336 ))
337 .expect("penumbra Community Pool FVK's `ak` must be a valid verification key by construction");
338
339 let nk = NullifierKey(Fq::from_le_bytes_mod_order(nk_hash.as_bytes()));
341
342 FullViewingKey::from_components(ak, nk)
344});
345
346async fn build_community_pool_transaction(
347 transaction_plan: TransactionPlan,
348) -> Result<Transaction> {
349 let effect_hash = transaction_plan.effect_hash(&COMMUNITY_POOL_FULL_VIEWING_KEY)?;
350 transaction_plan.build(
351 &COMMUNITY_POOL_FULL_VIEWING_KEY,
352 &WitnessData {
353 anchor: penumbra_sdk_tct::Tree::new().root(),
354 state_commitment_proofs: Default::default(),
355 },
356 &AuthorizationData {
357 effect_hash: Some(effect_hash),
358 spend_auths: Default::default(),
359 delegator_vote_auths: Default::default(),
360 lqt_vote_auths: Default::default(),
361 },
362 )
363}
364
365#[cfg(test)]
366mod test {
367 #[test]
369 fn community_pool_fvk_can_be_constructed() {
370 let _ = *super::COMMUNITY_POOL_FULL_VIEWING_KEY;
371 }
372}