From 385242076a82487dd834703efc4615c1a12bf91e Mon Sep 17 00:00:00 2001 From: katelyn martin Date: Fri, 19 Apr 2024 10:08:00 -0400 Subject: [PATCH] =?UTF-8?q?feat(governance):=20=F0=9F=A7=8A=20test=20that?= =?UTF-8?q?=20community=20pool=20spend=20proposals=20can=20be=20disabled?= =?UTF-8?q?=20(#4222)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes #4079. related to, but does not finish #2105. this introduces test coverage to show that community pool spend proposals can be disabled via a chain parameter. ### related changes some changes made, related to writing tests for the governance component: * 6b3be5892 refactor(governance): 💶 `Value` accessors for proposal actions * de16dace5 feat(tx): 🌮 `ActionPlan: From` for community pool actions * 4e6b874ed feat(amount): ➖ `Amount: std::ops::SubAssign` ### tests this addresses part of #2105. we should add tests that exercise the other tallying logic before that issue is complete. tests are added in the following commits: * fedfa8ded tests(app): 💭 test a community pool spend * 201fe4873 tests(app): 🏊 test a community pool deposit * 8543869d4 tests(app): 🥶 test that community pool can be frozen --- .../app_can_deposit_into_community_pool.rs | 129 ++++++ .../app_can_disable_community_pool_spends.rs | 370 +++++++++++++++++ .../app_can_propose_community_pool_spends.rs | 378 ++++++++++++++++++ .../governance/src/proposal_submit/action.rs | 27 +- .../src/proposal_withdraw/action.rs | 26 +- crates/core/num/src/amount.rs | 6 + crates/core/transaction/src/plan/action.rs | 18 + 7 files changed, 937 insertions(+), 17 deletions(-) create mode 100644 crates/core/app/tests/app_can_deposit_into_community_pool.rs create mode 100644 crates/core/app/tests/app_can_disable_community_pool_spends.rs create mode 100644 crates/core/app/tests/app_can_propose_community_pool_spends.rs diff --git a/crates/core/app/tests/app_can_deposit_into_community_pool.rs b/crates/core/app/tests/app_can_deposit_into_community_pool.rs new file mode 100644 index 0000000000..ecdee26f92 --- /dev/null +++ b/crates/core/app/tests/app_can_deposit_into_community_pool.rs @@ -0,0 +1,129 @@ +use { + self::common::BuilderExt, + anyhow::anyhow, + cnidarium::TempStorage, + penumbra_app::{genesis::AppState, server::consensus::Consensus}, + penumbra_asset::asset, + penumbra_community_pool::{CommunityPoolDeposit, StateReadExt}, + penumbra_keys::test_keys, + penumbra_mock_client::MockClient, + penumbra_mock_consensus::TestNode, + penumbra_num::Amount, + penumbra_proto::DomainType, + penumbra_shielded_pool::SpendPlan, + penumbra_transaction::{TransactionParameters, TransactionPlan}, + rand_core::OsRng, + std::collections::BTreeMap, + tap::{Tap, TapFallible}, + tracing::info, +}; + +mod common; + +/// Exercises that the app can deposit a note into the community pool. +#[tokio::test] +async fn app_can_deposit_into_community_pool() -> anyhow::Result<()> { + // Install a test logger, and acquire some temporary storage. + let guard = common::set_tracing_subscriber(); + let storage = TempStorage::new().await?; + + // Define our application state, and start the test node. + let mut test_node = { + let app_state = AppState::default(); + let consensus = Consensus::new(storage.as_ref().clone()); + TestNode::builder() + .single_validator() + .with_penumbra_auto_app_state(app_state)? + .init_chain(consensus) + .await + .tap_ok(|e| tracing::info!(hash = %e.last_app_hash_hex(), "finished init chain"))? + }; + + // Sync the mock client, using the test wallet's spend key, to the latest snapshot. + let client = MockClient::new(test_keys::SPEND_KEY.clone()) + .with_sync_to_storage(&storage) + .await? + .tap(|c| info!(client.notes = %c.notes.len(), "mock client synced to test storage")); + + // Take one of the test wallet's notes, and prepare to deposit it in the community pool. + let note = client + .notes + .values() + .cloned() + .next() + .ok_or_else(|| anyhow!("mock client had no note"))?; + + // Create a community pool transaction. + let mut plan = { + let value = note.value(); + let spend = SpendPlan::new( + &mut OsRng, + note.clone(), + client + .position(note.commit()) + .ok_or_else(|| anyhow!("input note commitment was unknown to mock client"))?, + ) + .into(); + let deposit = CommunityPoolDeposit { value }.into(); + TransactionPlan { + actions: vec![spend, deposit], + // Now fill out the remaining parts of the transaction needed for verification: + memo: None, + detection_data: None, // We'll set this automatically below + transaction_parameters: TransactionParameters { + chain_id: TestNode::<()>::CHAIN_ID.to_string(), + ..Default::default() + }, + } + }; + plan.populate_detection_data(OsRng, 0); + let tx = client.witness_auth_build(&plan).await?; + + // Execute the transaction, applying it to the chain state. + let pre_tx_snapshot = storage.latest_snapshot(); + test_node + .block() + .with_data(vec![tx.encode_to_vec()]) + .execute() + .await?; + let post_tx_snapshot = storage.latest_snapshot(); + + // Assert that the community pool balance looks correct for the deposited asset id, and that + // other amounts were not affected by the deposit. + { + type Balance = BTreeMap; + + let id = note.asset_id(); + let pre_tx_balance = pre_tx_snapshot.community_pool_balance().await?; + let post_tx_balance = post_tx_snapshot.community_pool_balance().await?; + + let get_balance_for_id = |balance: &Balance| balance.get(&id).copied().unwrap_or_default(); + let pre_tx_amount = get_balance_for_id(&pre_tx_balance); + let post_tx_amount = get_balance_for_id(&post_tx_balance); + assert_eq!( + pre_tx_amount + note.amount(), + post_tx_amount, + "community pool balance should include the deposited note" + ); + + let count_other_assets_in_pool = |balance: &Balance| { + balance + .into_iter() + // Skip the amount for our note's asset id. + .filter(|(&entry_id, _)| entry_id != id) + .map(|(_, &amount)| amount) + .sum::() + }; + assert_eq!( + count_other_assets_in_pool(&pre_tx_balance), + count_other_assets_in_pool(&post_tx_balance), + "other community pool balance amounts should not have changed" + ); + } + + // Free our temporary storage. + Ok(()) + .tap(|_| drop(test_node)) + .tap(|_| drop(storage)) + .tap(|_| drop(guard)) +} diff --git a/crates/core/app/tests/app_can_disable_community_pool_spends.rs b/crates/core/app/tests/app_can_disable_community_pool_spends.rs new file mode 100644 index 0000000000..bac8e9a281 --- /dev/null +++ b/crates/core/app/tests/app_can_disable_community_pool_spends.rs @@ -0,0 +1,370 @@ +use { + anyhow::anyhow, + cnidarium::TempStorage, + decaf377_rdsa::VerificationKey, + penumbra_app::{ + genesis::{AppState, Content}, + server::consensus::Consensus, + CommunityPoolStateReadExt as _, + }, + penumbra_community_pool::{ + CommunityPoolDeposit, CommunityPoolOutput, CommunityPoolSpend, StateReadExt as _, + }, + penumbra_governance::{ + Proposal, ProposalSubmit, StateReadExt as _, ValidatorVote, ValidatorVoteBody, + ValidatorVoteReason, + }, + penumbra_keys::{ + keys::{SpendKey, SpendKeyBytes}, + test_keys::{self}, + }, + penumbra_mock_client::MockClient, + penumbra_mock_consensus::TestNode, + penumbra_proto::{ + core::keys::v1::{GovernanceKey, IdentityKey}, + penumbra::core::component::stake::v1::Validator as PenumbraValidator, + DomainType, + }, + penumbra_shielded_pool::{genesis::Allocation, OutputPlan, SpendPlan}, + penumbra_stake::{component::validator_handler::ValidatorDataRead, DelegationToken}, + penumbra_transaction::{ + memo::MemoPlaintext, plan::MemoPlan, ActionPlan, TransactionParameters, TransactionPlan, + }, + rand::Rng, + rand_core::OsRng, + std::collections::BTreeMap, + tap::{Tap, TapFallible}, + tracing::{error_span, info, Instrument}, +}; + +mod common; + +const PROPOSAL_VOTING_BLOCKS: u64 = 3; + +/// Exercises that the app can disable proposals to spend community pool funds. +#[tokio::test] +async fn app_can_disable_community_pool_spends() -> anyhow::Result<()> { + // Install a test logger, and acquire some temporary storage. + let guard = common::set_tracing_subscriber(); + let storage = TempStorage::new().await?; + + // Define a helper to get the current community pool balance. + let pool_balance = || async { storage.latest_snapshot().community_pool_balance().await }; + let pending_pool_txs = || async { + storage + .latest_snapshot() + .pending_community_pool_transactions() + .await + }; + + // Generate a set of consensus keys. + let consensus_sk = ed25519_consensus::SigningKey::new(OsRng); + let consensus_vk = consensus_sk.verification_key(); + + // Generate a set of identity keys. + let spend_key: SpendKey = SpendKeyBytes(OsRng.gen()).into(); + let (identity_sk, identity_vk) = { + let sk = spend_key.spend_auth_key(); + let vk = VerificationKey::from(sk); + (sk, vk) + }; + let (governance_sk, governance_vk) = (identity_sk, identity_vk); + + // Define a validator and an associated genesis allocation. + let (validator, allocation) = { + let v = PenumbraValidator { + identity_key: Some(IdentityKey { + ik: identity_vk.to_bytes().to_vec(), + }), + // NB: for now, we will use the same key for governance. See the documentation of + // `GovernanceKey` for more information about cold storage of validator keys. + governance_key: Some(GovernanceKey { + gk: identity_vk.to_bytes().to_vec(), + }), + consensus_key: consensus_vk.as_bytes().to_vec(), + enabled: true, + sequence_number: 0, + name: String::default(), + website: String::default(), + description: String::default(), + funding_streams: Vec::default(), + }; + + let (address, _) = spend_key + .full_viewing_key() + .incoming() + .payment_address(0u32.into()); + + let ik = penumbra_stake::IdentityKey(identity_vk.into()); + let delegation_denom = DelegationToken::from(ik).denom(); + + let allocation = Allocation { + raw_amount: 1000u128.into(), + raw_denom: delegation_denom.to_string(), + address, + }; + + (v, allocation) + }; + + // Define our application state, and start the test node. + let mut test_node = { + let mut content = Content { + governance_content: penumbra_governance::genesis::Content { + governance_params: penumbra_governance::params::GovernanceParameters { + proposal_deposit_amount: 0_u32.into(), + proposal_voting_blocks: PROPOSAL_VOTING_BLOCKS, + ..Default::default() + }, + }, + community_pool_content: penumbra_community_pool::genesis::Content { + community_pool_params: penumbra_community_pool::params::CommunityPoolParameters { + // Disable community spend proposals. + community_pool_spend_proposals_enabled: false, + }, + }, + ..Default::default() + }; + content.stake_content.validators.push(validator); + content.shielded_pool_content.allocations.push(allocation); + let app_state = AppState::Content(content); + let app_state = serde_json::to_vec(&app_state).unwrap(); + let consensus = Consensus::new(storage.as_ref().clone()); + TestNode::builder() + .single_validator() + .app_state(app_state) + .init_chain(consensus) + .await + .tap_ok(|e| tracing::info!(hash = %e.last_app_hash_hex(), "finished init chain"))? + }; + let original_pool_balance = pool_balance().await?; + let [_validator] = storage + .latest_snapshot() + .validator_definitions() + .await? + .try_into() + .map_err(|validator| anyhow::anyhow!("expected one validator, got: {validator:?}"))?; + + // Sync the mock client, using the test wallet's spend key, to the latest snapshot. + let client = MockClient::new(test_keys::SPEND_KEY.clone()) + .with_sync_to_storage(&storage) + .await? + .tap(|c| info!(client.notes = %c.notes.len(), "mock client synced to test storage")); + + // Take one of the test wallet's notes, and prepare to deposit it in the community pool. + let note = client + .notes + .values() + .cloned() + .next() + .ok_or_else(|| anyhow!("mock client had no note"))?; + + // Create a community pool transaction. + let mut plan = { + let value = note.value(); + let spend = SpendPlan::new( + &mut OsRng, + note.clone(), + client + .position(note.commit()) + .ok_or_else(|| anyhow!("input note commitment was unknown to mock client"))?, + ) + .into(); + let deposit = CommunityPoolDeposit { value }.into(); + TransactionPlan { + actions: vec![spend, deposit], + // Now fill out the remaining parts of the transaction needed for verification: + memo: None, + detection_data: None, // We'll set this automatically below + transaction_parameters: TransactionParameters { + chain_id: TestNode::<()>::CHAIN_ID.to_string(), + ..Default::default() + }, + } + }; + plan.populate_detection_data(OsRng, 0); + let tx = client.witness_auth_build(&plan).await?; + + // Execute the transaction, applying it to the chain state. + test_node + .block() + .with_data(vec![tx.encode_to_vec()]) + .execute() + .instrument(error_span!("executing block with community pool deposit")) + .await?; + let post_deposit_pool_balance = pool_balance().await?; + + // Now, make a governance proposal that we should spend community pool funds, to return + // the note back to the test wallet. + let mut plan = { + let value = note.value(); + let proposed_tx_plan = TransactionPlan { + actions: vec![ + CommunityPoolSpend { value }.into(), + CommunityPoolOutput { + value, + address: *test_keys::ADDRESS_0, + } + .into(), + ], + memo: None, + detection_data: None, + transaction_parameters: TransactionParameters { + chain_id: TestNode::<()>::CHAIN_ID.to_string(), + ..Default::default() + }, + }; + let proposal_submit = ProposalSubmit { + proposal: Proposal { + id: 0_u64, + title: "return test deposit".to_owned(), + description: "a proposal to return the community pool deposit".to_owned(), + payload: penumbra_governance::ProposalPayload::CommunityPoolSpend { + transaction_plan: proposed_tx_plan.encode_to_vec(), + // transaction_plan: TransactionPlan::default().encode_to_vec(), + }, + }, + deposit_amount: 0_u32.into(), + }; + let proposal_nft_value = proposal_submit.proposal_nft_value(); + let proposal = ActionPlan::ProposalSubmit(proposal_submit); + TransactionPlan { + actions: vec![ + proposal, + // Next, create a new output of the exact same amount. + OutputPlan::new(&mut OsRng, proposal_nft_value, *test_keys::ADDRESS_0).into(), + ], + // Now fill out the remaining parts of the transaction needed for verification: + memo: Some(MemoPlan::new( + &mut OsRng, + MemoPlaintext::blank_memo(*test_keys::ADDRESS_0), + )?), + detection_data: None, + transaction_parameters: TransactionParameters { + chain_id: TestNode::<()>::CHAIN_ID.to_string(), + ..Default::default() + }, + } + }; + plan.populate_detection_data(OsRng, 0); + let tx = client.witness_auth_build(&plan).await?; + + // Execute the transaction, applying it to the chain state. + test_node + .block() + .with_data(vec![tx.encode_to_vec()]) + .execute() + .instrument(error_span!("executing block with governance proposal")) + .await?; + let post_proposal_pool_balance = pool_balance().await?; + let post_proposal_pending_txs = pending_pool_txs().await?; + let post_proposal_state = storage.latest_snapshot().proposal_state(0).await?; + + // Now make another transaction that will contain a validator vote upon our transaction. + let mut plan = { + let body = ValidatorVoteBody { + proposal: 0_u64, + vote: penumbra_governance::Vote::Yes, + identity_key: penumbra_stake::IdentityKey(identity_vk.to_bytes().into()), + governance_key: penumbra_stake::GovernanceKey(governance_vk), + reason: ValidatorVoteReason("test reason".to_owned()), + }; + let auth_sig = governance_sk.sign(OsRng, body.encode_to_vec().as_slice()); + let vote = ValidatorVote { body, auth_sig }.into(); + TransactionPlan { + actions: vec![vote], + memo: None, + detection_data: None, + transaction_parameters: TransactionParameters { + chain_id: TestNode::<()>::CHAIN_ID.to_string(), + ..Default::default() + }, + } + }; + plan.populate_detection_data(OsRng, 0); + let tx = client.witness_auth_build(&plan).await?; + + // Execute the transaction, applying it to the chain state. + test_node + .block() + .with_data(vec![tx.encode_to_vec()]) + .execute() + .instrument(error_span!("executing block with validator vote")) + .await?; + let post_vote_pool_balance = pool_balance().await?; + let post_vote_pending_txs = pending_pool_txs().await?; + let post_vote_state = storage.latest_snapshot().proposal_state(0).await?; + + test_node.fast_forward(PROPOSAL_VOTING_BLOCKS).await?; + let post_voting_period_pool_balance = pool_balance().await?; + let post_voting_period_pending_txs = pending_pool_txs().await?; + let post_voting_period_state = storage.latest_snapshot().proposal_state(0).await?; + + // At the outset, the pool should be empty. + assert_eq!( + original_pool_balance, + BTreeMap::default(), + "the community pool should be empty at the beginning of the chain" + ); + + // After we deposit a note into the community pool, we should see the original pool contents, + // plus the amount that we deposited. + assert_eq!( + [(note.asset_id(), note.amount())] + .into_iter() + .collect::>(), + post_deposit_pool_balance, + "a community pool deposit should be reflected in the visible balance, even if spends are disabled" + ); + + // A proposal should not itself affect the balance of the community pool. + assert_eq!( + post_deposit_pool_balance, post_proposal_pool_balance, + "the community pool balance should not be affected by a proposal" + ); + assert_eq!(post_proposal_state, None, "the proposal should be rejected"); + assert_eq!( + post_proposal_pending_txs.len(), + 0, + "no transaction(s) should be pending" + ); + + // ...nor should a vote by itself. + assert_eq!( + post_proposal_pool_balance, post_vote_pool_balance, + "the community pool balance should not be affected by a vote, even with quorum" + ); + assert_eq!( + post_vote_state, None, + "a vote for a rejected proposal should not cause it to enter the voting state" + ); + assert_eq!( + post_vote_pending_txs.len(), + 0, + "no transaction(s) should be pending" + ); + + // After any possible voting period, we should see the same pool balance. + assert_eq!( + post_voting_period_pool_balance, + [(note.asset_id(), note.amount())] + .into_iter() + .collect::>(), + "a rejected proposal should not decrease the funds of the community pool" + ); + assert_eq!( + post_voting_period_state, None, + "a proposal should be finished after the voting period completes" + ); + assert_eq!( + post_voting_period_pending_txs.len(), + 0, + "a proposal has been rejected, no transaction(s) are pending" + ); + + // Free our temporary storage. + Ok(()) + .tap(|_| drop(test_node)) + .tap(|_| drop(storage)) + .tap(|_| drop(guard)) +} diff --git a/crates/core/app/tests/app_can_propose_community_pool_spends.rs b/crates/core/app/tests/app_can_propose_community_pool_spends.rs new file mode 100644 index 0000000000..80d43d450e --- /dev/null +++ b/crates/core/app/tests/app_can_propose_community_pool_spends.rs @@ -0,0 +1,378 @@ +use { + anyhow::anyhow, + cnidarium::TempStorage, + decaf377_rdsa::VerificationKey, + penumbra_app::{ + genesis::{AppState, Content}, + server::consensus::Consensus, + CommunityPoolStateReadExt as _, + }, + penumbra_community_pool::{ + CommunityPoolDeposit, CommunityPoolOutput, CommunityPoolSpend, StateReadExt as _, + }, + penumbra_governance::{ + Proposal, ProposalSubmit, StateReadExt as _, ValidatorVote, ValidatorVoteBody, + ValidatorVoteReason, + }, + penumbra_keys::{ + keys::{SpendKey, SpendKeyBytes}, + test_keys::{self}, + }, + penumbra_mock_client::MockClient, + penumbra_mock_consensus::TestNode, + penumbra_proto::{ + core::keys::v1::{GovernanceKey, IdentityKey}, + penumbra::core::component::stake::v1::Validator as PenumbraValidator, + DomainType, + }, + penumbra_shielded_pool::{genesis::Allocation, OutputPlan, SpendPlan}, + penumbra_stake::{component::validator_handler::ValidatorDataRead, DelegationToken}, + penumbra_transaction::{ + memo::MemoPlaintext, plan::MemoPlan, ActionPlan, TransactionParameters, TransactionPlan, + }, + rand::Rng, + rand_core::OsRng, + std::collections::BTreeMap, + tap::{Tap, TapFallible}, + tracing::{error_span, info, Instrument}, +}; + +mod common; + +const PROPOSAL_VOTING_BLOCKS: u64 = 3; + +/// Exercises that the app can make proposals to spend community pool funds. +#[tokio::test] +async fn app_can_propose_community_pool_spends() -> anyhow::Result<()> { + // Install a test logger, and acquire some temporary storage. + let guard = common::set_tracing_subscriber(); + let storage = TempStorage::new().await?; + + // Define a helper to get the current community pool balance. + let pool_balance = || async { storage.latest_snapshot().community_pool_balance().await }; + let pending_pool_txs = || async { + storage + .latest_snapshot() + .pending_community_pool_transactions() + .await + }; + + // Generate a set of consensus keys. + let consensus_sk = ed25519_consensus::SigningKey::new(OsRng); + let consensus_vk = consensus_sk.verification_key(); + + // Generate a set of identity keys. + let spend_key: SpendKey = SpendKeyBytes(OsRng.gen()).into(); + let (identity_sk, identity_vk) = { + let sk = spend_key.spend_auth_key(); + let vk = VerificationKey::from(sk); + (sk, vk) + }; + let (governance_sk, governance_vk) = (identity_sk, identity_vk); + + // Define a validator and an associated genesis allocation. + let (validator, allocation) = { + let v = PenumbraValidator { + identity_key: Some(IdentityKey { + ik: identity_vk.to_bytes().to_vec(), + }), + // NB: for now, we will use the same key for governance. See the documentation of + // `GovernanceKey` for more information about cold storage of validator keys. + governance_key: Some(GovernanceKey { + gk: identity_vk.to_bytes().to_vec(), + }), + consensus_key: consensus_vk.as_bytes().to_vec(), + enabled: true, + sequence_number: 0, + name: String::default(), + website: String::default(), + description: String::default(), + funding_streams: Vec::default(), + }; + + let (address, _) = spend_key + .full_viewing_key() + .incoming() + .payment_address(0u32.into()); + + let ik = penumbra_stake::IdentityKey(identity_vk.into()); + let delegation_denom = DelegationToken::from(ik).denom(); + + let allocation = Allocation { + raw_amount: 1000u128.into(), + raw_denom: delegation_denom.to_string(), + address, + }; + + (v, allocation) + }; + + // Define our application state, and start the test node. + let mut test_node = { + let mut content = Content { + governance_content: penumbra_governance::genesis::Content { + governance_params: penumbra_governance::params::GovernanceParameters { + proposal_deposit_amount: 0_u32.into(), + proposal_voting_blocks: PROPOSAL_VOTING_BLOCKS, + ..Default::default() + }, + }, + ..Default::default() + }; + content.stake_content.validators.push(validator); + content.shielded_pool_content.allocations.push(allocation); + let app_state = AppState::Content(content); + let app_state = serde_json::to_vec(&app_state).unwrap(); + let consensus = Consensus::new(storage.as_ref().clone()); + TestNode::builder() + .single_validator() + .app_state(app_state) + .init_chain(consensus) + .await + .tap_ok(|e| tracing::info!(hash = %e.last_app_hash_hex(), "finished init chain"))? + }; + let original_pool_balance = pool_balance().await?; + let [_validator] = storage + .latest_snapshot() + .validator_definitions() + .await? + .try_into() + .map_err(|validator| anyhow::anyhow!("expected one validator, got: {validator:?}"))?; + + // Sync the mock client, using the test wallet's spend key, to the latest snapshot. + let client = MockClient::new(test_keys::SPEND_KEY.clone()) + .with_sync_to_storage(&storage) + .await? + .tap(|c| info!(client.notes = %c.notes.len(), "mock client synced to test storage")); + + // Take one of the test wallet's notes, and prepare to deposit it in the community pool. + let note = client + .notes + .values() + .cloned() + .next() + .ok_or_else(|| anyhow!("mock client had no note"))?; + + // Create a community pool transaction. + let mut plan = { + let value = note.value(); + let spend = SpendPlan::new( + &mut OsRng, + note.clone(), + client + .position(note.commit()) + .ok_or_else(|| anyhow!("input note commitment was unknown to mock client"))?, + ) + .into(); + let deposit = CommunityPoolDeposit { value }.into(); + TransactionPlan { + actions: vec![spend, deposit], + // Now fill out the remaining parts of the transaction needed for verification: + memo: None, + detection_data: None, // We'll set this automatically below + transaction_parameters: TransactionParameters { + chain_id: TestNode::<()>::CHAIN_ID.to_string(), + ..Default::default() + }, + } + }; + plan.populate_detection_data(OsRng, 0); + let tx = client.witness_auth_build(&plan).await?; + + // Execute the transaction, applying it to the chain state. + test_node + .block() + .with_data(vec![tx.encode_to_vec()]) + .execute() + .instrument(error_span!("executing block with community pool deposit")) + .await?; + let post_deposit_pool_balance = pool_balance().await?; + + // Now, make a governance proposal that we should spend community pool funds, to return + // the note back to the test wallet. + let mut plan = { + let value = note.value(); + let proposed_tx_plan = TransactionPlan { + actions: vec![ + CommunityPoolSpend { value }.into(), + CommunityPoolOutput { + value, + address: *test_keys::ADDRESS_0, + } + .into(), + ], + memo: None, + detection_data: None, + transaction_parameters: TransactionParameters { + chain_id: TestNode::<()>::CHAIN_ID.to_string(), + ..Default::default() + }, + }; + let proposal_submit = ProposalSubmit { + proposal: Proposal { + id: 0_u64, + title: "return test deposit".to_owned(), + description: "a proposal to return the community pool deposit".to_owned(), + payload: penumbra_governance::ProposalPayload::CommunityPoolSpend { + transaction_plan: proposed_tx_plan.encode_to_vec(), + // transaction_plan: TransactionPlan::default().encode_to_vec(), + }, + }, + deposit_amount: 0_u32.into(), + }; + let proposal_nft_value = proposal_submit.proposal_nft_value(); + let proposal = ActionPlan::ProposalSubmit(proposal_submit); + TransactionPlan { + actions: vec![ + proposal, + // Next, create a new output of the exact same amount. + OutputPlan::new(&mut OsRng, proposal_nft_value, *test_keys::ADDRESS_0).into(), + ], + // Now fill out the remaining parts of the transaction needed for verification: + memo: Some(MemoPlan::new( + &mut OsRng, + MemoPlaintext::blank_memo(*test_keys::ADDRESS_0), + )?), + detection_data: None, + transaction_parameters: TransactionParameters { + chain_id: TestNode::<()>::CHAIN_ID.to_string(), + ..Default::default() + }, + } + }; + plan.populate_detection_data(OsRng, 0); + let tx = client.witness_auth_build(&plan).await?; + + // Execute the transaction, applying it to the chain state. + test_node + .block() + .with_data(vec![tx.encode_to_vec()]) + .execute() + .instrument(error_span!("executing block with governance proposal")) + .await?; + let post_proposal_pool_balance = pool_balance().await?; + let post_proposal_pending_txs = pending_pool_txs().await?; + let post_proposal_state = storage.latest_snapshot().proposal_state(0).await?; + + // Now make another transaction that will contain a validator vote upon our transaction. + let mut plan = { + let body = ValidatorVoteBody { + proposal: 0_u64, + vote: penumbra_governance::Vote::Yes, + identity_key: penumbra_stake::IdentityKey(identity_vk.to_bytes().into()), + governance_key: penumbra_stake::GovernanceKey(governance_vk), + reason: ValidatorVoteReason("test reason".to_owned()), + }; + let auth_sig = governance_sk.sign(OsRng, body.encode_to_vec().as_slice()); + let vote = ValidatorVote { body, auth_sig }.into(); + TransactionPlan { + actions: vec![vote], + memo: None, + detection_data: None, + transaction_parameters: TransactionParameters { + chain_id: TestNode::<()>::CHAIN_ID.to_string(), + ..Default::default() + }, + } + }; + plan.populate_detection_data(OsRng, 0); + let tx = client.witness_auth_build(&plan).await?; + + // Execute the transaction, applying it to the chain state. + test_node + .block() + .with_data(vec![tx.encode_to_vec()]) + .execute() + .instrument(error_span!("executing block with validator vote")) + .await?; + let post_vote_pool_balance = pool_balance().await?; + let post_vote_pending_txs = pending_pool_txs().await?; + let post_vote_state = storage.latest_snapshot().proposal_state(0).await?; + + test_node.fast_forward(PROPOSAL_VOTING_BLOCKS).await?; + let post_voting_period_pool_balance = pool_balance().await?; + let post_voting_period_pending_txs = pending_pool_txs().await?; + let post_voting_period_state = storage.latest_snapshot().proposal_state(0).await?; + + // At the outset, the pool should be empty. + assert_eq!( + original_pool_balance, + BTreeMap::default(), + "the community pool should be empty at the beginning of the chain" + ); + + // After we deposit a note into the community pool, we should see the original pool contents, + // plus the amount that we deposited. + assert_eq!( + [(note.asset_id(), note.amount())] + .into_iter() + .collect::>(), + post_deposit_pool_balance, + "a community pool deposit should be reflected in the visible balance" + ); + + // A proposal should not itself affect the balance of the community pool. + assert_eq!( + post_deposit_pool_balance, post_proposal_pool_balance, + "the community pool balance should not be affected by a proposal" + ); + assert_eq!( + post_proposal_state, + Some(penumbra_governance::proposal_state::State::Voting), + "a new proposal should be in the voting phase" + ); + assert_eq!( + post_proposal_pending_txs.len(), + 0, + "a proposal is being voted upon, but its transaction(s) are not pending yet" + ); + + // ...nor should a vote by itself. + assert_eq!( + post_proposal_pool_balance, post_vote_pool_balance, + "the community pool balance should not be affected by a vote, even with quorum" + ); + assert_eq!( + post_vote_state, + Some(penumbra_governance::proposal_state::State::Voting), + "a proposal should remain in the voting phase" + ); + assert_eq!( + post_vote_pending_txs.len(), + 0, + "a proposal is being voted upon, but its transaction(s) are not pending yet" + ); + + // After the proposal passes, we should see the balance decrease by the amount proposed. + assert_eq!( + post_voting_period_pool_balance, + BTreeMap::default(), + "the successful proposal should decrease the funds of the community pool" + ); + assert_eq!( + post_voting_period_state, + Some(penumbra_governance::proposal_state::State::Finished { + outcome: penumbra_governance::proposal_state::Outcome::Passed, + }), + "a proposal should be finished after the voting period completes" + ); + assert_eq!( + post_voting_period_pending_txs.len(), + 1, + "a proposal has finished, its transaction(s) are pending" + ); + + // Move forward one block, and show that the transaction is no longer pending. + test_node.block().execute().await?; + assert_eq!( + pending_pool_txs().await?.len(), + 0, + "the community pool spend is no longer pending" + ); + + // Free our temporary storage. + Ok(()) + .tap(|_| drop(test_node)) + .tap(|_| drop(storage)) + .tap(|_| drop(guard)) +} diff --git a/crates/core/component/governance/src/proposal_submit/action.rs b/crates/core/component/governance/src/proposal_submit/action.rs index 6356ac14ee..820a0e6f81 100644 --- a/crates/core/component/governance/src/proposal_submit/action.rs +++ b/crates/core/component/governance/src/proposal_submit/action.rs @@ -30,21 +30,30 @@ impl EffectingData for ProposalSubmit { impl ProposalSubmit { /// Compute a commitment to the value contributed to a transaction by this proposal submission. pub fn balance(&self) -> Balance { - let deposit = Value { - amount: self.deposit_amount, - asset_id: *STAKING_TOKEN_ASSET_ID, - }; - - let proposal_nft = Value { - amount: Amount::from(1u64), - asset_id: ProposalNft::deposit(self.proposal.id).denom().into(), - }; + let deposit = self.deposit_value(); + let proposal_nft = self.proposal_nft_value(); // Proposal submissions *require* the deposit amount in order to be accepted, so they // contribute (-deposit) to the value balance of the transaction, and they contribute a // single proposal NFT to the value balance: Balance::from(proposal_nft) - Balance::from(deposit) } + + /// Returns the [`Value`] of this proposal submission's deposit. + fn deposit_value(&self) -> Value { + Value { + amount: self.deposit_amount, + asset_id: *STAKING_TOKEN_ASSET_ID, + } + } + + /// Returns the [`Value`] of the proposal NFT. + pub fn proposal_nft_value(&self) -> Value { + Value { + amount: Amount::from(1u64), + asset_id: ProposalNft::deposit(self.proposal.id).denom().into(), + } + } } impl From for pb::ProposalSubmit { diff --git a/crates/core/component/governance/src/proposal_withdraw/action.rs b/crates/core/component/governance/src/proposal_withdraw/action.rs index 387868ecd1..2d5b54f0d0 100644 --- a/crates/core/component/governance/src/proposal_withdraw/action.rs +++ b/crates/core/component/governance/src/proposal_withdraw/action.rs @@ -33,19 +33,29 @@ impl From for pb::ProposalWithdraw { } impl ProposalWithdraw { - /// Compute a commitment to the value contributed to a transaction by this proposal submission. + /// Compute a commitment to the value contributed to a transaction by this proposal withdrawal. pub fn balance(&self) -> Balance { - let voting_proposal_nft = Value { + let voting_proposal_nft = self.voting_proposal_nft_value(); + let withdrawn_proposal_nft = self.withdrawn_proposal_nft(); + + // Proposal withdrawals consume the submitted proposal and produce a withdrawn proposal: + Balance::from(withdrawn_proposal_nft) - Balance::from(voting_proposal_nft) + } + + /// Returns the [`Value`] of the proposal NFT. + fn voting_proposal_nft_value(&self) -> Value { + Value { amount: Amount::from(1u64), asset_id: ProposalNft::deposit(self.proposal).denom().into(), - }; - let withdrawn_proposal_nft = Value { + } + } + + /// Returns a withdrawal NFT. + fn withdrawn_proposal_nft(&self) -> Value { + Value { amount: Amount::from(1u64), asset_id: ProposalNft::unbonding_deposit(self.proposal).denom().into(), - }; - - // Proposal withdrawals consume the submitted proposal and produce a withdrawn proposal: - Balance::from(withdrawn_proposal_nft) - Balance::from(voting_proposal_nft) + } } } diff --git a/crates/core/num/src/amount.rs b/crates/core/num/src/amount.rs index c5e47afe67..2170c8b100 100644 --- a/crates/core/num/src/amount.rs +++ b/crates/core/num/src/amount.rs @@ -394,6 +394,12 @@ impl ops::Sub for Amount { } } +impl ops::SubAssign for Amount { + fn sub_assign(&mut self, rhs: Amount) { + self.inner -= rhs.inner; + } +} + impl ops::Rem for Amount { type Output = Amount; diff --git a/crates/core/transaction/src/plan/action.rs b/crates/core/transaction/src/plan/action.rs index 5d3039375d..69157c91f7 100644 --- a/crates/core/transaction/src/plan/action.rs +++ b/crates/core/transaction/src/plan/action.rs @@ -355,6 +355,24 @@ impl From for ActionPlan { } } +impl From for ActionPlan { + fn from(inner: CommunityPoolSpend) -> ActionPlan { + ActionPlan::CommunityPoolSpend(inner) + } +} + +impl From for ActionPlan { + fn from(inner: CommunityPoolOutput) -> ActionPlan { + ActionPlan::CommunityPoolOutput(inner) + } +} + +impl From for ActionPlan { + fn from(inner: CommunityPoolDeposit) -> ActionPlan { + ActionPlan::CommunityPoolDeposit(inner) + } +} + impl From for ActionPlan { fn from(inner: Ics20Withdrawal) -> ActionPlan { ActionPlan::Ics20Withdrawal(inner)