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 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 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 for (table_name, table_structure) in tables {
311 create_table(dbtx, table_name, table_structure).await?;
312 }
313
314 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 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 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}