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                        | 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        // These checks all formerly happened in the `check_historical` method,
127        // if profiling shows that they cause a bottleneck we could (CAREFULLY)
128        // move some of them back.
129
130        let ProposalSubmit {
131            deposit_amount,
132            proposal, // statelessly verified
133        } = self;
134
135        // Check that the deposit amount agrees with the parameters
136        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        // Check that the proposal ID is the correct next proposal ID
148        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 { .. } => { /* no stateful checks for signaling */ }
159            ProposalPayload::Emergency { .. } => { /* no stateful checks for emergency */ }
160            ProposalPayload::ParameterChange(change) => {
161                // Check that the parameter change is valid and could be applied to the current
162                // parameters. This doesn't guarantee that it will be valid when/if it passes but
163                // ensures that clearly malformed proposals are rejected upfront.
164                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                // If Community Pool spend proposals aren't enabled, then we can't allow them to be submitted
171                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                // Check that the transaction plan can be built without any witness or auth data and
178                // it passes stateless and stateful checks, and can be executed successfully in the
179                // current chain state. This doesn't guarantee that it will execute successfully at
180                // the time when the proposal passes, but we don't want to allow proposals that are
181                // obviously going to fail to execute.
182                //
183                // NOTE: we do not do stateful checks, see below
184                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                /*
193                // We skip stateful checks rather than doing them in simulation. Partly this is
194                // because it's easier to not check, but also it avoids having to reason about whether
195                // there are any cases where a transaction could be invalid when submitted but become
196                // valid when voting finishes (e.g., an undelegation?)
197
198                tx.check_historical(state.clone())
199                    .await
200                    .context("submitted Community Pool spend transaction failed stateful checks")?;
201                tx.check_and_execute(StateDelta::new(state)).await.context(
202                    "submitted Community Pool spend transaction failed to execute in current chain state",
203                )?;
204                 */
205            }
206            ProposalPayload::UpgradePlan { .. } => {
207                // TODO(erwan): no stateful checks for upgrade plan.
208            }
209            ProposalPayload::FreezeIbcClient { client_id } => {
210                // Check that the client ID is valid and that there is a corresponding
211                // client state. If the client state is already frozen, then freezing it
212                // is a no-op.
213                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                // Check that the client ID is valid and that there is a corresponding
219                // client state. If the client state is not frozen, then unfreezing it
220                // is a no-op.
221                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        // (end of former check_stateful checks)
228
229        let ProposalSubmit {
230            proposal,
231            deposit_amount,
232        } = self;
233
234        // If the proposal is a Community Pool spend proposal, we've already built it, but we need to build it
235        // again because we can't remember anything from `check_tx_stateful` to `execute`:
236        if let ProposalPayload::CommunityPoolSpend { transaction_plan } = &proposal.payload {
237            // Build the transaction again (this time we know it will succeed because it built and
238            // passed all checks in `check_tx_stateful`):
239            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            // Cache the built transaction in the state so we can use it later, without rebuilding:
246            state.put_community_pool_transaction(proposal.id, tx);
247        }
248
249        // Store the contents of the proposal and generate a fresh proposal id for it
250        let proposal_id = state
251            .new_proposal(proposal)
252            .await
253            .context("can create proposal")?;
254
255        // Set the deposit amount for the proposal
256        state.put_deposit_amount(proposal_id, *deposit_amount);
257
258        // Register the denom for the voting proposal NFT
259        state
260            .register_denom(&ProposalNft::deposit(proposal_id).denom())
261            .await;
262
263        // Register the denom for the vote receipt tokens
264        state
265            .register_denom(&VotingReceiptToken::new(proposal_id).denom())
266            .await;
267
268        // Set the proposal state to voting (votes start immediately)
269        state.put_proposal_state(proposal_id, ProposalState::Voting);
270
271        // Determine what block it is currently, and calculate when the proposal should start voting
272        // (now!) and finish voting (later...), then write that into the state
273        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        // Compute the effective starting TCT position for the proposal, by rounding the current
286        // position down to the start of the block.
287        let Some(sct_position) = state.get_sct().await.position() else {
288            anyhow::bail!("state commitment tree is full");
289        };
290        // All proposals start are considered to start at the beginning of the block, because this
291        // means there are no ordering games to be played within the block in which a proposal begins:
292        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        // Since there was a proposal submitted, ensure we track this so that clients can retain
296        // state needed to vote as delegators
297        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
307/// The full viewing key used to construct transactions made by the Penumbra Community Pool.
308///
309/// This full viewing key does not correspond to any known spend key; it is constructed from the
310/// hashes of two arbitrary strings.
311static COMMUNITY_POOL_FULL_VIEWING_KEY: Lazy<FullViewingKey> = Lazy::new(|| {
312    // We start with two different personalization strings for the hash function:
313    let ak_personalization = b"Penumbra_CP_ak";
314    let nk_personalization = b"Penumbra_CP_nk";
315
316    // We pick two different arbitrary strings to hash:
317    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    // We hash the two strings using their respective personalizations:
323    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    // We construct the `ak` component of the full viewing key from the hash of the first string:
331    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    // We construct the `nk` component of the full viewing key from the hash of the second string:
339    let nk = NullifierKey(Fq::from_le_bytes_mod_order(nk_hash.as_bytes()));
340
341    // We construct the full viewing key from the `ak` and `nk` components:
342    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    /// Ensure that the Community Pool full viewing key can be constructed and does not panic when referenced.
366    #[test]
367    fn community_pool_fvk_can_be_constructed() {
368        let _ = *super::COMMUNITY_POOL_FULL_VIEWING_KEY;
369    }
370}