Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions backend/src/modules/governance/entities/delegation.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
Unique,
} from 'typeorm';

@Entity('delegations')
@Unique(['delegatorAddress'])
@Index(['delegateAddress'])
export class Delegation {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
delegatorAddress: string;

@Column()
delegateAddress: string;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
123 changes: 91 additions & 32 deletions backend/src/modules/governance/governance-indexer.service.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Repository } from 'typeorm';
// import { ethers } from 'ethers';
import {
GovernanceProposal,
ProposalStatus,
} from './entities/governance-proposal.entity';
import { Vote, VoteDirection } from './entities/vote.entity';
import { Delegation } from './entities/delegation.entity';

/**
* Minimal ABI fragments for the DAO contract events we care about.
* ProposalCreated: emitted when a new proposal is submitted on-chain.
* VoteCast: emitted when a wallet casts a For (1) or Against (0) vote.
*/
const DAO_ABI_FRAGMENTS = [
'event ProposalCreated(uint256 indexed proposalId, address indexed proposer, string description, uint256 startBlock, uint256 endBlock)',
'event VoteCast(address indexed voter, uint256 indexed proposalId, uint8 support, uint256 weight)',
'event DelegationUpdated(address indexed delegator, address indexed delegate)',
'event ProposalStatusChanged(uint256 indexed proposalId, uint8 status)',
];

@Injectable()
Expand All @@ -29,6 +30,9 @@ export class GovernanceIndexerService implements OnModuleInit {
private readonly proposalRepo: Repository<GovernanceProposal>,
@InjectRepository(Vote)
private readonly voteRepo: Repository<Vote>,
@InjectRepository(Delegation)
private readonly delegationRepo: Repository<Delegation>,
private readonly eventEmitter: EventEmitter2,
) {}

onModuleInit() {
Expand All @@ -47,26 +51,15 @@ export class GovernanceIndexerService implements OnModuleInit {
}

// TODO: Implement ethers integration when ethers package is added
// this.provider = new ethers.JsonRpcProvider(rpcUrl);
// this.contract = new ethers.Contract(
// contractAddress,
// DAO_ABI_FRAGMENTS,
// this.provider,
// );
// this.contract.on('ProposalCreated', this.handleProposalCreated.bind(this));
// this.contract.on('VoteCast', this.handleVoteCast.bind(this));

this.logger.log(
`Governance indexer listening on contract ${contractAddress}`,
);
}

/**
* Handles the ProposalCreated event.
* Inserts a skeletal GovernanceProposal row with status=Active.
* Parses the on-chain ID and creates a local database entry.
*/
private async handleProposalCreated(
async handleProposalCreated(
proposalId: bigint,
proposer: string,
description: string,
Expand Down Expand Up @@ -96,18 +89,21 @@ export class GovernanceIndexerService implements OnModuleInit {
});

await this.proposalRepo.save(proposal);
this.logger.log(
`Indexed new proposal onChainId=${onChainId} from proposer=${proposer}`,
);
this.logger.log(`Indexed new proposal onChainId=${onChainId}`);

// Emit event for notifications
this.eventEmitter.emit('governance.proposal.created', {
proposalId: proposal.id,
onChainId,
proposer,
title: description.slice(0, 50), // Fallback title
});
}

/**
* Handles the VoteCast event.
* Isolates the direction (For=1, Against=0) and maps it to the Vote database table
* linked to the walletAddress and the corresponding GovernanceProposal.
* Supports updating proposal status based on voting outcomes.
*/
private async handleVoteCast(
async handleVoteCast(
voter: string,
proposalId: bigint,
support: number,
Expand All @@ -117,17 +113,14 @@ export class GovernanceIndexerService implements OnModuleInit {

const proposal = await this.proposalRepo.findOneBy({ onChainId });
if (!proposal) {
this.logger.warn(
`VoteCast received for unknown proposal ${onChainId} — skipping.`,
);
this.logger.warn(`VoteCast received for unknown proposal ${onChainId}`);
return;
}

// Map on-chain support value: 1 = FOR, 0 = AGAINST
const direction: VoteDirection =
support === 1 ? VoteDirection.FOR : VoteDirection.AGAINST;

// Upsert: one vote per wallet per proposal
const existing = await this.voteRepo.findOneBy({
walletAddress: voter,
proposalId: proposal.id,
Expand All @@ -137,9 +130,6 @@ export class GovernanceIndexerService implements OnModuleInit {
existing.direction = direction;
existing.weight = Number(weight);
await this.voteRepo.save(existing);
this.logger.debug(
`Updated vote for wallet=${voter} on proposal=${onChainId}`,
);
} else {
const vote = this.voteRepo.create({
walletAddress: voter,
Expand All @@ -149,9 +139,78 @@ export class GovernanceIndexerService implements OnModuleInit {
proposalId: proposal.id,
});
await this.voteRepo.save(vote);
this.logger.log(
`Indexed vote wallet=${voter} direction=${direction} proposal=${onChainId} weight=${weight}`,
);
}

// Emit event for notifications (to notify delegators)
this.eventEmitter.emit('governance.vote.cast', {
voter,
onChainId,
direction,
weight: weight.toString(),
});
}

/**
* Handles the DelegationUpdated event.
*/
async handleDelegationUpdated(
delegator: string,
delegate: string,
): Promise<void> {
const existing = await this.delegationRepo.findOneBy({
delegatorAddress: delegator,
});

if (existing) {
existing.delegateAddress = delegate;
await this.delegationRepo.save(existing);
} else {
const newDelegation = this.delegationRepo.create({
delegatorAddress: delegator,
delegateAddress: delegate,
});
await this.delegationRepo.save(newDelegation);
}

this.logger.log(`Updated delegation: ${delegator} -> ${delegate}`);
}

/**
* Handles the ProposalStatusChanged event.
*/
async handleProposalStatusChanged(
proposalId: bigint,
status: number,
): Promise<void> {
const onChainId = Number(proposalId);
const proposal = await this.proposalRepo.findOneBy({ onChainId });
if (!proposal) return;

// Map on-chain status to enum
let newStatus: ProposalStatus;
switch (status) {
case 1:
newStatus = ProposalStatus.PASSED;
break;
case 2:
newStatus = ProposalStatus.FAILED;
break;
case 3:
newStatus = ProposalStatus.CANCELLED;
break;
default:
newStatus = ProposalStatus.ACTIVE;
}

if (proposal.status !== newStatus) {
proposal.status = newStatus;
await this.proposalRepo.save(proposal);

this.eventEmitter.emit('governance.proposal.status_updated', {
proposalId: proposal.id,
onChainId,
status: newStatus,
});
}
}

Expand Down
5 changes: 4 additions & 1 deletion backend/src/modules/governance/governance.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { GovernanceController } from './governance.controller';
import { GovernanceProposalsController } from './governance-proposals.controller';
import { GovernanceService } from './governance.service';
Expand All @@ -10,12 +11,14 @@ import { UserModule } from '../user/user.module';
import { BlockchainModule } from '../blockchain/blockchain.module';
import { GovernanceProposal } from './entities/governance-proposal.entity';
import { Vote } from './entities/vote.entity';
import { Delegation } from './entities/delegation.entity';

@Module({
imports: [
UserModule,
BlockchainModule,
TypeOrmModule.forFeature([GovernanceProposal, Vote]),
EventEmitterModule.forRoot(),
TypeOrmModule.forFeature([GovernanceProposal, Vote, Delegation]),
],
controllers: [
GovernanceController,
Expand Down
25 changes: 25 additions & 0 deletions backend/src/modules/mail/mail.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,4 +238,29 @@ export class MailService {
this.logger.error(`Failed to send raw email to ${to}`, error);
}
}

async sendGovernanceEmail(
userEmail: string,
name: string,
subject: string,
message: string,
): Promise<void> {
try {
await this.mailerService.sendMail({
to: userEmail,
subject,
template: './generic-notification',
context: {
name: name || 'User',
message,
},
});
this.logger.log(`Governance email (${subject}) sent to ${userEmail}`);
} catch (error) {
this.logger.error(
`Failed to send governance email to ${userEmail}`,
error,
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export enum NotificationType {
PRODUCT_ALERT_TRIGGERED = 'PRODUCT_ALERT_TRIGGERED',
REBALANCING_RECOMMENDED = 'REBALANCING_RECOMMENDED',
ADMIN_CAPACITY_ALERT = 'ADMIN_CAPACITY_ALERT',
GOVERNANCE_PROPOSAL_CREATED = 'GOVERNANCE_PROPOSAL_CREATED',
GOVERNANCE_VOTING_REMINDER = 'GOVERNANCE_VOTING_REMINDER',
GOVERNANCE_PROPOSAL_QUEUED = 'GOVERNANCE_PROPOSAL_QUEUED',
GOVERNANCE_PROPOSAL_EXECUTED = 'GOVERNANCE_PROPOSAL_EXECUTED',
GOVERNANCE_DELEGATE_VOTED = 'GOVERNANCE_DELEGATE_VOTED',
}

@Entity('notifications')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
Index,
} from 'typeorm';
import { NotificationType } from './notification.entity';

@Entity('pending_notifications')
@Index(['userId', 'processed'])
export class PendingNotification {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column('uuid')
userId: string;

@Column({ type: 'enum', enum: NotificationType })
type: NotificationType;

@Column()
title: string;

@Column('text')
message: string;

@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any> | null;

@Column({ type: 'boolean', default: false })
processed: boolean;

@CreateDateColumn()
createdAt: Date;
}
Loading
Loading