From 2d8cb5125c454af87c1157c38e365882a34e9f9d Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Mon, 22 Apr 2024 00:06:56 -0700 Subject: [PATCH] added gov proposal webhooks --- src/data/webhooks/index.ts | 57 +++++++----- src/data/webhooks/notify/gov.ts | 151 ++++++++++++++++++++++++++++++++ src/data/webhooks/websockets.ts | 44 +++++++++- 3 files changed, 229 insertions(+), 23 deletions(-) create mode 100644 src/data/webhooks/notify/gov.ts diff --git a/src/data/webhooks/index.ts b/src/data/webhooks/index.ts index 939e5c6e..aa46f80e 100644 --- a/src/data/webhooks/index.ts +++ b/src/data/webhooks/index.ts @@ -6,6 +6,11 @@ import { State, WasmStateEvent } from '@/db' import { makeProposalCreated } from './discordNotifier' import { makeIndexerCwReceiptPaid } from './indexerCwReceipt' import { makeInboxJoinedDao } from './notify/dao' +import { + makeInboxGovProposalCreated, + makeInboxGovProposalPassed, + makeInboxGovProposalRejected, +} from './notify/gov' import { makeInboxPreProposeApprovalProposalCreated, makeInboxPreProposeApprovalProposalRejected, @@ -13,7 +18,11 @@ import { makeInboxProposalCreated, makeInboxProposalExecuted, } from './notify/proposal' -import { makeBroadcastVoteCast, makeProposalStatusChanged } from './websockets' +import { + makeBroadcastVoteCast, + makeDaoProposalStatusChanged, + makeGovProposalStatusChanged, +} from './websockets' let processedWebhooks: ProcessedWebhook[] | undefined export const getProcessedWebhooks = ( @@ -30,9 +39,13 @@ export const getProcessedWebhooks = ( makeInboxProposalClosed, makeInboxPreProposeApprovalProposalCreated, makeInboxPreProposeApprovalProposalRejected, + makeInboxGovProposalCreated, + makeInboxGovProposalPassed, + makeInboxGovProposalRejected, makeIndexerCwReceiptPaid, makeBroadcastVoteCast, - makeProposalStatusChanged, + makeDaoProposalStatusChanged, + makeGovProposalStatusChanged, ] const _webhooks: Webhook[] = [ @@ -76,26 +89,30 @@ export const getProcessedWebhooks = ( } } - // Wrap in try/catch in case a webhook errors. Don't want to prevent - // other webhooks from sending. - try { - return filter.matches?.(event) ?? true - } catch (error) { - console.error( - `Error matching webhook for ${event.constructor.name} ID ${event.id} at height ${event.block.height}: ${error}` - ) - Sentry.captureException(error, { - tags: { - type: 'failed-webhook-match', - }, - extra: { - event, - }, - }) + if (filter.matches) { + // Wrap in try/catch in case a webhook errors. Don't want to prevent + // other webhooks from sending. + try { + return filter.matches(event) + } catch (error) { + console.error( + `Error matching webhook for ${event.constructor.name} ID ${event.id} at height ${event.block.height}: ${error}` + ) + Sentry.captureException(error, { + tags: { + type: 'failed-webhook-match', + }, + extra: { + event, + }, + }) - // On error, do not match. - return false + // On error, do not match. + return false + } } + + return true }, } }) diff --git a/src/data/webhooks/notify/gov.ts b/src/data/webhooks/notify/gov.ts new file mode 100644 index 00000000..0a8dc200 --- /dev/null +++ b/src/data/webhooks/notify/gov.ts @@ -0,0 +1,151 @@ +import { ProposalStatus } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1/gov' +import { + getConfiguredChainConfig, + getDisplayNameForChainId, + getImageUrlForChainId, +} from '@dao-dao/utils' + +import { WebhookMaker, WebhookType } from '@/core/types' +import { decodeGovProposal } from '@/core/utils' +import { GovStateEvent } from '@/db' + +// Fire webhook when a gov proposal is created. +export const makeInboxGovProposalCreated: WebhookMaker = ( + config, + state +) => ({ + filter: { + EventType: GovStateEvent, + }, + endpoint: { + type: WebhookType.Url, + url: 'https://notifier.daodao.zone/notify', + method: 'POST', + headers: { + 'x-api-key': config.notifierSecret, + }, + }, + getValue: async (event, getLastEvent) => { + // Only fire the webhook the first time this exists. + if ((await getLastEvent()) !== null) { + return + } + + const { proposal, title } = decodeGovProposal(event.data) + + return { + chainId: state.chainId, + type: 'proposal_created', + data: { + chainId: state.chainId, + dao: getConfiguredChainConfig(state.chainId)?.name || 'GOV_PLACEHOLDER', + daoName: getDisplayNameForChainId(state.chainId), + imageUrl: getImageUrlForChainId(state.chainId), + proposalId: event.proposalId, + proposalTitle: proposal ? title : event.proposalId, + fromApprover: false, + }, + } + }, +}) + +// Fire webhook when a gov proposal is passed (or passed + execution failed). +export const makeInboxGovProposalPassed: WebhookMaker = ( + config, + state +) => ({ + filter: { + EventType: GovStateEvent, + }, + endpoint: { + type: WebhookType.Url, + url: 'https://notifier.daodao.zone/notify', + method: 'POST', + headers: { + 'x-api-key': config.notifierSecret, + }, + }, + getValue: async (event, getLastEvent) => { + // Only fire the webhook if the last event was not passed. + const lastEvent = await getLastEvent() + const lastDecoded = lastEvent && decodeGovProposal(lastEvent.data) + if ( + lastEvent && + // If could not decode and verify that last event was passed, ignore to + // avoid spamming when something breaks. + (!lastDecoded || + // If last event was passed (or passed + execution failed), ignore + // because already sent. + lastDecoded.status === ProposalStatus.PROPOSAL_STATUS_PASSED || + lastDecoded.status === ProposalStatus.PROPOSAL_STATUS_FAILED) + ) { + return + } + + const { proposal, title, status } = decodeGovProposal(event.data) + + return { + chainId: state.chainId, + type: 'proposal_executed', + data: { + chainId: state.chainId, + dao: getConfiguredChainConfig(state.chainId)?.name || 'GOV_PLACEHOLDER', + daoName: getDisplayNameForChainId(state.chainId), + imageUrl: getImageUrlForChainId(state.chainId), + proposalId: event.proposalId, + proposalTitle: proposal ? title : event.proposalId, + fromApprover: false, + failed: status === ProposalStatus.PROPOSAL_STATUS_FAILED, + }, + } + }, +}) + +// Fire webhook when a gov proposal is rejected. +export const makeInboxGovProposalRejected: WebhookMaker = ( + config, + state +) => ({ + filter: { + EventType: GovStateEvent, + }, + endpoint: { + type: WebhookType.Url, + url: 'https://notifier.daodao.zone/notify', + method: 'POST', + headers: { + 'x-api-key': config.notifierSecret, + }, + }, + getValue: async (event, getLastEvent) => { + // Only fire the webhook if the last event was not rejected. + const lastEvent = await getLastEvent() + const lastDecoded = lastEvent && decodeGovProposal(lastEvent.data) + if ( + lastEvent && + // If could not decode and verify that last event was rejected, ignore to + // avoid spamming when something breaks. + (!lastDecoded || + // If last event was rejected, ignore because already sent. + lastDecoded.status === ProposalStatus.PROPOSAL_STATUS_REJECTED) + ) { + return + } + + const { proposal, title } = decodeGovProposal(event.data) + + return { + chainId: state.chainId, + type: 'proposal_closed', + data: { + chainId: state.chainId, + dao: getConfiguredChainConfig(state.chainId)?.name || 'GOV_PLACEHOLDER', + daoName: getDisplayNameForChainId(state.chainId), + imageUrl: getImageUrlForChainId(state.chainId), + proposalId: event.proposalId, + proposalTitle: proposal ? title : event.proposalId, + fromApprover: false, + }, + } + }, +}) diff --git a/src/data/webhooks/websockets.ts b/src/data/webhooks/websockets.ts index a2a0b819..ecc89b70 100644 --- a/src/data/webhooks/websockets.ts +++ b/src/data/webhooks/websockets.ts @@ -1,6 +1,8 @@ +import { getConfiguredChainConfig } from '@dao-dao/utils' + import { Webhook, WebhookMaker, WebhookType } from '@/core/types' -import { dbKeyForKeys, dbKeyToKeys } from '@/core/utils' -import { State, WasmStateEvent } from '@/db' +import { dbKeyForKeys, dbKeyToKeys, decodeGovProposal } from '@/core/utils' +import { GovStateEvent, State, WasmStateEvent } from '@/db' import { activeProposalModules } from '../formulas/contract/daoCore/base' import { getDaoAddressForProposalModule } from './utils' @@ -85,7 +87,7 @@ export const makeBroadcastVoteCast: WebhookMaker = ( } // Broadcast to WebSockets when a proposal status changes, including creation. -export const makeProposalStatusChanged: WebhookMaker = ( +export const makeDaoProposalStatusChanged: WebhookMaker = ( config, state ) => @@ -139,3 +141,39 @@ export const makeProposalStatusChanged: WebhookMaker = ( } }, } + +// Broadcast to WebSockets when a gov proposal status changes, including +// creation. +export const makeGovProposalStatusChanged: WebhookMaker = ( + config, + state +) => + config.soketi && { + filter: { + EventType: GovStateEvent, + }, + endpoint: { + type: WebhookType.Soketi, + channel: `${state.chainId}_${ + getConfiguredChainConfig(state.chainId)?.name || 'GOV_PLACEHOLDER' + }`, + event: 'broadcast', + }, + getValue: async (event, getLastEvent) => { + // Only fire the webhook when the status changes. + const lastEvent = await getLastEvent() + const lastStatus = lastEvent && decodeGovProposal(lastEvent.data)?.status + const status = decodeGovProposal(event.data)?.status + if (lastStatus && lastStatus === status) { + return + } + + return { + type: 'proposal', + data: { + proposalId: event.proposalId, + status, + }, + } + }, + }