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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/sybil-resistance/sybil-resistance.module.ts
Original file line number Diff line number Diff line change
@@ -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],
Expand Down
211 changes: 211 additions & 0 deletions src/sybil-resistance/sybil-resistance.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -70,6 +88,12 @@ describe('SybilResistanceService', () => {
},
},
},
{
provide: getRepositoryToken(StakeEvent),
useValue: {
createQueryBuilder: jest.fn(() => makeQb({ total: null })),
},
},
{
provide: ConfigService,
useValue: {
Expand All @@ -84,6 +108,7 @@ describe('SybilResistanceService', () => {

service = module.get<SybilResistanceService>(SybilResistanceService);
prisma = module.get<any>(PrismaService);
stakeEventRepo = module.get(getRepositoryToken(StakeEvent));
configService = module.get<ConfigService>(ConfigService);
});

Expand All @@ -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);

Expand All @@ -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);
Expand Down Expand Up @@ -148,16 +176,19 @@ 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);
});

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);

Expand All @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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'));
Expand All @@ -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: {
Expand Down Expand Up @@ -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);

Expand All @@ -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);

Expand All @@ -670,17 +716,182 @@ 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);

expect(score1).toBe(score2);
});
});

// ─────────────────────────────────────────────────────────────────────────────
// 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 });
Expand Down
Loading