diff --git a/Cargo.lock b/Cargo.lock index 951f142cdc..c083ca9054 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5811,6 +5811,7 @@ dependencies = [ "cometindex", "penumbra-app", "penumbra-asset", + "penumbra-governance", "penumbra-num", "penumbra-proto", "penumbra-shielded-pool", diff --git a/crates/bin/pindexer/Cargo.toml b/crates/bin/pindexer/Cargo.toml index 958b49ddd5..0d0edfcb7a 100644 --- a/crates/bin/pindexer/Cargo.toml +++ b/crates/bin/pindexer/Cargo.toml @@ -14,6 +14,7 @@ publish = false cometindex = {workspace = true} penumbra-shielded-pool = {workspace = true, default-features = false} penumbra-stake = {workspace = true, default-features = false} +penumbra-governance = {workspace = true, default-features = false} penumbra-app = {workspace = true, default-features = false} penumbra-num = {workspace = true, default-features = false} penumbra-asset = {workspace = true, default-features = false} diff --git a/crates/bin/pindexer/src/governance.rs b/crates/bin/pindexer/src/governance.rs new file mode 100644 index 0000000000..f545a9f241 --- /dev/null +++ b/crates/bin/pindexer/src/governance.rs @@ -0,0 +1,371 @@ +use anyhow::{anyhow, Context, Result}; +use cometindex::{async_trait, sqlx, AppView, ContextualizedEvent, PgTransaction}; +use penumbra_governance::{ + proposal::ProposalPayloadToml, DelegatorVote, Proposal, ProposalDepositClaim, ProposalWithdraw, + ValidatorVote, Vote, +}; +use penumbra_proto::{ + core::component::{governance::v1 as pb, sct::v1 as sct_pb}, + event::ProtoEvent, +}; +use penumbra_stake::IdentityKey; + +#[derive(Debug)] +pub struct GovernanceProposals {} + +#[async_trait] +impl AppView for GovernanceProposals { + async fn init_chain( + &self, + dbtx: &mut PgTransaction, + _app_state: &serde_json::Value, + ) -> Result<(), anyhow::Error> { + sqlx::query(include_str!("governance/schema.sql")) + .execute(dbtx.as_mut()) + .await?; + + // TODO: If there are any governance-related genesis data, handle it here + // let app_state: penumbra_app::genesis::AppState = + // serde_json::from_value(app_state.clone()).context("error decoding app_state json")?; + // add_genesis_proposals(dbtx, &app_state).await?; + + Ok(()) + } + + fn is_relevant(&self, type_str: &str) -> bool { + matches!( + type_str, + "penumbra.core.component.governance.v1.EventProposalSubmit" + | "penumbra.core.component.governance.v1.EventDelegatorVote" + | "penumbra.core.component.governance.v1.EventValidatorVote" + | "penumbra.core.component.governance.v1.EventProposalWithdraw" + | "penumbra.core.component.governance.v1.EventProposalPassed" + | "penumbra.core.component.governance.v1.EventProposalFailed" + | "penumbra.core.component.governance.v1.EventProposalSlashed" + | "penumbra.core.component.governance.v1.EventProposalDepositClaim" + | "penumbra.core.component.sct.v1.EventBlockRoot" + ) + } + + async fn index_event( + &self, + dbtx: &mut PgTransaction, + event: &ContextualizedEvent, + ) -> Result<(), anyhow::Error> { + match event.event.kind.as_str() { + "penumbra.core.component.governance.v1.EventProposalSubmit" => { + let pe = pb::EventProposalSubmit::from_event(event.as_ref())?; + let proposal = pe + .submit + .ok_or_else(|| anyhow!("missing submit in event"))? + .proposal + .ok_or_else(|| anyhow!("missing proposal in event"))? + .try_into() + .context("error converting proposal")?; + handle_proposal_submit(dbtx, proposal, event.block_height).await?; + } + "penumbra.core.component.governance.v1.EventDelegatorVote" => { + let pe = pb::EventDelegatorVote::from_event(event.as_ref())?; + let vote = pe + .vote + .ok_or_else(|| anyhow!("missing vote in event"))? + .try_into() + .context("error converting delegator vote")?; + let validator_identity_key = pe + .validator_identity_key + .ok_or_else(|| anyhow!("missing validator identity key in event"))? + .try_into() + .context("error converting validator identity key")?; + handle_delegator_vote(dbtx, vote, validator_identity_key, event.block_height) + .await?; + } + "penumbra.core.component.governance.v1.EventValidatorVote" => { + let pe = pb::EventValidatorVote::from_event(event.as_ref())?; + let vote = pe + .vote + .ok_or_else(|| anyhow!("missing vote in event"))? + .try_into() + .context("error converting vote")?; + handle_validator_vote(dbtx, vote, event.block_height).await?; + } + "penumbra.core.component.governance.v1.EventProposalWithdraw" => { + let pe = pb::EventProposalWithdraw::from_event(event.as_ref())?; + let proposal_withdraw: ProposalWithdraw = pe + .withdraw + .ok_or_else(|| anyhow!("missing withdraw in event"))? + .try_into() + .context("error converting proposal withdraw")?; + let proposal = proposal_withdraw.proposal; + let reason = proposal_withdraw.reason; + handle_proposal_withdraw(dbtx, proposal, reason).await?; + } + "penumbra.core.component.governance.v1.EventProposalPassed" => { + let pe = pb::EventProposalPassed::from_event(event.as_ref())?; + let proposal = pe + .proposal + .ok_or_else(|| anyhow!("missing proposal in event"))? + .try_into() + .context("error converting proposal")?; + handle_proposal_passed(dbtx, proposal).await?; + } + "penumbra.core.component.governance.v1.EventProposalFailed" => { + let pe = pb::EventProposalFailed::from_event(event.as_ref())?; + let proposal = pe + .proposal + .ok_or_else(|| anyhow!("missing proposal in event"))? + .try_into() + .context("error converting proposal")?; + handle_proposal_failed(dbtx, proposal).await?; + } + "penumbra.core.component.governance.v1.EventProposalSlashed" => { + let pe = pb::EventProposalSlashed::from_event(event.as_ref())?; + let proposal = pe + .proposal + .ok_or_else(|| anyhow!("missing proposal in event"))? + .try_into() + .context("error converting proposal")?; + handle_proposal_slashed(dbtx, proposal).await?; + } + "penumbra.core.component.governance.v1.EventProposalDepositClaim" => { + let pe = pb::EventProposalDepositClaim::from_event(event.as_ref())?; + let deposit_claim = pe + .deposit_claim + .ok_or_else(|| anyhow!("missing deposit claim in event"))? + .try_into() + .context("error converting deposit claim")?; + handle_proposal_deposit_claim(dbtx, deposit_claim).await?; + } + "penumbra.core.component.sct.v1.EventBlockRoot" => { + let pe = sct_pb::EventBlockRoot::from_event(event.as_ref())?; + handle_block_root(dbtx, pe.height).await?; + } + _ => {} + } + + Ok(()) + } +} + +async fn handle_proposal_submit( + dbtx: &mut PgTransaction<'_>, + proposal: Proposal, + block_height: u64, +) -> Result<()> { + use penumbra_governance::ProposalKind::*; + let payload_type = match proposal.kind() { + Signaling => "SIGNALING", + Emergency => "EMERGENCY", + ParameterChange => "PARAMETER_CHANGE", + CommunityPoolSpend => "COMMUNITY_POOL_SPEND", + UpgradePlan => "UPGRADE_PLAN", + FreezeIbcClient => "FREEZE_IBC_CLIENT", + UnfreezeIbcClient => "UNFREEZE_IBC_CLIENT", + }; + let proposal_data = + serde_json::to_value(ProposalPayloadToml::from(proposal.payload)).expect("can serialize"); + sqlx::query( + "INSERT INTO governance_proposals ( + proposal_id, title, description, payload_type, payload_data, + start_block_height, stage, status + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + ) + .bind(proposal.id as i32) + .bind(&proposal.title) + .bind(&proposal.description) + .bind(payload_type) + .bind(proposal_data) + .bind(block_height as i64) + .bind("VOTING") + .bind("VOTING") + .execute(dbtx.as_mut()) + .await?; + + Ok(()) +} + +async fn handle_delegator_vote( + dbtx: &mut PgTransaction<'_>, + vote: DelegatorVote, + validator_identity_key: IdentityKey, + block_height: u64, +) -> Result<()> { + let vote_body = vote.body; + let vote = match vote_body.vote { + Vote::Yes => "YES", + Vote::No => "NO", + Vote::Abstain => "ABSTAIN", + }; + + sqlx::query( + "INSERT INTO delegator_votes ( + proposal_id, validator_identity_key, vote, voting_power, block_height + ) + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(vote_body.proposal as i32) + .bind(validator_identity_key.to_string()) + .bind(vote) + .bind(vote_body.unbonded_amount.value() as i64) + .bind(block_height as i64) + .execute(dbtx.as_mut()) + .await?; + + Ok(()) +} + +async fn handle_validator_vote( + dbtx: &mut PgTransaction<'_>, + vote: ValidatorVote, + block_height: u64, +) -> Result<()> { + let vote_body = vote.body; + let vote = match vote_body.vote { + Vote::Yes => "YES", + Vote::No => "NO", + Vote::Abstain => "ABSTAIN", + }; + + sqlx::query( + "INSERT INTO validator_votes ( + proposal_id, identity_key, vote, voting_power, block_height + ) + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(vote_body.proposal as i32) + .bind(&vote_body.identity_key.to_string()) + .bind(vote) + .bind(0i64) // Voting power for validators is not included in the vote event + .bind(block_height as i64) + .execute(dbtx.as_mut()) + .await?; + + Ok(()) +} + +async fn handle_proposal_withdraw( + dbtx: &mut PgTransaction<'_>, + proposal_id: u64, + reason: String, +) -> Result<()> { + sqlx::query( + "UPDATE governance_proposals + SET + status = $1, + is_withdrawn = $2, + withdrawal_reason = $3 + WHERE proposal_id = $4", + ) + .bind("WITHDRAWN") + .bind(true) + .bind(reason) + .bind(proposal_id as i32) + .execute(dbtx.as_mut()) + .await?; + + Ok(()) +} + +async fn handle_proposal_passed(dbtx: &mut PgTransaction<'_>, proposal: Proposal) -> Result<()> { + sqlx::query( + "UPDATE governance_proposals + SET + stage = $1, + status = $2, + outcome = $3 + WHERE proposal_id = $4", + ) + .bind("FINISHED") + .bind("FINISHED") + .bind("PASSED") + .bind(proposal.id as i32) + .execute(dbtx.as_mut()) + .await?; + + Ok(()) +} + +async fn handle_proposal_failed(dbtx: &mut PgTransaction<'_>, proposal: Proposal) -> Result<()> { + sqlx::query( + "UPDATE governance_proposals + SET + stage = $1, + status = $2, + outcome = $3 + WHERE proposal_id = $4", + ) + .bind("FINISHED") + .bind("FINISHED") + .bind("FAILED") + .bind(proposal.id as i32) + .execute(dbtx.as_mut()) + .await?; + + Ok(()) +} + +async fn handle_proposal_slashed(dbtx: &mut PgTransaction<'_>, proposal: Proposal) -> Result<()> { + sqlx::query( + "UPDATE governance_proposals + SET + stage = $1, + status = $2, + outcome = $3 + WHERE proposal_id = $4", + ) + .bind("FINISHED") + .bind("FINISHED") + .bind("SLASHED") + .bind(proposal.id as i32) + .execute(dbtx.as_mut()) + .await?; + + Ok(()) +} + +async fn handle_proposal_deposit_claim( + dbtx: &mut PgTransaction<'_>, + deposit_claim: ProposalDepositClaim, +) -> Result<()> { + sqlx::query( + "UPDATE governance_proposals + SET + status = $1 + WHERE proposal_id = $2 AND status = $3", + ) + .bind("CLAIMED") + .bind(deposit_claim.proposal as i32) + .bind("FINISHED") + .execute(dbtx.as_mut()) + .await?; + + // Check if the update was successful + if sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM governance_proposals WHERE proposal_id = $1 AND status = $2", + ) + .bind(deposit_claim.proposal as i32) + .bind("CLAIMED") + .fetch_one(dbtx.as_mut()) + .await? + == 0 + { + anyhow::bail!("Failed to update proposal status to CLAIMED. The proposal might not exist or wasn't in the FINISHED state."); + } + + Ok(()) +} + +async fn handle_block_root(dbtx: &mut PgTransaction<'_>, height: u64) -> Result<()> { + sqlx::query( + "INSERT INTO current_block_height (height) + VALUES ($1) + ON CONFLICT (height) DO UPDATE + SET height = EXCLUDED.height + WHERE EXCLUDED.height > current_block_height.height", + ) + .bind(height as i64) + .execute(dbtx.as_mut()) + .await?; + + Ok(()) +} diff --git a/crates/bin/pindexer/src/governance/schema.sql b/crates/bin/pindexer/src/governance/schema.sql new file mode 100644 index 0000000000..7efcd587d2 --- /dev/null +++ b/crates/bin/pindexer/src/governance/schema.sql @@ -0,0 +1,75 @@ +CREATE TYPE proposal_stage AS ENUM ('VOTING', 'FINISHED'); +CREATE TYPE proposal_status AS ENUM ('VOTING', 'WITHDRAWN', 'FINISHED', 'CLAIMED'); +CREATE TYPE payload_type AS ENUM ('SIGNALING', 'EMERGENCY', 'PARAMETER_CHANGE', 'COMMUNITY_POOL_SPEND', 'UPGRADE_PLAN', 'FREEZE_IBC_CLIENT', 'UNFREEZE_IBC_CLIENT'); +CREATE TYPE vote_type AS ENUM ('ABSTAIN', 'YES', 'NO'); +CREATE TYPE proposal_outcome AS ENUM ('PASSED', 'FAILED', 'SLASHED'); + +CREATE TABLE governance_proposals ( +id SERIAL PRIMARY KEY, +proposal_id INTEGER NOT NULL, +title TEXT NOT NULL, +description TEXT, +payload_type payload_type NOT NULL, +payload_data JSONB, +start_block_height BIGINT, +end_block_height BIGINT, +start_position BIGINT, +stage proposal_stage NOT NULL, +status proposal_status NOT NULL, +proposal_deposit_amount BIGINT, +outcome proposal_outcome, +is_withdrawn BOOLEAN DEFAULT FALSE, +withdrawal_reason TEXT, +CONSTRAINT check_proposal_id CHECK (proposal_id >= 0), +CONSTRAINT check_start_block_height CHECK (start_block_height >= 0), +CONSTRAINT check_end_block_height CHECK (end_block_height >= 0), +CONSTRAINT check_start_position CHECK (start_position >= 0), +CONSTRAINT check_proposal_deposit_amount CHECK (proposal_deposit_amount >= 0), +CONSTRAINT check_withdrawal_consistency CHECK ( + (is_withdrawn = TRUE AND withdrawal_reason IS NOT NULL) OR + (is_withdrawn = FALSE AND withdrawal_reason IS NULL) +), +CONSTRAINT check_proposal_outcome CHECK ( + (stage = 'FINISHED' AND outcome IS NOT NULL) OR + (stage = 'VOTING' AND outcome IS NULL) +), +CONSTRAINT check_stage_status_consistency CHECK ( + (stage = 'VOTING' AND status IN ('VOTING', 'WITHDRAWN')) OR + (stage = 'FINISHED' AND status IN ('FINISHED', 'CLAIMED')) +), +CONSTRAINT check_outcome_withdrawal_consistency CHECK ( + (is_withdrawn = FALSE AND outcome = 'PASSED') OR + (is_withdrawn = TRUE AND (outcome != 'PASSED' OR outcome IS NULL)) +) +); + +CREATE TABLE validator_votes ( +id SERIAL PRIMARY KEY, +proposal_id INTEGER NOT NULL, +identity_key TEXT NOT NULL, +vote vote_type NOT NULL, +voting_power BIGINT NOT NULL, +block_height BIGINT NOT NULL, +FOREIGN KEY (proposal_id) REFERENCES governance_proposals(proposal_id), +CONSTRAINT check_voting_power CHECK (voting_power >= 0), +CONSTRAINT check_block_height CHECK (block_height >= 0) +); + +CREATE TABLE delegator_votes ( +id SERIAL PRIMARY KEY, +proposal_id INTEGER NOT NULL, +validator_identity_key TEXT NOT NULL, +vote vote_type NOT NULL, +voting_power BIGINT NOT NULL, +block_height BIGINT NOT NULL, +FOREIGN KEY (proposal_id) REFERENCES governance_proposals(proposal_id), +CONSTRAINT check_voting_power CHECK (voting_power >= 0), +CONSTRAINT check_block_height CHECK (block_height >= 0) +); + +CREATE TABLE current_block_height ( +height BIGINT NOT NULL +); + +INSERT INTO current_block_height (height) VALUES (0) +ON CONFLICT (height) DO NOTHING; \ No newline at end of file diff --git a/crates/bin/pindexer/src/indexer_ext.rs b/crates/bin/pindexer/src/indexer_ext.rs index 693924626f..426dffd70b 100644 --- a/crates/bin/pindexer/src/indexer_ext.rs +++ b/crates/bin/pindexer/src/indexer_ext.rs @@ -10,5 +10,6 @@ impl IndexerExt for cometindex::Indexer { .with_index(crate::stake::MissedBlocks {}) .with_index(crate::stake::DelegationTxs {}) .with_index(crate::stake::UndelegationTxs {}) + .with_index(crate::governance::GovernanceProposals {}) } } diff --git a/crates/bin/pindexer/src/lib.rs b/crates/bin/pindexer/src/lib.rs index 11b0ac6603..4be3ceb368 100644 --- a/crates/bin/pindexer/src/lib.rs +++ b/crates/bin/pindexer/src/lib.rs @@ -6,3 +6,5 @@ pub use indexer_ext::IndexerExt; pub mod shielded_pool; pub mod stake; + +pub mod governance;