diff --git a/src/db/connection.ts b/src/db/connection.ts index 6ab34afc..776d49fb 100644 --- a/src/db/connection.ts +++ b/src/db/connection.ts @@ -17,7 +17,8 @@ import { ComputationDependency, Contract, DistributionCommunityPoolStateEvent, - GovStateEvent, + GovProposal, + GovProposalVote, StakingSlashEvent, State, Validator, @@ -45,7 +46,8 @@ const getModelsForType = (type: DbType): SequelizeOptions['models'] => ComputationDependency, Contract, DistributionCommunityPoolStateEvent, - GovStateEvent, + GovProposal, + GovProposalVote, StakingSlashEvent, State, Validator, diff --git a/src/db/dependable.ts b/src/db/dependable.ts index fe11005f..a37d23fc 100644 --- a/src/db/dependable.ts +++ b/src/db/dependable.ts @@ -3,7 +3,8 @@ import { ComputationDependentKey, DependableEventModel } from '@/types' import { BankStateEvent, DistributionCommunityPoolStateEvent, - GovStateEvent, + GovProposal, + GovProposalVote, StakingSlashEvent, WasmStateEvent, WasmStateEventTransformation, @@ -16,7 +17,8 @@ export const getDependableEventModels = (): typeof DependableEventModel[] => [ WasmTxEvent, StakingSlashEvent, BankStateEvent, - GovStateEvent, + GovProposal, + GovProposalVote, DistributionCommunityPoolStateEvent, ] diff --git a/src/db/migrations/20240816161812-rename-gov-state-event-to-gov-proposal.ts b/src/db/migrations/20240816161812-rename-gov-state-event-to-gov-proposal.ts new file mode 100644 index 00000000..4b714f15 --- /dev/null +++ b/src/db/migrations/20240816161812-rename-gov-state-event-to-gov-proposal.ts @@ -0,0 +1,11 @@ +import { QueryInterface } from 'sequelize' + +module.exports = { + async up(queryInterface: QueryInterface) { + await queryInterface.renameTable('GovStateEvents', 'GovProposals') + }, + + async down(queryInterface: QueryInterface) { + await queryInterface.renameTable('GovProposals', 'GovStateEvents') + }, +} diff --git a/src/db/migrations/20240816165030-create-gov-proposal-vote.ts b/src/db/migrations/20240816165030-create-gov-proposal-vote.ts new file mode 100644 index 00000000..6a17e39b --- /dev/null +++ b/src/db/migrations/20240816165030-create-gov-proposal-vote.ts @@ -0,0 +1,67 @@ +import { QueryInterface, fn } from 'sequelize' +import { DataType } from 'sequelize-typescript' + +module.exports = { + async up(queryInterface: QueryInterface) { + await queryInterface.createTable('GovProposalVotes', { + id: { + primaryKey: true, + autoIncrement: true, + type: DataType.INTEGER, + }, + proposalId: { + allowNull: false, + type: DataType.BIGINT, + }, + voterAddress: { + allowNull: false, + type: DataType.STRING, + }, + blockHeight: { + allowNull: false, + type: DataType.BIGINT, + }, + blockTimeUnixMs: { + allowNull: false, + type: DataType.BIGINT, + }, + blockTimestamp: { + allowNull: false, + type: DataType.DATE, + }, + data: { + allowNull: false, + type: DataType.TEXT, + }, + createdAt: { + allowNull: false, + type: DataType.DATE, + defaultValue: fn('NOW'), + }, + updatedAt: { + allowNull: false, + type: DataType.DATE, + defaultValue: fn('NOW'), + }, + }) + await queryInterface.addIndex('GovProposalVotes', { + unique: true, + fields: ['blockHeight', 'proposalId', 'voterAddress'], + }) + await queryInterface.addIndex('GovProposalVotes', { + fields: ['proposalId'], + }) + await queryInterface.addIndex('GovProposalVotes', { + fields: ['voterAddress'], + }) + await queryInterface.addIndex('GovProposalVotes', { + fields: ['blockHeight'], + }) + await queryInterface.addIndex('GovProposalVotes', { + fields: ['blockTimeUnixMs'], + }) + }, + async down(queryInterface: QueryInterface) { + await queryInterface.dropTable('GovProposalVotes') + }, +} diff --git a/src/db/models/Computation.ts b/src/db/models/Computation.ts index 51ab1384..740c5e44 100644 --- a/src/db/models/Computation.ts +++ b/src/db/models/Computation.ts @@ -147,7 +147,9 @@ export class Computation extends Model { await Promise.all( getDependableEventModels().map(async (DependableEventModel) => { const namespacedKeys = this.dependencies.filter(({ key }) => - key.startsWith(DependableEventModel.dependentKeyNamespace) + key.startsWith( + DependableEventModel.dependentKeyNamespace + ':' + ) ) if (namespacedKeys.length === 0) { return null diff --git a/src/db/models/GovStateEvent.ts b/src/db/models/GovProposal.ts similarity index 88% rename from src/db/models/GovStateEvent.ts rename to src/db/models/GovProposal.ts index b9b3aa5b..46e094d7 100644 --- a/src/db/models/GovStateEvent.ts +++ b/src/db/models/GovProposal.ts @@ -34,7 +34,7 @@ import { getDependentKey } from '@/utils' }, ], }) -export class GovStateEvent extends DependableEventModel { +export class GovProposal extends DependableEventModel { @AllowNull(false) @Column(DataType.BIGINT) declare proposalId: string @@ -64,16 +64,16 @@ export class GovStateEvent extends DependableEventModel { } get dependentKey(): string { - return getDependentKey(GovStateEvent.dependentKeyNamespace, this.proposalId) + return getDependentKey(GovProposal.dependentKeyNamespace, this.proposalId) } // Get the previous event for this proposalId. If this is the first event for // this proposalId, return null. Cache the result so it can be reused since // this shouldn't change. - previousEvent?: GovStateEvent | null - async getPreviousEvent(cache = true): Promise { + previousEvent?: GovProposal | null + async getPreviousEvent(cache = true): Promise { if (this.previousEvent === undefined || !cache) { - this.previousEvent = await GovStateEvent.findOne({ + this.previousEvent = await GovProposal.findOne({ where: { proposalId: this.proposalId, blockHeight: { @@ -87,7 +87,7 @@ export class GovStateEvent extends DependableEventModel { return this.previousEvent } - static dependentKeyNamespace = DependentKeyNamespace.GovStateEvent + static dependentKeyNamespace = DependentKeyNamespace.GovProposal static blockHeightKey: string = 'blockHeight' static blockTimeUnixMsKey: string = 'blockTimeUnixMs' diff --git a/src/db/models/GovProposalVote.ts b/src/db/models/GovProposalVote.ts new file mode 100644 index 00000000..75b8d5ab --- /dev/null +++ b/src/db/models/GovProposalVote.ts @@ -0,0 +1,178 @@ +import { Op, WhereOptions } from 'sequelize' +import { AllowNull, Column, DataType, Table } from 'sequelize-typescript' + +import { + Block, + ComputationDependentKey, + DependableEventModel, + DependentKeyNamespace, +} from '@/types' +import { getDependentKey } from '@/utils' + +@Table({ + timestamps: true, + indexes: [ + // Only one vote can be cast for a proposal ID by a voter at a given block + // height. This ensures events are not duplicated if they attempt exporting + // multiple times. + { + unique: true, + fields: ['blockHeight', 'proposalId', 'voterAddress'], + }, + { + fields: ['proposalId'], + }, + { + fields: ['voterAddress'], + }, + { + // Speed up ordering queries. + fields: ['blockHeight'], + }, + { + // Speed up ordering queries. + fields: ['blockTimeUnixMs'], + }, + ], +}) +export class GovProposalVote extends DependableEventModel { + @AllowNull(false) + @Column(DataType.BIGINT) + declare proposalId: string + + @AllowNull(false) + @Column(DataType.STRING) + declare voterAddress: string + + @AllowNull(false) + @Column(DataType.BIGINT) + declare blockHeight: string + + @AllowNull(false) + @Column(DataType.BIGINT) + declare blockTimeUnixMs: string + + @AllowNull(false) + @Column(DataType.DATE) + declare blockTimestamp: Date + + // Base64-encoded protobuf data. + @AllowNull(false) + @Column(DataType.TEXT) + declare data: string + + get block(): Block { + return { + height: BigInt(this.blockHeight), + timeUnixMs: BigInt(this.blockTimeUnixMs), + } + } + + get dependentKey(): string { + return getDependentKey( + GovProposalVote.dependentKeyNamespace, + this.proposalId, + this.voterAddress + ) + } + + // Get the previous event for this proposalId. If this is the first event for + // this proposalId, return null. Cache the result so it can be reused since + // this shouldn't change. + previousEvent?: GovProposalVote | null + async getPreviousEvent(cache = true): Promise { + if (this.previousEvent === undefined || !cache) { + this.previousEvent = await GovProposalVote.findOne({ + where: { + proposalId: this.proposalId, + voterAddress: this.voterAddress, + blockHeight: { + [Op.lt]: this.blockHeight, + }, + }, + order: [['blockHeight', 'DESC']], + }) + } + + return this.previousEvent + } + + static dependentKeyNamespace = DependentKeyNamespace.GovProposalVote + static blockHeightKey: string = 'blockHeight' + static blockTimeUnixMsKey: string = 'blockTimeUnixMs' + + // Returns a where clause that will match all events that are described by the + // dependent keys. + static getWhereClauseForDependentKeys( + dependentKeys: ComputationDependentKey[] + ): WhereOptions { + const dependentKeysByProposalId = dependentKeys.reduce( + (acc, dependentKey) => { + // 1. Remove namespace from key. + const key = dependentKey.key.replace( + new RegExp(`^${this.dependentKeyNamespace}:`), + '' + ) + + // 2. Extract proposalId from key. + // Dependent keys for any proposal start with "*:". + const proposalId = key.startsWith('*:') ? '' : key.split(':')[0] + + const voterAddress = key + // 3. Remove proposalId from key. + .replace(new RegExp(`^${proposalId || '\\*'}:`), '') + // 4. Replace wildcard symbol with LIKE wildcard for database query. + .replace(/\*/g, '%') + + return { + ...acc, + [proposalId]: [ + ...(acc[proposalId] ?? []), + { + voterAddress, + prefix: dependentKey.prefix, + }, + ], + } + }, + {} as Record + ) + + return { + [Op.or]: Object.entries(dependentKeysByProposalId).map( + ([proposalId, keys]) => { + const exactKeys = keys + .filter( + ({ voterAddress, prefix }) => + !prefix && !voterAddress.includes('%') + ) + .map(({ voterAddress }) => voterAddress) + const wildcardKeys = keys + .filter( + ({ voterAddress, prefix }) => prefix || voterAddress.includes('%') + ) + .map( + ({ voterAddress, prefix }) => voterAddress + (prefix ? '%' : '') + ) + + return { + // Only include if proposalId is defined. + ...(proposalId && { proposalId }), + // Related logic in `makeComputationDependencyWhere` in + // `src/db/computation.ts`. + voterAddress: { + [Op.or]: [ + // Exact matches. + ...(exactKeys.length > 0 ? [{ [Op.in]: exactKeys }] : []), + // Wildcards. May or may not be prefixes. + ...wildcardKeys.map((voterAddress) => ({ + [Op.like]: voterAddress, + })), + ], + }, + } + } + ), + } + } +} diff --git a/src/db/models/index.ts b/src/db/models/index.ts index 22097b8f..9db28dab 100644 --- a/src/db/models/index.ts +++ b/src/db/models/index.ts @@ -11,7 +11,8 @@ export * from './Computation' export * from './ComputationDependency' export * from './Contract' export * from './DistributionCommunityPoolStateEvent' -export * from './GovStateEvent' +export * from './GovProposal' +export * from './GovProposalVote' export * from './StakingSlashEvent' export * from './State' export * from './Validator' diff --git a/src/formulas/compute.ts b/src/formulas/compute.ts index 2984a88d..84d1fe9a 100644 --- a/src/formulas/compute.ts +++ b/src/formulas/compute.ts @@ -246,7 +246,7 @@ export const computeRange = async ({ await Promise.all( getDependableEventModels().map(async (DependableEventModel) => { const namespacedDependentKeys = newDependentKeys.filter(({ key }) => - key.startsWith(DependableEventModel.dependentKeyNamespace) + key.startsWith(DependableEventModel.dependentKeyNamespace + ':') ) if (namespacedDependentKeys.length === 0) { return [] diff --git a/src/formulas/env.ts b/src/formulas/env.ts index 4b9451bf..d38800fd 100644 --- a/src/formulas/env.ts +++ b/src/formulas/env.ts @@ -4,7 +4,8 @@ import { BankStateEvent, Contract, DistributionCommunityPoolStateEvent, - GovStateEvent, + GovProposal, + GovProposalVote, StakingSlashEvent, WasmStateEvent, WasmStateEventTransformation, @@ -32,6 +33,10 @@ import { FormulaProposalCountGetter, FormulaProposalGetter, FormulaProposalObject, + FormulaProposalVoteCountGetter, + FormulaProposalVoteGetter, + FormulaProposalVoteObject, + FormulaProposalVotesGetter, FormulaProposalsGetter, FormulaQuerier, FormulaSlashEventsGetter, @@ -1119,7 +1124,7 @@ export const getEnv = ({ const getProposal: FormulaProposalGetter = async (proposalId) => { const dependentKey = getDependentKey( - GovStateEvent.dependentKeyNamespace, + GovProposal.dependentKeyNamespace, proposalId ) dependentKeys?.push({ @@ -1134,7 +1139,7 @@ export const getEnv = ({ // either it exists or it doesn't (null). cachedEvent !== undefined ? cachedEvent?.[0] - : await GovStateEvent.findOne({ + : await GovProposal.findOne({ where: { proposalId, blockHeight: blockHeightFilter, @@ -1144,7 +1149,7 @@ export const getEnv = ({ // Type-check. Should never happen assuming dependent key namespaces are // unique across different event types. - if (event && !(event instanceof GovStateEvent)) { + if (event && !(event instanceof GovProposal)) { throw new Error('Incorrect event type.') } @@ -1173,7 +1178,7 @@ export const getEnv = ({ offset = 0 ) => { const dependentKey = - getDependentKey(GovStateEvent.dependentKeyNamespace) + ':' + getDependentKey(GovProposal.dependentKeyNamespace) + ':' dependentKeys?.push({ key: dependentKey, prefix: true, @@ -1186,11 +1191,11 @@ export const getEnv = ({ // If undefined, we haven't tried to fetch them yet. If not undefined, // either they exist or they don't (null). cachedEvents !== undefined - ? ((cachedEvents ?? []) as GovStateEvent[]) + ? ((cachedEvents ?? []) as GovProposal[]) : // Only load ID, proposal ID, and block height, so we can filter // properly before loading all data. This must match the query in // `getProposalCount` since it uses the same cache key. - await GovStateEvent.findAll({ + await GovProposal.findAll({ attributes: [ // DISTINCT ON is not directly supported by Sequelize, so we need // to cast to unknown and back to string to insert this at the @@ -1216,7 +1221,7 @@ export const getEnv = ({ // Type-check. Should never happen assuming dependent key namespaces are // unique across different event types. - if (events.some((event) => !(event instanceof GovStateEvent))) { + if (events.some((event) => !(event instanceof GovProposal))) { throw new Error('Incorrect event type.') } @@ -1234,7 +1239,7 @@ export const getEnv = ({ ) .slice(offset, limit === undefined ? undefined : offset + limit) - const eventsWithData = await GovStateEvent.findAll({ + const eventsWithData = await GovProposal.findAll({ where: { id: filteredEvents.map((event) => event.id), }, @@ -1259,7 +1264,7 @@ export const getEnv = ({ const getProposalCount: FormulaProposalCountGetter = async () => { const dependentKey = - getDependentKey(GovStateEvent.dependentKeyNamespace) + ':' + getDependentKey(GovProposal.dependentKeyNamespace) + ':' dependentKeys?.push({ key: dependentKey, prefix: true, @@ -1272,11 +1277,11 @@ export const getEnv = ({ // If undefined, we haven't tried to fetch them yet. If not undefined, // either they exist or they don't (null). cachedEvents !== undefined - ? ((cachedEvents ?? []) as GovStateEvent[]) + ? ((cachedEvents ?? []) as GovProposal[]) : // Only load ID, proposal ID, and block height, so we can filter // properly. This must match the query in `getProposals` since it uses // the same cache key. - await GovStateEvent.findAll({ + await GovProposal.findAll({ attributes: [ // DISTINCT ON is not directly supported by Sequelize, so we need // to cast to unknown and back to string to insert this at the @@ -1302,7 +1307,223 @@ export const getEnv = ({ // Type-check. Should never happen assuming dependent key namespaces are // unique across different event types. - if (events.some((event) => !(event instanceof GovStateEvent))) { + if (events.some((event) => !(event instanceof GovProposal))) { + throw new Error('Incorrect event type.') + } + + // Cache events, null if nonexistent. + if (cachedEvents === undefined) { + cache.events[dependentKey] = events.length ? events : null + } + + // Call hook. + await onFetch?.(events) + + return events.length + } + + const getProposalVote: FormulaProposalVoteGetter = async ( + proposalId, + voterAddress + ) => { + const dependentKey = getDependentKey( + GovProposalVote.dependentKeyNamespace, + proposalId, + voterAddress + ) + dependentKeys?.push({ + key: dependentKey, + prefix: false, + }) + + // Check cache. + const cachedEvent = cache.events[dependentKey] + const event = + // If undefined, we haven't tried to fetch it yet. If not undefined, + // either it exists or it doesn't (null). + cachedEvent !== undefined + ? cachedEvent?.[0] + : await GovProposalVote.findOne({ + where: { + proposalId, + voterAddress, + blockHeight: blockHeightFilter, + }, + order: [['blockHeight', 'DESC']], + }) + + // Type-check. Should never happen assuming dependent key namespaces are + // unique across different event types. + if (event && !(event instanceof GovProposalVote)) { + throw new Error('Incorrect event type.') + } + + // Cache event, null if nonexistent. + if (cachedEvent === undefined) { + cache.events[dependentKey] = event ? [event] : null + } + + // If no event found, return undefined. + if (!event) { + return + } + + // Call hook. + await onFetch?.([event]) + + return { + id: event.proposalId, + voter: event.voterAddress, + data: event.data, + } + } + + const getProposalVotes: FormulaProposalVotesGetter = async ( + proposalId, + ascending = false, + limit = undefined, + offset = 0 + ) => { + const dependentKey = + getDependentKey(GovProposalVote.dependentKeyNamespace, proposalId) + ':' + dependentKeys?.push({ + key: dependentKey, + prefix: true, + }) + + // Check cache. + const cachedEvents = cache.events[dependentKey] + + const events = + // If undefined, we haven't tried to fetch them yet. If not undefined, + // either they exist or they don't (null). + cachedEvents !== undefined + ? ((cachedEvents ?? []) as GovProposalVote[]) + : // Only load ID, proposal ID, voter address, and block height, so we + // can filter properly before loading all data. This must match the + // query in `getProposalVoteCount` since it uses the same cache key. + await GovProposalVote.findAll({ + attributes: [ + // DISTINCT ON is not directly supported by Sequelize, so we need + // to cast to unknown and back to string to insert this at the + // beginning of the query. This ensures we use the most recent + // version of each proposal. + Sequelize.literal( + 'DISTINCT ON("proposalId", "voterAddress") \'\'' + ) as unknown as string, + 'proposalId', + 'voterAddress', + 'id', + 'blockHeight', + 'blockTimeUnixMs', + ], + where: { + proposalId, + blockHeight: blockHeightFilter, + }, + order: [ + // Needs to be first so we can use DISTINCT ON. + ['proposalId', 'ASC'], + ['voterAddress', 'ASC'], + ['blockHeight', 'DESC'], + ], + }) + + // Type-check. Should never happen assuming dependent key namespaces are + // unique across different event types. + if (events.some((event) => !(event instanceof GovProposalVote))) { + throw new Error('Incorrect event type.') + } + + // Cache events, null if nonexistent. + if (cachedEvents === undefined) { + cache.events[dependentKey] = events.length ? events : null + } + + // Filter events before fetching data. + const filteredEvents = events + .sort( + ascending + ? (a, b) => Number(a.blockHeight) - Number(b.blockHeight) + : (a, b) => Number(b.blockHeight) - Number(a.blockHeight) + ) + .slice(offset, limit === undefined ? undefined : offset + limit) + + const eventsWithData = await GovProposalVote.findAll({ + where: { + id: filteredEvents.map((event) => event.id), + }, + order: [['blockHeight', ascending ? 'ASC' : 'DESC']], + }) + + // If no events found, return undefined. + if (!eventsWithData.length) { + return + } + + // Call hook. + await onFetch?.(eventsWithData) + + return eventsWithData.map( + ({ proposalId, voterAddress, data }): FormulaProposalVoteObject => ({ + id: proposalId, + voter: voterAddress, + data, + }) + ) + } + + const getProposalVoteCount: FormulaProposalVoteCountGetter = async ( + proposalId + ) => { + const dependentKey = + getDependentKey(GovProposalVote.dependentKeyNamespace, proposalId) + ':' + dependentKeys?.push({ + key: dependentKey, + prefix: true, + }) + + // Check cache. + const cachedEvents = cache.events[dependentKey] + + const events = + // If undefined, we haven't tried to fetch them yet. If not undefined, + // either they exist or they don't (null). + cachedEvents !== undefined + ? ((cachedEvents ?? []) as GovProposalVote[]) + : // Only load ID, proposal ID, voter address, and block height, so we + // can filter properly before loading all data. This must match the + // query in `getProposalVoteCount` since it uses the same cache key. + await GovProposalVote.findAll({ + attributes: [ + // DISTINCT ON is not directly supported by Sequelize, so we need + // to cast to unknown and back to string to insert this at the + // beginning of the query. This ensures we use the most recent + // version of each proposal. + Sequelize.literal( + 'DISTINCT ON("proposalId", "voterAddress") \'\'' + ) as unknown as string, + 'proposalId', + 'voterAddress', + 'id', + 'blockHeight', + 'blockTimeUnixMs', + ], + where: { + proposalId, + blockHeight: blockHeightFilter, + }, + order: [ + // Needs to be first so we can use DISTINCT ON. + ['proposalId', 'ASC'], + ['voterAddress', 'ASC'], + ['blockHeight', 'DESC'], + ], + }) + + // Type-check. Should never happen assuming dependent key namespaces are + // unique across different event types. + if (events.some((event) => !(event instanceof GovProposalVote))) { throw new Error('Incorrect event type.') } @@ -1408,6 +1629,9 @@ export const getEnv = ({ getProposal, getProposals, getProposalCount, + getProposalVote, + getProposalVotes, + getProposalVoteCount, getCommunityPoolBalances, diff --git a/src/formulas/formulas/generic/gov.ts b/src/formulas/formulas/generic/gov.ts index 57e75b44..f5bc7618 100644 --- a/src/formulas/formulas/generic/gov.ts +++ b/src/formulas/formulas/generic/gov.ts @@ -1,9 +1,11 @@ import { FormulaDecodedProposalObject, + FormulaDecodedProposalVoteObject, FormulaProposalObject, + FormulaProposalVoteObject, GenericFormula, } from '@/types' -import { decodeGovProposal } from '@/utils' +import { decodeGovProposal, decodeGovProposalVote } from '@/utils' export const proposal: GenericFormula = { compute: async ({ getProposal, args: { id } }) => { @@ -22,7 +24,7 @@ export const proposal: GenericFormula = { } export const decodedProposal: GenericFormula< - FormulaDecodedProposalObject | null, + FormulaDecodedProposalObject, { id: string } > = { compute: async ({ getProposal, args: { id } }) => { @@ -131,3 +133,148 @@ export const reverseProposals: GenericFormula< } }, } + +export const vote: GenericFormula< + FormulaProposalVoteObject, + { + id: string + voter: string + } +> = { + compute: async ({ getProposalVote, args: { id, voter } }) => { + if (!id) { + throw new Error('missing `id`') + } + if (!voter) { + throw new Error('missing `voter`') + } + + const vote = await getProposalVote(id, voter) + + if (!vote) { + throw new Error('vote not found') + } + + return vote + }, +} + +export const decodedVote: GenericFormula< + FormulaDecodedProposalVoteObject, + { + id: string + voter: string + } +> = { + compute: async ({ getProposalVote, args: { id, voter } }) => { + if (!id) { + throw new Error('missing `id`') + } + if (!voter) { + throw new Error('missing `voter`') + } + + const vote = await getProposalVote(id, voter) + if (!vote) { + throw new Error('vote not found') + } + + const decoded = decodeGovProposalVote(vote.data) + + return { + id: Number(id), + voter, + data: vote.data, + vote: + decoded?.options.length === 1 ? decoded.options[0].option : undefined, + weightedOptions: decoded?.options || [], + metadata: decoded && 'metadata' in decoded ? decoded.metadata : undefined, + } + }, +} + +export const votes: GenericFormula< + { + votes: FormulaProposalVoteObject[] + total: number + }, + { + id: string + offset?: string + limit?: string + } +> = { + compute: async ({ + getProposalVotes, + getProposalVoteCount, + args: { id, offset, limit }, + }) => { + if (!id) { + throw new Error('missing `id`') + } + + const offsetNum = offset ? Math.max(0, Number(offset)) : 0 + const limitNum = limit ? Math.max(0, Number(limit)) : undefined + + if (isNaN(offsetNum)) { + throw new Error('invalid `offset`') + } + if (limitNum !== undefined && isNaN(limitNum)) { + throw new Error('invalid `limit`') + } + + // Sort ascending. + const votes = (await getProposalVotes(id, true, limitNum, offsetNum)) || [] + + // Should be cached since all votes were just fetched above. + const total = await getProposalVoteCount(id) + + return { + votes, + total, + } + }, +} + +export const reverseVotes: GenericFormula< + { + votes: FormulaProposalVoteObject[] + total: number + }, + { + id: string + offset?: string + limit?: string + } +> = { + compute: async ({ + getProposalVotes, + getProposalVoteCount, + args: { id, offset, limit }, + }) => { + if (!id) { + throw new Error('missing `id`') + } + + const offsetNum = offset ? Math.max(0, Number(offset)) : 0 + const limitNum = limit ? Math.max(0, Number(limit)) : undefined + + if (isNaN(offsetNum)) { + throw new Error('invalid `offset`') + } + if (limitNum !== undefined && isNaN(limitNum)) { + throw new Error('invalid `limit`') + } + + // Sort descending. + const votes = (await getProposalVotes(id, false, limitNum, offsetNum)) || [] + + // Should be cached since all proposals were just fetched above. + const total = await getProposalVoteCount(id) + + return { + votes, + total, + } + }, +} diff --git a/src/scripts/db/seedDev.ts b/src/scripts/db/seedDev.ts index 6c2d3855..12367bbb 100644 --- a/src/scripts/db/seedDev.ts +++ b/src/scripts/db/seedDev.ts @@ -6,7 +6,7 @@ import { AccountKey, AccountKeyCredit, AccountKeyCreditPaymentSource, - GovStateEvent, + GovProposal, State, loadDb, } from '@/db' @@ -67,7 +67,7 @@ export const main = async () => { // Add gov. const blockTimestamp = new Date() - await GovStateEvent.bulkCreate([ + await GovProposal.bulkCreate([ { proposalId: '1', blockHeight: 1, diff --git a/src/search/indexers/gov.ts b/src/search/indexers/gov.ts index a452f655..07f93e8d 100644 --- a/src/search/indexers/gov.ts +++ b/src/search/indexers/gov.ts @@ -1,6 +1,6 @@ import { Sequelize } from 'sequelize' -import { GovStateEvent } from '@/db' +import { GovProposal, GovProposalVote } from '@/db' import { FormulaType, MeilisearchIndexUpdate, @@ -25,7 +25,7 @@ export const govProposals: MeilisearchIndexer = { 'value.votingEndTime', ], matches: ({ event }) => { - if (!(event instanceof GovStateEvent)) { + if (!(event instanceof GovProposal)) { return } @@ -42,7 +42,7 @@ export const govProposals: MeilisearchIndexer = { } }, getBulkUpdates: async () => { - const events = await GovStateEvent.findAll({ + const events = await GovProposal.findAll({ attributes: [ // DISTINCT ON is not directly supported by Sequelize, so we need to // cast to unknown and back to string to insert this at the beginning of @@ -74,3 +74,75 @@ export const govProposals: MeilisearchIndexer = { ) }, } + +export const govProposalVotes: MeilisearchIndexer = { + id: 'gov-proposal-votes', + index: 'gov-proposal-votes', + automatic: true, + filterableAttributes: [ + 'value.id', + 'value.title', + 'value.description', + 'value.status', + ], + sortableAttributes: [ + 'value.id', + 'value.submitTime', + 'value.depositEndTime', + 'value.votingStartTime', + 'value.votingEndTime', + ], + matches: ({ event }) => { + if (!(event instanceof GovProposalVote)) { + return + } + + return { + id: [event.proposalId, event.voterAddress].join(':'), + formula: { + type: FormulaType.Generic, + name: 'gov/decodedVote', + targetAddress: '_', + args: { + id: event.proposalId, + voter: event.voterAddress, + }, + }, + } + }, + getBulkUpdates: async () => { + const events = await GovProposalVote.findAll({ + attributes: [ + // DISTINCT ON is not directly supported by Sequelize, so we need to + // cast to unknown and back to string to insert this at the beginning of + // the query. This ensures we use the most recent version of the name + // for each contract. + Sequelize.literal( + 'DISTINCT ON("proposalId", "voterAddress") \'\'' + ) as unknown as string, + 'proposalId', + 'voterAddress', + ], + order: [ + // Needs to be first so we can use DISTINCT ON. + ['proposalId', 'ASC'], + ['voterAddress', 'ASC'], + ], + }) + + return events.map( + ({ proposalId, voterAddress }): MeilisearchIndexUpdate => ({ + id: [proposalId, voterAddress].join(':'), + formula: { + type: FormulaType.Generic, + name: 'gov/decodedVote', + targetAddress: '_', + args: { + id: proposalId, + voter: voterAddress, + }, + }, + }) + ) + }, +} diff --git a/src/search/indexers/index.ts b/src/search/indexers/index.ts index fb9aa2de..c8eed5f6 100644 --- a/src/search/indexers/index.ts +++ b/src/search/indexers/index.ts @@ -1,11 +1,12 @@ import { MeilisearchIndexer } from '@/types' import { daoProposals, daos } from './daos' -import { govProposals } from './gov' +import { govProposalVotes, govProposals } from './gov' // Add meilisearch indexers here. export const meilisearchIndexers: MeilisearchIndexer[] = [ daos, daoProposals, govProposals, + govProposalVotes, ] diff --git a/src/server/test/indexer/computer/formulas/gov.ts b/src/server/test/indexer/computer/formulas/gov.ts index ff121182..18e4fe27 100644 --- a/src/server/test/indexer/computer/formulas/gov.ts +++ b/src/server/test/indexer/computer/formulas/gov.ts @@ -1,17 +1,17 @@ import request from 'supertest' -import { GovStateEvent, State } from '@/db' +import { GovProposal, GovProposalVote, State } from '@/db' import { app } from '../../app' import { ComputerTestOptions } from '../types' export const loadGovTests = (options: ComputerTestOptions) => { describe('gov', () => { - describe('basic', () => { + describe('proposals', () => { beforeEach(async () => { // Set up gov. const blockTimestamp = new Date() - await GovStateEvent.bulkCreate([ + await GovProposal.bulkCreate([ { proposalId: '1', blockHeight: 1, @@ -418,5 +418,429 @@ export const loadGovTests = (options: ComputerTestOptions) => { ]) }) }) + + describe('votes', () => { + beforeEach(async () => { + // Set up gov. + const blockTimestamp = new Date() + await GovProposalVote.bulkCreate([ + { + proposalId: '1', + voterAddress: 'a', + blockHeight: 1, + blockTimeUnixMs: 1, + blockTimestamp, + data: '1-1', + }, + { + proposalId: '1', + voterAddress: 'a', + blockHeight: 2, + blockTimeUnixMs: 2, + blockTimestamp, + data: '1-2', + }, + { + proposalId: '1', + voterAddress: 'b', + blockHeight: 2, + blockTimeUnixMs: 2, + blockTimestamp, + data: '1-2', + }, + { + proposalId: '2', + voterAddress: 'b', + blockHeight: 3, + blockTimeUnixMs: 3, + blockTimestamp, + data: '2-3', + }, + ]) + + await (await State.getSingleton())!.update({ + latestBlockHeight: 4, + latestBlockTimeUnixMs: 4, + lastGovBlockHeightExported: 4, + }) + }) + + it('returns correct vote response for a single block', async () => { + await request(app.callback()) + .get('/generic/_/gov/vote?id=1&voter=a&block=1:1') + .set('x-api-key', options.apiKey) + .expect(200) + .expect({ + id: '1', + voter: 'a', + data: '1-1', + }) + + await request(app.callback()) + .get('/generic/_/gov/vote?id=1&voter=a&block=3:3') + .set('x-api-key', options.apiKey) + .expect(200) + .expect({ + id: '1', + voter: 'a', + data: '1-2', + }) + + await request(app.callback()) + .get('/generic/_/gov/vote?id=1&voter=b&block=3:3') + .set('x-api-key', options.apiKey) + .expect(200) + .expect({ + id: '1', + voter: 'b', + data: '1-2', + }) + + // Returns latest if no block. + await request(app.callback()) + .get('/generic/_/gov/vote?id=1&voter=b') + .set('x-api-key', options.apiKey) + .expect(200) + .expect({ + id: '1', + voter: 'b', + data: '1-2', + }) + }) + + it('returns correct vote response for multiple blocks', async () => { + await request(app.callback()) + .get('/generic/_/gov/vote?id=1&voter=a&blocks=1:1..3:3') + .set('x-api-key', options.apiKey) + .expect(200) + .expect([ + { + value: { + id: '1', + voter: 'a', + data: '1-1', + }, + blockHeight: 1, + blockTimeUnixMs: 1, + }, + { + value: { + id: '1', + voter: 'a', + data: '1-2', + }, + blockHeight: 2, + blockTimeUnixMs: 2, + }, + ]) + + await request(app.callback()) + .get('/generic/_/gov/vote?id=1&voter=a&blocks=1:1..3:3&blockStep=2') + .set('x-api-key', options.apiKey) + .expect(200) + .expect([ + { + at: '1', + value: { + id: '1', + voter: 'a', + data: '1-1', + }, + blockHeight: 1, + blockTimeUnixMs: 1, + }, + { + at: '3', + value: { + id: '1', + voter: 'a', + data: '1-2', + }, + blockHeight: 2, + blockTimeUnixMs: 2, + }, + ]) + }) + + it('returns correct vote response for multiple times', async () => { + await request(app.callback()) + .get('/generic/_/gov/vote?id=1&voter=a×=1..3') + .set('x-api-key', options.apiKey) + .expect(200) + .expect([ + { + value: { + id: '1', + voter: 'a', + data: '1-1', + }, + blockHeight: 1, + blockTimeUnixMs: 1, + }, + { + value: { + id: '1', + voter: 'a', + data: '1-2', + }, + blockHeight: 2, + blockTimeUnixMs: 2, + }, + ]) + + await request(app.callback()) + .get('/generic/_/gov/vote?id=1&voter=a×=1..3&timeStep=2') + .set('x-api-key', options.apiKey) + .expect(200) + .expect([ + { + at: '1', + value: { + id: '1', + voter: 'a', + data: '1-1', + }, + blockHeight: 1, + blockTimeUnixMs: 1, + }, + { + at: '3', + value: { + id: '1', + voter: 'a', + data: '1-2', + }, + blockHeight: 2, + blockTimeUnixMs: 2, + }, + ]) + }) + + it('returns correct votes response for a single block', async () => { + await request(app.callback()) + .get('/generic/_/gov/votes?id=1&block=1:1') + .set('x-api-key', options.apiKey) + .expect(200) + .expect({ + votes: [ + { + id: '1', + voter: 'a', + data: '1-1', + }, + ], + total: 1, + }) + + await request(app.callback()) + .get('/generic/_/gov/votes?id=1&block=3:3') + .set('x-api-key', options.apiKey) + .expect(200) + .expect({ + votes: [ + { + id: '1', + voter: 'a', + data: '1-2', + }, + { + id: '1', + voter: 'b', + data: '1-2', + }, + ], + total: 2, + }) + + // Returns latest if no block. + await request(app.callback()) + .get('/generic/_/gov/votes?id=1') + .set('x-api-key', options.apiKey) + .expect(200) + .expect({ + votes: [ + { + id: '1', + voter: 'a', + data: '1-2', + }, + { + id: '1', + voter: 'b', + data: '1-2', + }, + ], + total: 2, + }) + }) + + it('returns correct votes response for multiple blocks', async () => { + await request(app.callback()) + .get('/generic/_/gov/votes?id=1&blocks=1:1..3:3') + .set('x-api-key', options.apiKey) + .expect(200) + .expect([ + { + value: { + votes: [ + { + id: '1', + voter: 'a', + data: '1-1', + }, + ], + total: 1, + }, + blockHeight: 1, + blockTimeUnixMs: 1, + }, + { + value: { + votes: [ + { + id: '1', + voter: 'a', + data: '1-2', + }, + { + id: '1', + voter: 'b', + data: '1-2', + }, + ], + total: 2, + }, + blockHeight: 2, + blockTimeUnixMs: 2, + }, + ]) + + await request(app.callback()) + .get('/generic/_/gov/votes?id=1&blocks=1:1..3:3&blockStep=2') + .set('x-api-key', options.apiKey) + .expect(200) + .expect([ + { + at: '1', + value: { + votes: [ + { + id: '1', + voter: 'a', + data: '1-1', + }, + ], + total: 1, + }, + blockHeight: 1, + blockTimeUnixMs: 1, + }, + { + at: '3', + value: { + votes: [ + { + id: '1', + voter: 'a', + data: '1-2', + }, + { + id: '1', + voter: 'b', + data: '1-2', + }, + ], + total: 2, + }, + blockHeight: 2, + blockTimeUnixMs: 2, + }, + ]) + }) + + it('returns correct votes response for multiple times', async () => { + await request(app.callback()) + .get('/generic/_/gov/votes?id=1×=1..3') + .set('x-api-key', options.apiKey) + .expect(200) + .expect([ + { + value: { + votes: [ + { + id: '1', + voter: 'a', + data: '1-1', + }, + ], + total: 1, + }, + blockHeight: 1, + blockTimeUnixMs: 1, + }, + { + value: { + votes: [ + { + id: '1', + voter: 'a', + data: '1-2', + }, + { + id: '1', + voter: 'b', + data: '1-2', + }, + ], + total: 2, + }, + blockHeight: 2, + blockTimeUnixMs: 2, + }, + ]) + + await request(app.callback()) + .get('/generic/_/gov/votes?id=1×=1..3&timeStep=2') + .set('x-api-key', options.apiKey) + .expect(200) + .expect([ + { + at: '1', + value: { + votes: [ + { + id: '1', + voter: 'a', + data: '1-1', + }, + ], + total: 1, + }, + blockHeight: 1, + blockTimeUnixMs: 1, + }, + { + at: '3', + value: { + votes: [ + { + id: '1', + voter: 'a', + data: '1-2', + }, + { + id: '1', + voter: 'b', + data: '1-2', + }, + ], + total: 2, + }, + blockHeight: 2, + blockTimeUnixMs: 2, + }, + ]) + }) + }) }) } diff --git a/src/tracer/handlers/gov.ts b/src/tracer/handlers/gov.ts index 8cb4ab95..eb13a500 100644 --- a/src/tracer/handlers/gov.ts +++ b/src/tracer/handlers/gov.ts @@ -1,9 +1,10 @@ -import { fromBase64 } from '@cosmjs/encoding' +import { fromBase64, toBech32 } from '@cosmjs/encoding' import retry from 'async-await-retry' import { Sequelize } from 'sequelize' import { - GovStateEvent, + GovProposal, + GovProposalVote, State, updateComputationValidityDependentOnChanges, } from '@/db' @@ -12,69 +13,140 @@ import { Handler, HandlerMaker, ParsedGovStateEvent } from '@/types' const STORE_NAME = 'gov' export const gov: HandlerMaker = async ({ + config: { bech32Prefix }, updateComputations, }) => { const match: Handler['match'] = (trace) => { // ProposalsKeyPrefix = 0x00 - // gov keys are formatted as: + // proposla keys are formatted as: // ProposalsKeyPrefix || proposalIdBytes - // Not sure why a proposal would ever be deleted... - if (trace.operation === 'delete') { - return - } - - const keyData = fromBase64(trace.key) - if (keyData[0] !== 0x00 || keyData.length !== 9) { - return - } + // VotesKeyPrefix = 0x20 + // vote keys are formatted as: + // VotesKeyPrefix || proposalIdBytes || voterAddressBytes - let proposalId - try { - proposalId = Buffer.from(keyData.slice(1)).readBigUInt64BE().toString() - } catch { - // Ignore decoding errors. + // Proposals should never be deleted, and votes get cleared when the + // proposal closes. We can ignore all of these since we want to maintain + // history. + if (trace.operation === 'delete' || !trace.value) { return } - // Get code ID and block timestamp from chain. - const blockHeight = BigInt(trace.metadata.blockHeight).toString() - - const blockTimeUnixMs = BigInt(trace.blockTimeUnixMs).toString() - const blockTimestamp = new Date(trace.blockTimeUnixMs) - - // If no value, ignore. - if (!trace.value) { - return - } - - return { - id: [blockHeight, proposalId].join(':'), - proposalId, - blockHeight, - blockTimeUnixMs, - blockTimestamp, - data: trace.value, + const keyData = fromBase64(trace.key) + switch (keyData[0]) { + // proposals + case 0x00: { + if (keyData.length !== 9) { + return + } + + let proposalId + try { + proposalId = Buffer.from(keyData.slice(1)) + .readBigUInt64BE() + .toString() + } catch { + // Ignore decoding errors. + return + } + + // Get code ID and block timestamp from chain. + const blockHeight = BigInt(trace.metadata.blockHeight).toString() + + const blockTimeUnixMs = BigInt(trace.blockTimeUnixMs).toString() + const blockTimestamp = new Date(trace.blockTimeUnixMs) + + return { + id: [blockHeight, proposalId].join(':'), + type: 'proposal', + data: { + proposalId, + blockHeight, + blockTimeUnixMs, + blockTimestamp, + data: trace.value, + }, + } + } + // votes + case 0x20: { + let proposalId + try { + proposalId = Buffer.from(keyData.slice(1)) + .readBigUInt64BE() + .toString() + } catch { + // Ignore decoding errors. + return + } + + // Get code ID and block timestamp from chain. + const blockHeight = BigInt(trace.metadata.blockHeight).toString() + + const blockTimeUnixMs = BigInt(trace.blockTimeUnixMs).toString() + const blockTimestamp = new Date(trace.blockTimeUnixMs) + + // Address is length-prefixed. + const addressLength = keyData[9] + if (keyData.length !== 10 + addressLength) { + return + } + + const voterAddress = toBech32(bech32Prefix, keyData.slice(10)) + + return { + id: [blockHeight, proposalId, voterAddress].join(':'), + type: 'vote', + data: { + proposalId, + voterAddress, + blockHeight, + blockTimeUnixMs, + blockTimestamp, + data: trace.value, + }, + } + } } } const process: Handler['process'] = async (events) => { - const exportEvents = async () => - // Unique index on [blockHeight, proposalId] ensures that we don't insert - // duplicate events. If we encounter a duplicate, we update the `data` - // field in case event processing for a block was batched separately. - events.length > 0 - ? await GovStateEvent.bulkCreate(events, { - updateOnDuplicate: ['data'], - }) - : [] + const exportEvents = async () => { + const proposals = events.flatMap((e) => + e.type === 'proposal' ? e.data : [] + ) + const votes = events.flatMap((e) => (e.type === 'vote' ? e.data : [])) + + return await Promise.all([ + ...(proposals.length > 0 + ? [ + GovProposal.bulkCreate(proposals, { + // Unique index ensures that we don't insert duplicate events. + // If we encounter a duplicate, we update the `data` field in + // case event processing for a block was batched separately. + updateOnDuplicate: ['data'], + }), + ] + : []), + ...(votes.length > 0 + ? [ + GovProposalVote.bulkCreate(votes, { + // Unique index ensures that we don't insert duplicate events. + // If we encounter a duplicate, we update the `data` field in + // case event processing for a block was batched separately. + updateOnDuplicate: ['data'], + }), + ] + : []), + ]) + } // Retry 3 times with exponential backoff starting at 100ms delay. const exportedEvents = (await retry(exportEvents, [], { retriesMax: 3, exponential: true, interval: 100, - })) as GovStateEvent[] + })) as GovProposal[] if (updateComputations) { await updateComputationValidityDependentOnChanges(exportedEvents) diff --git a/src/types/db.ts b/src/types/db.ts index bd872c37..2ecbb4a9 100644 --- a/src/types/db.ts +++ b/src/types/db.ts @@ -19,7 +19,8 @@ export enum DependentKeyNamespace { WasmTxEvent = 'wasm_tx', StakingSlash = 'staking_slash', BankStateEvent = 'bank_state', - GovStateEvent = 'gov_state', + GovProposal = 'gov_proposal', + GovProposalVote = 'gov_proposal_vote', DistributionCommunityPoolStateEvent = 'distribution_community_pool_state', } diff --git a/src/types/formulas.ts b/src/types/formulas.ts index 95a80498..a9678aaf 100644 --- a/src/types/formulas.ts +++ b/src/types/formulas.ts @@ -1,4 +1,8 @@ -import { ProposalStatus } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1/gov' +import { + ProposalStatus, + VoteOption, + WeightedVoteOption, +} from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1/gov' import { BindOrReplacements, WhereOptions } from 'sequelize' import type { Contract, StakingSlashEvent, WasmTxEvent } from '@/db' @@ -189,6 +193,30 @@ export type FormulaDecodedProposalObject = { votingEndTime?: number } +export type FormulaProposalVoteObject = { + id: string + voter: string + data: string +} + +export type FormulaDecodedProposalVoteObject = { + id: number + voter: string + data: string + /** + * If only one full-weight vote cast, this will be the chosen option. + */ + vote?: VoteOption + /** + * All weighted votes. + */ + weightedOptions: WeightedVoteOption[] + /** + * V1 votes may have metadata attached. + */ + metadata?: string +} + export type FormulaProposalGetter = ( proposalId: string ) => Promise @@ -201,6 +229,22 @@ export type FormulaProposalsGetter = ( export type FormulaProposalCountGetter = () => Promise +export type FormulaProposalVoteGetter = ( + proposalId: string, + voter: string +) => Promise + +export type FormulaProposalVotesGetter = ( + proposalId: string, + ascending?: boolean, + limit?: number, + offset?: number +) => Promise + +export type FormulaProposalVoteCountGetter = ( + proposalId: string +) => Promise + export type FormulaQuerier = ( query: string, bindParams?: BindOrReplacements @@ -241,6 +285,9 @@ export type Env = {}> = { getProposal: FormulaProposalGetter getProposals: FormulaProposalsGetter getProposalCount: FormulaProposalCountGetter + getProposalVote: FormulaProposalVoteGetter + getProposalVotes: FormulaProposalVotesGetter + getProposalVoteCount: FormulaProposalVoteCountGetter getCommunityPoolBalances: FormulaCommunityPoolBalancesGetter /** diff --git a/src/types/tracer.ts b/src/types/tracer.ts index 5a01ac5d..22d323a4 100644 --- a/src/types/tracer.ts +++ b/src/types/tracer.ts @@ -80,13 +80,28 @@ export type WasmExportData = } } -export type ParsedGovStateEvent = { - proposalId: string - blockHeight: string - blockTimeUnixMs: string - blockTimestamp: Date - data: string -} +export type ParsedGovStateEvent = + | { + type: 'proposal' + data: { + proposalId: string + blockHeight: string + blockTimeUnixMs: string + blockTimestamp: Date + data: string + } + } + | { + type: 'vote' + data: { + proposalId: string + voterAddress: string + blockHeight: string + blockTimeUnixMs: string + blockTimestamp: Date + data: string + } + } export type ParsedDistributionCommunityPoolStateEvent = { blockHeight: string diff --git a/src/types/webhooks.ts b/src/types/webhooks.ts index 322e71d0..d39080d7 100644 --- a/src/types/webhooks.ts +++ b/src/types/webhooks.ts @@ -30,7 +30,7 @@ export type Webhook< /** * Required to filter events by type. This should be set to the class itself * of the type of event to consider. This can be any class that extends - * DependableEventModel, such as WasmStateEvent or GovStateEvent. + * DependableEventModel, such as WasmStateEvent or GovProposal. */ EventType: new (...args: any) => Event } & Partial<{ diff --git a/src/utils/gov.ts b/src/utils/gov.ts index c6506092..09b5ce91 100644 --- a/src/utils/gov.ts +++ b/src/utils/gov.ts @@ -2,8 +2,12 @@ import { fromBase64 } from '@cosmjs/encoding' import { ProposalStatus, Proposal as ProposalV1, + Vote as VoteV1, } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1/gov' -import { Proposal as ProposalV1Beta1 } from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1beta1/gov' +import { + Proposal as ProposalV1Beta1, + Vote as VoteV1Beta1, +} from '@dao-dao/types/protobuf/codegen/cosmos/gov/v1beta1/gov' /** * Potentially decode a base64 string for a gov proposal. @@ -48,3 +52,21 @@ export const decodeGovProposal = ( status, } } + +/** + * Potentially decode a base64 string for a gov proposal vote. + */ +export const decodeGovProposalVote = ( + base64Data: string +): VoteV1 | VoteV1Beta1 | undefined => { + let decoded: VoteV1 | VoteV1Beta1 | undefined + try { + decoded = VoteV1.decode(fromBase64(base64Data)) + } catch { + try { + decoded = VoteV1Beta1.decode(fromBase64(base64Data)) + } catch {} + } + + return decoded +} diff --git a/src/webhooks/webhooks/notify/gov.ts b/src/webhooks/webhooks/notify/gov.ts index 48d8f257..68b0a008 100644 --- a/src/webhooks/webhooks/notify/gov.ts +++ b/src/webhooks/webhooks/notify/gov.ts @@ -5,17 +5,17 @@ import { getImageUrlForChainId, } from '@dao-dao/utils' -import { GovStateEvent } from '@/db' +import { GovProposal } from '@/db' import { WebhookMaker, WebhookType } from '@/types' import { decodeGovProposal } from '@/utils' // Fire webhook when a gov proposal is created. -export const makeInboxGovProposalCreated: WebhookMaker = ( +export const makeInboxGovProposalCreated: WebhookMaker = ( config, state ) => ({ filter: { - EventType: GovStateEvent, + EventType: GovProposal, }, endpoint: { type: WebhookType.Url, @@ -64,12 +64,12 @@ export const makeInboxGovProposalCreated: WebhookMaker = ( }) // Fire webhook when a gov proposal is passed (or passed + execution failed). -export const makeInboxGovProposalPassed: WebhookMaker = ( +export const makeInboxGovProposalPassed: WebhookMaker = ( config, state ) => ({ filter: { - EventType: GovStateEvent, + EventType: GovProposal, }, endpoint: { type: WebhookType.Url, @@ -122,12 +122,12 @@ export const makeInboxGovProposalPassed: WebhookMaker = ( }) // Fire webhook when a gov proposal is rejected. -export const makeInboxGovProposalRejected: WebhookMaker = ( +export const makeInboxGovProposalRejected: WebhookMaker = ( config, state ) => ({ filter: { - EventType: GovStateEvent, + EventType: GovProposal, }, endpoint: { type: WebhookType.Url, diff --git a/src/webhooks/webhooks/websockets.ts b/src/webhooks/webhooks/websockets.ts index 975e8e36..0ba20bc6 100644 --- a/src/webhooks/webhooks/websockets.ts +++ b/src/webhooks/webhooks/websockets.ts @@ -1,6 +1,6 @@ import { getConfiguredChainConfig } from '@dao-dao/utils' -import { GovStateEvent, State, WasmStateEvent } from '@/db' +import { GovProposal, State, WasmStateEvent } from '@/db' import { activeProposalModules } from '@/formulas/formulas/contract/daoCore/base' import { Webhook, WebhookMaker, WebhookType } from '@/types' import { dbKeyForKeys, dbKeyToKeys, decodeGovProposal } from '@/utils' @@ -144,13 +144,13 @@ export const makeDaoProposalStatusChanged: WebhookMaker = ( // Broadcast to WebSockets when a gov proposal status changes, including // creation. -export const makeGovProposalStatusChanged: WebhookMaker = ( +export const makeGovProposalStatusChanged: WebhookMaker = ( config, state ) => config.soketi && { filter: { - EventType: GovStateEvent, + EventType: GovProposal, }, endpoint: { type: WebhookType.Soketi,