penumbra_app/action_handler/actions/submit.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370
use std::str::FromStr;
use anyhow::{Context, Result};
use async_trait::async_trait;
use decaf377::Fq;
use decaf377_rdsa::{VerificationKey, VerificationKeyBytes};
use ibc_types::core::client::ClientId;
use once_cell::sync::Lazy;
use cnidarium::StateWrite;
use penumbra_asset::STAKING_TOKEN_DENOM;
use penumbra_community_pool::component::StateReadExt as _;
use penumbra_governance::{
component::{StateReadExt as _, StateWriteExt as _},
event,
proposal::{Proposal, ProposalPayload},
proposal_state::State as ProposalState,
ProposalNft, ProposalSubmit, VotingReceiptToken,
};
use penumbra_ibc::component::ClientStateReadExt;
use penumbra_keys::keys::{FullViewingKey, NullifierKey};
use penumbra_proto::{DomainType, StateWriteProto as _};
use penumbra_sct::component::clock::EpochRead;
use penumbra_sct::component::tree::SctRead;
use penumbra_shielded_pool::component::AssetRegistry;
use penumbra_transaction::{AuthorizationData, Transaction, TransactionPlan, WitnessData};
use crate::app::StateReadExt;
use crate::community_pool_ext::CommunityPoolStateWriteExt;
use crate::{action_handler::AppActionHandler, params::change::ParameterChangeExt as _};
// IMPORTANT: these length limits are enforced by consensus! Changing them will change which
// transactions are accepted by the network, and so they *cannot* be changed without a network
// upgrade!
// This is enough room to print "Proposal #999,999: $TITLE" in 99 characters (and the
// proposal title itself in 80), a decent line width for a modern terminal, as well as a
// reasonable length for other interfaces.
pub const PROPOSAL_TITLE_LIMIT: usize = 80; // ⚠️ DON'T CHANGE THIS (see above)!
// Limit the size of a description to 10,000 characters (a reasonable limit borrowed from
// the Cosmos SDK).
pub const PROPOSAL_DESCRIPTION_LIMIT: usize = 10_000; // ⚠️ DON'T CHANGE THIS (see above)!
#[async_trait]
impl AppActionHandler for ProposalSubmit {
type CheckStatelessContext = ();
async fn check_stateless(&self, _context: ()) -> Result<()> {
let ProposalSubmit {
proposal,
deposit_amount: _, // we don't check the deposit amount because it's defined by state
} = self;
let Proposal {
id: _, // we can't check the ID statelessly because it's defined by state
title,
description,
payload,
} = proposal;
if title.len() > PROPOSAL_TITLE_LIMIT {
anyhow::bail!("proposal title must fit within {PROPOSAL_TITLE_LIMIT} characters");
}
if description.len() > PROPOSAL_DESCRIPTION_LIMIT {
anyhow::bail!(
"proposal description must fit within {PROPOSAL_DESCRIPTION_LIMIT} characters"
);
}
use penumbra_governance::ProposalPayload::*;
match payload {
Signaling { commit: _ } => { /* all signaling proposals are valid */ }
Emergency { halt_chain: _ } => { /* all emergency proposals are valid */ }
ParameterChange(_change) => { /* no stateless checks -- see check-and-execute below */ }
CommunityPoolSpend { transaction_plan } => {
// Check to make sure that the transaction plan contains only valid actions for the
// Community Pool (none of them should require proving to build):
use penumbra_transaction::plan::ActionPlan::*;
let parsed_transaction_plan = TransactionPlan::decode(&transaction_plan[..])
.context("transaction plan was malformed")?;
for action in &parsed_transaction_plan.actions {
match action {
Spend(_) | Output(_) | Swap(_) | SwapClaim(_) | DelegatorVote(_)
| UndelegateClaim(_) => {
anyhow::bail!("invalid action in Community Pool spend proposal (would require proving)")
}
Delegate(_) | Undelegate(_) => {
anyhow::bail!("invalid action in Community Pool spend proposal (can't claim outputs of undelegation)")
}
ProposalSubmit(_) | ProposalWithdraw(_) | ProposalDepositClaim(_) => {
anyhow::bail!("invalid action in Community Pool spend proposal (not allowed to manipulate proposals from within proposals)")
}
ValidatorDefinition(_)
| IbcAction(_)
| ValidatorVote(_)
| PositionOpen(_)
| PositionClose(_)
| PositionWithdraw(_)
| CommunityPoolSpend(_)
| CommunityPoolOutput(_)
| Ics20Withdrawal(_)
| CommunityPoolDeposit(_)
| ActionDutchAuctionSchedule(_)
| ActionDutchAuctionEnd(_)
| ActionDutchAuctionWithdraw(_) => {}
}
}
}
UpgradePlan { .. } => {}
FreezeIbcClient { client_id } => {
let _ = &ClientId::from_str(client_id)
.context("can't decode client id from IBC proposal")?;
}
UnfreezeIbcClient { client_id } => {
let _ = &ClientId::from_str(client_id)
.context("can't decode client id from IBC proposal")?;
}
}
Ok(())
}
async fn check_and_execute<S: StateWrite>(&self, mut state: S) -> Result<()> {
// These checks all formerly happened in the `check_historical` method,
// if profiling shows that they cause a bottleneck we could (CAREFULLY)
// move some of them back.
let ProposalSubmit {
deposit_amount,
proposal, // statelessly verified
} = self;
// Check that the deposit amount agrees with the parameters
let governance_parameters = state.get_governance_params().await?;
if *deposit_amount != governance_parameters.proposal_deposit_amount {
anyhow::bail!(
"submitted proposal deposit of {}{} does not match required proposal deposit of {}{}",
deposit_amount,
*STAKING_TOKEN_DENOM,
governance_parameters.proposal_deposit_amount,
*STAKING_TOKEN_DENOM,
);
}
// Check that the proposal ID is the correct next proposal ID
let next_proposal_id = state.next_proposal_id().await?;
if proposal.id != next_proposal_id {
anyhow::bail!(
"submitted proposal ID {} does not match expected proposal ID {}",
proposal.id,
next_proposal_id,
);
}
match &proposal.payload {
ProposalPayload::Signaling { .. } => { /* no stateful checks for signaling */ }
ProposalPayload::Emergency { .. } => { /* no stateful checks for emergency */ }
ProposalPayload::ParameterChange(change) => {
// Check that the parameter change is valid and could be applied to the current
// parameters. This doesn't guarantee that it will be valid when/if it passes but
// ensures that clearly malformed proposals are rejected upfront.
let current_parameters = state.get_app_params().await?;
change
.apply_changes(current_parameters)
.context("proposed parameter changes do not apply to current parameters")?;
}
ProposalPayload::CommunityPoolSpend { transaction_plan } => {
// If Community Pool spend proposals aren't enabled, then we can't allow them to be submitted
let community_pool_parameters = state.get_community_pool_params().await?;
anyhow::ensure!(
community_pool_parameters.community_pool_spend_proposals_enabled,
"Community Pool spend proposals are not enabled",
);
// Check that the transaction plan can be built without any witness or auth data and
// it passes stateless and stateful checks, and can be executed successfully in the
// current chain state. This doesn't guarantee that it will execute successfully at
// the time when the proposal passes, but we don't want to allow proposals that are
// obviously going to fail to execute.
//
// NOTE: we do not do stateful checks, see below
let parsed_transaction_plan = TransactionPlan::decode(&transaction_plan[..])
.context("transaction plan was malformed")?;
let tx = build_community_pool_transaction(parsed_transaction_plan.clone())
.await
.context("failed to build submitted Community Pool spend transaction plan")?;
tx.check_stateless(()).await.context(
"submitted Community Pool spend transaction failed stateless checks",
)?;
/*
// We skip stateful checks rather than doing them in simulation. Partly this is
// because it's easier to not check, but also it avoids having to reason about whether
// there are any cases where a transaction could be invalid when submitted but become
// valid when voting finishes (e.g., an undelegation?)
tx.check_historical(state.clone())
.await
.context("submitted Community Pool spend transaction failed stateful checks")?;
tx.check_and_execute(StateDelta::new(state)).await.context(
"submitted Community Pool spend transaction failed to execute in current chain state",
)?;
*/
}
ProposalPayload::UpgradePlan { .. } => {
// TODO(erwan): no stateful checks for upgrade plan.
}
ProposalPayload::FreezeIbcClient { client_id } => {
// Check that the client ID is valid and that there is a corresponding
// client state. If the client state is already frozen, then freezing it
// is a no-op.
let client_id = &ClientId::from_str(client_id)
.map_err(|e| tonic::Status::aborted(format!("invalid client id: {e}")))?;
let _ = state.get_client_state(client_id).await?;
}
ProposalPayload::UnfreezeIbcClient { client_id } => {
// Check that the client ID is valid and that there is a corresponding
// client state. If the client state is not frozen, then unfreezing it
// is a no-op.
let client_id = &ClientId::from_str(client_id)
.map_err(|e| tonic::Status::aborted(format!("invalid client id: {e}")))?;
let _ = state.get_client_state(client_id).await?;
}
}
// (end of former check_stateful checks)
let ProposalSubmit {
proposal,
deposit_amount,
} = self;
// If the proposal is a Community Pool spend proposal, we've already built it, but we need to build it
// again because we can't remember anything from `check_tx_stateful` to `execute`:
if let ProposalPayload::CommunityPoolSpend { transaction_plan } = &proposal.payload {
// Build the transaction again (this time we know it will succeed because it built and
// passed all checks in `check_tx_stateful`):
let parsed_transaction_plan = TransactionPlan::decode(&transaction_plan[..])
.context("transaction plan was malformed")?;
let tx = build_community_pool_transaction(parsed_transaction_plan.clone())
.await
.context("failed to build submitted Community Pool spend transaction plan in execute step")?;
// Cache the built transaction in the state so we can use it later, without rebuilding:
state.put_community_pool_transaction(proposal.id, tx);
}
// Store the contents of the proposal and generate a fresh proposal id for it
let proposal_id = state
.new_proposal(proposal)
.await
.context("can create proposal")?;
// Set the deposit amount for the proposal
state.put_deposit_amount(proposal_id, *deposit_amount);
// Register the denom for the voting proposal NFT
state
.register_denom(&ProposalNft::deposit(proposal_id).denom())
.await;
// Register the denom for the vote receipt tokens
state
.register_denom(&VotingReceiptToken::new(proposal_id).denom())
.await;
// Set the proposal state to voting (votes start immediately)
state.put_proposal_state(proposal_id, ProposalState::Voting);
// Determine what block it is currently, and calculate when the proposal should start voting
// (now!) and finish voting (later...), then write that into the state
let governance_params = state
.get_governance_params()
.await
.context("can get chain params")?;
let current_block = state
.get_block_height()
.await
.context("can get block height")?;
let voting_end = current_block + governance_params.proposal_voting_blocks;
state.put_proposal_voting_start(proposal_id, current_block);
state.put_proposal_voting_end(proposal_id, voting_end);
// Compute the effective starting TCT position for the proposal, by rounding the current
// position down to the start of the block.
let Some(sct_position) = state.get_sct().await.position() else {
anyhow::bail!("state commitment tree is full");
};
// All proposals start are considered to start at the beginning of the block, because this
// means there are no ordering games to be played within the block in which a proposal begins:
let proposal_start_position = (sct_position.epoch(), sct_position.block(), 0).into();
state.put_proposal_voting_start_position(proposal_id, proposal_start_position);
// Since there was a proposal submitted, ensure we track this so that clients can retain
// state needed to vote as delegators
state.mark_proposal_started();
state.record_proto(event::proposal_submit(self, current_block, voting_end));
tracing::debug!(proposal = %proposal_id, "created proposal");
Ok(())
}
}
/// The full viewing key used to construct transactions made by the Penumbra Community Pool.
///
/// This full viewing key does not correspond to any known spend key; it is constructed from the
/// hashes of two arbitrary strings.
static COMMUNITY_POOL_FULL_VIEWING_KEY: Lazy<FullViewingKey> = Lazy::new(|| {
// We start with two different personalization strings for the hash function:
let ak_personalization = b"Penumbra_CP_ak";
let nk_personalization = b"Penumbra_CP_nk";
// We pick two different arbitrary strings to hash:
let ak_hash_input =
b"This hash input is used to form the `ak` component of the Penumbra Community Pool's full viewing key.";
let nk_hash_input =
b"This hash input is used to form the `nk` component of the Penumbra Community Pool's full viewing key.";
// We hash the two strings using their respective personalizations:
let ak_hash = blake2b_simd::Params::new()
.personal(ak_personalization)
.hash(ak_hash_input);
let nk_hash = blake2b_simd::Params::new()
.personal(nk_personalization)
.hash(nk_hash_input);
// We construct the `ak` component of the full viewing key from the hash of the first string:
let ak = VerificationKey::try_from(VerificationKeyBytes::from(
decaf377::Element::encode_to_curve(&Fq::from_le_bytes_mod_order(ak_hash.as_bytes()))
.vartime_compress()
.0,
))
.expect("penumbra Community Pool FVK's `ak` must be a valid verification key by construction");
// We construct the `nk` component of the full viewing key from the hash of the second string:
let nk = NullifierKey(Fq::from_le_bytes_mod_order(nk_hash.as_bytes()));
// We construct the full viewing key from the `ak` and `nk` components:
FullViewingKey::from_components(ak, nk)
});
async fn build_community_pool_transaction(
transaction_plan: TransactionPlan,
) -> Result<Transaction> {
let effect_hash = transaction_plan.effect_hash(&COMMUNITY_POOL_FULL_VIEWING_KEY)?;
transaction_plan.build(
&COMMUNITY_POOL_FULL_VIEWING_KEY,
&WitnessData {
anchor: penumbra_tct::Tree::new().root(),
state_commitment_proofs: Default::default(),
},
&AuthorizationData {
effect_hash: Some(effect_hash),
spend_auths: Default::default(),
delegator_vote_auths: Default::default(),
},
)
}
#[cfg(test)]
mod test {
/// Ensure that the Community Pool full viewing key can be constructed and does not panic when referenced.
#[test]
fn community_pool_fvk_can_be_constructed() {
let _ = *super::COMMUNITY_POOL_FULL_VIEWING_KEY;
}
}