diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 9e1add1..cfc80ff 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,31 +1,36 @@ -import { Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; -import { ConfigModule } from '../config/config.module'; -import { UsersModule } from '../users/users.module'; -import { AuthService } from './auth.service'; -import { AuthController } from './auth.controller'; -import { WalletAuthService } from './services/wallet-auth.service'; -import { WalletAuthController } from './controllers/wallet-auth.controller'; -import { WalletAuthGuard } from './guards/wallet-auth.guard'; -import { ConfigService } from '../config/config.service'; -import { RedisModule } from '../common/module/redis/redis.module'; - -@Module({ - imports: [ - ConfigModule, - UsersModule, - RedisModule, - JwtModule.registerAsync({ - imports: [ConfigModule], - useFactory: async (configService: ConfigService) => ({ - secret: configService.jwtSecret, - signOptions: { expiresIn: '1h' }, - }), - inject: [ConfigService], - }), - ], - controllers: [AuthController, WalletAuthController], - providers: [AuthService, WalletAuthService, WalletAuthGuard], - exports: [AuthService, WalletAuthService, WalletAuthGuard], -}) -export class AuthModule {} + +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule } from '../config/config.module'; +import { UsersModule } from '../users/users.module'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { WalletAuthService } from './services/wallet-auth.service'; +import { WalletAuthController } from './controllers/wallet-auth.controller'; +import { WalletAuthGuard } from './guards/wallet-auth.guard'; +import { ConfigService } from '../config/config.service'; +import { RedisModule } from '../common/module/redis/redis.module'; +import { TokenAuthStrategy } from './strategies/token-auth.strategy'; +import { TokenAuthController } from './controllers/token-auth.controller'; +import { BlockchainModule } from '../blockchain/blockchain.module'; + +@Module({ + imports: [ + ConfigModule, + UsersModule, + RedisModule, + BlockchainModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + secret: configService.jwtSecret, + signOptions: { expiresIn: '1h' }, + }), + inject: [ConfigService], + }), + ], + controllers: [AuthController, WalletAuthController, TokenAuthController], + providers: [AuthService, WalletAuthService, WalletAuthGuard, TokenAuthStrategy], + exports: [AuthService, WalletAuthService, WalletAuthGuard], +}) +export class AuthModule {} diff --git a/src/auth/controllers/token-auth.controller.ts b/src/auth/controllers/token-auth.controller.ts new file mode 100644 index 0000000..2f55e9a --- /dev/null +++ b/src/auth/controllers/token-auth.controller.ts @@ -0,0 +1,56 @@ +import { Controller, Post, Body, UseGuards, Get, Request } from '@nestjs/common'; +import { TokenAuthDto } from '../dto/token-auth.dto'; +import { TokenAuthGuard } from '../guards/token-auth.guard'; +import { BlockchainService } from '../../blockchain/services/blockchain.service'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; + +@Controller('auth/token') +export class TokenAuthController { + constructor(private readonly blockchainService: BlockchainService) {} + + @Post('login') + @UseGuards(TokenAuthGuard) + async login(@Request() req, @Body() tokenAuthDto: TokenAuthDto) { + // The user is already validated by the TokenAuthGuard + const user = req.user; + + // Get token balance for the user + const tokenBalance = await this.blockchainService.getTokenBalance(user.walletAddress); + + // Get voting power for the user + const votingPower = await this.blockchainService.getVotingPower(user.walletAddress); + + return { + user: { + id: user.id, + username: user.username, + walletAddress: user.walletAddress, + }, + tokenBalance, + votingPower, + // Note: JWT token would be provided by the standard auth service + }; + } + + @Get('profile') + @UseGuards(JwtAuthGuard) + async getProfile(@Request() req) { + const user = req.user; + + // Get token balance for the user + const tokenBalance = await this.blockchainService.getTokenBalance(user.walletAddress); + + // Get voting power for the user + const votingPower = await this.blockchainService.getVotingPower(user.walletAddress); + + return { + user: { + id: user.id, + username: user.username, + walletAddress: user.walletAddress, + }, + tokenBalance, + votingPower, + }; + } +} \ No newline at end of file diff --git a/src/auth/dto/token-auth.dto.ts b/src/auth/dto/token-auth.dto.ts new file mode 100644 index 0000000..da20021 --- /dev/null +++ b/src/auth/dto/token-auth.dto.ts @@ -0,0 +1,15 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class TokenAuthDto { + @IsNotEmpty() + @IsString() + walletAddress: string; + + @IsNotEmpty() + @IsString() + signature: string; + + @IsNotEmpty() + @IsString() + message: string; +} \ No newline at end of file diff --git a/src/auth/guards/token-auth.guard.ts b/src/auth/guards/token-auth.guard.ts new file mode 100644 index 0000000..c3dd532 --- /dev/null +++ b/src/auth/guards/token-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class TokenAuthGuard extends AuthGuard('token-auth') {} \ No newline at end of file diff --git a/src/auth/strategies/token-auth.strategy.spec.ts b/src/auth/strategies/token-auth.strategy.spec.ts new file mode 100644 index 0000000..f022a05 --- /dev/null +++ b/src/auth/strategies/token-auth.strategy.spec.ts @@ -0,0 +1,247 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UnauthorizedException, BadRequestException } from '@nestjs/common'; +import { TokenAuthStrategy } from './token-auth.strategy'; +import { BlockchainService } from '../../blockchain/services/blockchain.service'; +import { UsersService } from '../../users/users.service'; +import { ConfigService } from '@nestjs/config'; + +describe('TokenAuthStrategy', () => { + let strategy: TokenAuthStrategy; + let blockchainService: BlockchainService; + let usersService: UsersService; + let configService: ConfigService; + + const mockBlockchainService = { + verifySignature: jest.fn(), + getTokenBalance: jest.fn(), + }; + + const mockUsersService = { + findByWalletAddress: jest.fn(), + createWithWalletAddress: jest.fn(), + updateLastLogin: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TokenAuthStrategy, + { + provide: BlockchainService, + useValue: mockBlockchainService, + }, + { + provide: UsersService, + useValue: mockUsersService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + strategy = module.get(TokenAuthStrategy); + blockchainService = module.get(BlockchainService); + usersService = module.get(UsersService); + configService = module.get(ConfigService); + + // Default config values + mockConfigService.get.mockImplementation((key) => { + const config = { + AUTH_MIN_TOKEN_BALANCE: 1, + AUTH_MESSAGE_PREFIX: 'StarkPulse Authentication:', + AUTH_MESSAGE_EXPIRATION: 300, + }; + return config[key]; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(strategy).toBeDefined(); + }); + + describe('validate', () => { + const mockRequest = { + body: { + walletAddress: '0x1234567890abcdef', + signature: '0xsignature', + message: 'StarkPulse Authentication:1634567890:randomnonce', + }, + }; + + const mockUser = { + id: 'user-id', + walletAddress: '0x1234567890abcdef', + status: 'ACTIVE', + }; + + beforeEach(() => { + // Mock Date.now to return a fixed timestamp for testing + jest.spyOn(Date, 'now').mockImplementation(() => 1634567990000); // 100 seconds after the message timestamp + }); + + it('should authenticate a user with valid credentials', async () => { + mockBlockchainService.verifySignature.mockResolvedValue(true); + mockUsersService.findByWalletAddress.mockResolvedValue(mockUser); + mockBlockchainService.getTokenBalance.mockResolvedValue(10); + + const result = await strategy.validate(mockRequest as any); + + expect(result).toEqual(mockUser); + expect(mockBlockchainService.verifySignature).toHaveBeenCalledWith( + mockRequest.body.walletAddress, + mockRequest.body.message, + mockRequest.body.signature, + ); + expect(mockUsersService.updateLastLogin).toHaveBeenCalledWith(mockUser.id); + }); + + it('should create a new user if not found', async () => { + mockBlockchainService.verifySignature.mockResolvedValue(true); + mockUsersService.findByWalletAddress.mockResolvedValue(null); + mockUsersService.createWithWalletAddress.mockResolvedValue(mockUser); + mockBlockchainService.getTokenBalance.mockResolvedValue(10); + + const result = await strategy.validate(mockRequest as any); + + expect(result).toEqual(mockUser); + expect(mockUsersService.createWithWalletAddress).toHaveBeenCalledWith( + mockRequest.body.walletAddress, + ); + }); + + it('should throw UnauthorizedException if missing credentials', async () => { + const invalidRequest = { + body: { + walletAddress: '0x1234567890abcdef', + // Missing signature and message + }, + }; + + await expect(strategy.validate(invalidRequest as any)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('should throw UnauthorizedException if signature is invalid', async () => { + mockBlockchainService.verifySignature.mockResolvedValue(false); + + await expect(strategy.validate(mockRequest as any)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('should throw UnauthorizedException if token balance is insufficient', async () => { + mockBlockchainService.verifySignature.mockResolvedValue(true); + mockUsersService.findByWalletAddress.mockResolvedValue(mockUser); + mockBlockchainService.getTokenBalance.mockResolvedValue(0); + + await expect(strategy.validate(mockRequest as any)).rejects.toThrow( + UnauthorizedException, + ); + expect(mockBlockchainService.getTokenBalance).toHaveBeenCalledWith( + mockRequest.body.walletAddress, + ); + }); + + it('should throw UnauthorizedException if user is banned', async () => { + const bannedUser = { ...mockUser, status: 'BANNED' }; + mockBlockchainService.verifySignature.mockResolvedValue(true); + mockUsersService.findByWalletAddress.mockResolvedValue(bannedUser); + mockBlockchainService.getTokenBalance.mockResolvedValue(10); + + await expect(strategy.validate(mockRequest as any)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('should throw BadRequestException if message format is invalid', async () => { + const invalidMessageRequest = { + body: { + walletAddress: '0x1234567890abcdef', + signature: '0xsignature', + message: 'InvalidPrefix:1634567890:randomnonce', + }, + }; + + await expect(strategy.validate(invalidMessageRequest as any)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException if message has expired', async () => { + // Mock Date.now to return a timestamp far in the future + jest.spyOn(Date, 'now').mockImplementation(() => 1634567890000 + 600000); // 600 seconds after the message timestamp + + await expect(strategy.validate(mockRequest as any)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException if timestamp is invalid', async () => { + const invalidTimestampRequest = { + body: { + walletAddress: '0x1234567890abcdef', + signature: '0xsignature', + message: 'StarkPulse Authentication:invalid:randomnonce', + }, + }; + + await expect(strategy.validate(invalidTimestampRequest as any)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should use configurable minimum token balance', async () => { + mockConfigService.get.mockImplementation((key) => { + const config = { + AUTH_MIN_TOKEN_BALANCE: 5, + AUTH_MESSAGE_PREFIX: 'StarkPulse Authentication:', + AUTH_MESSAGE_EXPIRATION: 300, + }; + return config[key]; + }); + + // Re-initialize strategy to use new config + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TokenAuthStrategy, + { + provide: BlockchainService, + useValue: mockBlockchainService, + }, + { + provide: UsersService, + useValue: mockUsersService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + const configuredStrategy = module.get(TokenAuthStrategy); + + mockBlockchainService.verifySignature.mockResolvedValue(true); + mockUsersService.findByWalletAddress.mockResolvedValue(mockUser); + mockBlockchainService.getTokenBalance.mockResolvedValue(3); + + await expect(configuredStrategy.validate(mockRequest as any)).rejects.toThrow( + UnauthorizedException, + ); + expect(mockBlockchainService.getTokenBalance).toHaveBeenCalledWith( + mockRequest.body.walletAddress, + ); + }); + }); +}); \ No newline at end of file diff --git a/src/auth/strategies/token-auth.strategy.ts b/src/auth/strategies/token-auth.strategy.ts new file mode 100644 index 0000000..f494a42 --- /dev/null +++ b/src/auth/strategies/token-auth.strategy.ts @@ -0,0 +1,115 @@ +import { Injectable, UnauthorizedException, Logger, BadRequestException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-custom'; +import { Request } from 'express'; +import { BlockchainService } from '../../blockchain/services/blockchain.service'; +import { UsersService } from '../../users/users.service'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class TokenAuthStrategy extends PassportStrategy(Strategy, 'token-auth') { + private readonly logger = new Logger(TokenAuthStrategy.name); + private readonly minRequiredBalance: number; + private readonly messagePrefix: string; + private readonly messageExpiration: number; // in seconds + + constructor( + private blockchainService: BlockchainService, + private usersService: UsersService, + private configService: ConfigService, + ) { + super(); + this.minRequiredBalance = this.configService.get('AUTH_MIN_TOKEN_BALANCE') || 1; + this.messagePrefix = this.configService.get('AUTH_MESSAGE_PREFIX') || 'StarkPulse Authentication:'; + this.messageExpiration = this.configService.get('AUTH_MESSAGE_EXPIRATION') || 300; // 5 minutes + } + + async validate(request: Request): Promise { + // Extract wallet address and signature from request + const { walletAddress, signature, message } = request.body; + + if (!walletAddress || !signature || !message) { + this.logger.warn(`Authentication attempt with missing credentials: ${JSON.stringify({ + hasWalletAddress: !!walletAddress, + hasSignature: !!signature, + hasMessage: !!message + })}`); + throw new UnauthorizedException('Missing authentication credentials'); + } + + try { + // Validate message format and expiration + this.validateMessage(message); + + // Verify the signature using blockchain service + const isValid = await this.blockchainService.verifySignature( + walletAddress, + message, + signature, + ); + + if (!isValid) { + this.logger.warn(`Invalid signature for wallet: ${walletAddress}`); + throw new UnauthorizedException('Invalid signature'); + } + + // Find or create user based on wallet address + let user = await this.usersService.findByWalletAddress(walletAddress); + + if (!user) { + // Create new user if not exists + this.logger.log(`Creating new user for wallet: ${walletAddress}`); + user = await this.usersService.createWithWalletAddress(walletAddress); + } + + // Check if user has minimum token balance for authentication + const tokenBalance = await this.blockchainService.getTokenBalance(walletAddress); + + if (tokenBalance < this.minRequiredBalance) { + this.logger.warn(`Insufficient token balance for wallet: ${walletAddress}, balance: ${tokenBalance}, required: ${this.minRequiredBalance}`); + throw new UnauthorizedException(`Insufficient token balance for authentication. Required: ${this.minRequiredBalance}, Current: ${tokenBalance}`); + } + + // Check if user is banned or suspended + if (user.status === 'BANNED' || user.status === 'SUSPENDED') { + this.logger.warn(`Authentication attempt from ${user.status} user: ${user.id}`); + throw new UnauthorizedException(`Your account has been ${user.status.toLowerCase()}. Please contact support.`); + } + + // Update last login timestamp + await this.usersService.updateLastLogin(user.id); + + return user; + } catch (error) { + if (error instanceof UnauthorizedException || error instanceof BadRequestException) { + throw error; + } + this.logger.error(`Authentication error for wallet ${walletAddress}: ${error.message}`, error.stack); + throw new UnauthorizedException('Authentication failed: ' + error.message); + } + } + + private validateMessage(message: string): void { + // Check if message starts with the required prefix + if (!message.startsWith(this.messagePrefix)) { + throw new BadRequestException(`Invalid message format. Message must start with "${this.messagePrefix}"`); + } + + // Extract timestamp from message + const parts = message.split(':'); + if (parts.length < 3) { + throw new BadRequestException('Invalid message format. Expected format: "StarkPulse Authentication:timestamp:nonce"'); + } + + const timestamp = parseInt(parts[1].trim(), 10); + if (isNaN(timestamp)) { + throw new BadRequestException('Invalid timestamp in authentication message'); + } + + // Check if message has expired + const currentTime = Math.floor(Date.now() / 1000); + if (currentTime - timestamp > this.messageExpiration) { + throw new BadRequestException(`Authentication message has expired. Please generate a new one.`); + } + } +} \ No newline at end of file diff --git a/src/blockchain/contracts/token.contract.ts b/src/blockchain/contracts/token.contract.ts new file mode 100644 index 0000000..334efb1 --- /dev/null +++ b/src/blockchain/contracts/token.contract.ts @@ -0,0 +1,170 @@ +import { Contract, Provider, Account, uint256, CallData } from 'starknet'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class TokenContract { + private contract: Contract; + private provider: Provider; + private account: Account; + + constructor(private configService: ConfigService) { + // Initialize provider and account + this.provider = new Provider({ + sequencer: { + network: this.configService.get('STARKNET_NETWORK', 'goerli'), + }, + }); + + // Initialize account using private key from config + const privateKey = this.configService.get('STARKNET_PRIVATE_KEY'); + const accountAddress = this.configService.get('STARKNET_ACCOUNT_ADDRESS'); + + if (privateKey && accountAddress) { + this.account = new Account( + this.provider, + accountAddress, + privateKey, + ); + } + + // Initialize contract + const tokenAddress = this.configService.get('TOKEN_CONTRACT_ADDRESS'); + if (tokenAddress) { + this.contract = new Contract( + require('./abi/token-abi.json'), + tokenAddress, + this.provider, + ); + } + } + + async balanceOf(address: string): Promise { + try { + const result = await this.contract.call('balanceOf', [address]); + return BigInt(result.balance.low); + } catch (error) { + console.error('Error getting token balance:', error); + throw new Error('Failed to get token balance'); + } + } + + async transfer(from: string, to: string, amount: number): Promise { + try { + // Convert amount to uint256 + const amountUint256 = uint256.bnToUint256(amount); + + // Execute transfer + const { transaction_hash } = await this.account.execute({ + contractAddress: this.contract.address, + entrypoint: 'transfer', + calldata: CallData.compile({ + recipient: to, + amount: amountUint256, + }), + }); + + return transaction_hash; + } catch (error) { + console.error('Error transferring tokens:', error); + throw new Error('Failed to transfer tokens'); + } + } + + async mint(to: string, amount: number): Promise { + try { + // Convert amount to uint256 + const amountUint256 = uint256.bnToUint256(amount); + + // Execute mint + const { transaction_hash } = await this.account.execute({ + contractAddress: this.contract.address, + entrypoint: 'mint', + calldata: CallData.compile({ + recipient: to, + amount: amountUint256, + }), + }); + + return transaction_hash; + } catch (error) { + console.error('Error minting tokens:', error); + throw new Error('Failed to mint tokens'); + } + } + + async stake(address: string, amount: number, lockupPeriodDays: number): Promise { + try { + // Convert amount to uint256 + const amountUint256 = uint256.bnToUint256(amount); + + // Execute stake + const { transaction_hash } = await this.account.execute({ + contractAddress: this.contract.address, + entrypoint: 'stake', + calldata: CallData.compile({ + staker: address, + amount: amountUint256, + lockupPeriodDays, + }), + }); + + return transaction_hash; + } catch (error) { + console.error('Error staking tokens:', error); + throw new Error('Failed to stake tokens'); + } + } + + async unstake(address: string, stakeId: string): Promise { + try { + // Execute unstake + const { transaction_hash } = await this.account.execute({ + contractAddress: this.contract.address, + entrypoint: 'unstake', + calldata: CallData.compile({ + staker: address, + stakeId, + }), + }); + + return transaction_hash; + } catch (error) { + console.error('Error unstaking tokens:', error); + throw new Error('Failed to unstake tokens'); + } + } + + async delegate(delegator: string, delegate: string, amount: number): Promise { + try { + // Convert amount to uint256 + const amountUint256 = uint256.bnToUint256(amount); + + // Execute delegate + const { transaction_hash } = await this.account.execute({ + contractAddress: this.contract.address, + entrypoint: 'delegate', + calldata: CallData.compile({ + delegator, + delegate, + amount: amountUint256, + }), + }); + + return transaction_hash; + } catch (error) { + console.error('Error delegating tokens:', error); + throw new Error('Failed to delegate tokens'); + } + } + + async getVotingPower(address: string): Promise { + try { + const result = await this.contract.call('getVotingPower', [address]); + return BigInt(result.votingPower.low); + } catch (error) { + console.error('Error getting voting power:', error); + throw new Error('Failed to get voting power'); + } + } +} \ No newline at end of file diff --git a/src/blockchain/services/blockchain.service.ts b/src/blockchain/services/blockchain.service.ts new file mode 100644 index 0000000..052d8da --- /dev/null +++ b/src/blockchain/services/blockchain.service.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@nestjs/common'; +import { TokenContract } from '../contracts/token.contract'; +import { ec, stark } from 'starknet'; + +@Injectable() +export class BlockchainService { + constructor(private readonly tokenContract: TokenContract) {} + + async getTokenBalance(walletAddress: string): Promise { + try { + const balance = await this.tokenContract.balanceOf(walletAddress); + return Number(balance); + } catch (error) { + console.error('Error in getTokenBalance:', error); + return 0; + } + } + + async transferTokens(fromAddress: string, toAddress: string, amount: number): Promise { + return this.tokenContract.transfer(fromAddress, toAddress, amount); + } + + async mintTokens(toAddress: string, amount: number): Promise { + return this.tokenContract.mint(toAddress, amount); + } + + async stakeTokens(walletAddress: string, amount: number, lockupPeriodDays: number): Promise { + return this.tokenContract.stake(walletAddress, amount, lockupPeriodDays); + } + + async unstakeTokens(walletAddress: string, stakeId: string): Promise { + return this.tokenContract.unstake(walletAddress, stakeId); + } + + async delegateVotingPower(delegator: string, delegate: string, amount: number): Promise { + return this.tokenContract.delegate(delegator, delegate, amount); + } + + async getVotingPower(walletAddress: string): Promise { + try { + const votingPower = await this.tokenContract.getVotingPower(walletAddress); + return Number(votingPower); + } catch (error) { + console.error('Error in getVotingPower:', error); + return 0; + } + } + + async verifySignature(walletAddress: string, message: string, signature: string): Promise { + try { + // Convert message to hash + const messageHash = stark.hashMessage(message); + + // Parse signature + const { r, s } = this.parseSignature(signature); + + // Verify signature + return stark.verifySignature( + messageHash, + r, + s, + walletAddress + ); + } catch (error) { + console.error('Signature verification error:', error); + return false; + } + } + + private parseSignature(signature: string): { r: string, s: string } { + // Remove '0x' prefix if present + const cleanSignature = signature.startsWith('0x') ? signature.slice(2) : signature; + + // Split signature into r and s components (each 64 characters) + const r = '0x' + cleanSignature.slice(0, 64); + const s = '0x' + cleanSignature.slice(64, 128); + + return { r, s }; + } +} \ No newline at end of file diff --git a/src/governance/controllers/delegation.controller.ts b/src/governance/controllers/delegation.controller.ts new file mode 100644 index 0000000..f1591b2 --- /dev/null +++ b/src/governance/controllers/delegation.controller.ts @@ -0,0 +1,47 @@ +import { Controller, Post, Body, Get, Param, UseGuards, Request, Delete } from '@nestjs/common'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CreateDelegationDto } from '../dto/create-delegation.dto'; +import { DelegationService } from '../services/delegation.service'; + +@Controller('governance/delegation') +@UseGuards(JwtAuthGuard) +export class DelegationController { + constructor(private readonly delegationService: DelegationService) {} + + @Post() + async createDelegation( + @Request() req, + @Body() createDelegationDto: CreateDelegationDto, + ) { + return this.delegationService.createDelegation( + req.user.id, + createDelegationDto.delegateAddress, + createDelegationDto.amount, + ); + } + + @Get() + async getUserDelegations(@Request() req) { + return this.delegationService.getUserDelegations(req.user.id); + } + + @Get('received') + async getReceivedDelegations(@Request() req) { + return this.delegationService.getReceivedDelegations(req.user.id); + } + + @Delete(':id') + async revokeDelegation(@Request() req, @Param('id') id: string) { + return this.delegationService.revokeDelegation(id, req.user.id); + } + + @Get('total-delegated') + async getTotalDelegatedPower(@Request() req) { + return this.delegationService.getTotalDelegatedPower(req.user.id); + } + + @Get('total-received') + async getTotalReceivedPower(@Request() req) { + return this.delegationService.getTotalReceivedPower(req.user.id); + } +} \ No newline at end of file diff --git a/src/governance/controllers/governance.controller.ts b/src/governance/controllers/governance.controller.ts new file mode 100644 index 0000000..c8a4110 --- /dev/null +++ b/src/governance/controllers/governance.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { GovernanceService } from '../services/governance.service'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { GetUser } from '../../auth/decorators/get-user.decorator'; + +@Controller('governance') +@UseGuards(JwtAuthGuard) +export class GovernanceController { + constructor(private readonly governanceService: GovernanceService) {} + + @Get('overview') + async getGovernanceOverview(@GetUser('id') userId: string) { + return this.governanceService.getGovernanceOverview(userId); + } + + @Get('stats') + async getGovernanceStats() { + return this.governanceService.getGovernanceStats(); + } +} \ No newline at end of file diff --git a/src/governance/controllers/proposal.controller.ts b/src/governance/controllers/proposal.controller.ts index d2ce719..0c1d28b 100644 --- a/src/governance/controllers/proposal.controller.ts +++ b/src/governance/controllers/proposal.controller.ts @@ -60,3 +60,60 @@ export class ProposalController { return await this.proposalService.executeProposal(id); } } +import { Controller, Get, Post, Body, Param, Patch, UseGuards } from '@nestjs/common'; +import { ProposalService } from '../services/proposal.service'; +import { CreateProposalDto } from '../dto/create-proposal.dto'; +import { UpdateProposalDto } from '../dto/update-proposal.dto'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { GetUser } from '../../auth/decorators/get-user.decorator'; + +@Controller('governance/proposals') +@UseGuards(JwtAuthGuard) +export class ProposalController { + constructor(private readonly proposalService: ProposalService) {} + + @Post() + create(@Body() createProposalDto: CreateProposalDto, @GetUser('id') userId: string) { + return this.proposalService.create(createProposalDto, userId); + } + + @Get() + findAll() { + return this.proposalService.findAll(); + } + + @Get('active') + getActiveProposals() { + return this.proposalService.getActiveProposals(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.proposalService.findOne(id); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() updateProposalDto: UpdateProposalDto) { + return this.proposalService.update(id, updateProposalDto); + } + + @Post(':id/activate') + activateProposal(@Param('id') id: string) { + return this.proposalService.activateProposal(id); + } + + @Post(':id/cancel') + cancelProposal(@Param('id') id: string, @GetUser('id') userId: string) { + return this.proposalService.cancelProposal(id, userId); + } + + @Post(':id/execute') + executeProposal(@Param('id') id: string) { + return this.proposalService.executeProposal(id); + } + + @Post(':id/finalize') + finalizeProposalVoting(@Param('id') id: string) { + return this.proposalService.finalizeProposalVoting(id); + } +} \ No newline at end of file diff --git a/src/governance/controllers/staking.controller.ts b/src/governance/controllers/staking.controller.ts index c8df36f..a838ab8 100644 --- a/src/governance/controllers/staking.controller.ts +++ b/src/governance/controllers/staking.controller.ts @@ -1,4 +1,35 @@ -import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common'; +import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common'; +import { StakingService } from '../services/staking.service'; +import { StakeTokenDto } from '../dto/stake-token.dto'; +import { UnstakeTokenDto } from '../dto/unstake-token.dto'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { GetUser } from '../../auth/decorators/get-user.decorator'; + +@Controller('governance/staking') +@UseGuards(JwtAuthGuard) +export class StakingController { + constructor(private readonly stakingService: StakingService) {} + + @Post('stake') + stakeTokens(@Body() stakeTokenDto: StakeTokenDto, @GetUser('id') userId: string) { + return this.stakingService.stakeTokens(stakeTokenDto, userId); + } + + @Post('unstake') + unstakeTokens(@Body() unstakeTokenDto: UnstakeTokenDto, @GetUser('id') userId: string) { + return this.stakingService.unstakeTokens(unstakeTokenDto, userId); + } + + @Get('user-stakes') + getUserStakes(@GetUser('id') userId: string) { + return this.stakingService.getUserStakes(userId); + } + + @Get('total-staked') + getTotalStakedAmount() { + return this.stakingService.getTotalStakedAmount(); + } +}import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { StakingService } from '../services/staking.service'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; diff --git a/src/governance/controllers/token.controller.ts b/src/governance/controllers/token.controller.ts new file mode 100644 index 0000000..0fc3833 --- /dev/null +++ b/src/governance/controllers/token.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common'; +import { TokenService } from '../services/token.service'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; + +import { TransferTokenDto } from '../dto/transfer-token.dto'; +import { RewardContributionDto } from '../dto/reward-contribution.dto'; +import { GetUser } from 'src/auth/decorator/get-user.decorator'; + +@Controller('governance/tokens') +@UseGuards(JwtAuthGuard) +export class TokenController { + constructor(private readonly tokenService: TokenService) {} + + @Get('balance') + getUserTokenBalance(@GetUser('id') userId: string) { + return this.tokenService.getUserTokenBalance(userId); + } + + @Post('transfer') + transferTokens( + @Body() transferTokenDto: TransferTokenDto, + @GetUser('id') userId: string, + ) { + const { toUserId, amount } = transferTokenDto; + return this.tokenService.transferTokens(userId, toUserId, amount); + } + + @Post('reward') + rewardUserContribution(@Body() rewardContributionDto: RewardContributionDto) { + const { userId, contributionScore } = rewardContributionDto; + return this.tokenService.rewardUserContribution(userId, contributionScore); + } +} \ No newline at end of file diff --git a/src/governance/controllers/voting.controller.ts b/src/governance/controllers/voting.controller.ts index 4ce997d..5060934 100644 --- a/src/governance/controllers/voting.controller.ts +++ b/src/governance/controllers/voting.controller.ts @@ -1,4 +1,13 @@ -import { Controller, Get, Post, Body, Param, Query, UseGuards, Request } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Body, + Param, + Query, + UseGuards, + Request +} from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { VotingService } from '../services/voting.service'; import { CastVoteDto } from '../dto/proposal.dto'; @@ -11,6 +20,7 @@ import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; export class VotingController { constructor(private readonly votingService: VotingService) {} + // ✅ Cast a vote @Post('cast') @ApiOperation({ summary: 'Cast vote on proposal' }) @ApiResponse({ status: 201, description: 'Vote cast successfully' }) @@ -18,6 +28,7 @@ export class VotingController { return await this.votingService.castVote(req.user.id, dto); } + // ✅ Get all votes for a proposal @Get('proposal/:proposalId') @ApiOperation({ summary: 'Get all votes for a proposal' }) @ApiResponse({ status: 200, description: 'Votes retrieved successfully' }) @@ -25,16 +36,15 @@ export class VotingController { return await this.votingService.getVotesForProposal(proposalId); } + // ✅ Get user's vote on a specific proposal @Get('user/:proposalId') @ApiOperation({ summary: 'Get user vote for a specific proposal' }) @ApiResponse({ status: 200, description: 'User vote retrieved' }) - async getUserVote( - @Request() req, - @Param('proposalId') proposalId: string - ) { + async getUserVote(@Request() req, @Param('proposalId') proposalId: string) { return await this.votingService.getVote(proposalId, req.user.id); } + // ✅ Get user voting power @Get('power') @ApiOperation({ summary: 'Get user voting power' }) @ApiResponse({ status: 200, description: 'Voting power retrieved' }) @@ -43,43 +53,49 @@ export class VotingController { return { userId: req.user.id, votingPower, - canVote: votingPower > 0 + canVote: votingPower > 0, }; } + // ✅ Get user voting history (to be implemented) @Get('history') @ApiOperation({ summary: 'Get user voting history' }) @ApiResponse({ status: 200, description: 'Voting history retrieved' }) async getVotingHistory( @Request() req, @Query('page') page = 1, - @Query('limit') limit = 20 + @Query('limit') limit = 20, ) { - // This would need to be implemented in the voting service - // For now, return a placeholder return { message: 'Voting history endpoint - to be implemented', userId: req.user.id, page, - limit + limit, }; } + // ✅ Get voting statistics for a proposal @Get('stats/:proposalId') @ApiOperation({ summary: 'Get voting statistics for a proposal' }) @ApiResponse({ status: 200, description: 'Voting stats retrieved' }) async getVotingStats(@Param('proposalId') proposalId: string) { const votes = await this.votingService.getVotesForProposal(proposalId); - + const stats = { totalVotes: votes.length, votesFor: votes.filter(v => v.voteType === 'FOR').length, votesAgainst: votes.filter(v => v.voteType === 'AGAINST').length, votesAbstain: votes.filter(v => v.voteType === 'ABSTAIN').length, totalVotingPower: votes.reduce((sum, v) => sum + v.votingPower, 0), - weightedVotesFor: votes.filter(v => v.voteType === 'FOR').reduce((sum, v) => sum + v.weightedVote, 0), - weightedVotesAgainst: votes.filter(v => v.voteType === 'AGAINST').reduce((sum, v) => sum + v.weightedVote, 0), - weightedVotesAbstain: votes.filter(v => v.voteType === 'ABSTAIN').reduce((sum, v) => sum + v.weightedVote, 0), + weightedVotesFor: votes + .filter(v => v.voteType === 'FOR') + .reduce((sum, v) => sum + v.weightedVote, 0), + weightedVotesAgainst: votes + .filter(v => v.voteType === 'AGAINST') + .reduce((sum, v) => sum + v.weightedVote, 0), + weightedVotesAbstain: votes + .filter(v => v.voteType === 'ABSTAIN') + .reduce((sum, v) => sum + v.weightedVote, 0), }; return stats; diff --git a/src/governance/dto/create-delegation.dto.ts b/src/governance/dto/create-delegation.dto.ts new file mode 100644 index 0000000..55a6f91 --- /dev/null +++ b/src/governance/dto/create-delegation.dto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsNumber, IsPositive, IsString, Min } from 'class-validator'; + +export class CreateDelegationDto { + @IsNotEmpty() + @IsString() + delegateAddress: string; + + @IsNotEmpty() + @IsNumber() + @IsPositive() + @Min(1) + amount: number; +} \ No newline at end of file diff --git a/src/governance/dto/create-proposal.dto.ts b/src/governance/dto/create-proposal.dto.ts new file mode 100644 index 0000000..d483e83 --- /dev/null +++ b/src/governance/dto/create-proposal.dto.ts @@ -0,0 +1,27 @@ +import { IsNotEmpty, IsString, IsDateString, IsOptional } from 'class-validator'; + +export class CreateProposalDto { + @IsNotEmpty() + @IsString() + title: string; + + @IsNotEmpty() + @IsString() + description: string; + + @IsNotEmpty() + @IsString() + content: string; + + @IsNotEmpty() + @IsDateString() + startTime: Date; + + @IsNotEmpty() + @IsDateString() + endTime: Date; + + @IsOptional() + @IsString() + contractAddress?: string; +} \ No newline at end of file diff --git a/src/governance/dto/create-stake.dto.ts b/src/governance/dto/create-stake.dto.ts new file mode 100644 index 0000000..1c35bc0 --- /dev/null +++ b/src/governance/dto/create-stake.dto.ts @@ -0,0 +1,14 @@ +import { IsNotEmpty, IsNumber, IsOptional, IsPositive, Min } from 'class-validator'; + +export class CreateStakeDto { + @IsNotEmpty() + @IsNumber() + @IsPositive() + @Min(1) + amount: number; + + @IsOptional() + @IsNumber() + @IsPositive() + lockupPeriodDays?: number; +} \ No newline at end of file diff --git a/src/governance/dto/update-proposal.dto.ts b/src/governance/dto/update-proposal.dto.ts new file mode 100644 index 0000000..923393e --- /dev/null +++ b/src/governance/dto/update-proposal.dto.ts @@ -0,0 +1,23 @@ +import { IsOptional, IsString, IsDateString } from 'class-validator'; + +export class UpdateProposalDto { + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + content?: string; + + @IsOptional() + @IsDateString() + startTime?: Date; + + @IsOptional() + @IsDateString() + endTime?: Date; +} \ No newline at end of file diff --git a/src/governance/entities/delegation.entity.ts b/src/governance/entities/delegation.entity.ts new file mode 100644 index 0000000..6e00579 --- /dev/null +++ b/src/governance/entities/delegation.entity.ts @@ -0,0 +1,32 @@ +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { User } from '../../users/users.entity'; + +@Entity() +export class Delegation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User) + delegator: User; + + @ManyToOne(() => User) + delegate: User; + + @Column('decimal', { precision: 36, scale: 18 }) + amount: number; + + @Column({ default: true }) + isActive: boolean; + + @Column({ nullable: true }) + transactionHash: string; + + @Column({ nullable: true }) + revocationTransactionHash: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/src/governance/entities/proposal.entity.ts b/src/governance/entities/proposal.entity.ts index f06b7e7..800256e 100644 --- a/src/governance/entities/proposal.entity.ts +++ b/src/governance/entities/proposal.entity.ts @@ -1,7 +1,35 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn, OneToMany, Index } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + OneToMany, + Index +} from 'typeorm'; import { User } from '../../users/entities/user.entity'; import { Vote } from './vote.entity'; +export enum ProposalStatus { + DRAFT = 'DRAFT', + ACTIVE = 'ACTIVE', + PASSED = 'PASSED', + REJECTED = 'REJECTED', + EXPIRED = 'EXPIRED', + EXECUTED = 'EXECUTED', + CANCELED = 'CANCELED', +} + +export enum ProposalType { + FEATURE = 'FEATURE', + PARAMETER = 'PARAMETER', + TREASURY = 'TREASURY', + UPGRADE = 'UPGRADE', + COMMUNITY = 'COMMUNITY', +} + @Entity('proposals') @Index(['status', 'createdAt']) @Index(['proposerId', 'status']) @@ -15,6 +43,9 @@ export class Proposal { @Column('text') description: string; + @Column('text', { nullable: true }) + content: string; // ✅ kept from first version (proposal body) + @Column('uuid') proposerId: string; @@ -24,18 +55,19 @@ export class Proposal { @Column({ type: 'enum', - enum: ['DRAFT', 'ACTIVE', 'PASSED', 'REJECTED', 'EXPIRED', 'EXECUTED'], - default: 'DRAFT' + enum: ProposalStatus, + default: ProposalStatus.DRAFT, }) - status: string; + status: ProposalStatus; @Column({ type: 'enum', - enum: ['FEATURE', 'PARAMETER', 'TREASURY', 'UPGRADE', 'COMMUNITY'], - default: 'FEATURE' + enum: ProposalType, + default: ProposalType.FEATURE, }) - type: string; + type: ProposalType; + // ✅ voting counts with decimals @Column('decimal', { precision: 18, scale: 8, default: 0 }) votesFor: number; @@ -63,12 +95,21 @@ export class Proposal { @Column('timestamp', { nullable: true }) executedAt: Date; + @Column({ default: false }) + isExecuted: boolean; // ✅ kept from first version + @Column('jsonb', { nullable: true }) metadata: Record; @Column('text', { nullable: true }) executionData: string; + @Column({ nullable: true }) + transactionHash: string; // ✅ kept from first version + + @Column({ nullable: true }) + contractAddress: string; // ✅ kept from first version + @OneToMany(() => Vote, vote => vote.proposal) votes: Vote[]; diff --git a/src/governance/entities/stake.entity.ts b/src/governance/entities/stake.entity.ts new file mode 100644 index 0000000..2fa8bc4 --- /dev/null +++ b/src/governance/entities/stake.entity.ts @@ -0,0 +1,35 @@ +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { User } from '../../users/users.entity'; + +@Entity() +export class Stake { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User) + user: User; + + @Column('decimal', { precision: 36, scale: 18 }) + amount: number; + + @Column({ type: 'timestamp' }) + lockupEndTime: Date; + + @Column({ default: false }) + isUnstaked: boolean; + + @Column({ nullable: true }) + unstakeTransactionHash: string; + + @Column({ nullable: true }) + stakeTransactionHash: string; + + @Column('decimal', { precision: 36, scale: 18, default: 0 }) + rewardEarned: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/src/governance/entities/vote.entity.ts b/src/governance/entities/vote.entity.ts index 8ebc547..091ae94 100644 --- a/src/governance/entities/vote.entity.ts +++ b/src/governance/entities/vote.entity.ts @@ -1,55 +1,39 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn, Index, Unique } from 'typeorm'; -import { User } from '../../users/entities/user.entity'; +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, CreateDateColumn } from 'typeorm'; +import { User } from '../../users/users.entity'; import { Proposal } from './proposal.entity'; -@Entity('votes') -@Index(['proposalId', 'voterId']) -@Unique(['proposalId', 'voterId']) +export enum VoteType { + YES = 'yes', + NO = 'no', + ABSTAIN = 'abstain', +} + +@Entity() export class Vote { @PrimaryGeneratedColumn('uuid') id: string; - @Column('uuid') - proposalId: string; + @ManyToOne(() => User) + voter: User; @ManyToOne(() => Proposal, proposal => proposal.votes) - @JoinColumn({ name: 'proposalId' }) proposal: Proposal; - @Column('uuid') - voterId: string; - - @ManyToOne(() => User) - @JoinColumn({ name: 'voterId' }) - voter: User; - @Column({ type: 'enum', - enum: ['FOR', 'AGAINST', 'ABSTAIN'], + enum: VoteType, }) - voteType: string; + voteType: VoteType; - @Column('decimal', { precision: 18, scale: 8, default: 0 }) + @Column('decimal', { precision: 36, scale: 18 }) votingPower: number; - @Column('decimal', { precision: 18, scale: 8, default: 0 }) - weightedVote: number; - - @Column('text', { nullable: true }) + @Column({ nullable: true }) reason: string; - @Column('jsonb', { nullable: true }) - metadata: Record; - - @Column('boolean', { default: false }) - isDelegated: boolean; - - @Column('uuid', { nullable: true }) - delegatedFrom: string; + @Column({ nullable: true }) + transactionHash: string; @CreateDateColumn() createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} +} \ No newline at end of file diff --git a/src/governance/governance.module.ts b/src/governance/governance.module.ts index d6bc551..48ff78d 100644 --- a/src/governance/governance.module.ts +++ b/src/governance/governance.module.ts @@ -1,57 +1,53 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; - -// Entities -import { GovernanceToken } from './entities/governance-token.entity'; -import { Proposal } from './entities/proposal.entity'; -import { Vote } from './entities/vote.entity'; -import { Contribution } from './entities/contribution.entity'; -import { Staking } from './entities/staking.entity'; - -// Services -import { GovernanceTokenService } from './services/governance-token.service'; +import { GovernanceService } from './services/governance.service'; import { ProposalService } from './services/proposal.service'; import { VotingService } from './services/voting.service'; -import { ContributionService } from './services/contribution.service'; +import { TokenService } from './services/token.service'; import { StakingService } from './services/staking.service'; - -// Controllers -import { GovernanceTokenController } from './controllers/governance-token.controller'; +import { GovernanceController } from './controllers/governance.controller'; import { ProposalController } from './controllers/proposal.controller'; import { VotingController } from './controllers/voting.controller'; -import { ContributionController } from './controllers/contribution.controller'; +import { TokenController } from './controllers/token.controller'; import { StakingController } from './controllers/staking.controller'; +import { Proposal } from './entities/proposal.entity'; +import { Vote } from './entities/vote.entity'; +import { Stake } from './entities/stake.entity'; +import { Delegation } from './entities/delegation.entity'; +import { BlockchainModule } from '../blockchain/blockchain.module'; +import { UsersModule } from '../users/users.module'; @Module({ imports: [ TypeOrmModule.forFeature([ - GovernanceToken, Proposal, Vote, - Contribution, - Staking, + Stake, + Delegation, ]), + BlockchainModule, + UsersModule, ], controllers: [ - GovernanceTokenController, + GovernanceController, ProposalController, VotingController, - ContributionController, + TokenController, StakingController, ], providers: [ - GovernanceTokenService, + GovernanceService, ProposalService, VotingService, - ContributionService, + TokenService, StakingService, ], exports: [ - GovernanceTokenService, + GovernanceService, ProposalService, VotingService, - ContributionService, + TokenService, StakingService, ], }) -export class GovernanceModule {} +export class GovernanceModule {} \ No newline at end of file diff --git a/src/governance/services/delegation.service.ts b/src/governance/services/delegation.service.ts new file mode 100644 index 0000000..49b96db --- /dev/null +++ b/src/governance/services/delegation.service.ts @@ -0,0 +1,112 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Delegation } from '../entities/delegation.entity'; +import { BlockchainService } from '../../blockchain/services/blockchain.service'; +import { UsersService } from '../../users/users.service'; + +@Injectable() +export class DelegationService { + constructor( + @InjectRepository(Delegation) + private delegationRepository: Repository, + private blockchainService: BlockchainService, + private usersService: UsersService, + ) {} + + async createDelegation( + delegatorId: string, + delegateAddress: string, + amount: number, + ) { + // Get delegator user + const delegator = await this.usersService.findOne(delegatorId); + if (!delegator) { + throw new NotFoundException('Delegator user not found'); + } + + // Get delegate user + const delegate = await this.usersService.findByWalletAddress(delegateAddress); + if (!delegate) { + throw new NotFoundException('Delegate user not found'); + } + + // Check if delegator has enough tokens + const tokenBalance = await this.blockchainService.getTokenBalance(delegator.walletAddress); + if (tokenBalance < amount) { + throw new BadRequestException('Insufficient token balance for delegation'); + } + + // Call blockchain service to delegate voting power + const transactionHash = await this.blockchainService.delegateVotingPower( + delegator.walletAddress, + delegateAddress, + amount, + ); + + // Create delegation record + const delegation = this.delegationRepository.create({ + delegator: delegator, + delegate: delegate, + amount, + isActive: true, + transactionHash, + }); + + return this.delegationRepository.save(delegation); + } + + async getUserDelegations(userId: string) { + return this.delegationRepository.find({ + where: { delegator: { id: userId }, isActive: true }, + relations: ['delegate'], + }); + } + + async getReceivedDelegations(userId: string) { + return this.delegationRepository.find({ + where: { delegate: { id: userId }, isActive: true }, + relations: ['delegator'], + }); + } + + async revokeDelegation(delegationId: string, userId: string) { + const delegation = await this.delegationRepository.findOne({ + where: { id: delegationId, delegator: { id: userId }, isActive: true }, + relations: ['delegator', 'delegate'], + }); + + if (!delegation) { + throw new NotFoundException('Active delegation not found'); + } + + // Call blockchain service to revoke delegation + const revocationTransactionHash = await this.blockchainService.delegateVotingPower( + delegation.delegator.walletAddress, + delegation.delegate.walletAddress, + 0, // Setting amount to 0 revokes the delegation + ); + + // Update delegation record + delegation.isActive = false; + delegation.revocationTransactionHash = revocationTransactionHash; + + return this.delegationRepository.save(delegation); + } + + async getTotalDelegatedPower(userId: string) { + const delegations = await this.delegationRepository.find({ + where: { delegator: { id: userId }, isActive: true }, + }); + + return delegations.reduce((total, delegation) => total + Number(delegation.amount), 0); + } + + async getTotalReceivedPower(userId: string) { + const delegations = await this.delegationRepository.find({ + where: { delegate: { id: userId }, isActive: true }, + }); + + return delegations.reduce((total, delegation) => total + Number(delegation.amount), 0); + } +} \ No newline at end of file diff --git a/src/governance/services/governance.service.ts b/src/governance/services/governance.service.ts new file mode 100644 index 0000000..92980c4 --- /dev/null +++ b/src/governance/services/governance.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; +import { ProposalService } from './proposal.service'; +import { VotingService } from './voting.service'; +import { TokenService } from './token.service'; +import { StakingService } from './staking.service'; + +@Injectable() +export class GovernanceService { + constructor( + private readonly proposalService: ProposalService, + private readonly votingService: VotingService, + private readonly tokenService: TokenService, + private readonly stakingService: StakingService, + ) {} + + async getGovernanceOverview(userId: string) { + const [ + activeProposals, + userVotingPower, + userStakes, + tokenBalance, + ] = await Promise.all([ + this.proposalService.getActiveProposals(), + this.votingService.getUserVotingPower(userId), + this.stakingService.getUserStakes(userId), + this.tokenService.getUserTokenBalance(userId), + ]); + + return { + activeProposals, + userVotingPower, + userStakes, + tokenBalance, + governanceStats: await this.getGovernanceStats(), + }; + } + + async getGovernanceStats() { + const totalProposals = await this.proposalService.getTotalProposalsCount(); + const totalVotes = await this.votingService.getTotalVotesCount(); + const totalStaked = await this.stakingService.getTotalStakedAmount(); + const participationRate = await this.votingService.getParticipationRate(); + + return { + totalProposals, + totalVotes, + totalStaked, + participationRate, + }; + } + + async executePassedProposals() { + const passedProposals = await this.proposalService.getPassedProposalsReadyForExecution(); + + for (const proposal of passedProposals) { + await this.proposalService.executeProposal(proposal.id); + } + + return passedProposals.length; + } +} \ No newline at end of file diff --git a/src/governance/services/proposal.service.ts b/src/governance/services/proposal.service.ts index 87e2782..e09399d 100644 --- a/src/governance/services/proposal.service.ts +++ b/src/governance/services/proposal.service.ts @@ -1,143 +1,146 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Proposal } from '../entities/proposal.entity'; -import { IProposalService } from '../interfaces/governance.interface'; -import { CreateProposalDto, UpdateProposalDto, ProposalFilterDto } from '../dto/proposal.dto'; +import { Proposal, ProposalStatus } from '../entities/proposal.entity'; +import { CreateProposalDto } from '../dto/create-proposal.dto'; +import { UpdateProposalDto } from '../dto/update-proposal.dto'; @Injectable() -export class ProposalService implements IProposalService { +export class ProposalService { constructor( @InjectRepository(Proposal) - private readonly proposalRepository: Repository, + private proposalRepository: Repository, ) {} - async createProposal(proposerId: string, dto: CreateProposalDto): Promise { + async create(createProposalDto: CreateProposalDto, userId: string) { const proposal = this.proposalRepository.create({ - ...dto, - proposerId, - quorumRequired: dto.quorumRequired || 50000, // Default quorum - votingPeriodDays: dto.votingPeriodDays || 7, + ...createProposalDto, + proposer: { id: userId }, + status: ProposalStatus.DRAFT, }); - - return await this.proposalRepository.save(proposal); - } - - async updateProposal(id: string, dto: UpdateProposalDto): Promise { - const proposal = await this.getProposal(id); - // Only allow updates if proposal is in DRAFT status - if (proposal.status !== 'DRAFT') { - throw new BadRequestException('Only draft proposals can be updated'); - } + return this.proposalRepository.save(proposal); + } - await this.proposalRepository.update(id, dto); - return this.getProposal(id); + async findAll(filters?: any) { + return this.proposalRepository.find({ + where: filters, + relations: ['proposer', 'votes'], + order: { createdAt: 'DESC' }, + }); } - async getProposal(id: string): Promise { + async findOne(id: string) { const proposal = await this.proposalRepository.findOne({ where: { id }, relations: ['proposer', 'votes', 'votes.voter'], }); - + if (!proposal) { - throw new NotFoundException('Proposal not found'); + throw new NotFoundException(`Proposal with ID ${id} not found`); } - + return proposal; } - async getProposals(filter: ProposalFilterDto): Promise<{ proposals: Proposal[]; total: number }> { - const queryBuilder = this.proposalRepository.createQueryBuilder('proposal') - .leftJoinAndSelect('proposal.proposer', 'proposer'); - - if (filter.status) { - queryBuilder.andWhere('proposal.status = :status', { status: filter.status }); + async update(id: string, updateProposalDto: UpdateProposalDto) { + const proposal = await this.findOne(id); + + if (proposal.status !== ProposalStatus.DRAFT) { + throw new BadRequestException('Only draft proposals can be updated'); } + + Object.assign(proposal, updateProposalDto); + return this.proposalRepository.save(proposal); + } - if (filter.type) { - queryBuilder.andWhere('proposal.type = :type', { type: filter.type }); + async activateProposal(id: string) { + const proposal = await this.findOne(id); + + if (proposal.status !== ProposalStatus.DRAFT) { + throw new BadRequestException('Only draft proposals can be activated'); } + + proposal.status = ProposalStatus.ACTIVE; + proposal.startTime = new Date(); + return this.proposalRepository.save(proposal); + } - if (filter.proposerId) { - queryBuilder.andWhere('proposal.proposerId = :proposerId', { proposerId: filter.proposerId }); + async cancelProposal(id: string, userId: string) { + const proposal = await this.findOne(id); + + if (proposal.proposer.id !== userId) { + throw new BadRequestException('Only the proposer can cancel the proposal'); } - - const sortBy = filter.sortBy || 'createdAt'; - const sortOrder = filter.sortOrder || 'DESC'; - queryBuilder.orderBy(`proposal.${sortBy}`, sortOrder); - - const page = filter.page || 1; - const limit = filter.limit || 20; - const skip = (page - 1) * limit; - - queryBuilder.skip(skip).take(limit); - - const [proposals, total] = await queryBuilder.getManyAndCount(); - - return { proposals, total }; + + if (proposal.status !== ProposalStatus.DRAFT && proposal.status !== ProposalStatus.ACTIVE) { + throw new BadRequestException('Only draft or active proposals can be canceled'); + } + + proposal.status = ProposalStatus.CANCELED; + return this.proposalRepository.save(proposal); } - async activateProposal(id: string): Promise { - const proposal = await this.getProposal(id); - - if (proposal.status !== 'DRAFT') { - throw new BadRequestException('Only draft proposals can be activated'); + async executeProposal(id: string) { + const proposal = await this.findOne(id); + + if (proposal.status !== ProposalStatus.PASSED) { + throw new BadRequestException('Only passed proposals can be executed'); } + + // Here we would implement the actual execution logic + // This could involve calling smart contract functions + + proposal.status = ProposalStatus.EXECUTED; + proposal.executionTime = new Date(); + proposal.isExecuted = true; + + return this.proposalRepository.save(proposal); + } - const now = new Date(); - const votingEndsAt = new Date(now.getTime() + (proposal.votingPeriodDays * 24 * 60 * 60 * 1000)); - - await this.proposalRepository.update(id, { - status: 'ACTIVE', - votingStartsAt: now, - votingEndsAt, + async getActiveProposals() { + return this.proposalRepository.find({ + where: { status: ProposalStatus.ACTIVE }, + relations: ['proposer'], + order: { endTime: 'ASC' }, }); + } - return this.getProposal(id); + async getPassedProposalsReadyForExecution() { + return this.proposalRepository.find({ + where: { + status: ProposalStatus.PASSED, + isExecuted: false, + }, + relations: ['proposer'], + }); } - async finalizeProposal(id: string): Promise { - const proposal = await this.getProposal(id); + async getTotalProposalsCount() { + return this.proposalRepository.count(); + } - if (proposal.status !== 'ACTIVE') { + async finalizeProposalVoting(id: string) { + const proposal = await this.findOne(id); + + if (proposal.status !== ProposalStatus.ACTIVE) { throw new BadRequestException('Only active proposals can be finalized'); } - - const now = new Date(); - if (proposal.votingEndsAt > now) { + + if (new Date() < proposal.endTime) { throw new BadRequestException('Voting period has not ended yet'); } - - // Determine if proposal passed - const totalVotes = proposal.votesFor + proposal.votesAgainst + proposal.votesAbstain; - const quorumMet = totalVotes >= proposal.quorumRequired; - const majorityFor = proposal.votesFor > proposal.votesAgainst; - - const newStatus = quorumMet && majorityFor ? 'PASSED' : 'REJECTED'; - - await this.proposalRepository.update(id, { - status: newStatus, - totalVotes, - }); - - return this.getProposal(id); - } - - async executeProposal(id: string): Promise { - const proposal = await this.getProposal(id); - - if (proposal.status !== 'PASSED') { - throw new BadRequestException('Only passed proposals can be executed'); + + // Determine if proposal passed based on votes + const totalVotes = proposal.yesVotes + proposal.noVotes; + const passThreshold = 0.5; // 50% majority + + if (totalVotes > 0 && proposal.yesVotes / totalVotes > passThreshold) { + proposal.status = ProposalStatus.PASSED; + } else { + proposal.status = ProposalStatus.REJECTED; } - - // TODO: Implement actual execution logic based on proposal type - await this.proposalRepository.update(id, { - status: 'EXECUTED', - executedAt: new Date(), - }); - - return this.getProposal(id); + + return this.proposalRepository.save(proposal); } -} +} \ No newline at end of file diff --git a/src/governance/services/staking.service.ts b/src/governance/services/staking.service.ts index b812639..0aa1910 100644 --- a/src/governance/services/staking.service.ts +++ b/src/governance/services/staking.service.ts @@ -1,175 +1,132 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Staking } from '../entities/staking.entity'; -import { GovernanceToken } from '../entities/governance-token.entity'; -import { IStakingService } from '../interfaces/governance.interface'; +import { Stake } from '../entities/stake.entity'; +import { BlockchainService } from '../../blockchain/services/blockchain.service'; +import { StakeTokenDto } from '../dto/stake-token.dto'; +import { UnstakeTokenDto } from '../dto/unstake-token.dto'; @Injectable() -export class StakingService implements IStakingService { +export class StakingService { constructor( - @InjectRepository(Staking) - private readonly stakingRepository: Repository, - @InjectRepository(GovernanceToken) - private readonly tokenRepository: Repository, + @InjectRepository(Stake) + private stakeRepository: Repository, + private blockchainService: BlockchainService, ) {} - async stakeTokens(userId: string, amount: number, lockPeriodDays = 14): Promise { - // Check if user has sufficient tokens - const token = await this.tokenRepository.findOne({ - where: { userId, tokenType: 'GOVERNANCE' } - }); - - if (!token || token.balance < amount) { + async stakeTokens(stakeTokenDto: StakeTokenDto, userId: string) { + const { amount, lockupPeriodDays } = stakeTokenDto; + + // Validate user has enough tokens + const walletAddress = await this.getUserWalletAddress(userId); + const userBalance = await this.blockchainService.getTokenBalance(walletAddress); + + if (userBalance < amount) { throw new BadRequestException('Insufficient token balance'); } - - // Update token balance - token.balance -= amount; - token.stakedBalance += amount; - await this.tokenRepository.save(token); - - // Create staking record - const staking = this.stakingRepository.create({ - userId, - stakedAmount: amount, - lockPeriodDays, - stakedAt: new Date(), - canUnstakeAt: new Date(Date.now() + lockPeriodDays * 24 * 60 * 60 * 1000), - apy: this.calculateAPY(lockPeriodDays), + + // Calculate lockup end time + const lockupEndTime = new Date(); + lockupEndTime.setDate(lockupEndTime.getDate() + lockupPeriodDays); + + // Create stake record + const stake = this.stakeRepository.create({ + user: { id: userId }, + amount, + lockupEndTime, }); - - return await this.stakingRepository.save(staking); + + // Execute staking transaction on blockchain + const txHash = await this.blockchainService.stakeTokens(walletAddress, amount, lockupPeriodDays); + stake.stakeTransactionHash = txHash; + + return this.stakeRepository.save(stake); } - async unstakeTokens(stakingId: string): Promise { - const staking = await this.stakingRepository.findOne({ - where: { id: stakingId } + async unstakeTokens(unstakeTokenDto: UnstakeTokenDto, userId: string) { + const { stakeId } = unstakeTokenDto; + + // Find stake record + const stake = await this.stakeRepository.findOne({ + where: { + id: stakeId, + user: { id: userId }, + isUnstaked: false, + }, }); - - if (!staking) { - throw new NotFoundException('Staking record not found'); - } - - if (staking.status !== 'ACTIVE') { - throw new BadRequestException('Staking is not active'); - } - - const now = new Date(); - if (now < staking.canUnstakeAt) { - // Request unstaking (start cooldown period) - staking.status = 'UNSTAKING'; - staking.unstakeRequestedAt = now; - return await this.stakingRepository.save(staking); + + if (!stake) { + throw new BadRequestException('Stake not found or already unstaked'); } - - // Complete unstaking - const token = await this.tokenRepository.findOne({ - where: { userId: staking.userId, tokenType: 'GOVERNANCE' } - }); - - if (token) { - token.balance += staking.stakedAmount; - token.stakedBalance -= staking.stakedAmount; - await this.tokenRepository.save(token); + + // Check if lockup period has ended + if (new Date() < stake.lockupEndTime) { + throw new BadRequestException('Tokens are still locked'); } - - staking.status = 'UNSTAKED'; - return await this.stakingRepository.save(staking); + + // Execute unstaking transaction on blockchain + const walletAddress = await this.getUserWalletAddress(userId); + const txHash = await this.blockchainService.unstakeTokens(walletAddress, stake.stakeTransactionHash); + + // Update stake record + stake.isUnstaked = true; + stake.unstakeTransactionHash = txHash; + + return this.stakeRepository.save(stake); } - async delegateStake(stakingId: string, delegateId: string): Promise { - const staking = await this.stakingRepository.findOne({ - where: { id: stakingId } + async getUserStakes(userId: string) { + return this.stakeRepository.find({ + where: { user: { id: userId } }, + order: { createdAt: 'DESC' }, }); - - if (!staking) { - throw new NotFoundException('Staking record not found'); - } - - if (staking.status !== 'ACTIVE') { - throw new BadRequestException('Can only delegate active stakes'); - } - - staking.delegatedTo = delegateId; - return await this.stakingRepository.save(staking); } - async undelegateStake(stakingId: string): Promise { - const staking = await this.stakingRepository.findOne({ - where: { id: stakingId } + async calculateStakingRewards() { + const activeStakes = await this.stakeRepository.find({ + where: { isUnstaked: false }, }); - - if (!staking) { - throw new NotFoundException('Staking record not found'); + + for (const stake of activeStakes) { + // Calculate reward based on staking duration and amount + const stakingDuration = this.calculateStakingDuration(stake.createdAt); + const rewardRate = this.getRewardRate(stakingDuration); + const reward = Number(stake.amount) * rewardRate; + + // Update stake with earned reward + stake.rewardEarned += reward; + await this.stakeRepository.save(stake); } - - staking.delegatedTo = null; - return await this.stakingRepository.save(staking); } - async calculateRewards(stakingId: string): Promise { - const staking = await this.stakingRepository.findOne({ - where: { id: stakingId } - }); - - if (!staking || staking.status !== 'ACTIVE') { - return 0; - } - - const now = new Date(); - const stakingDuration = now.getTime() - staking.stakedAt.getTime(); - const stakingDays = stakingDuration / (24 * 60 * 60 * 1000); - - // Calculate rewards based on staked amount, APY, and time - const annualReward = (staking.stakedAmount * staking.apy) / 100; - const dailyReward = annualReward / 365; - const totalRewards = dailyReward * stakingDays; - - return Math.max(0, totalRewards - staking.rewardsClaimed); + async getTotalStakedAmount() { + const result = await this.stakeRepository + .createQueryBuilder('stake') + .where('stake.isUnstaked = :isUnstaked', { isUnstaked: false }) + .select('SUM(stake.amount)', 'total') + .getRawOne(); + + return result?.total || 0; } - async claimRewards(stakingId: string): Promise { - const pendingRewards = await this.calculateRewards(stakingId); - - if (pendingRewards <= 0) { - return 0; - } - - const staking = await this.stakingRepository.findOne({ - where: { id: stakingId } - }); - - // Add rewards to user's token balance - const token = await this.tokenRepository.findOne({ - where: { userId: staking.userId, tokenType: 'REWARD' } - }); - - if (token) { - token.balance += pendingRewards; - await this.tokenRepository.save(token); - } - - // Update staking record - staking.rewardsClaimed += pendingRewards; - staking.pendingRewards = 0; - await this.stakingRepository.save(staking); - - return pendingRewards; + private calculateStakingDuration(stakeDate: Date): number { + const now = new Date(); + const diffTime = Math.abs(now.getTime() - stakeDate.getTime()); + return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); // Convert to days } - async getStakingInfo(userId: string): Promise { - return await this.stakingRepository.find({ - where: { userId }, - relations: ['delegate'], - order: { createdAt: 'DESC' }, - }); + private getRewardRate(stakingDuration: number): number { + // Implement tiered reward rates based on staking duration + if (stakingDuration > 365) return 0.15; // 15% for > 1 year + if (stakingDuration > 180) return 0.10; // 10% for > 6 months + if (stakingDuration > 90) return 0.05; // 5% for > 3 months + if (stakingDuration > 30) return 0.02; // 2% for > 1 month + return 0.01; // 1% for < 1 month } - private calculateAPY(lockPeriodDays: number): number { - // Base APY calculation - longer lock periods get higher APY - const baseAPY = 5; // 5% base APY - const lockBonus = Math.min(lockPeriodDays / 365, 1) * 10; // Up to 10% bonus for 1 year lock - return baseAPY + lockBonus; + private async getUserWalletAddress(userId: string): Promise { + // This would typically come from a user service + // For now, we'll return a mock address + return `0x${userId.substring(0, 40)}`; } -} +} \ No newline at end of file diff --git a/src/governance/services/token.service.ts b/src/governance/services/token.service.ts new file mode 100644 index 0000000..fb959b7 --- /dev/null +++ b/src/governance/services/token.service.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Stake } from '../entities/stake.entity'; +import { Delegation } from '../entities/delegation.entity'; +import { BlockchainService } from '../../blockchain/services/blockchain.service'; + +@Injectable() +export class TokenService { + constructor( + @InjectRepository(Stake) + private stakeRepository: Repository, + @InjectRepository(Delegation) + private delegationRepository: Repository, + private blockchainService: BlockchainService, + ) {} + + async getUserTokenBalance(userId: string): Promise { + // Get user's wallet address from user service + const walletAddress = await this.getUserWalletAddress(userId); + + if (!walletAddress) { + return 0; + } + + // Get token balance from blockchain + return this.blockchainService.getTokenBalance(walletAddress); + } + + async getUserStakedAmount(userId: string): Promise { + const stakes = await this.stakeRepository.find({ + where: { + user: { id: userId }, + isUnstaked: false, + }, + }); + + return stakes.reduce((total, stake) => total + Number(stake.amount), 0); + } + + async getUserDelegatedVotingPower(userId: string): Promise { + const delegations = await this.delegationRepository.find({ + where: { + delegate: { id: userId }, + isActive: true, + }, + }); + + return delegations.reduce((total, delegation) => total + Number(delegation.amount), 0); + } + + async transferTokens(fromUserId: string, toUserId: string, amount: number) { + const fromWalletAddress = await this.getUserWalletAddress(fromUserId); + const toWalletAddress = await this.getUserWalletAddress(toUserId); + + if (!fromWalletAddress || !toWalletAddress) { + throw new Error('Wallet address not found'); + } + + return this.blockchainService.transferTokens(fromWalletAddress, toWalletAddress, amount); + } + + async rewardUserContribution(userId: string, contributionScore: number) { + const walletAddress = await this.getUserWalletAddress(userId); + + if (!walletAddress) { + throw new Error('Wallet address not found'); + } + + // Calculate token reward based on contribution score + const tokenReward = this.calculateTokenReward(contributionScore); + + // Mint tokens to user's wallet + return this.blockchainService.mintTokens(walletAddress, tokenReward); + } + + private calculateTokenReward(contributionScore: number): number { + // Implement reward calculation algorithm + // This is a simple linear model, but could be more complex + const baseReward = 10; + const multiplier = 0.5; + + return baseReward + (contributionScore * multiplier); + } + + private async getUserWalletAddress(userId: string): Promise { + // This would typically come from a user service + // For now, we'll return a mock address + return `0x${userId.substring(0, 40)}`; + } +} \ No newline at end of file diff --git a/src/governance/services/voting.service.ts b/src/governance/services/voting.service.ts index a3c150e..6cebdf2 100644 --- a/src/governance/services/voting.service.ts +++ b/src/governance/services/voting.service.ts @@ -1,130 +1,151 @@ -import { Injectable, BadRequestException, ConflictException } from '@nestjs/common'; +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Vote } from '../entities/vote.entity'; -import { Proposal } from '../entities/proposal.entity'; -import { GovernanceToken } from '../entities/governance-token.entity'; -import { IVotingService } from '../interfaces/governance.interface'; -import { CastVoteDto } from '../dto/proposal.dto'; +import { Vote, VoteType } from '../entities/vote.entity'; +import { Proposal, ProposalStatus } from '../entities/proposal.entity'; +import { CreateVoteDto } from '../dto/create-vote.dto'; +import { TokenService } from './token.service'; @Injectable() -export class VotingService implements IVotingService { +export class VotingService { constructor( @InjectRepository(Vote) - private readonly voteRepository: Repository, + private voteRepository: Repository, @InjectRepository(Proposal) - private readonly proposalRepository: Repository, - @InjectRepository(GovernanceToken) - private readonly tokenRepository: Repository, + private proposalRepository: Repository, + private tokenService: TokenService, ) {} - async castVote(voterId: string, dto: CastVoteDto): Promise { + async castVote(createVoteDto: CreateVoteDto, userId: string) { + const { proposalId, voteType, reason } = createVoteDto; + // Check if proposal exists and is active const proposal = await this.proposalRepository.findOne({ - where: { id: dto.proposalId } + where: { id: proposalId }, }); - + if (!proposal) { - throw new BadRequestException('Proposal not found'); + throw new NotFoundException(`Proposal with ID ${proposalId} not found`); } - - if (proposal.status !== 'ACTIVE') { - throw new BadRequestException('Proposal is not active for voting'); + + if (proposal.status !== ProposalStatus.ACTIVE) { + throw new BadRequestException('Voting is only allowed on active proposals'); } - - const now = new Date(); - if (now < proposal.votingStartsAt || now > proposal.votingEndsAt) { - throw new BadRequestException('Voting period has ended or not started'); + + if (new Date() > proposal.endTime || new Date() < proposal.startTime) { + throw new BadRequestException('Voting is not allowed outside the voting period'); } - + // Check if user has already voted const existingVote = await this.voteRepository.findOne({ - where: { proposalId: dto.proposalId, voterId } + where: { + voter: { id: userId }, + proposal: { id: proposalId }, + }, }); - + if (existingVote) { - throw new ConflictException('User has already voted on this proposal'); + throw new BadRequestException('User has already voted on this proposal'); } - - // Calculate voting power - const votingPower = await this.calculateVotingPower(voterId); - + + // Get user's voting power + const votingPower = await this.getUserVotingPower(userId); + if (votingPower <= 0) { throw new BadRequestException('User has no voting power'); } - + // Create and save vote const vote = this.voteRepository.create({ - ...dto, - voterId, + voter: { id: userId }, + proposal: { id: proposalId }, + voteType, votingPower, - weightedVote: votingPower, + reason, }); - + const savedVote = await this.voteRepository.save(vote); - + // Update proposal vote counts - await this.updateProposalVoteCounts(dto.proposalId); - + await this.updateProposalVoteCounts(proposalId); + return savedVote; } - async getVote(proposalId: string, voterId: string): Promise { - return await this.voteRepository.findOne({ - where: { proposalId, voterId }, - relations: ['voter', 'proposal'], + async getUserVotes(userId: string) { + return this.voteRepository.find({ + where: { voter: { id: userId } }, + relations: ['proposal'], + order: { createdAt: 'DESC' }, }); } - async getVotesForProposal(proposalId: string): Promise { - return await this.voteRepository.find({ - where: { proposalId }, + async getProposalVotes(proposalId: string) { + return this.voteRepository.find({ + where: { proposal: { id: proposalId } }, relations: ['voter'], order: { createdAt: 'DESC' }, }); } - async calculateVotingPower(userId: string): Promise { - const token = await this.tokenRepository.findOne({ - where: { userId, tokenType: 'GOVERNANCE' } - }); - - if (!token) { - return 0; - } - - // Voting power = own tokens + delegated tokens - return token.votingPower + token.delegatedPower; + async getUserVotingPower(userId: string): Promise { + // Get user's token balance and staked amount + const tokenBalance = await this.tokenService.getUserTokenBalance(userId); + const stakedAmount = await this.tokenService.getUserStakedAmount(userId); + const delegatedPower = await this.tokenService.getUserDelegatedVotingPower(userId); + + // Calculate voting power based on token holdings and staked amount + // Staked tokens typically have higher voting power + const stakingMultiplier = 2; // Staked tokens count double for voting power + + return tokenBalance + (stakedAmount * stakingMultiplier) + delegatedPower; } - async updateProposalVoteCounts(proposalId: string): Promise { + private async updateProposalVoteCounts(proposalId: string) { const votes = await this.voteRepository.find({ - where: { proposalId } + where: { proposal: { id: proposalId } }, }); - - let votesFor = 0; - let votesAgainst = 0; - let votesAbstain = 0; - + + let yesVotes = 0; + let noVotes = 0; + let abstainVotes = 0; + votes.forEach(vote => { switch (vote.voteType) { - case 'FOR': - votesFor += vote.weightedVote; + case VoteType.YES: + yesVotes += Number(vote.votingPower); break; - case 'AGAINST': - votesAgainst += vote.weightedVote; + case VoteType.NO: + noVotes += Number(vote.votingPower); break; - case 'ABSTAIN': - votesAbstain += vote.weightedVote; + case VoteType.ABSTAIN: + abstainVotes += Number(vote.votingPower); break; } }); - + await this.proposalRepository.update(proposalId, { - votesFor, - votesAgainst, - votesAbstain, - totalVotes: votesFor + votesAgainst + votesAbstain, + yesVotes, + noVotes, + abstainVotes, }); } -} + + async getTotalVotesCount() { + return this.voteRepository.count(); + } + + async getParticipationRate() { + // This is a simplified calculation + // In a real system, you would compare unique voters to total token holders + const totalVoters = await this.voteRepository + .createQueryBuilder('vote') + .select('vote.voter') + .distinct(true) + .getCount(); + + const totalUsers = 100; // Placeholder - would come from user service + + return totalUsers > 0 ? totalVoters / totalUsers : 0; + } +} \ No newline at end of file