pindexer/
governance.rs

1use anyhow::{anyhow, Context, Result};
2use cometindex::{
3    async_trait,
4    index::{EventBatch, EventBatchContext},
5    sqlx, AppView, ContextualizedEvent, PgTransaction,
6};
7use penumbra_sdk_governance::{
8    proposal::ProposalPayloadToml, proposal_state, DelegatorVote, Proposal, ProposalDepositClaim,
9    ProposalWithdraw, ValidatorVote,
10};
11use penumbra_sdk_num::Amount;
12use penumbra_sdk_proto::{
13    core::component::governance::v1::{self as pb},
14    event::ProtoEvent,
15};
16use penumbra_sdk_stake::IdentityKey;
17
18#[derive(Debug)]
19pub struct GovernanceProposals {}
20
21const EVENT_PROPOSAL_SUBMIT: &str = "penumbra.core.component.governance.v1.EventProposalSubmit";
22const EVENT_DELEGATOR_VOTE: &str = "penumbra.core.component.governance.v1.EventDelegatorVote";
23const EVENT_VALIDATOR_VOTE: &str = "penumbra.core.component.governance.v1.EventValidatorVote";
24const EVENT_PROPOSAL_WITHDRAW: &str = "penumbra.core.component.governance.v1.EventProposalWithdraw";
25const EVENT_PROPOSAL_PASSED: &str = "penumbra.core.component.governance.v1.EventProposalPassed";
26const EVENT_PROPOSAL_FAILED: &str = "penumbra.core.component.governance.v1.EventProposalFailed";
27const EVENT_PROPOSAL_SLASHED: &str = "penumbra.core.component.governance.v1.EventProposalSlashed";
28const EVENT_PROPOSAL_DEPOSIT_CLAIM: &str =
29    "penumbra.core.component.governance.v1.EventProposalDepositClaim";
30
31impl GovernanceProposals {
32    async fn index_event(
33        &self,
34        dbtx: &mut PgTransaction<'_>,
35        event: ContextualizedEvent<'_>,
36    ) -> Result<(), anyhow::Error> {
37        match event.event.kind.as_str() {
38            EVENT_PROPOSAL_SUBMIT => {
39                let pe = pb::EventProposalSubmit::from_event(event.as_ref())?;
40                let start_block_height = pe.start_height;
41                let end_block_height = pe.end_height;
42                let submit = pe
43                    .submit
44                    .ok_or_else(|| anyhow!("missing submit in event"))?;
45                let deposit_amount = submit
46                    .deposit_amount
47                    .ok_or_else(|| anyhow!("missing deposit amount in event"))?
48                    .try_into()
49                    .context("error converting deposit amount")?;
50                let proposal = submit
51                    .proposal
52                    .ok_or_else(|| anyhow!("missing proposal in event"))?
53                    .try_into()
54                    .context("error converting proposal")?;
55                handle_proposal_submit(
56                    dbtx,
57                    proposal,
58                    deposit_amount,
59                    start_block_height,
60                    end_block_height,
61                    event.block_height,
62                )
63                .await?;
64            }
65            EVENT_DELEGATOR_VOTE => {
66                let pe = pb::EventDelegatorVote::from_event(event.as_ref())?;
67                let vote = pe
68                    .vote
69                    .ok_or_else(|| anyhow!("missing vote in event"))?
70                    .try_into()
71                    .context("error converting delegator vote")?;
72                let validator_identity_key = pe
73                    .validator_identity_key
74                    .ok_or_else(|| anyhow!("missing validator identity key in event"))?
75                    .try_into()
76                    .context("error converting validator identity key")?;
77                handle_delegator_vote(dbtx, vote, validator_identity_key, event.block_height)
78                    .await?;
79            }
80            EVENT_VALIDATOR_VOTE => {
81                let pe = pb::EventValidatorVote::from_event(event.as_ref())?;
82                let voting_power = pe.voting_power;
83                let vote = pe
84                    .vote
85                    .ok_or_else(|| anyhow!("missing vote in event"))?
86                    .try_into()
87                    .context("error converting vote")?;
88                handle_validator_vote(dbtx, vote, voting_power, event.block_height).await?;
89            }
90            EVENT_PROPOSAL_WITHDRAW => {
91                let pe = pb::EventProposalWithdraw::from_event(event.as_ref())?;
92                let proposal_withdraw: ProposalWithdraw = pe
93                    .withdraw
94                    .ok_or_else(|| anyhow!("missing withdraw in event"))?
95                    .try_into()
96                    .context("error converting proposal withdraw")?;
97                let proposal = proposal_withdraw.proposal;
98                let reason = proposal_withdraw.reason;
99                handle_proposal_withdraw(dbtx, proposal, reason).await?;
100            }
101            EVENT_PROPOSAL_PASSED => {
102                let pe = pb::EventProposalPassed::from_event(event.as_ref())?;
103                let proposal = pe
104                    .proposal
105                    .ok_or_else(|| anyhow!("missing proposal in event"))?
106                    .try_into()
107                    .context("error converting proposal")?;
108                handle_proposal_passed(dbtx, proposal).await?;
109            }
110            EVENT_PROPOSAL_FAILED => {
111                let pe = pb::EventProposalFailed::from_event(event.as_ref())?;
112                let proposal = pe
113                    .proposal
114                    .ok_or_else(|| anyhow!("missing proposal in event"))?
115                    .try_into()
116                    .context("error converting proposal")?;
117                handle_proposal_failed(dbtx, proposal).await?;
118            }
119            EVENT_PROPOSAL_SLASHED => {
120                let pe = pb::EventProposalSlashed::from_event(event.as_ref())?;
121                let proposal = pe
122                    .proposal
123                    .ok_or_else(|| anyhow!("missing proposal in event"))?
124                    .try_into()
125                    .context("error converting proposal")?;
126                handle_proposal_slashed(dbtx, proposal).await?;
127            }
128            EVENT_PROPOSAL_DEPOSIT_CLAIM => {
129                let pe = pb::EventProposalDepositClaim::from_event(event.as_ref())?;
130                let deposit_claim = pe
131                    .deposit_claim
132                    .ok_or_else(|| anyhow!("missing deposit claim in event"))?
133                    .try_into()
134                    .context("error converting deposit claim")?;
135                handle_proposal_deposit_claim(dbtx, deposit_claim).await?;
136            }
137            _ => {}
138        }
139
140        Ok(())
141    }
142}
143
144#[async_trait]
145impl AppView for GovernanceProposals {
146    async fn init_chain(
147        &self,
148        dbtx: &mut PgTransaction,
149        _app_state: &serde_json::Value,
150    ) -> Result<(), anyhow::Error> {
151        // Define table structures
152        let tables = vec![
153            (
154                "governance_proposals",
155                r"
156                id SERIAL PRIMARY KEY,
157                proposal_id INTEGER NOT NULL UNIQUE,
158                title TEXT NOT NULL,
159                description TEXT NOT NULL,
160                kind JSONB NOT NULL,
161                payload JSONB,
162                start_block_height BIGINT NOT NULL,
163                end_block_height BIGINT NOT NULL,
164                state JSONB NOT NULL,
165                proposal_deposit_amount BIGINT NOT NULL,
166                withdrawn BOOLEAN DEFAULT FALSE,
167                withdrawal_reason TEXT
168                ",
169            ),
170            (
171                "governance_validator_votes",
172                r"
173                id SERIAL PRIMARY KEY,
174                proposal_id INTEGER NOT NULL,
175                identity_key TEXT NOT NULL,
176                vote JSONB NOT NULL,
177                voting_power BIGINT NOT NULL,
178                block_height BIGINT NOT NULL,
179                FOREIGN KEY (proposal_id) REFERENCES governance_proposals(proposal_id)
180                ",
181            ),
182            (
183                "governance_delegator_votes",
184                r"
185                id SERIAL PRIMARY KEY,
186                proposal_id INTEGER NOT NULL,
187                identity_key TEXT NOT NULL,
188                vote JSONB NOT NULL,
189                voting_power BIGINT NOT NULL,
190                block_height BIGINT NOT NULL,
191                FOREIGN KEY (proposal_id) REFERENCES governance_proposals(proposal_id)
192                ",
193            ),
194        ];
195
196        // Define indexes
197        let indexes = vec![
198            (
199                "governance_proposals",
200                "proposal_id",
201                "idx_governance_proposals_id",
202            ),
203            (
204                "governance_proposals",
205                "title text_pattern_ops",
206                "idx_governance_proposals_title",
207            ),
208            (
209                "governance_proposals",
210                "kind",
211                "idx_governance_proposals_kind",
212            ),
213            (
214                "governance_proposals",
215                "start_block_height DESC",
216                "idx_governance_proposals_start_block_height",
217            ),
218            (
219                "governance_proposals",
220                "end_block_height DESC",
221                "idx_governance_proposals_end_block_height",
222            ),
223            (
224                "governance_proposals",
225                "state",
226                "idx_governance_proposals_state",
227            ),
228            (
229                "governance_proposals",
230                "withdrawn",
231                "idx_governance_proposals_withdrawn",
232            ),
233            (
234                "governance_validator_votes",
235                "proposal_id",
236                "idx_governance_validator_votes_proposal_id",
237            ),
238            (
239                "governance_validator_votes",
240                "identity_key",
241                "idx_governance_validator_votes_identity_key",
242            ),
243            (
244                "governance_validator_votes",
245                "vote",
246                "idx_governance_validator_votes_vote",
247            ),
248            (
249                "governance_validator_votes",
250                "voting_power",
251                "idx_governance_validator_votes_voting_power",
252            ),
253            (
254                "governance_validator_votes",
255                "block_height",
256                "idx_governance_validator_votes_block_height",
257            ),
258            (
259                "governance_delegator_votes",
260                "proposal_id",
261                "idx_governance_delegator_votes_proposal_id",
262            ),
263            (
264                "governance_delegator_votes",
265                "identity_key",
266                "idx_governance_delegator_votes_identity_key",
267            ),
268            (
269                "governance_delegator_votes",
270                "vote",
271                "idx_governance_delegator_votes_vote",
272            ),
273            (
274                "governance_delegator_votes",
275                "voting_power",
276                "idx_governance_delegator_votes_voting_power",
277            ),
278            (
279                "governance_delegator_votes",
280                "block_height",
281                "idx_governance_delegator_votes_block_height",
282            ),
283        ];
284
285        async fn create_table(
286            dbtx: &mut PgTransaction<'_>,
287            table_name: &str,
288            structure: &str,
289        ) -> Result<()> {
290            let query = format!("CREATE TABLE IF NOT EXISTS {} ({})", table_name, structure);
291            sqlx::query(&query).execute(dbtx.as_mut()).await?;
292            Ok(())
293        }
294
295        async fn create_index(
296            dbtx: &mut PgTransaction<'_>,
297            table_name: &str,
298            column: &str,
299            index_name: &str,
300        ) -> Result<()> {
301            let query = format!(
302                "CREATE INDEX IF NOT EXISTS {} ON {}({})",
303                index_name, table_name, column
304            );
305            sqlx::query(&query).execute(dbtx.as_mut()).await?;
306            Ok(())
307        }
308
309        // Create tables
310        for (table_name, table_structure) in tables {
311            create_table(dbtx, table_name, table_structure).await?;
312        }
313
314        // Create indexes
315        for (table_name, column, index_name) in indexes {
316            create_index(dbtx, table_name, column, index_name).await?;
317        }
318
319        Ok(())
320    }
321
322    fn name(&self) -> String {
323        "governance".to_string()
324    }
325
326    async fn index_batch(
327        &self,
328        dbtx: &mut PgTransaction,
329        batch: EventBatch,
330        _ctx: EventBatchContext,
331    ) -> Result<(), anyhow::Error> {
332        for event in batch.events() {
333            self.index_event(dbtx, event).await?;
334        }
335        Ok(())
336    }
337}
338
339async fn handle_proposal_submit(
340    dbtx: &mut PgTransaction<'_>,
341    proposal: Proposal,
342    deposit_amount: Amount,
343    start_block_height: u64,
344    end_block_height: u64,
345    _block_height: u64,
346) -> Result<()> {
347    sqlx::query(
348        "INSERT INTO governance_proposals (
349            proposal_id, title, description, kind, payload, start_block_height, end_block_height, state, proposal_deposit_amount
350        )
351         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
352         ON CONFLICT (proposal_id) DO NOTHING",
353    )
354    .bind(proposal.id as i64)
355    .bind(&proposal.title)
356    .bind(&proposal.description)
357    .bind(serde_json::to_value(proposal.kind())?)
358    .bind(serde_json::to_value(ProposalPayloadToml::from(proposal.payload))?)
359    .bind(start_block_height as i64)
360    .bind(end_block_height as i64)
361    .bind(serde_json::to_value(proposal_state::State::Voting)?)
362    .bind(deposit_amount.value() as i64)
363    .execute(dbtx.as_mut())
364    .await?;
365
366    Ok(())
367}
368
369async fn handle_delegator_vote(
370    dbtx: &mut PgTransaction<'_>,
371    vote: DelegatorVote,
372    identity_key: IdentityKey,
373    block_height: u64,
374) -> Result<()> {
375    sqlx::query(
376        "INSERT INTO governance_delegator_votes (
377            proposal_id, identity_key, vote, voting_power, block_height
378        )
379         VALUES ($1, $2, $3, $4, $5)",
380    )
381    .bind(vote.body.proposal as i64)
382    .bind(&identity_key.to_string())
383    .bind(serde_json::to_value(vote.body.vote)?)
384    .bind(vote.body.unbonded_amount.value() as i64)
385    .bind(block_height as i64)
386    .execute(dbtx.as_mut())
387    .await?;
388
389    Ok(())
390}
391
392async fn handle_validator_vote(
393    dbtx: &mut PgTransaction<'_>,
394    vote: ValidatorVote,
395    voting_power: u64,
396    block_height: u64,
397) -> Result<()> {
398    sqlx::query(
399        "INSERT INTO governance_validator_votes (
400            proposal_id, identity_key, vote, voting_power, block_height
401        )
402         VALUES ($1, $2, $3, $4, $5)",
403    )
404    .bind(vote.body.proposal as i64)
405    .bind(&vote.body.identity_key.to_string())
406    .bind(serde_json::to_value(vote.body.vote)?)
407    .bind(voting_power as i64)
408    .bind(block_height as i64)
409    .execute(dbtx.as_mut())
410    .await?;
411
412    Ok(())
413}
414
415async fn handle_proposal_withdraw(
416    dbtx: &mut PgTransaction<'_>,
417    proposal_id: u64,
418    reason: String,
419) -> Result<()> {
420    sqlx::query(
421        "UPDATE governance_proposals
422         SET withdrawn = TRUE, withdrawal_reason = $2
423         WHERE proposal_id = $1",
424    )
425    .bind(proposal_id as i64)
426    .bind(&reason)
427    .execute(dbtx.as_mut())
428    .await?;
429
430    Ok(())
431}
432
433async fn handle_proposal_passed(dbtx: &mut PgTransaction<'_>, proposal: Proposal) -> Result<()> {
434    sqlx::query(
435        "UPDATE governance_proposals
436         SET state = $2
437         WHERE proposal_id = $1",
438    )
439    .bind(proposal.id as i64)
440    .bind(serde_json::to_value(proposal_state::State::Finished {
441        outcome: proposal_state::Outcome::Passed,
442    })?)
443    .execute(dbtx.as_mut())
444    .await?;
445
446    Ok(())
447}
448
449async fn handle_proposal_failed(dbtx: &mut PgTransaction<'_>, proposal: Proposal) -> Result<()> {
450    // Determine if the proposal was withdrawn before it concluded, and if so, why
451    let reason: Option<String> = sqlx::query_scalar(
452        "SELECT withdrawal_reason
453         FROM governance_proposals
454         WHERE proposal_id = $1 AND withdrawn = TRUE
455         LIMIT 1",
456    )
457    .bind(proposal.id as i64)
458    .fetch_optional(dbtx.as_mut())
459    .await?;
460    let withdrawn = proposal_state::Withdrawn::from(reason);
461
462    sqlx::query(
463        "UPDATE governance_proposals
464         SET state = $2
465         WHERE proposal_id = $1",
466    )
467    .bind(proposal.id as i64)
468    .bind(serde_json::to_value(proposal_state::State::Finished {
469        outcome: proposal_state::Outcome::Failed { withdrawn },
470    })?)
471    .execute(dbtx.as_mut())
472    .await?;
473
474    Ok(())
475}
476
477async fn handle_proposal_slashed(dbtx: &mut PgTransaction<'_>, proposal: Proposal) -> Result<()> {
478    // Determine if the proposal was withdrawn before it concluded, and if so, why
479    let reason: Option<String> = sqlx::query_scalar(
480        "SELECT withdrawal_reason
481         FROM governance_proposals
482         WHERE proposal_id = $1 AND withdrawn = TRUE
483         LIMIT 1",
484    )
485    .bind(proposal.id as i64)
486    .fetch_optional(dbtx.as_mut())
487    .await?;
488    let withdrawn = proposal_state::Withdrawn::from(reason);
489
490    sqlx::query(
491        "UPDATE governance_proposals
492         SET state = $2
493         WHERE proposal_id = $1",
494    )
495    .bind(proposal.id as i64)
496    .bind(serde_json::to_value(proposal_state::State::Finished {
497        outcome: proposal_state::Outcome::Slashed { withdrawn },
498    })?)
499    .execute(dbtx.as_mut())
500    .await?;
501
502    Ok(())
503}
504
505async fn handle_proposal_deposit_claim(
506    dbtx: &mut PgTransaction<'_>,
507    deposit_claim: ProposalDepositClaim,
508) -> Result<()> {
509    let current_state: serde_json::Value = sqlx::query_scalar(
510        "SELECT state
511        FROM governance_proposals
512        WHERE proposal_id = $1",
513    )
514    .bind(deposit_claim.proposal as i64)
515    .fetch_one(dbtx.as_mut())
516    .await?;
517
518    let current_state: proposal_state::State = serde_json::from_value(current_state)?;
519
520    let outcome = match current_state {
521        proposal_state::State::Finished { outcome } => outcome,
522        _ => {
523            return Err(anyhow!(
524                "proposal {} is not in a finished state",
525                deposit_claim.proposal
526            ))
527        }
528    };
529
530    sqlx::query(
531        "UPDATE governance_proposals
532         SET state = $2
533         WHERE proposal_id = $1",
534    )
535    .bind(deposit_claim.proposal as i64)
536    .bind(serde_json::to_value(proposal_state::State::Claimed {
537        outcome,
538    })?)
539    .execute(dbtx.as_mut())
540    .await?;
541
542    Ok(())
543}