penumbra_sdk_app/action_handler/actions/
submit.rs

1use 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
32// IMPORTANT: these length limits are enforced by consensus! Changing them will change which
33// transactions are accepted by the network, and so they *cannot* be changed without a network
34// upgrade!
35
36// This is enough room to print "Proposal #999,999: $TITLE" in 99 characters (and the
37// proposal title itself in 80), a decent line width for a modern terminal, as well as a
38// reasonable length for other interfaces.
39pub const PROPOSAL_TITLE_LIMIT: usize = 80; // ⚠️ DON'T CHANGE THIS (see above)!
40
41// Limit the size of a description to 10,000 characters (a reasonable limit borrowed from
42// the Cosmos SDK).
43pub const PROPOSAL_DESCRIPTION_LIMIT: usize = 10_000; // ⚠️ DON'T CHANGE THIS (see above)!
44
45#[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: _, // we don't check the deposit amount because it's defined by state
52        } = self;
53        let Proposal {
54            id: _, // we can't check the ID statelessly because it's defined by state
55            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: _ } => { /* all signaling proposals are valid */ }
73            Emergency { halt_chain: _ } => { /* all emergency proposals are valid */ }
74            ParameterChange(_change) => { /* no stateless checks -- see check-and-execute below */ }
75            CommunityPoolSpend { transaction_plan } => {
76                // Check to make sure that the transaction plan contains only valid actions for the
77                // Community Pool (none of them should require proving to build):
78                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        // These checks all formerly happened in the `check_historical` method,
128        // if profiling shows that they cause a bottleneck we could (CAREFULLY)
129        // move some of them back.
130
131        let ProposalSubmit {
132            deposit_amount,
133            proposal, // statelessly verified
134        } = self;
135
136        // Check that the deposit amount agrees with the parameters
137        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        // Check that the proposal ID is the correct next proposal ID
149        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 { .. } => { /* no stateful checks for signaling */ }
160            ProposalPayload::Emergency { .. } => { /* no stateful checks for emergency */ }
161            ProposalPayload::ParameterChange(change) => {
162                // Check that the parameter change is valid and could be applied to the current
163                // parameters. This doesn't guarantee that it will be valid when/if it passes but
164                // ensures that clearly malformed proposals are rejected upfront.
165                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                // If Community Pool spend proposals aren't enabled, then we can't allow them to be submitted
172                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                // Check that the transaction plan can be built without any witness or auth data and
179                // it passes stateless and stateful checks, and can be executed successfully in the
180                // current chain state. This doesn't guarantee that it will execute successfully at
181                // the time when the proposal passes, but we don't want to allow proposals that are
182                // obviously going to fail to execute.
183                //
184                // NOTE: we do not do stateful checks, see below
185                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                /*
194                // We skip stateful checks rather than doing them in simulation. Partly this is
195                // because it's easier to not check, but also it avoids having to reason about whether
196                // there are any cases where a transaction could be invalid when submitted but become
197                // valid when voting finishes (e.g., an undelegation?)
198
199                tx.check_historical(state.clone())
200                    .await
201                    .context("submitted Community Pool spend transaction failed stateful checks")?;
202                tx.check_and_execute(StateDelta::new(state)).await.context(
203                    "submitted Community Pool spend transaction failed to execute in current chain state",
204                )?;
205                 */
206            }
207            ProposalPayload::UpgradePlan { .. } => {
208                // TODO(erwan): no stateful checks for upgrade plan.
209            }
210            ProposalPayload::FreezeIbcClient { client_id } => {
211                // Check that the client ID is valid and that there is a corresponding
212                // client state. If the client state is already frozen, then freezing it
213                // is a no-op.
214                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                // Check that the client ID is valid and that there is a corresponding
220                // client state. If the client state is not frozen, then unfreezing it
221                // is a no-op.
222                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        // (end of former check_stateful checks)
229
230        let ProposalSubmit {
231            proposal,
232            deposit_amount,
233        } = self;
234
235        // If the proposal is a Community Pool spend proposal, we've already built it, but we need to build it
236        // again because we can't remember anything from `check_tx_stateful` to `execute`:
237        if let ProposalPayload::CommunityPoolSpend { transaction_plan } = &proposal.payload {
238            // Build the transaction again (this time we know it will succeed because it built and
239            // passed all checks in `check_tx_stateful`):
240            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            // Cache the built transaction in the state so we can use it later, without rebuilding:
247            state.put_community_pool_transaction(proposal.id, tx);
248        }
249
250        // Store the contents of the proposal and generate a fresh proposal id for it
251        let proposal_id = state
252            .new_proposal(proposal)
253            .await
254            .context("can create proposal")?;
255
256        // Set the deposit amount for the proposal
257        state.put_deposit_amount(proposal_id, *deposit_amount);
258
259        // Register the denom for the voting proposal NFT
260        state
261            .register_denom(&ProposalNft::deposit(proposal_id).denom())
262            .await;
263
264        // Register the denom for the vote receipt tokens
265        state
266            .register_denom(&VotingReceiptToken::new(proposal_id).denom())
267            .await;
268
269        // Set the proposal state to voting (votes start immediately)
270        state.put_proposal_state(proposal_id, ProposalState::Voting);
271
272        // Determine what block it is currently, and calculate when the proposal should start voting
273        // (now!) and finish voting (later...), then write that into the state
274        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        // Compute the effective starting TCT position for the proposal, by rounding the current
287        // position down to the start of the block.
288        let Some(sct_position) = state.get_sct().await.position() else {
289            anyhow::bail!("state commitment tree is full");
290        };
291        // All proposals start are considered to start at the beginning of the block, because this
292        // means there are no ordering games to be played within the block in which a proposal begins:
293        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        // Since there was a proposal submitted, ensure we track this so that clients can retain
297        // state needed to vote as delegators
298        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
308/// The full viewing key used to construct transactions made by the Penumbra Community Pool.
309///
310/// This full viewing key does not correspond to any known spend key; it is constructed from the
311/// hashes of two arbitrary strings.
312static COMMUNITY_POOL_FULL_VIEWING_KEY: Lazy<FullViewingKey> = Lazy::new(|| {
313    // We start with two different personalization strings for the hash function:
314    let ak_personalization = b"Penumbra_CP_ak";
315    let nk_personalization = b"Penumbra_CP_nk";
316
317    // We pick two different arbitrary strings to hash:
318    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    // We hash the two strings using their respective personalizations:
324    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    // We construct the `ak` component of the full viewing key from the hash of the first string:
332    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    // We construct the `nk` component of the full viewing key from the hash of the second string:
340    let nk = NullifierKey(Fq::from_le_bytes_mod_order(nk_hash.as_bytes()));
341
342    // We construct the full viewing key from the `ak` and `nk` components:
343    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    /// Ensure that the Community Pool full viewing key can be constructed and does not panic when referenced.
368    #[test]
369    fn community_pool_fvk_can_be_constructed() {
370        let _ = *super::COMMUNITY_POOL_FULL_VIEWING_KEY;
371    }
372}