From cb5e938c85ded3b698ef8b63bd7939e01f0c746d Mon Sep 17 00:00:00 2001 From: Trusty08 Date: Wed, 24 Jun 2026 03:57:41 +0100 Subject: [PATCH] fix(sybil): use historical stake deposits for stakingWeight (#197) --- .../sybil-resistance.module.ts | 4 +- .../sybil-resistance.service.spec.ts | 211 ++++++++++++++++++ .../sybil-resistance.service.ts | 44 +++- 3 files changed, 253 insertions(+), 6 deletions(-) diff --git a/src/sybil-resistance/sybil-resistance.module.ts b/src/sybil-resistance/sybil-resistance.module.ts index 5f9e93f..98becd0 100644 --- a/src/sybil-resistance/sybil-resistance.module.ts +++ b/src/sybil-resistance/sybil-resistance.module.ts @@ -1,12 +1,14 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { SybilResistanceService } from './sybil-resistance.service'; import { SybilResistanceController } from './sybil-resistance.controller'; import { SybilResistantVotingService } from './sybil-resistant-voting.service'; import { PrismaModule } from '../prisma/prisma.module'; +import { StakeEvent } from '../staking/entities/stake-event.entity'; @Module({ - imports: [PrismaModule, ConfigModule], + imports: [PrismaModule, ConfigModule, TypeOrmModule.forFeature([StakeEvent])], controllers: [SybilResistanceController], providers: [SybilResistanceService, SybilResistantVotingService], exports: [SybilResistanceService, SybilResistantVotingService], diff --git a/src/sybil-resistance/sybil-resistance.service.spec.ts b/src/sybil-resistance/sybil-resistance.service.spec.ts index 8def08c..0a83a57 100644 --- a/src/sybil-resistance/sybil-resistance.service.spec.ts +++ b/src/sybil-resistance/sybil-resistance.service.spec.ts @@ -1,6 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { SybilResistanceService } from './sybil-resistance.service'; +import { StakeEvent } from '../staking/entities/stake-event.entity'; +import { StakingEventType } from '../staking/types/staking-event.type'; jest.mock('../prisma/prisma.service', () => { return { @@ -23,9 +26,21 @@ jest.mock('../prisma/prisma.service', () => { import { PrismaService } from '../prisma/prisma.service'; import { NotFoundException } from '@nestjs/common'; +/** Helper: builds a mock QueryBuilder that returns `result` from getRawOne */ +function makeQb(result: { total: string | null } | null) { + const qb: any = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue(result), + }; + return qb; +} + describe('SybilResistanceService', () => { let service: SybilResistanceService; let prisma: any; + let stakeEventRepo: any; let configService: ConfigService; // Mock user data @@ -58,6 +73,9 @@ describe('SybilResistanceService', () => { findMany: jest.fn(), update: jest.fn(), }, + worldIdVerification: { + findFirst: jest.fn().mockResolvedValue(null), + }, sybilScore: { create: jest.fn(), findFirst: jest.fn(), @@ -70,6 +88,12 @@ describe('SybilResistanceService', () => { }, }, }, + { + provide: getRepositoryToken(StakeEvent), + useValue: { + createQueryBuilder: jest.fn(() => makeQb({ total: null })), + }, + }, { provide: ConfigService, useValue: { @@ -84,6 +108,7 @@ describe('SybilResistanceService', () => { service = module.get(SybilResistanceService); prisma = module.get(PrismaService); + stakeEventRepo = module.get(getRepositoryToken(StakeEvent)); configService = module.get(ConfigService); }); @@ -94,6 +119,7 @@ describe('SybilResistanceService', () => { describe('computeSybilScore', () => { it('should compute a Sybil score deterministically', async () => { jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); const { score, details } = await service.computeSybilScore(mockUserId); @@ -118,9 +144,11 @@ describe('SybilResistanceService', () => { const verifiedUser: any = { ...mockUser, worldcoinVerified: true }; jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(unverifiedUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); const { score: unverifiedScore } = await service.computeSybilScore(mockUserId); jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(verifiedUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce({ id: 'v1', verifiedAt: new Date() }); const { score: verifiedScore } = await service.computeSybilScore(mockUserId); expect(verifiedScore).toBeGreaterThanOrEqual(unverifiedScore); @@ -148,9 +176,11 @@ describe('SybilResistanceService', () => { }; jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(newWalletUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); const { score: newScore } = await service.computeSybilScore(mockUserId); jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(oldWalletUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); const { score: oldScore } = await service.computeSybilScore(mockUserId); expect(oldScore).toBeGreaterThan(newScore); @@ -158,6 +188,7 @@ describe('SybilResistanceService', () => { it('should include calculation details for explainability', async () => { jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); const { details } = await service.computeSybilScore(mockUserId); @@ -177,6 +208,7 @@ describe('SybilResistanceService', () => { describe('recordSybilScore', () => { it('should store a Sybil score snapshot', async () => { jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); const mockScoreRecord = { id: 'score-1', userId: mockUserId, @@ -241,6 +273,7 @@ describe('SybilResistanceService', () => { it('should compute and store score if none exists', async () => { jest.spyOn(prisma.sybilScore, 'findFirst').mockResolvedValueOnce(null); jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); const mockNewScore = { id: 'score-2', @@ -342,6 +375,7 @@ describe('SybilResistanceService', () => { worldcoinVerified: true, }; jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(verifiedUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce({ id: 'v1', verifiedAt: new Date() }); const mockScore = { id: 'score-1', @@ -397,6 +431,7 @@ describe('SybilResistanceService', () => { worldcoinVerified: true, }; jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUserAfter); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce({ id: 'v1', verifiedAt: new Date() }); const mockScore = { id: 'score-1', @@ -499,6 +534,7 @@ describe('SybilResistanceService', () => { jest.spyOn(prisma.user, 'findMany').mockResolvedValueOnce(users); jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(user1); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); jest.spyOn(prisma.sybilScore, 'create').mockResolvedValueOnce({ id: 'score-1', @@ -526,6 +562,7 @@ describe('SybilResistanceService', () => { jest.spyOn(prisma.user, 'findMany').mockResolvedValueOnce(users); jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(users[0]); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); jest .spyOn(prisma.sybilScore, 'create') .mockRejectedValueOnce(new Error('Database error')); @@ -546,9 +583,16 @@ describe('SybilResistanceService', () => { provide: PrismaService, useValue: { user: { findUnique: jest.fn(), findMany: jest.fn(), update: jest.fn() }, + worldIdVerification: { findFirst: jest.fn().mockResolvedValue(null) }, sybilScore: { create: jest.fn(), findFirst: jest.fn(), findMany: jest.fn() }, }, }, + { + provide: getRepositoryToken(StakeEvent), + useValue: { + createQueryBuilder: jest.fn(() => makeQb({ total: null })), + }, + }, { provide: ConfigService, useValue: { @@ -644,6 +688,7 @@ describe('SybilResistanceService', () => { }; jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(userNoWallets); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); const { score, details } = await service.computeSybilScore(mockUserId); @@ -655,6 +700,7 @@ describe('SybilResistanceService', () => { it('should normalize all component scores to 0-1 range', async () => { jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); const { details } = await service.computeSybilScore(mockUserId); @@ -670,10 +716,12 @@ describe('SybilResistanceService', () => { it('should produce deterministic scores for same input', async () => { jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); const { score: score1 } = await service.computeSybilScore(mockUserId); jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); const { score: score2 } = await service.computeSybilScore(mockUserId); @@ -681,6 +729,169 @@ describe('SybilResistanceService', () => { }); }); + // ───────────────────────────────────────────────────────────────────────────── + // stakingWeight — historical stake tests (issue #197) + // ───────────────────────────────────────────────────────────────────────────── + describe('stakingWeight — historical stake integration (issue #197)', () => { + const ONE_TOKEN = BigInt('1000000000000000000'); // 1e18 + + /** + * Configures the stakeEventRepo mock to return a given historical total. + * Pass null to simulate no deposit history. + */ + function mockHistoricalTotal(total: string | null) { + stakeEventRepo.createQueryBuilder.mockReturnValue(makeQb(total ? { total } : { total: null })); + } + + it('stakingScore should be 0 when user has never staked (no deposit history)', async () => { + jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); + mockHistoricalTotal(null); + + const { details } = await service.computeSybilScore(mockUserId); + expect(details.componentScores.staking).toBe(0); + }); + + it('stakingScore should be > 0 when user has stake deposit history', async () => { + jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); + // Deposit exactly MIN_STAKING_FOR_FULL_SCORE + mockHistoricalTotal(ONE_TOKEN.toString()); + + const { details } = await service.computeSybilScore(mockUserId); + expect(details.componentScores.staking).toBeGreaterThan(0); + }); + + it('stakingScore should reach 1.0 at or above MIN_STAKING_FOR_FULL_SCORE', async () => { + jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); + mockHistoricalTotal(ONE_TOKEN.toString()); + + const { details } = await service.computeSybilScore(mockUserId); + expect(details.componentScores.staking).toBe(1); + }); + + it('stakingScore should NOT decrease when a user withdraws their active stake', async () => { + // First, compute score BEFORE withdrawal (1 token deposited) + jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); + mockHistoricalTotal(ONE_TOKEN.toString()); + + const { details: beforeWithdrawal } = await service.computeSybilScore(mockUserId); + + // Now simulate withdrawal: active stake is 0 BUT historical deposits remain 1 token. + // The fix means we still read from StakeEvent deposits, so the score should be the same. + jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); + // Historical total is unchanged (deposits don't decrease on withdrawal) + mockHistoricalTotal(ONE_TOKEN.toString()); + + const { details: afterWithdrawal } = await service.computeSybilScore(mockUserId); + + expect(afterWithdrawal.componentScores.staking).toBeGreaterThanOrEqual( + beforeWithdrawal.componentScores.staking, + ); + }); + + it('stakingScore should reflect cumulative deposits across multiple stake events', async () => { + // User staked 0.3 + 0.4 + 0.3 = 1.0 token total across 3 events + const total = (BigInt('300000000000000000') + BigInt('400000000000000000') + BigInt('300000000000000000')).toString(); + + jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); + mockHistoricalTotal(total); + + const { details } = await service.computeSybilScore(mockUserId); + expect(details.componentScores.staking).toBe(1); + }); + + it('stakingScore should be higher for users with more historical deposits (partial stake)', async () => { + const smallDeposit = (ONE_TOKEN / BigInt(10)).toString(); // 0.1 token + const largeDeposit = (ONE_TOKEN / BigInt(2)).toString(); // 0.5 token + + jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); + mockHistoricalTotal(smallDeposit); + const { details: smallDetails } = await service.computeSybilScore(mockUserId); + + jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); + mockHistoricalTotal(largeDeposit); + const { details: largeDetails } = await service.computeSybilScore(mockUserId); + + expect(largeDetails.componentScores.staking).toBeGreaterThan(smallDetails.componentScores.staking); + }); + + it('stakingScore should be 0 for users with no wallets (no addresses to query)', async () => { + const userNoWallets = { ...mockUser, wallets: [] }; + + jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(userNoWallets); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); + + const { details } = await service.computeSybilScore(mockUserId); + + // No wallets → no addresses to query → stakeEventRepo should NOT be called + expect(stakeEventRepo.createQueryBuilder).not.toHaveBeenCalled(); + expect(details.componentScores.staking).toBe(0); + }); + + it('stakingScore should not exceed 1.0 even for very large historical deposits', async () => { + // 100x the threshold + const hugeDeposit = (ONE_TOKEN * BigInt(100)).toString(); + + jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); + mockHistoricalTotal(hugeDeposit); + + const { details } = await service.computeSybilScore(mockUserId); + expect(details.componentScores.staking).toBeLessThanOrEqual(1.0); + }); + + it('composite score should be higher for a user with stake history vs zero stake', async () => { + // User with stake history + jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); + mockHistoricalTotal(ONE_TOKEN.toString()); + const { score: withStake } = await service.computeSybilScore(mockUserId); + + // Same user with no stake history + jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); + mockHistoricalTotal(null); + const { score: withoutStake } = await service.computeSybilScore(mockUserId); + + expect(withStake).toBeGreaterThan(withoutStake); + }); + + it('should query StakeEvent with STAKE_DEPOSITED type filter (not WITHDRAWN or SLASHED)', async () => { + jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); + mockHistoricalTotal('0'); + + await service.computeSybilScore(mockUserId); + + const qb = stakeEventRepo.createQueryBuilder.mock.results[0].value; + expect(qb.andWhere).toHaveBeenCalledWith( + expect.stringContaining('type'), + expect.objectContaining({ type: StakingEventType.STAKE_DEPOSITED }), + ); + }); + + it('should query using the wallet address of linked wallets', async () => { + jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser); + jest.spyOn(prisma.worldIdVerification, 'findFirst').mockResolvedValueOnce(null); + mockHistoricalTotal('0'); + + await service.computeSybilScore(mockUserId); + + const qb = stakeEventRepo.createQueryBuilder.mock.results[0].value; + expect(qb.where).toHaveBeenCalledWith( + expect.stringContaining('walletAddress'), + expect.objectContaining({ addresses: [mockWallet.address] }), + ); + }); + }); + describe('cleanupScoreHistory', () => { it('should delete scores older than 1 year and return count', async () => { jest.spyOn(prisma.sybilScore, 'deleteMany').mockResolvedValueOnce({ count: 99 }); diff --git a/src/sybil-resistance/sybil-resistance.service.ts b/src/sybil-resistance/sybil-resistance.service.ts index 0460442..deca289 100644 --- a/src/sybil-resistance/sybil-resistance.service.ts +++ b/src/sybil-resistance/sybil-resistance.service.ts @@ -1,6 +1,10 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { PrismaService } from '../prisma/prisma.service'; +import { StakeEvent } from '../staking/entities/stake-event.entity'; +import { StakingEventType } from '../staking/types/staking-event.type'; /** * Calculation details structure for explainability @@ -47,6 +51,8 @@ export class SybilResistanceService { constructor( private prisma: PrismaService, private configService: ConfigService, + @InjectRepository(StakeEvent) + private readonly stakeEventRepo: Repository, ) { this.MIN_CLAIMS_FOR_ACCURACY_SCORE = this.configService.get( 'sybil.minClaimsForAccuracyScore', @@ -96,7 +102,7 @@ export class SybilResistanceService { */ private async gatherSignals( user: { id: string, worldcoinVerified?: boolean } | null, - wallets: Array<{ linkedAt: Date }>, + wallets: Array<{ address?: string; linkedAt: Date }>, ): Promise { // Calculate wallet age (use oldest linked wallet) @@ -116,9 +122,37 @@ export class SybilResistanceService { worldcoinVerified = verification !== null; } - // TODO: Integrate with staking module once available - // For now, default to 0 total staked amount - const totalStakedAmount = BigInt(0); + // Compute historical staking weight across all linked wallets. + // + // WHY HISTORICAL STAKE? + // Using only the current active stake (Stake.amount) is vulnerable to Sybil + // manipulation: an attacker can stake to inflate their score, wait for the + // score to be recorded, then withdraw — resetting their active stake to zero + // while retaining the inflated score history. + // + // Instead we sum all STAKE_DEPOSITED events across the user's wallets. + // Deposits are immutable on-chain; they cannot be "un-deposited" by + // withdrawing. This makes stakingWeight reflect total historical commitment + // rather than a momentary snapshot, preventing stake-and-flee Sybil attacks. + let totalHistoricalStake = BigInt(0); + if (wallets.length > 0) { + const walletAddresses = wallets + .map((w: any) => w.address) + .filter((a: string | undefined): a is string => Boolean(a)); + + if (walletAddresses.length > 0) { + const depositEvents = await this.stakeEventRepo + .createQueryBuilder('se') + .select('SUM(se.amount::numeric)', 'total') + .where('se.walletAddress IN (:...addresses)', { addresses: walletAddresses }) + .andWhere('se.type = :type', { type: StakingEventType.STAKE_DEPOSITED }) + .getRawOne<{ total: string | null }>(); + + if (depositEvents?.total) { + totalHistoricalStake = BigInt(Math.floor(Number(depositEvents.total))); + } + } + } // TODO: Integrate with claims module for accuracy metrics // For now, default to no claims @@ -128,7 +162,7 @@ export class SybilResistanceService { return { worldcoinVerified, oldestWalletAgeMs, - totalStakedAmount, + totalStakedAmount: totalHistoricalStake, claimsVotedOn, claimsCorrect, };