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 | IbcAction(_)
97 | ValidatorVote(_)
98 | PositionOpen(_)
99 | PositionClose(_)
100 | PositionWithdraw(_)
101 | CommunityPoolSpend(_)
102 | CommunityPoolOutput(_)
103 | Ics20Withdrawal(_)
104 | CommunityPoolDeposit(_)
105 | ActionDutchAuctionSchedule(_)
106 | ActionDutchAuctionEnd(_)
107 | ActionDutchAuctionWithdraw(_) => {}
108 }
109 }
110 }
111 UpgradePlan { .. } => {}
112 FreezeIbcClient { client_id } => {
113 let _ = &ClientId::from_str(client_id)
114 .context("can't decode client id from IBC proposal")?;
115 }
116 UnfreezeIbcClient { client_id } => {
117 let _ = &ClientId::from_str(client_id)
118 .context("can't decode client id from IBC proposal")?;
119 }
120 }
121
122 Ok(())
123 }
124
125 async fn check_and_execute<S: StateWrite>(&self, mut state: S) -> Result<()> {
126 let ProposalSubmit {
131 deposit_amount,
132 proposal, } = self;
134
135 let governance_parameters = state.get_governance_params().await?;
137 if *deposit_amount != governance_parameters.proposal_deposit_amount {
138 anyhow::bail!(
139 "submitted proposal deposit of {}{} does not match required proposal deposit of {}{}",
140 deposit_amount,
141 *STAKING_TOKEN_DENOM,
142 governance_parameters.proposal_deposit_amount,
143 *STAKING_TOKEN_DENOM,
144 );
145 }
146
147 let next_proposal_id = state.next_proposal_id().await?;
149 if proposal.id != next_proposal_id {
150 anyhow::bail!(
151 "submitted proposal ID {} does not match expected proposal ID {}",
152 proposal.id,
153 next_proposal_id,
154 );
155 }
156
157 match &proposal.payload {
158 ProposalPayload::Signaling { .. } => { }
159 ProposalPayload::Emergency { .. } => { }
160 ProposalPayload::ParameterChange(change) => {
161 let current_parameters = state.get_app_params().await?;
165 change
166 .apply_changes(current_parameters)
167 .context("proposed parameter changes do not apply to current parameters")?;
168 }
169 ProposalPayload::CommunityPoolSpend { transaction_plan } => {
170 let community_pool_parameters = state.get_community_pool_params().await?;
172 anyhow::ensure!(
173 community_pool_parameters.community_pool_spend_proposals_enabled,
174 "Community Pool spend proposals are not enabled",
175 );
176
177 let parsed_transaction_plan = TransactionPlan::decode(&transaction_plan[..])
185 .context("transaction plan was malformed")?;
186 let tx = build_community_pool_transaction(parsed_transaction_plan.clone())
187 .await
188 .context("failed to build submitted Community Pool spend transaction plan")?;
189 tx.check_stateless(()).await.context(
190 "submitted Community Pool spend transaction failed stateless checks",
191 )?;
192 }
206 ProposalPayload::UpgradePlan { .. } => {
207 }
209 ProposalPayload::FreezeIbcClient { client_id } => {
210 let client_id = &ClientId::from_str(client_id)
214 .map_err(|e| tonic::Status::aborted(format!("invalid client id: {e}")))?;
215 let _ = state.get_client_state(client_id).await?;
216 }
217 ProposalPayload::UnfreezeIbcClient { client_id } => {
218 let client_id = &ClientId::from_str(client_id)
222 .map_err(|e| tonic::Status::aborted(format!("invalid client id: {e}")))?;
223 let _ = state.get_client_state(client_id).await?;
224 }
225 }
226
227 let ProposalSubmit {
230 proposal,
231 deposit_amount,
232 } = self;
233
234 if let ProposalPayload::CommunityPoolSpend { transaction_plan } = &proposal.payload {
237 let parsed_transaction_plan = TransactionPlan::decode(&transaction_plan[..])
240 .context("transaction plan was malformed")?;
241 let tx = build_community_pool_transaction(parsed_transaction_plan.clone())
242 .await
243 .context("failed to build submitted Community Pool spend transaction plan in execute step")?;
244
245 state.put_community_pool_transaction(proposal.id, tx);
247 }
248
249 let proposal_id = state
251 .new_proposal(proposal)
252 .await
253 .context("can create proposal")?;
254
255 state.put_deposit_amount(proposal_id, *deposit_amount);
257
258 state
260 .register_denom(&ProposalNft::deposit(proposal_id).denom())
261 .await;
262
263 state
265 .register_denom(&VotingReceiptToken::new(proposal_id).denom())
266 .await;
267
268 state.put_proposal_state(proposal_id, ProposalState::Voting);
270
271 let governance_params = state
274 .get_governance_params()
275 .await
276 .context("can get chain params")?;
277 let current_block = state
278 .get_block_height()
279 .await
280 .context("can get block height")?;
281 let voting_end = current_block + governance_params.proposal_voting_blocks;
282 state.put_proposal_voting_start(proposal_id, current_block);
283 state.put_proposal_voting_end(proposal_id, voting_end);
284
285 let Some(sct_position) = state.get_sct().await.position() else {
288 anyhow::bail!("state commitment tree is full");
289 };
290 let proposal_start_position = (sct_position.epoch(), sct_position.block(), 0).into();
293 state.put_proposal_voting_start_position(proposal_id, proposal_start_position);
294
295 state.mark_proposal_started();
298
299 state.record_proto(event::proposal_submit(self, current_block, voting_end));
300
301 tracing::debug!(proposal = %proposal_id, "created proposal");
302
303 Ok(())
304 }
305}
306
307static COMMUNITY_POOL_FULL_VIEWING_KEY: Lazy<FullViewingKey> = Lazy::new(|| {
312 let ak_personalization = b"Penumbra_CP_ak";
314 let nk_personalization = b"Penumbra_CP_nk";
315
316 let ak_hash_input =
318 b"This hash input is used to form the `ak` component of the Penumbra Community Pool's full viewing key.";
319 let nk_hash_input =
320 b"This hash input is used to form the `nk` component of the Penumbra Community Pool's full viewing key.";
321
322 let ak_hash = blake2b_simd::Params::new()
324 .personal(ak_personalization)
325 .hash(ak_hash_input);
326 let nk_hash = blake2b_simd::Params::new()
327 .personal(nk_personalization)
328 .hash(nk_hash_input);
329
330 let ak = VerificationKey::try_from(VerificationKeyBytes::from(
332 decaf377::Element::encode_to_curve(&Fq::from_le_bytes_mod_order(ak_hash.as_bytes()))
333 .vartime_compress()
334 .0,
335 ))
336 .expect("penumbra Community Pool FVK's `ak` must be a valid verification key by construction");
337
338 let nk = NullifierKey(Fq::from_le_bytes_mod_order(nk_hash.as_bytes()));
340
341 FullViewingKey::from_components(ak, nk)
343});
344
345async fn build_community_pool_transaction(
346 transaction_plan: TransactionPlan,
347) -> Result<Transaction> {
348 let effect_hash = transaction_plan.effect_hash(&COMMUNITY_POOL_FULL_VIEWING_KEY)?;
349 transaction_plan.build(
350 &COMMUNITY_POOL_FULL_VIEWING_KEY,
351 &WitnessData {
352 anchor: penumbra_sdk_tct::Tree::new().root(),
353 state_commitment_proofs: Default::default(),
354 },
355 &AuthorizationData {
356 effect_hash: Some(effect_hash),
357 spend_auths: Default::default(),
358 delegator_vote_auths: Default::default(),
359 },
360 )
361}
362
363#[cfg(test)]
364mod test {
365 #[test]
367 fn community_pool_fvk_can_be_constructed() {
368 let _ = *super::COMMUNITY_POOL_FULL_VIEWING_KEY;
369 }
370}