From 05bf59a2b30f5e4646c66460891e8e5e0da83fed Mon Sep 17 00:00:00 2001 From: KAMALDEEN333 Date: Sun, 24 Aug 2025 09:22:59 +0100 Subject: [PATCH 1/5] Token-Based Created Token-Based Incensive and goverance --- src/blockchain/contracts/token.contract.ts | 170 ++++++++++++++++++ src/blockchain/services/blockchain.service.ts | 47 +++++ .../controllers/governance.controller.ts | 20 +++ .../controllers/proposal.controller.ts | 57 ++++++ .../controllers/staking.controller.ts | 32 ++++ .../controllers/token.controller.ts | 32 ++++ .../controllers/voting.controller.ts | 31 ++++ src/governance/dto/create-proposal.dto.ts | 27 +++ src/governance/dto/update-proposal.dto.ts | 23 +++ src/governance/entities/delegation.entity.ts | 32 ++++ src/governance/entities/proposal.entity.ts | 73 ++++++++ src/governance/entities/stake.entity.ts | 35 ++++ src/governance/entities/vote.entity.ts | 39 ++++ src/governance/governance.module.ts | 53 ++++++ src/governance/services/governance.service.ts | 61 +++++++ src/governance/services/proposal.service.ts | 146 +++++++++++++++ src/governance/services/staking.service.ts | 132 ++++++++++++++ src/governance/services/token.service.ts | 91 ++++++++++ src/governance/services/voting.service.ts | 151 ++++++++++++++++ 19 files changed, 1252 insertions(+) create mode 100644 src/blockchain/contracts/token.contract.ts create mode 100644 src/blockchain/services/blockchain.service.ts create mode 100644 src/governance/controllers/governance.controller.ts create mode 100644 src/governance/controllers/proposal.controller.ts create mode 100644 src/governance/controllers/staking.controller.ts create mode 100644 src/governance/controllers/token.controller.ts create mode 100644 src/governance/controllers/voting.controller.ts create mode 100644 src/governance/dto/create-proposal.dto.ts create mode 100644 src/governance/dto/update-proposal.dto.ts create mode 100644 src/governance/entities/delegation.entity.ts create mode 100644 src/governance/entities/proposal.entity.ts create mode 100644 src/governance/entities/stake.entity.ts create mode 100644 src/governance/entities/vote.entity.ts create mode 100644 src/governance/governance.module.ts create mode 100644 src/governance/services/governance.service.ts create mode 100644 src/governance/services/proposal.service.ts create mode 100644 src/governance/services/staking.service.ts create mode 100644 src/governance/services/token.service.ts create mode 100644 src/governance/services/voting.service.ts 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..f6875ea --- /dev/null +++ b/src/blockchain/services/blockchain.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { TokenContract } from '../contracts/token.contract'; + +@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; + } + } +} \ 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 new file mode 100644 index 0000000..5046dd4 --- /dev/null +++ b/src/governance/controllers/proposal.controller.ts @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..4ade2db --- /dev/null +++ b/src/governance/controllers/staking.controller.ts @@ -0,0 +1,32 @@ +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(); + } +} \ No newline at end of file diff --git a/src/governance/controllers/token.controller.ts b/src/governance/controllers/token.controller.ts new file mode 100644 index 0000000..94998dc --- /dev/null +++ b/src/governance/controllers/token.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common'; +import { TokenService } from '../services/token.service'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { GetUser } from '../../auth/decorators/get-user.decorator'; +import { TransferTokenDto } from '../dto/transfer-token.dto'; +import { RewardContributionDto } from '../dto/reward-contribution.dto'; + +@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 new file mode 100644 index 0000000..537e5b8 --- /dev/null +++ b/src/governance/controllers/voting.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common'; +import { VotingService } from '../services/voting.service'; +import { CreateVoteDto } from '../dto/create-vote.dto'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { GetUser } from '../../auth/decorators/get-user.decorator'; + +@Controller('governance/voting') +@UseGuards(JwtAuthGuard) +export class VotingController { + constructor(private readonly votingService: VotingService) {} + + @Post('vote') + castVote(@Body() createVoteDto: CreateVoteDto, @GetUser('id') userId: string) { + return this.votingService.castVote(createVoteDto, userId); + } + + @Get('user/votes') + getUserVotes(@GetUser('id') userId: string) { + return this.votingService.getUserVotes(userId); + } + + @Get('user/voting-power') + getUserVotingPower(@GetUser('id') userId: string) { + return this.votingService.getUserVotingPower(userId); + } + + @Get('proposal/:proposalId/votes') + getProposalVotes(@Param('proposalId') proposalId: string) { + return this.votingService.getProposalVotes(proposalId); + } +} \ 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/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 new file mode 100644 index 0000000..5ec5b68 --- /dev/null +++ b/src/governance/entities/proposal.entity.ts @@ -0,0 +1,73 @@ +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, OneToMany, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { User } from '../../users/users.entity'; +import { Vote } from './vote.entity'; + +export enum ProposalStatus { + DRAFT = 'draft', + ACTIVE = 'active', + PASSED = 'passed', + REJECTED = 'rejected', + EXECUTED = 'executed', + CANCELED = 'canceled', +} + +@Entity() +export class Proposal { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + title: string; + + @Column('text') + description: string; + + @Column('text') + content: string; + + @Column({ + type: 'enum', + enum: ProposalStatus, + default: ProposalStatus.DRAFT, + }) + status: ProposalStatus; + + @Column({ type: 'timestamp' }) + startTime: Date; + + @Column({ type: 'timestamp' }) + endTime: Date; + + @Column({ nullable: true }) + executionTime: Date; + + @Column({ default: 0 }) + yesVotes: number; + + @Column({ default: 0 }) + noVotes: number; + + @Column({ default: 0 }) + abstainVotes: number; + + @Column({ default: false }) + isExecuted: boolean; + + @ManyToOne(() => User) + proposer: User; + + @OneToMany(() => Vote, vote => vote.proposal) + votes: Vote[]; + + @Column({ nullable: true }) + transactionHash: string; + + @Column({ nullable: true }) + contractAddress: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file 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 new file mode 100644 index 0000000..091ae94 --- /dev/null +++ b/src/governance/entities/vote.entity.ts @@ -0,0 +1,39 @@ +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, CreateDateColumn } from 'typeorm'; +import { User } from '../../users/users.entity'; +import { Proposal } from './proposal.entity'; + +export enum VoteType { + YES = 'yes', + NO = 'no', + ABSTAIN = 'abstain', +} + +@Entity() +export class Vote { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User) + voter: User; + + @ManyToOne(() => Proposal, proposal => proposal.votes) + proposal: Proposal; + + @Column({ + type: 'enum', + enum: VoteType, + }) + voteType: VoteType; + + @Column('decimal', { precision: 36, scale: 18 }) + votingPower: number; + + @Column({ nullable: true }) + reason: string; + + @Column({ nullable: true }) + transactionHash: string; + + @CreateDateColumn() + createdAt: Date; +} \ No newline at end of file diff --git a/src/governance/governance.module.ts b/src/governance/governance.module.ts new file mode 100644 index 0000000..48ff78d --- /dev/null +++ b/src/governance/governance.module.ts @@ -0,0 +1,53 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { GovernanceService } from './services/governance.service'; +import { ProposalService } from './services/proposal.service'; +import { VotingService } from './services/voting.service'; +import { TokenService } from './services/token.service'; +import { StakingService } from './services/staking.service'; +import { GovernanceController } from './controllers/governance.controller'; +import { ProposalController } from './controllers/proposal.controller'; +import { VotingController } from './controllers/voting.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([ + Proposal, + Vote, + Stake, + Delegation, + ]), + BlockchainModule, + UsersModule, + ], + controllers: [ + GovernanceController, + ProposalController, + VotingController, + TokenController, + StakingController, + ], + providers: [ + GovernanceService, + ProposalService, + VotingService, + TokenService, + StakingService, + ], + exports: [ + GovernanceService, + ProposalService, + VotingService, + TokenService, + StakingService, + ], +}) +export class GovernanceModule {} \ 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 new file mode 100644 index 0000000..e09399d --- /dev/null +++ b/src/governance/services/proposal.service.ts @@ -0,0 +1,146 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +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 { + constructor( + @InjectRepository(Proposal) + private proposalRepository: Repository, + ) {} + + async create(createProposalDto: CreateProposalDto, userId: string) { + const proposal = this.proposalRepository.create({ + ...createProposalDto, + proposer: { id: userId }, + status: ProposalStatus.DRAFT, + }); + + return this.proposalRepository.save(proposal); + } + + async findAll(filters?: any) { + return this.proposalRepository.find({ + where: filters, + relations: ['proposer', 'votes'], + order: { createdAt: 'DESC' }, + }); + } + + async findOne(id: string) { + const proposal = await this.proposalRepository.findOne({ + where: { id }, + relations: ['proposer', 'votes', 'votes.voter'], + }); + + if (!proposal) { + throw new NotFoundException(`Proposal with ID ${id} not found`); + } + + return proposal; + } + + 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); + } + + 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); + } + + 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'); + } + + 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 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); + } + + async getActiveProposals() { + return this.proposalRepository.find({ + where: { status: ProposalStatus.ACTIVE }, + relations: ['proposer'], + order: { endTime: 'ASC' }, + }); + } + + async getPassedProposalsReadyForExecution() { + return this.proposalRepository.find({ + where: { + status: ProposalStatus.PASSED, + isExecuted: false, + }, + relations: ['proposer'], + }); + } + + async getTotalProposalsCount() { + return this.proposalRepository.count(); + } + + async finalizeProposalVoting(id: string) { + const proposal = await this.findOne(id); + + if (proposal.status !== ProposalStatus.ACTIVE) { + throw new BadRequestException('Only active proposals can be finalized'); + } + + if (new Date() < proposal.endTime) { + throw new BadRequestException('Voting period has not ended yet'); + } + + // 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; + } + + 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 new file mode 100644 index 0000000..0aa1910 --- /dev/null +++ b/src/governance/services/staking.service.ts @@ -0,0 +1,132 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +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 { + constructor( + @InjectRepository(Stake) + private stakeRepository: Repository, + private blockchainService: BlockchainService, + ) {} + + 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'); + } + + // 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, + }); + + // Execute staking transaction on blockchain + const txHash = await this.blockchainService.stakeTokens(walletAddress, amount, lockupPeriodDays); + stake.stakeTransactionHash = txHash; + + return this.stakeRepository.save(stake); + } + + 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 (!stake) { + throw new BadRequestException('Stake not found or already unstaked'); + } + + // Check if lockup period has ended + if (new Date() < stake.lockupEndTime) { + throw new BadRequestException('Tokens are still locked'); + } + + // 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 getUserStakes(userId: string) { + return this.stakeRepository.find({ + where: { user: { id: userId } }, + order: { createdAt: 'DESC' }, + }); + } + + async calculateStakingRewards() { + const activeStakes = await this.stakeRepository.find({ + where: { isUnstaked: false }, + }); + + 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); + } + } + + 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; + } + + 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 + } + + 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 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 new file mode 100644 index 0000000..6cebdf2 --- /dev/null +++ b/src/governance/services/voting.service.ts @@ -0,0 +1,151 @@ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +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 { + constructor( + @InjectRepository(Vote) + private voteRepository: Repository, + @InjectRepository(Proposal) + private proposalRepository: Repository, + private tokenService: TokenService, + ) {} + + 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: proposalId }, + }); + + if (!proposal) { + throw new NotFoundException(`Proposal with ID ${proposalId} not found`); + } + + if (proposal.status !== ProposalStatus.ACTIVE) { + throw new BadRequestException('Voting is only allowed on active proposals'); + } + + 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: { + voter: { id: userId }, + proposal: { id: proposalId }, + }, + }); + + if (existingVote) { + throw new BadRequestException('User has already voted on this proposal'); + } + + // 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({ + voter: { id: userId }, + proposal: { id: proposalId }, + voteType, + votingPower, + reason, + }); + + const savedVote = await this.voteRepository.save(vote); + + // Update proposal vote counts + await this.updateProposalVoteCounts(proposalId); + + return savedVote; + } + + async getUserVotes(userId: string) { + return this.voteRepository.find({ + where: { voter: { id: userId } }, + relations: ['proposal'], + order: { createdAt: 'DESC' }, + }); + } + + async getProposalVotes(proposalId: string) { + return this.voteRepository.find({ + where: { proposal: { id: proposalId } }, + relations: ['voter'], + order: { createdAt: 'DESC' }, + }); + } + + 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; + } + + private async updateProposalVoteCounts(proposalId: string) { + const votes = await this.voteRepository.find({ + where: { proposal: { id: proposalId } }, + }); + + let yesVotes = 0; + let noVotes = 0; + let abstainVotes = 0; + + votes.forEach(vote => { + switch (vote.voteType) { + case VoteType.YES: + yesVotes += Number(vote.votingPower); + break; + case VoteType.NO: + noVotes += Number(vote.votingPower); + break; + case VoteType.ABSTAIN: + abstainVotes += Number(vote.votingPower); + break; + } + }); + + await this.proposalRepository.update(proposalId, { + 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 From 588a4ce52a3c6febc7b2f3b894fde399d0b35023 Mon Sep 17 00:00:00 2001 From: KAMALDEEN333 Date: Sun, 24 Aug 2025 10:13:30 +0100 Subject: [PATCH 2/5] Update voting.controller.ts --- .../controllers/voting.controller.ts | 74 ++++++++----------- 1 file changed, 30 insertions(+), 44 deletions(-) diff --git a/src/governance/controllers/voting.controller.ts b/src/governance/controllers/voting.controller.ts index 458e8b3..5060934 100644 --- a/src/governance/controllers/voting.controller.ts +++ b/src/governance/controllers/voting.controller.ts @@ -1,34 +1,13 @@ -import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common'; -import { VotingService } from '../services/voting.service'; -import { CreateVoteDto } from '../dto/create-vote.dto'; -import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; -import { GetUser } from '../../auth/decorators/get-user.decorator'; - -@Controller('governance/voting') -@UseGuards(JwtAuthGuard) -export class VotingController { - constructor(private readonly votingService: VotingService) {} - - @Post('vote') - castVote(@Body() createVoteDto: CreateVoteDto, @GetUser('id') userId: string) { - return this.votingService.castVote(createVoteDto, userId); - } - - @Get('user/votes') - getUserVotes(@GetUser('id') userId: string) { - return this.votingService.getUserVotes(userId); - } - - @Get('user/voting-power') - getUserVotingPower(@GetUser('id') userId: string) { - return this.votingService.getUserVotingPower(userId); - } - - @Get('proposal/:proposalId/votes') - getProposalVotes(@Param('proposalId') proposalId: string) { - return this.votingService.getProposalVotes(proposalId); - } -}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'; @@ -41,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' }) @@ -48,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' }) @@ -55,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' }) @@ -73,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; From 8937cd8d14d881562c24a57bc0af79126793c4b8 Mon Sep 17 00:00:00 2001 From: KAMALDEEN333 Date: Sun, 24 Aug 2025 10:20:50 +0100 Subject: [PATCH 3/5] Token-Based adjustments --- src/auth/auth.module.ts | 8 +- src/auth/controllers/token-auth.controller.ts | 56 ++++++++ src/auth/dto/token-auth.dto.ts | 15 ++ src/auth/guards/token-auth.guard.ts | 5 + src/auth/strategies/token-auth.strategy.ts | 58 ++++++++ src/blockchain/services/blockchain.service.ts | 33 +++++ .../controllers/delegation.controller.ts | 47 +++++++ .../controllers/staking.controller.ts | 129 +++++++++++++++++- .../controllers/token.controller.ts | 3 +- src/governance/dto/create-delegation.dto.ts | 13 ++ src/governance/dto/create-stake.dto.ts | 14 ++ src/governance/entities/proposal.entity.ts | 121 ++++++---------- src/governance/services/delegation.service.ts | 112 +++++++++++++++ 13 files changed, 534 insertions(+), 80 deletions(-) create mode 100644 src/auth/controllers/token-auth.controller.ts create mode 100644 src/auth/dto/token-auth.dto.ts create mode 100644 src/auth/guards/token-auth.guard.ts create mode 100644 src/auth/strategies/token-auth.strategy.ts create mode 100644 src/governance/controllers/delegation.controller.ts create mode 100644 src/governance/dto/create-delegation.dto.ts create mode 100644 src/governance/dto/create-stake.dto.ts create mode 100644 src/governance/services/delegation.service.ts diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index f2a11a1..d056166 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -9,12 +9,16 @@ 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) => ({ @@ -24,8 +28,8 @@ import { RedisModule } from '../common/module/redis/redis.module'; inject: [ConfigService], }), ], - controllers: [AuthController, WalletAuthController], - providers: [AuthService, WalletAuthService, WalletAuthGuard], + 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.ts b/src/auth/strategies/token-auth.strategy.ts new file mode 100644 index 0000000..ec461e9 --- /dev/null +++ b/src/auth/strategies/token-auth.strategy.ts @@ -0,0 +1,58 @@ +import { Injectable, UnauthorizedException } 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'; + +@Injectable() +export class TokenAuthStrategy extends PassportStrategy(Strategy, 'token-auth') { + constructor( + private blockchainService: BlockchainService, + private usersService: UsersService, + ) { + super(); + } + + async validate(request: Request): Promise { + // Extract wallet address and signature from request + const { walletAddress, signature, message } = request.body; + + if (!walletAddress || !signature || !message) { + throw new UnauthorizedException('Missing authentication credentials'); + } + + try { + // Verify the signature using blockchain service + const isValid = await this.blockchainService.verifySignature( + walletAddress, + message, + signature, + ); + + if (!isValid) { + 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 + user = await this.usersService.createWithWalletAddress(walletAddress); + } + + // Check if user has minimum token balance for authentication + const tokenBalance = await this.blockchainService.getTokenBalance(walletAddress); + const minRequiredBalance = 1; // Minimum token balance required for authentication + + if (tokenBalance < minRequiredBalance) { + throw new UnauthorizedException('Insufficient token balance for authentication'); + } + + return user; + } catch (error) { + throw new UnauthorizedException('Authentication failed: ' + error.message); + } + } +} \ No newline at end of file diff --git a/src/blockchain/services/blockchain.service.ts b/src/blockchain/services/blockchain.service.ts index f6875ea..052d8da 100644 --- a/src/blockchain/services/blockchain.service.ts +++ b/src/blockchain/services/blockchain.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { TokenContract } from '../contracts/token.contract'; +import { ec, stark } from 'starknet'; @Injectable() export class BlockchainService { @@ -44,4 +45,36 @@ export class BlockchainService { 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/staking.controller.ts b/src/governance/controllers/staking.controller.ts index 4ade2db..a838ab8 100644 --- a/src/governance/controllers/staking.controller.ts +++ b/src/governance/controllers/staking.controller.ts @@ -29,4 +29,131 @@ export class StakingController { getTotalStakedAmount() { return this.stakingService.getTotalStakedAmount(); } -} \ No newline at end of file +}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'; + +@ApiTags('Staking') +@Controller('governance/staking') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class StakingController { + constructor(private readonly stakingService: StakingService) {} + + @Post('stake') + @ApiOperation({ summary: 'Stake governance tokens' }) + @ApiResponse({ status: 201, description: 'Tokens staked successfully' }) + async stakeTokens( + @Request() req, + @Body() body: { amount: number; lockPeriodDays?: number } + ) { + return await this.stakingService.stakeTokens( + req.user.id, + body.amount, + body.lockPeriodDays + ); + } + + @Post(':id/unstake') + @ApiOperation({ summary: 'Unstake tokens' }) + @ApiResponse({ status: 200, description: 'Unstaking process initiated' }) + async unstakeTokens(@Param('id') stakingId: string) { + return await this.stakingService.unstakeTokens(stakingId); + } + + @Post(':id/delegate') + @ApiOperation({ summary: 'Delegate staked tokens' }) + @ApiResponse({ status: 200, description: 'Delegation successful' }) + async delegateStake( + @Param('id') stakingId: string, + @Body() body: { delegateId: string } + ) { + return await this.stakingService.delegateStake(stakingId, body.delegateId); + } + + @Post(':id/undelegate') + @ApiOperation({ summary: 'Undelegate staked tokens' }) + @ApiResponse({ status: 200, description: 'Undelegation successful' }) + async undelegateStake(@Param('id') stakingId: string) { + return await this.stakingService.undelegateStake(stakingId); + } + + @Get(':id/rewards') + @ApiOperation({ summary: 'Calculate pending rewards' }) + @ApiResponse({ status: 200, description: 'Rewards calculated successfully' }) + async calculateRewards(@Param('id') stakingId: string) { + const pendingRewards = await this.stakingService.calculateRewards(stakingId); + return { + stakingId, + pendingRewards, + canClaim: pendingRewards > 0 + }; + } + + @Post(':id/claim-rewards') + @ApiOperation({ summary: 'Claim staking rewards' }) + @ApiResponse({ status: 200, description: 'Rewards claimed successfully' }) + async claimRewards(@Param('id') stakingId: string) { + const claimedAmount = await this.stakingService.claimRewards(stakingId); + return { + stakingId, + claimedAmount, + success: claimedAmount > 0 + }; + } + + @Get('positions') + @ApiOperation({ summary: 'Get user staking positions' }) + @ApiResponse({ status: 200, description: 'Staking positions retrieved successfully' }) + async getStakingPositions(@Request() req) { + const stakingInfo = await this.stakingService.getStakingInfo(req.user.id); + + // Calculate totals + const totalStaked = stakingInfo + .filter(s => s.status === 'ACTIVE') + .reduce((sum, s) => sum + s.stakedAmount, 0); + + const totalRewardsClaimed = stakingInfo + .reduce((sum, s) => sum + s.rewardsClaimed, 0); + + // Calculate total pending rewards + const pendingRewardsPromises = stakingInfo + .filter(s => s.status === 'ACTIVE') + .map(s => this.stakingService.calculateRewards(s.id)); + + const pendingRewardsArray = await Promise.all(pendingRewardsPromises); + const totalPendingRewards = pendingRewardsArray.reduce((sum, reward) => sum + reward, 0); + + return { + positions: stakingInfo, + summary: { + totalStaked, + totalRewardsClaimed, + totalPendingRewards, + activePositions: stakingInfo.filter(s => s.status === 'ACTIVE').length, + totalPositions: stakingInfo.length + } + }; + } + + @Get('apy-calculator') + @ApiOperation({ summary: 'Calculate APY for different lock periods' }) + @ApiResponse({ status: 200, description: 'APY information retrieved' }) + async getAPYInfo() { + const apyTiers = [ + { lockPeriodDays: 14, apy: 5 }, + { lockPeriodDays: 30, apy: 6 }, + { lockPeriodDays: 90, apy: 8 }, + { lockPeriodDays: 180, apy: 11 }, + { lockPeriodDays: 365, apy: 15 } + ]; + + return { + apyTiers, + description: 'Longer lock periods provide higher APY rewards', + minLockPeriod: 14, + maxLockPeriod: 365 + }; + } +} diff --git a/src/governance/controllers/token.controller.ts b/src/governance/controllers/token.controller.ts index 94998dc..0fc3833 100644 --- a/src/governance/controllers/token.controller.ts +++ b/src/governance/controllers/token.controller.ts @@ -1,9 +1,10 @@ import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common'; import { TokenService } from '../services/token.service'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; -import { GetUser } from '../../auth/decorators/get-user.decorator'; + 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) 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-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/entities/proposal.entity.ts b/src/governance/entities/proposal.entity.ts index 7d68669..800256e 100644 --- a/src/governance/entities/proposal.entity.ts +++ b/src/governance/entities/proposal.entity.ts @@ -1,78 +1,34 @@ -import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, OneToMany, CreateDateColumn, UpdateDateColumn } from 'typeorm'; -import { User } from '../../users/users.entity'; +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', - EXECUTED = 'executed', - CANCELED = 'canceled', + DRAFT = 'DRAFT', + ACTIVE = 'ACTIVE', + PASSED = 'PASSED', + REJECTED = 'REJECTED', + EXPIRED = 'EXPIRED', + EXECUTED = 'EXECUTED', + CANCELED = 'CANCELED', } -@Entity() -export class Proposal { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column() - title: string; - - @Column('text') - description: string; - - @Column('text') - content: string; - - @Column({ - type: 'enum', - enum: ProposalStatus, - default: ProposalStatus.DRAFT, - }) - status: ProposalStatus; - - @Column({ type: 'timestamp' }) - startTime: Date; - - @Column({ type: 'timestamp' }) - endTime: Date; - - @Column({ nullable: true }) - executionTime: Date; - - @Column({ default: 0 }) - yesVotes: number; - - @Column({ default: 0 }) - noVotes: number; - - @Column({ default: 0 }) - abstainVotes: number; - - @Column({ default: false }) - isExecuted: boolean; - - @ManyToOne(() => User) - proposer: User; - - @OneToMany(() => Vote, vote => vote.proposal) - votes: Vote[]; - - @Column({ nullable: true }) - transactionHash: string; - - @Column({ nullable: true }) - contractAddress: string; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -}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 ProposalType { + FEATURE = 'FEATURE', + PARAMETER = 'PARAMETER', + TREASURY = 'TREASURY', + UPGRADE = 'UPGRADE', + COMMUNITY = 'COMMUNITY', +} @Entity('proposals') @Index(['status', 'createdAt']) @@ -87,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; @@ -96,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; @@ -135,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/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 From ab231df186e49125eb280beaa38a70c3bb795201 Mon Sep 17 00:00:00 2001 From: KAMALDEEN333 Date: Sun, 24 Aug 2025 10:23:29 +0100 Subject: [PATCH 4/5] Token-Based Enhancements --- src/auth/strategies/token-auth.strategy.ts | 65 ++++++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/src/auth/strategies/token-auth.strategy.ts b/src/auth/strategies/token-auth.strategy.ts index ec461e9..f494a42 100644 --- a/src/auth/strategies/token-auth.strategy.ts +++ b/src/auth/strategies/token-auth.strategy.ts @@ -1,17 +1,27 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +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 { @@ -19,10 +29,18 @@ export class TokenAuthStrategy extends PassportStrategy(Strategy, 'token-auth') 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, @@ -31,6 +49,7 @@ export class TokenAuthStrategy extends PassportStrategy(Strategy, 'token-auth') ); if (!isValid) { + this.logger.warn(`Invalid signature for wallet: ${walletAddress}`); throw new UnauthorizedException('Invalid signature'); } @@ -39,20 +58,58 @@ export class TokenAuthStrategy extends PassportStrategy(Strategy, 'token-auth') 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); - const minRequiredBalance = 1; // Minimum token balance required for authentication + + 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}`); + } - if (tokenBalance < minRequiredBalance) { - throw new UnauthorizedException('Insufficient token balance for authentication'); + // 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 From 97ae8bb869dbfade10f08a1b17c6a7c4cf315a66 Mon Sep 17 00:00:00 2001 From: KAMALDEEN333 Date: Sun, 24 Aug 2025 10:25:34 +0100 Subject: [PATCH 5/5] Token-Based Wrote test --- .../strategies/token-auth.strategy.spec.ts | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 src/auth/strategies/token-auth.strategy.spec.ts 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