diff --git a/src/modules/governance/votes/alerts/alert-generator.util.ts b/src/modules/governance/votes/alerts/alert-generator.util.ts new file mode 100644 index 0000000..fc54994 --- /dev/null +++ b/src/modules/governance/votes/alerts/alert-generator.util.ts @@ -0,0 +1,132 @@ +import { VoteOutcome, ProposalImpact, AlertSeverity } from '../enums'; + +export interface AlertData { + proposalId: string; + chainId: number; + proposalTitle: string; + alertType: string; + severity: AlertSeverity; + outcome?: string; + message: string; + proposalLink?: string; + network?: string; + metadata?: Record; + impact?: ProposalImpact; +} + +const OUTCOME_ALERT_MAP: Record AlertData | null> = { + [VoteOutcome.Passed]: data => ({ + ...data, + alertType: 'PROPOSAL_PASSED', + severity: + data.impact === ProposalImpact.SecurityRelated ? AlertSeverity.Critical : AlertSeverity.High, + outcome: VoteOutcome.Passed, + message: `Proposal "${data.proposalTitle}" has passed voting`, + }), + [VoteOutcome.Rejected]: data => ({ + ...data, + alertType: 'PROPOSAL_REJECTED', + severity: AlertSeverity.Medium, + outcome: VoteOutcome.Rejected, + message: `Proposal "${data.proposalTitle}" has been rejected`, + }), + [VoteOutcome.Executed]: data => ({ + ...data, + alertType: 'PROPOSAL_EXECUTED', + severity: + data.impact === ProposalImpact.SecurityRelated ? AlertSeverity.Critical : AlertSeverity.High, + outcome: VoteOutcome.Executed, + message: `Proposal "${data.proposalTitle}" has been executed`, + }), + [VoteOutcome.Expired]: data => ({ + ...data, + alertType: 'PROPOSAL_EXPIRED', + severity: AlertSeverity.Low, + outcome: VoteOutcome.Expired, + message: `Proposal "${data.proposalTitle}" has expired without execution`, + }), + [VoteOutcome.Cancelled]: data => ({ + ...data, + alertType: 'PROPOSAL_CANCELLED', + severity: AlertSeverity.Low, + outcome: VoteOutcome.Cancelled, + message: `Proposal "${data.proposalTitle}" has been cancelled`, + }), + [VoteOutcome.Pending]: () => null, +}; + +const IMPACT_ALERT_MAP: Record = { + [ProposalImpact.ProtocolUpgrade]: AlertSeverity.High, + [ProposalImpact.ValidatorChange]: AlertSeverity.High, + [ProposalImpact.TreasuryChange]: AlertSeverity.Medium, + [ProposalImpact.GovernanceParameterChange]: AlertSeverity.Medium, + [ProposalImpact.ContractMigration]: AlertSeverity.High, + [ProposalImpact.SecurityRelated]: AlertSeverity.Critical, + [ProposalImpact.LowImpact]: AlertSeverity.Low, +}; + +export function generateAlertForOutcome( + proposalId: string, + chainId: number, + proposalTitle: string, + outcome: VoteOutcome, + impact: ProposalImpact, + proposalLink?: string, + network?: string, +): AlertData | null { + const baseData: AlertData = { + proposalId, + chainId, + proposalTitle, + alertType: '', + severity: AlertSeverity.Info, + outcome, + message: '', + proposalLink, + network, + metadata: { impact }, + }; + + const alertGenerator = OUTCOME_ALERT_MAP[outcome]; + if (!alertGenerator) { + return null; + } + + const alertData = alertGenerator(baseData); + if (!alertData) { + return null; + } + + alertData.severity = IMPACT_ALERT_MAP[impact] || alertData.severity; + + if (impact === ProposalImpact.SecurityRelated && outcome === VoteOutcome.Passed) { + alertData.alertType = 'EMERGENCY_PROPOSAL_APPROVED'; + alertData.message = `EMERGENCY: Security-related proposal "${proposalTitle}" has been approved`; + } + + if (impact === ProposalImpact.ProtocolUpgrade && outcome === VoteOutcome.Passed) { + alertData.alertType = 'HIGH_IMPACT_PROTOCOL_UPGRADE'; + alertData.message = `HIGH IMPACT: Protocol upgrade proposal "${proposalTitle}" has been approved`; + } + + if (impact === ProposalImpact.TreasuryChange && outcome === VoteOutcome.Passed) { + alertData.alertType = 'TREASURY_PROPOSAL_APPROVED'; + alertData.message = `Treasury proposal "${proposalTitle}" has been approved`; + } + + return alertData; +} + +export function getNetworkName(chainId: number): string { + const networkMap: Record = { + 1: 'Ethereum Mainnet', + 10: 'Optimism', + 56: 'BSC', + 137: 'Polygon', + 250: 'Fantom', + 42161: 'Arbitrum', + 43114: 'Avalanche', + }; + + return networkMap[chainId] || `Chain ${chainId}`; +} diff --git a/src/modules/governance/votes/alerts/index.ts b/src/modules/governance/votes/alerts/index.ts new file mode 100644 index 0000000..1f6629e --- /dev/null +++ b/src/modules/governance/votes/alerts/index.ts @@ -0,0 +1 @@ +export * from './alert-generator.util'; diff --git a/src/modules/governance/votes/dto/create-vote-alert.dto.ts b/src/modules/governance/votes/dto/create-vote-alert.dto.ts new file mode 100644 index 0000000..d7adc13 --- /dev/null +++ b/src/modules/governance/votes/dto/create-vote-alert.dto.ts @@ -0,0 +1,14 @@ +import { AlertSeverity } from '../enums/alert-severity.enum'; + +export class CreateVoteAlertDto { + proposalId!: string; + chainId!: number; + proposalTitle!: string; + alertType!: string; + severity?: AlertSeverity; + outcome?: string; + message!: string; + proposalLink?: string; + network?: string; + metadata?: Record; +} diff --git a/src/modules/governance/votes/dto/create-vote-outcome.dto.ts b/src/modules/governance/votes/dto/create-vote-outcome.dto.ts new file mode 100644 index 0000000..02535fb --- /dev/null +++ b/src/modules/governance/votes/dto/create-vote-outcome.dto.ts @@ -0,0 +1,25 @@ +import { VoteOutcome } from '../enums/vote-outcome.enum'; +import { ProposalType } from '../enums/proposal-type.enum'; +import { ProposalImpact } from '../enums/proposal-impact.enum'; + +export class CreateVoteOutcomeDto { + proposalId!: string; + chainId!: number; + proposalTitle!: string; + proposalDescription?: string; + proposalType?: ProposalType; + proposalImpact?: ProposalImpact; + outcome?: VoteOutcome; + votingStartTime!: Date; + votingEndTime!: Date; + votingEndedAt?: Date; + executionTimestamp?: Date; + totalVotes?: string; + yesVotes?: string; + noVotes?: string; + abstainVotes?: string; + vetoVotes?: string; + participationPercentage?: number; + proposalLink?: string; + previousState?: string; +} diff --git a/src/modules/governance/votes/dto/index.ts b/src/modules/governance/votes/dto/index.ts new file mode 100644 index 0000000..185548c --- /dev/null +++ b/src/modules/governance/votes/dto/index.ts @@ -0,0 +1,4 @@ +export * from './create-vote-outcome.dto'; +export * from './update-vote-outcome.dto'; +export * from './vote-outcome-query.dto'; +export * from './create-vote-alert.dto'; diff --git a/src/modules/governance/votes/dto/update-vote-outcome.dto.ts b/src/modules/governance/votes/dto/update-vote-outcome.dto.ts new file mode 100644 index 0000000..56ef99d --- /dev/null +++ b/src/modules/governance/votes/dto/update-vote-outcome.dto.ts @@ -0,0 +1,19 @@ +import { VoteOutcome } from '../enums/vote-outcome.enum'; +import { ProposalType } from '../enums/proposal-type.enum'; +import { ProposalImpact } from '../enums/proposal-impact.enum'; + +export class UpdateVoteOutcomeDto { + outcome?: VoteOutcome; + votingEndedAt?: Date; + executionTimestamp?: Date; + totalVotes?: string; + yesVotes?: string; + noVotes?: string; + abstainVotes?: string; + vetoVotes?: string; + participationPercentage?: number; + proposalType?: ProposalType; + proposalImpact?: ProposalImpact; + processed?: boolean; + previousState?: string; +} diff --git a/src/modules/governance/votes/dto/vote-outcome-query.dto.ts b/src/modules/governance/votes/dto/vote-outcome-query.dto.ts new file mode 100644 index 0000000..0c5772b --- /dev/null +++ b/src/modules/governance/votes/dto/vote-outcome-query.dto.ts @@ -0,0 +1,16 @@ +import { VoteOutcome } from '../enums/vote-outcome.enum'; +import { ProposalType } from '../enums/proposal-type.enum'; +import { ProposalImpact } from '../enums/proposal-impact.enum'; + +export class VoteOutcomeQueryDto { + chainId?: number; + proposalId?: string; + outcome?: VoteOutcome; + proposalType?: ProposalType; + proposalImpact?: ProposalImpact; + fromVotingEndedAt?: Date; + toVotingEndedAt?: Date; + processed?: boolean; + limit?: number; + offset?: number; +} diff --git a/src/modules/governance/votes/entities/vote-alert.entity.ts b/src/modules/governance/votes/entities/vote-alert.entity.ts new file mode 100644 index 0000000..51986e9 --- /dev/null +++ b/src/modules/governance/votes/entities/vote-alert.entity.ts @@ -0,0 +1,55 @@ +import { Entity, PrimaryGeneratedColumn, Column, Index, CreateDateColumn } from 'typeorm'; +import { AlertSeverity } from '../enums/alert-severity.enum'; + +@Entity('governance_vote_alerts') +@Index(['proposalId', 'chainId']) +@Index(['chainId', 'severity']) +@Index(['alertType']) +@Index(['createdAt']) +export class VoteAlertEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'proposal_id' }) + proposalId!: string; + + @Column({ name: 'chain_id' }) + chainId!: number; + + @Column({ name: 'proposal_title' }) + proposalTitle!: string; + + @Column({ name: 'alert_type' }) + alertType!: string; + + @Column({ + type: 'varchar', + enum: AlertSeverity, + default: AlertSeverity.Info, + }) + severity!: AlertSeverity; + + @Column({ name: 'outcome', nullable: true }) + outcome?: string; + + @Column({ name: 'message', type: 'text' }) + message!: string; + + @Column({ name: 'proposal_link', nullable: true }) + proposalLink?: string; + + @Column({ name: 'network', nullable: true }) + network?: string; + + @Column({ name: 'notified', type: 'boolean', default: false }) + notified!: boolean; + + @Column({ name: 'notification_sent_at', type: 'timestamp', nullable: true }) + notificationSentAt?: Date; + + @Column({ type: 'simple-json', nullable: true }) + metadata?: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/src/modules/governance/votes/entities/vote-outcome.entity.ts b/src/modules/governance/votes/entities/vote-outcome.entity.ts new file mode 100644 index 0000000..5dd23fa --- /dev/null +++ b/src/modules/governance/votes/entities/vote-outcome.entity.ts @@ -0,0 +1,106 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { VoteOutcome } from '../enums/vote-outcome.enum'; +import { ProposalType } from '../enums/proposal-type.enum'; +import { ProposalImpact } from '../enums/proposal-impact.enum'; + +@Entity('governance_vote_outcomes') +@Index(['proposalId', 'chainId'], { unique: true }) +@Index(['chainId', 'outcome']) +@Index(['chainId', 'votingEndedAt']) +@Index(['proposalType']) +@Index(['proposalImpact']) +export class VoteOutcomeEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'proposal_id' }) + proposalId!: string; + + @Column({ name: 'chain_id' }) + chainId!: number; + + @Column({ name: 'proposal_title' }) + proposalTitle!: string; + + @Column({ name: 'proposal_description', type: 'text', nullable: true }) + proposalDescription?: string; + + @Column({ + type: 'varchar', + enum: ProposalType, + default: ProposalType.Other, + }) + proposalType!: ProposalType; + + @Column({ + type: 'varchar', + enum: ProposalImpact, + default: ProposalImpact.LowImpact, + }) + proposalImpact!: ProposalImpact; + + @Column({ + type: 'varchar', + enum: VoteOutcome, + default: VoteOutcome.Pending, + }) + outcome!: VoteOutcome; + + @Column({ name: 'voting_start_time', type: 'timestamp' }) + votingStartTime!: Date; + + @Column({ name: 'voting_end_time', type: 'timestamp' }) + votingEndTime!: Date; + + @Column({ name: 'voting_ended_at', type: 'timestamp', nullable: true }) + votingEndedAt?: Date; + + @Column({ name: 'execution_timestamp', type: 'timestamp', nullable: true }) + executionTimestamp?: Date; + + @Column({ name: 'total_votes', default: '0' }) + totalVotes!: string; + + @Column({ name: 'yes_votes', default: '0' }) + yesVotes!: string; + + @Column({ name: 'no_votes', default: '0' }) + noVotes!: string; + + @Column({ name: 'abstain_votes', default: '0' }) + abstainVotes!: string; + + @Column({ name: 'veto_votes', default: '0' }) + vetoVotes!: string; + + @Column({ + name: 'participation_percentage', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + }) + participationPercentage?: number; + + @Column({ name: 'proposal_link', nullable: true }) + proposalLink?: string; + + @Column({ name: 'previous_state', nullable: true }) + previousState?: string; + + @Column({ name: 'processed', type: 'boolean', default: false }) + processed!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/src/modules/governance/votes/enums/alert-severity.enum.ts b/src/modules/governance/votes/enums/alert-severity.enum.ts new file mode 100644 index 0000000..b4cd075 --- /dev/null +++ b/src/modules/governance/votes/enums/alert-severity.enum.ts @@ -0,0 +1,7 @@ +export enum AlertSeverity { + Critical = 'CRITICAL', + High = 'HIGH', + Medium = 'MEDIUM', + Low = 'LOW', + Info = 'INFO', +} diff --git a/src/modules/governance/votes/enums/index.ts b/src/modules/governance/votes/enums/index.ts new file mode 100644 index 0000000..22cbdd6 --- /dev/null +++ b/src/modules/governance/votes/enums/index.ts @@ -0,0 +1,5 @@ +export * from './vote-outcome.enum'; +export * from './vote-status.enum'; +export * from './proposal-type.enum'; +export * from './alert-severity.enum'; +export * from './proposal-impact.enum'; diff --git a/src/modules/governance/votes/enums/proposal-impact.enum.ts b/src/modules/governance/votes/enums/proposal-impact.enum.ts new file mode 100644 index 0000000..23ca600 --- /dev/null +++ b/src/modules/governance/votes/enums/proposal-impact.enum.ts @@ -0,0 +1,9 @@ +export enum ProposalImpact { + ProtocolUpgrade = 'PROTOCOL_UPGRADE', + ValidatorChange = 'VALIDATOR_CHANGE', + TreasuryChange = 'TREASURY_CHANGE', + GovernanceParameterChange = 'GOVERNANCE_PARAMETER_CHANGE', + ContractMigration = 'CONTRACT_MIGRATION', + SecurityRelated = 'SECURITY_RELATED', + LowImpact = 'LOW_IMPACT', +} diff --git a/src/modules/governance/votes/enums/proposal-type.enum.ts b/src/modules/governance/votes/enums/proposal-type.enum.ts new file mode 100644 index 0000000..ba57ba9 --- /dev/null +++ b/src/modules/governance/votes/enums/proposal-type.enum.ts @@ -0,0 +1,11 @@ +export enum ProposalType { + ProtocolUpgrade = 'PROTOCOL_UPGRADE', + ParameterChange = 'PARAMETER_CHANGE', + TreasuryAllocation = 'TREASURY_ALLOCATION', + ValidatorChange = 'VALIDATOR_CHANGE', + ContractMigration = 'CONTRACT_MIGRATION', + SecurityProposal = 'SECURITY_PROPOSAL', + EmergencyAction = 'EMERGENCY_ACTION', + NetworkFeature = 'NETWORK_FEATURE', + Other = 'OTHER', +} diff --git a/src/modules/governance/votes/enums/vote-outcome.enum.ts b/src/modules/governance/votes/enums/vote-outcome.enum.ts new file mode 100644 index 0000000..3634573 --- /dev/null +++ b/src/modules/governance/votes/enums/vote-outcome.enum.ts @@ -0,0 +1,8 @@ +export enum VoteOutcome { + Passed = 'PASSED', + Rejected = 'REJECTED', + Executed = 'EXECUTED', + Expired = 'EXPIRED', + Cancelled = 'CANCELLED', + Pending = 'PENDING', +} diff --git a/src/modules/governance/votes/enums/vote-status.enum.ts b/src/modules/governance/votes/enums/vote-status.enum.ts new file mode 100644 index 0000000..06056cb --- /dev/null +++ b/src/modules/governance/votes/enums/vote-status.enum.ts @@ -0,0 +1,7 @@ +export enum VoteStatus { + Active = 'ACTIVE', + Completed = 'COMPLETED', + Queued = 'QUEUED', + Cancelled = 'CANCELLED', + Expired = 'EXPIRED', +} diff --git a/src/modules/governance/votes/governance-vote.processor.ts b/src/modules/governance/votes/governance-vote.processor.ts new file mode 100644 index 0000000..79af001 --- /dev/null +++ b/src/modules/governance/votes/governance-vote.processor.ts @@ -0,0 +1,80 @@ +import { Logger } from '../../../utils/logger'; +import { GovernanceVoteRepository } from './governance-vote.repository'; +import { VoteMonitoringResult } from './interfaces'; +import { IGovernanceVoteProcessor } from './interfaces/governance-vote-service.interface'; +import { generateAlertForOutcome } from './alerts/alert-generator.util'; + +export class GovernanceVoteProcessor implements IGovernanceVoteProcessor { + private logger: Logger; + + constructor(private voteRepository: GovernanceVoteRepository) { + this.logger = new Logger('GovernanceVoteProcessor'); + } + + async processVoteResult(result: VoteMonitoringResult): Promise { + this.logger.debug(`Processing vote result for proposal ${result.proposalId}`); + + if (!result.stateChanged && !result.votesUpdated) { + this.logger.debug(`No changes detected for proposal ${result.proposalId}, skipping`); + return; + } + + if (result.stateChanged) { + await this.handleStateChange(result); + } + + if (result.votesUpdated) { + await this.handleVoteUpdate(result); + } + + await this.voteRepository.markAsProcessed(result.proposalId, result.chainId); + } + + async processVoteResults(results: VoteMonitoringResult[]): Promise { + this.logger.info(`Processing ${results.length} vote results`); + + for (const result of results) { + try { + await this.processVoteResult(result); + } catch (error) { + this.logger.error(`Failed to process vote result for proposal ${result.proposalId}`, error); + } + } + + this.logger.info(`Completed processing ${results.length} vote results`); + } + + private async handleStateChange(result: VoteMonitoringResult): Promise { + this.logger.info( + `State changed for proposal ${result.proposalId}: ${result.previousOutcome} -> ${result.currentOutcome}`, + ); + + const voteOutcome = await this.voteRepository.findVoteOutcome( + result.proposalId, + result.chainId, + ); + + if (!voteOutcome) { + this.logger.warn(`Vote outcome not found for proposal ${result.proposalId}`); + return; + } + + const alert = generateAlertForOutcome( + result.proposalId, + result.chainId, + voteOutcome.proposalTitle, + result.currentOutcome, + voteOutcome.proposalImpact, + voteOutcome.proposalLink, + ); + + if (alert) { + await this.voteRepository.createAlert(alert); + this.logger.info(`Generated alert for proposal ${result.proposalId}: ${alert.alertType}`); + } + } + + private async handleVoteUpdate(result: VoteMonitoringResult): Promise { + this.logger.debug(`Votes updated for proposal ${result.proposalId}`); + } +} diff --git a/src/modules/governance/votes/governance-vote.repository.ts b/src/modules/governance/votes/governance-vote.repository.ts new file mode 100644 index 0000000..33b3849 --- /dev/null +++ b/src/modules/governance/votes/governance-vote.repository.ts @@ -0,0 +1,205 @@ +import { DataSource, Repository } from 'typeorm'; +import { VoteOutcomeEntity } from './entities/vote-outcome.entity'; +import { VoteAlertEntity } from './entities/vote-alert.entity'; +import { CreateVoteOutcomeDto, UpdateVoteOutcomeDto, VoteOutcomeQueryDto } from './dto'; +import { Logger } from '../../../utils/logger'; + +export class GovernanceVoteRepository { + private voteOutcomeRepo: Repository; + private voteAlertRepo: Repository; + private logger: Logger; + + constructor(private dataSource: DataSource) { + this.voteOutcomeRepo = dataSource.getRepository(VoteOutcomeEntity); + this.voteAlertRepo = dataSource.getRepository(VoteAlertEntity); + this.logger = new Logger('GovernanceVoteRepository'); + } + + // ─── Vote Outcomes ─────────────────────────────────────────────────────────────── + + async upsertVoteOutcome(dto: CreateVoteOutcomeDto): Promise { + const existing = await this.voteOutcomeRepo.findOne({ + where: { proposalId: dto.proposalId, chainId: dto.chainId }, + }); + + if (existing) { + const updateData: UpdateVoteOutcomeDto = { + outcome: dto.outcome, + votingEndedAt: dto.votingEndedAt, + executionTimestamp: dto.executionTimestamp, + totalVotes: dto.totalVotes, + yesVotes: dto.yesVotes, + noVotes: dto.noVotes, + abstainVotes: dto.abstainVotes, + vetoVotes: dto.vetoVotes, + participationPercentage: dto.participationPercentage, + proposalType: dto.proposalType, + proposalImpact: dto.proposalImpact, + previousState: existing.outcome, + }; + + const updated = this.voteOutcomeRepo.merge(existing, updateData); + await this.voteOutcomeRepo.save(updated); + this.logger.debug(`Updated vote outcome for proposal ${dto.proposalId}`); + return updated; + } + + const entity = this.voteOutcomeRepo.create({ + ...dto, + processed: false, + }); + + await this.voteOutcomeRepo.save(entity); + this.logger.info(`Created vote outcome for proposal ${dto.proposalId} (chain ${dto.chainId})`); + return entity; + } + + async findVoteOutcome(proposalId: string, chainId: number): Promise { + return this.voteOutcomeRepo.findOne({ + where: { proposalId, chainId }, + }); + } + + async searchVoteOutcomes( + query: VoteOutcomeQueryDto, + ): Promise<{ items: VoteOutcomeEntity[]; total: number }> { + const qb = this.voteOutcomeRepo.createQueryBuilder('vo'); + + if (query.chainId) { + qb.andWhere('vo.chainId = :chainId', { chainId: query.chainId }); + } + if (query.proposalId) { + qb.andWhere('vo.proposalId = :proposalId', { proposalId: query.proposalId }); + } + if (query.outcome) { + qb.andWhere('vo.outcome = :outcome', { outcome: query.outcome }); + } + if (query.proposalType) { + qb.andWhere('vo.proposalType = :proposalType', { proposalType: query.proposalType }); + } + if (query.proposalImpact) { + qb.andWhere('vo.proposalImpact = :proposalImpact', { proposalImpact: query.proposalImpact }); + } + if (query.fromVotingEndedAt) { + qb.andWhere('vo.votingEndedAt >= :fromVotingEndedAt', { + fromVotingEndedAt: query.fromVotingEndedAt, + }); + } + if (query.toVotingEndedAt) { + qb.andWhere('vo.votingEndedAt <= :toVotingEndedAt', { + toVotingEndedAt: query.toVotingEndedAt, + }); + } + if (query.processed !== undefined) { + qb.andWhere('vo.processed = :processed', { processed: query.processed }); + } + + qb.orderBy('vo.votingEndedAt', 'DESC') + .skip(query.offset ?? 0) + .take(query.limit ?? 50); + + const [items, total] = await qb.getManyAndCount(); + return { items, total }; + } + + async getUnprocessedOutcomes(chainId?: number): Promise { + const qb = this.voteOutcomeRepo + .createQueryBuilder('vo') + .where('vo.processed = :processed', { processed: false }); + + if (chainId) { + qb.andWhere('vo.chainId = :chainId', { chainId }); + } + + return qb.getMany(); + } + + async markAsProcessed(proposalId: string, chainId: number): Promise { + await this.voteOutcomeRepo.update({ proposalId, chainId }, { processed: true }); + this.logger.debug(`Marked proposal ${proposalId} as processed`); + } + + async updateVoteOutcome( + proposalId: string, + chainId: number, + updateData: UpdateVoteOutcomeDto, + ): Promise { + await this.voteOutcomeRepo.update( + { proposalId, chainId }, + { ...updateData, updatedAt: new Date() }, + ); + this.logger.debug(`Updated vote outcome for proposal ${proposalId}`); + } + + // ─── Vote Alerts ─────────────────────────────────────────────────────────────────── + + async createAlert(dto: { + proposalId: string; + chainId: number; + proposalTitle: string; + alertType: string; + severity: string; + outcome?: string; + message: string; + proposalLink?: string; + network?: string; + metadata?: Record; + }): Promise { + const entity = this.voteAlertRepo.create({ + ...dto, + notified: false, + }); + + await this.voteAlertRepo.save(entity); + this.logger.info(`Created alert for proposal ${dto.proposalId}: ${dto.alertType}`); + return entity; + } + + async findUnnotifiedAlerts(chainId?: number): Promise { + const qb = this.voteAlertRepo + .createQueryBuilder('va') + .where('va.notified = :notified', { notified: false }); + + if (chainId) { + qb.andWhere('va.chainId = :chainId', { chainId }); + } + + return qb.orderBy('va.createdAt', 'ASC').getMany(); + } + + async markAlertAsNotified(alertId: string): Promise { + await this.voteAlertRepo.update( + { id: alertId }, + { notified: true, notificationSentAt: new Date() }, + ); + this.logger.debug(`Marked alert ${alertId} as notified`); + } + + async searchAlerts( + chainId?: number, + proposalId?: string, + alertType?: string, + limit: number = 100, + ): Promise { + const qb = this.voteAlertRepo.createQueryBuilder('va'); + + if (chainId) { + qb.andWhere('va.chainId = :chainId', { chainId }); + } + if (proposalId) { + qb.andWhere('va.proposalId = :proposalId', { proposalId }); + } + if (alertType) { + qb.andWhere('va.alertType = :alertType', { alertType }); + } + + return qb.orderBy('va.createdAt', 'DESC').limit(limit).getMany(); + } + + async getAlertsForProposal(proposalId: string, chainId: number): Promise { + return this.voteAlertRepo.find({ + where: { proposalId, chainId }, + order: { createdAt: 'ASC' }, + }); + } +} diff --git a/src/modules/governance/votes/governance-vote.scheduler.ts b/src/modules/governance/votes/governance-vote.scheduler.ts new file mode 100644 index 0000000..35fbf32 --- /dev/null +++ b/src/modules/governance/votes/governance-vote.scheduler.ts @@ -0,0 +1,79 @@ +import { Logger } from '../../../utils/logger'; +import { GovernanceVoteService } from './governance-vote.service'; +import { GovernanceVoteProcessor } from './governance-vote.processor'; +import { IGovernanceVoteScheduler } from './interfaces/governance-vote-service.interface'; + +export class GovernanceVoteScheduler implements IGovernanceVoteScheduler { + private logger: Logger; + private intervalId: ReturnType | null = null; + private isRunningFlag: boolean = false; + + constructor( + private voteService: GovernanceVoteService, + private voteProcessor: GovernanceVoteProcessor, + private pollIntervalMs: number = 60000, + ) { + this.logger = new Logger('GovernanceVoteScheduler'); + } + + async start(): Promise { + if (this.isRunningFlag) { + this.logger.warn('Scheduler is already running'); + return; + } + + this.logger.info(`Starting governance vote scheduler with ${this.pollIntervalMs}ms interval`); + + this.isRunningFlag = true; + + await this.runMonitoringCycle(); + + this.intervalId = setInterval(async () => { + try { + await this.runMonitoringCycle(); + } catch (error) { + this.logger.error('Error in monitoring cycle', error); + } + }, this.pollIntervalMs); + + this.logger.info('Governance vote scheduler started successfully'); + } + + async stop(): Promise { + if (!this.isRunningFlag) { + this.logger.warn('Scheduler is not running'); + return; + } + + this.logger.info('Stopping governance vote scheduler'); + + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + + this.isRunningFlag = false; + + this.logger.info('Governance vote scheduler stopped'); + } + + isRunning(): boolean { + return this.isRunningFlag; + } + + private async runMonitoringCycle(): Promise { + this.logger.info('Starting monitoring cycle'); + + try { + const results = await this.voteService.monitorProposals(); + this.logger.info(`Monitoring cycle completed: ${results.length} proposals checked`); + + if (results.length > 0) { + await this.voteProcessor.processVoteResults(results); + } + } catch (error) { + this.logger.error('Monitoring cycle failed', error); + throw error; + } + } +} diff --git a/src/modules/governance/votes/governance-vote.service.ts b/src/modules/governance/votes/governance-vote.service.ts new file mode 100644 index 0000000..5d58234 --- /dev/null +++ b/src/modules/governance/votes/governance-vote.service.ts @@ -0,0 +1,296 @@ +import { ethers } from 'ethers'; +import { Logger } from '../../../utils/logger'; +import { ProposalRepository } from '../proposals/ProposalRepository'; +import { ProposalDetector } from '../proposals/ProposalDetector'; +import { ProposalState } from '../proposals/types'; +import { GovernanceVoteRepository } from './governance-vote.repository'; +import { VoteOutcome } from './enums'; +import { VoteOutcomeEntity } from './entities/vote-outcome.entity'; +import { IGovernanceVoteService, VoteMonitoringResult, ProposalVoteData } from './interfaces'; +import { GovernanceVoteConfig } from './interfaces/governance-vote-config.interface'; +import { classifyProposal } from './utils/proposal-classifier.util'; + +const STATE_TO_OUTCOME_MAP: Record = { + [ProposalState.Pending]: VoteOutcome.Pending, + [ProposalState.Active]: VoteOutcome.Pending, + [ProposalState.Canceled]: VoteOutcome.Cancelled, + [ProposalState.Defeated]: VoteOutcome.Rejected, + [ProposalState.Succeeded]: VoteOutcome.Passed, + [ProposalState.Queued]: VoteOutcome.Passed, + [ProposalState.Expired]: VoteOutcome.Expired, + [ProposalState.Executed]: VoteOutcome.Executed, +}; + +export class GovernanceVoteService implements IGovernanceVoteService { + private logger: Logger; + private detectors: Map = new Map(); + private configs: GovernanceVoteConfig[] = []; + + constructor( + private proposalRepository: ProposalRepository, + private voteRepository: GovernanceVoteRepository, + private provider: ethers.Provider, + configs: GovernanceVoteConfig[], + ) { + this.logger = new Logger('GovernanceVoteService'); + this.configs = configs.filter(c => c.enabled !== false); + this.initializeDetectors(); + } + + private initializeDetectors(): void { + for (const config of this.configs) { + const detector = new ProposalDetector(this.provider, { + governorAddress: config.governorAddress, + chainId: config.chainId, + pollIntervalMs: config.pollIntervalMs, + }); + this.detectors.set(config.chainId, detector); + this.logger.info(`Initialized detector for chain ${config.chainId}`); + } + } + + async monitorProposals(): Promise { + this.logger.info('Starting proposal monitoring cycle'); + const results: VoteMonitoringResult[] = []; + + for (const config of this.configs) { + try { + const chainResults = await this.monitorChain(config.chainId); + results.push(...chainResults); + } catch (error) { + this.logger.error(`Failed to monitor chain ${config.chainId}`, error); + } + } + + this.logger.info(`Monitoring cycle complete. Processed ${results.length} proposals`); + return results; + } + + private async monitorChain(chainId: number): Promise { + this.logger.debug(`Monitoring chain ${chainId}`); + const results: VoteMonitoringResult[] = []; + + const { items: activeProposals } = await this.proposalRepository.searchProposals(chainId, { + state: ProposalState.Active, + limit: 100, + }); + + const { items: succeededProposals } = await this.proposalRepository.searchProposals(chainId, { + state: ProposalState.Succeeded, + limit: 50, + }); + + const { items: queuedProposals } = await this.proposalRepository.searchProposals(chainId, { + state: ProposalState.Queued, + limit: 50, + }); + + const proposalsToCheck = [...activeProposals, ...succeededProposals, ...queuedProposals]; + + for (const proposal of proposalsToCheck) { + try { + const result = await this.checkProposal(proposal.proposalId, chainId); + results.push(result); + } catch (error) { + this.logger.error(`Failed to check proposal ${proposal.proposalId}`, error); + } + } + + return results; + } + + async checkProposal(proposalId: string, chainId: number): Promise { + this.logger.debug(`Checking proposal ${proposalId} on chain ${chainId}`); + + const detector = this.detectors.get(chainId); + if (!detector) { + throw new Error(`No detector found for chain ${chainId}`); + } + + const currentState = await detector.getProposalState(proposalId); + const votes = await detector.getProposalVotes(proposalId); + + const existingOutcome = await this.voteRepository.findVoteOutcome(proposalId, chainId); + const previousOutcome = existingOutcome?.outcome; + + const currentOutcome = STATE_TO_OUTCOME_MAP[currentState]; + const stateChanged = previousOutcome !== currentOutcome; + + const votingEnded = this.isVotingEndedState(currentState); + const votingEndedAt = votingEnded ? new Date() : undefined; + + const executionTimestamp = currentState === ProposalState.Executed ? new Date() : undefined; + + const totalVotes = this.sumVotes(votes.forVotes, votes.againstVotes, votes.abstainVotes); + const participationPercentage = existingOutcome + ? this.calculateParticipationPercentage(totalVotes, existingOutcome) + : undefined; + + const proposal = await this.proposalRepository.findProposal(proposalId, chainId); + if (!proposal) { + throw new Error(`Proposal ${proposalId} not found in repository`); + } + + const classification = classifyProposal(proposal.description, proposal.title); + + await this.voteRepository.upsertVoteOutcome({ + proposalId, + chainId, + proposalTitle: proposal.title, + proposalDescription: proposal.description, + proposalType: classification.type, + proposalImpact: classification.impact, + outcome: currentOutcome, + votingStartTime: new Date(), + votingEndTime: new Date(), + votingEndedAt, + executionTimestamp, + totalVotes, + yesVotes: votes.forVotes, + noVotes: votes.againstVotes, + abstainVotes: votes.abstainVotes, + vetoVotes: '0', + participationPercentage, + proposalLink: this.generateProposalLink(proposalId, chainId), + previousState: previousOutcome, + }); + + const votesUpdated = this.votesChanged(existingOutcome, votes); + + this.logger.debug( + `Proposal ${proposalId}: ${previousOutcome} -> ${currentOutcome}, votesUpdated: ${votesUpdated}`, + ); + + return { + proposalId, + chainId, + previousOutcome, + currentOutcome, + stateChanged, + votesUpdated, + timestamp: new Date(), + }; + } + + async getActiveProposals(chainId: number): Promise { + const { items: activeProposals } = await this.proposalRepository.searchProposals(chainId, { + state: ProposalState.Active, + limit: 100, + }); + + const voteData: ProposalVoteData[] = []; + + for (const proposal of activeProposals) { + try { + const data = await this.getProposalVoteData(proposal.proposalId, chainId); + if (data) { + voteData.push(data); + } + } catch (error) { + this.logger.error(`Failed to get vote data for proposal ${proposal.proposalId}`, error); + } + } + + return voteData; + } + + async getProposalVoteData(proposalId: string, chainId: number): Promise { + const proposal = await this.proposalRepository.findProposal(proposalId, chainId); + if (!proposal) { + return null; + } + + const detector = this.detectors.get(chainId); + if (!detector) { + return null; + } + + const votes = await detector.getProposalVotes(proposalId); + const totalVotes = this.sumVotes(votes.forVotes, votes.againstVotes, votes.abstainVotes); + + return { + proposalId, + chainId, + title: proposal.title, + description: proposal.description, + votingStartTime: new Date(), + votingEndTime: new Date(), + totalVotes, + yesVotes: votes.forVotes, + noVotes: votes.againstVotes, + abstainVotes: votes.abstainVotes, + participationPercentage: undefined, + currentState: proposal.state, + }; + } + + async isVotingEnded(proposalId: string, chainId: number): Promise { + const detector = this.detectors.get(chainId); + if (!detector) { + throw new Error(`No detector found for chain ${chainId}`); + } + + const state = await detector.getProposalState(proposalId); + return this.isVotingEndedState(state); + } + + private isVotingEndedState(state: ProposalState): boolean { + return [ + ProposalState.Succeeded, + ProposalState.Defeated, + ProposalState.Expired, + ProposalState.Canceled, + ProposalState.Executed, + ].includes(state); + } + + private sumVotes(...voteStrings: string[]): string { + return voteStrings + .reduce((sum, votes) => { + const voteValue = BigInt(votes || '0'); + return sum + voteValue; + }, BigInt(0)) + .toString(); + } + + private votesChanged( + existingOutcome: VoteOutcomeEntity | null, + currentVotes: { forVotes: string; againstVotes: string; abstainVotes: string }, + ): boolean { + if (!existingOutcome) { + return true; + } + + return ( + existingOutcome.yesVotes !== currentVotes.forVotes || + existingOutcome.noVotes !== currentVotes.againstVotes || + existingOutcome.abstainVotes !== currentVotes.abstainVotes + ); + } + + private calculateParticipationPercentage( + totalVotes: string, + existingOutcome: VoteOutcomeEntity | null, + ): number | undefined { + if (!existingOutcome || !existingOutcome.totalVotes || existingOutcome.totalVotes === '0') { + return undefined; + } + + const total = BigInt(totalVotes); + const previousTotal = BigInt(existingOutcome.totalVotes); + + if (total === previousTotal) { + return existingOutcome.participationPercentage; + } + + return undefined; + } + + private generateProposalLink(proposalId: string, chainId: number): string { + const config = this.configs.find(c => c.chainId === chainId); + if (config?.proposalLinkTemplate) { + return config.proposalLinkTemplate.replace('{proposalId}', proposalId); + } + return ''; + } +} diff --git a/src/modules/governance/votes/index.ts b/src/modules/governance/votes/index.ts new file mode 100644 index 0000000..d6892ee --- /dev/null +++ b/src/modules/governance/votes/index.ts @@ -0,0 +1,11 @@ +export * from './governance-vote.service'; +export * from './governance-vote.processor'; +export * from './governance-vote.scheduler'; +export * from './governance-vote.repository'; +export * from './enums'; +export * from './interfaces'; +export * from './dto'; +export * from './alerts'; +export * from './utils'; +export * from './entities/vote-outcome.entity'; +export * from './entities/vote-alert.entity'; diff --git a/src/modules/governance/votes/interfaces/governance-vote-config.interface.ts b/src/modules/governance/votes/interfaces/governance-vote-config.interface.ts new file mode 100644 index 0000000..76d6074 --- /dev/null +++ b/src/modules/governance/votes/interfaces/governance-vote-config.interface.ts @@ -0,0 +1,24 @@ +import { ProposalType } from '../enums/proposal-type.enum'; +import { ProposalImpact } from '../enums/proposal-impact.enum'; + +export interface GovernanceVoteConfig { + chainId: number; + governorAddress: string; + pollIntervalMs?: number; + enabled?: boolean; + networkName?: string; + proposalLinkTemplate?: string; +} + +export interface ProposalClassificationRule { + keywords: string[]; + type: ProposalType; + impact: ProposalImpact; +} + +export interface GovernanceVoteMonitoringConfig { + chains: GovernanceVoteConfig[]; + classificationRules: ProposalClassificationRule[]; + defaultPollIntervalMs: number; + alertEnabled: boolean; +} diff --git a/src/modules/governance/votes/interfaces/governance-vote-service.interface.ts b/src/modules/governance/votes/interfaces/governance-vote-service.interface.ts new file mode 100644 index 0000000..6813504 --- /dev/null +++ b/src/modules/governance/votes/interfaces/governance-vote-service.interface.ts @@ -0,0 +1,20 @@ +import { VoteMonitoringResult, ProposalVoteData } from './vote-monitoring-result.interface'; + +export interface IGovernanceVoteService { + monitorProposals(): Promise; + checkProposal(proposalId: string, chainId: number): Promise; + getActiveProposals(chainId: number): Promise; + getProposalVoteData(proposalId: string, chainId: number): Promise; + isVotingEnded(proposalId: string, chainId: number): Promise; +} + +export interface IGovernanceVoteProcessor { + processVoteResult(result: VoteMonitoringResult): Promise; + processVoteResults(results: VoteMonitoringResult[]): Promise; +} + +export interface IGovernanceVoteScheduler { + start(): Promise; + stop(): Promise; + isRunning(): boolean; +} diff --git a/src/modules/governance/votes/interfaces/index.ts b/src/modules/governance/votes/interfaces/index.ts new file mode 100644 index 0000000..b76a21b --- /dev/null +++ b/src/modules/governance/votes/interfaces/index.ts @@ -0,0 +1,3 @@ +export * from './governance-vote-config.interface'; +export * from './vote-monitoring-result.interface'; +export * from './governance-vote-service.interface'; diff --git a/src/modules/governance/votes/interfaces/vote-monitoring-result.interface.ts b/src/modules/governance/votes/interfaces/vote-monitoring-result.interface.ts new file mode 100644 index 0000000..8766f18 --- /dev/null +++ b/src/modules/governance/votes/interfaces/vote-monitoring-result.interface.ts @@ -0,0 +1,27 @@ +import { VoteOutcome } from '../enums/vote-outcome.enum'; + +export interface VoteMonitoringResult { + proposalId: string; + chainId: number; + previousOutcome?: VoteOutcome; + currentOutcome: VoteOutcome; + stateChanged: boolean; + votesUpdated: boolean; + timestamp: Date; +} + +export interface ProposalVoteData { + proposalId: string; + chainId: number; + title: string; + description?: string; + votingStartTime: Date; + votingEndTime: Date; + totalVotes: string; + yesVotes: string; + noVotes: string; + abstainVotes: string; + vetoVotes?: string; + participationPercentage?: number; + currentState: string; +} diff --git a/src/modules/governance/votes/utils/index.ts b/src/modules/governance/votes/utils/index.ts new file mode 100644 index 0000000..fdb959a --- /dev/null +++ b/src/modules/governance/votes/utils/index.ts @@ -0,0 +1 @@ +export * from './proposal-classifier.util'; diff --git a/src/modules/governance/votes/utils/proposal-classifier.util.ts b/src/modules/governance/votes/utils/proposal-classifier.util.ts new file mode 100644 index 0000000..30a53d7 --- /dev/null +++ b/src/modules/governance/votes/utils/proposal-classifier.util.ts @@ -0,0 +1,70 @@ +import { ProposalType, ProposalImpact } from '../enums'; + +export interface ProposalClassification { + type: ProposalType; + impact: ProposalImpact; +} + +const DEFAULT_CLASSIFICATION: ProposalClassification = { + type: ProposalType.Other, + impact: ProposalImpact.LowImpact, +}; + +const CLASSIFICATION_RULES: Array<{ + keywords: string[]; + type: ProposalType; + impact: ProposalImpact; +}> = [ + { + keywords: ['upgrade', 'migration', 'contract upgrade', 'implementation'], + type: ProposalType.ProtocolUpgrade, + impact: ProposalImpact.ProtocolUpgrade, + }, + { + keywords: ['validator', 'node operator', 'staking', 'slashing'], + type: ProposalType.ValidatorChange, + impact: ProposalImpact.ValidatorChange, + }, + { + keywords: ['treasury', 'grant', 'funding', 'budget', 'allocation'], + type: ProposalType.TreasuryAllocation, + impact: ProposalImpact.TreasuryChange, + }, + { + keywords: ['parameter', 'threshold', 'quorum', 'timelock', 'delay'], + type: ProposalType.ParameterChange, + impact: ProposalImpact.GovernanceParameterChange, + }, + { + keywords: ['security', 'emergency', 'pause', 'unpause', 'critical'], + type: ProposalType.SecurityProposal, + impact: ProposalImpact.SecurityRelated, + }, + { + keywords: ['emergency', 'urgent', 'immediate'], + type: ProposalType.EmergencyAction, + impact: ProposalImpact.SecurityRelated, + }, + { + keywords: ['feature', 'enable', 'activate', 'new'], + type: ProposalType.NetworkFeature, + impact: ProposalImpact.LowImpact, + }, +]; + +export function classifyProposal(description: string, title: string): ProposalClassification { + const text = `${title} ${description}`.toLowerCase(); + + for (const rule of CLASSIFICATION_RULES) { + for (const keyword of rule.keywords) { + if (text.includes(keyword.toLowerCase())) { + return { + type: rule.type, + impact: rule.impact, + }; + } + } + } + + return DEFAULT_CLASSIFICATION; +}