Skip to content

Commit bb5d64f

Browse files
authored
Merge pull request #231 from Arome8240/fix/be-215-sybil-min-claims-configurable
fix(sybil): make MIN_CLAIMS_FOR_ACCURACY_SCORE configurable via env var
2 parents c080658 + 73a3ec3 commit bb5d64f

6 files changed

Lines changed: 142 additions & 5 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ POLLING_INTERVAL_MS=12000
9393
# ]'
9494
INDEXED_CONTRACTS='[]'
9595

96+
# ==============================================
97+
# Sybil Resistance Configuration
98+
# ==============================================
99+
# Minimum number of claims a user must have voted on before their
100+
# accuracy score contributes to their sybil resistance score.
101+
SYBIL_MIN_CLAIMS_FOR_ACCURACY_SCORE=5
102+
96103
# ==============================================
97104
# Prisma Configuration (SQLite/LibSQL)
98105
# ==============================================

src/app.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { AppController } from './app.controller';
66
import { AppService } from './app.service';
77
import { RewardsModule } from './rewards/rewards.module';
88
import blockchainConfig from './config/blockchain.config';
9+
import sybilConfig from './config/sybil.config';
910
import { TypeOrmModule } from '@nestjs/typeorm';
1011
import { BlockchainModule } from './blockchain/blockchain.module';
1112
import { DisputeModule } from './dispute/dispute.module';
@@ -218,7 +219,7 @@ async function createThrottlerStorage(configService: ConfigService): Promise<any
218219
imports: [
219220
ConfigModule.forRoot({
220221
isGlobal: true,
221-
load: [blockchainConfig, throttlerConfig],
222+
load: [blockchainConfig, throttlerConfig, sybilConfig],
222223
envFilePath: ['.env.local', '.env'],
223224
}),
224225
TypeOrmModule.forRoot({

src/config/sybil.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { registerAs } from '@nestjs/config';
2+
3+
export default registerAs('sybil', () => ({
4+
minClaimsForAccuracyScore: parseInt(
5+
process.env.SYBIL_MIN_CLAIMS_FOR_ACCURACY_SCORE ?? '5',
6+
10,
7+
),
8+
}));

src/sybil-resistance/sybil-resistance.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { Module } from '@nestjs/common';
2+
import { ConfigModule } from '@nestjs/config';
23
import { SybilResistanceService } from './sybil-resistance.service';
34
import { SybilResistanceController } from './sybil-resistance.controller';
45
import { SybilResistantVotingService } from './sybil-resistant-voting.service';
56
import { PrismaModule } from '../prisma/prisma.module';
67

78
@Module({
8-
imports: [PrismaModule],
9+
imports: [PrismaModule, ConfigModule],
910
controllers: [SybilResistanceController],
1011
providers: [SybilResistanceService, SybilResistantVotingService],
1112
exports: [SybilResistanceService, SybilResistantVotingService],

src/sybil-resistance/sybil-resistance.service.spec.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { Test, TestingModule } from '@nestjs/testing';
2+
import { ConfigService } from '@nestjs/config';
23
import { SybilResistanceService } from './sybil-resistance.service';
34
import { PrismaService } from '../prisma/prisma.service';
45
import { NotFoundException } from '@nestjs/common';
56

67
describe('SybilResistanceService', () => {
78
let service: SybilResistanceService;
89
let prisma: any;
10+
let configService: ConfigService;
911

1012
// Mock user data
1113
const mockUserId = 'test-user-id';
@@ -44,11 +46,21 @@ describe('SybilResistanceService', () => {
4446
},
4547
},
4648
},
49+
{
50+
provide: ConfigService,
51+
useValue: {
52+
get: jest.fn((key: string, defaultValue?: any) => {
53+
if (key === 'sybil.minClaimsForAccuracyScore') return defaultValue ?? 5;
54+
return defaultValue;
55+
}),
56+
},
57+
},
4758
],
4859
}).compile();
4960

5061
service = module.get<SybilResistanceService>(SybilResistanceService);
5162
prisma = module.get<any>(PrismaService);
63+
configService = module.get<ConfigService>(ConfigService);
5264
});
5365

5466
afterEach(() => {
@@ -474,6 +486,105 @@ describe('SybilResistanceService', () => {
474486
});
475487
});
476488

489+
describe('MIN_CLAIMS_FOR_ACCURACY_SCORE configurability', () => {
490+
async function buildServiceWithMinClaims(minClaims: number): Promise<SybilResistanceService> {
491+
const mod = await Test.createTestingModule({
492+
providers: [
493+
SybilResistanceService,
494+
{
495+
provide: PrismaService,
496+
useValue: {
497+
user: { findUnique: jest.fn(), findMany: jest.fn(), update: jest.fn() },
498+
sybilScore: { create: jest.fn(), findFirst: jest.fn(), findMany: jest.fn() },
499+
},
500+
},
501+
{
502+
provide: ConfigService,
503+
useValue: {
504+
get: (key: string, defaultValue?: any) =>
505+
key === 'sybil.minClaimsForAccuracyScore' ? minClaims : defaultValue,
506+
},
507+
},
508+
],
509+
}).compile();
510+
return mod.get<SybilResistanceService>(SybilResistanceService);
511+
}
512+
513+
it('should use the default threshold of 5 when env var is not overridden', () => {
514+
// ConfigService mock returns default (5) — accuracy score is 0 for < 5 claims
515+
jest.spyOn(prisma.user, 'findUnique').mockResolvedValue({
516+
...mockUser,
517+
worldcoinVerified: false,
518+
wallets: [],
519+
});
520+
// Access private field via any cast to verify initialization
521+
expect((service as any).MIN_CLAIMS_FOR_ACCURACY_SCORE).toBe(5);
522+
});
523+
524+
it('should read MIN_CLAIMS_FOR_ACCURACY_SCORE from ConfigService on construction', async () => {
525+
const customService = await buildServiceWithMinClaims(10);
526+
expect((customService as any).MIN_CLAIMS_FOR_ACCURACY_SCORE).toBe(10);
527+
});
528+
529+
it('should not award accuracy score when claims voted on is below configured threshold', async () => {
530+
const customService = await buildServiceWithMinClaims(10);
531+
const prismaInCustom = (customService as any).prisma;
532+
533+
// Provide a user whose claimsVotedOn would be below threshold
534+
jest.spyOn(prismaInCustom.user, 'findUnique').mockResolvedValue({
535+
...mockUser,
536+
wallets: [],
537+
});
538+
539+
const { details } = await customService.computeSybilScore(mockUserId);
540+
expect(details.componentScores.accuracy).toBe(0);
541+
});
542+
543+
it('should award accuracy score when claims voted on meets custom threshold', async () => {
544+
// Use threshold of 3 and manually inject enough claims via gatherSignals override
545+
const customService = await buildServiceWithMinClaims(3);
546+
const prismaInCustom = (customService as any).prisma;
547+
548+
jest.spyOn(prismaInCustom.user, 'findUnique').mockResolvedValue({
549+
...mockUser,
550+
wallets: [],
551+
});
552+
553+
// Spy on private gatherSignals to inject 4 correct out of 4 votes (above threshold 3)
554+
jest.spyOn(customService as any, 'gatherSignals').mockResolvedValue({
555+
worldcoinVerified: false,
556+
oldestWalletAgeMs: 0,
557+
totalStakedAmount: BigInt(0),
558+
claimsVotedOn: 4,
559+
claimsCorrect: 4,
560+
});
561+
562+
const { details } = await customService.computeSybilScore(mockUserId);
563+
expect(details.componentScores.accuracy).toBe(1);
564+
});
565+
566+
it('should treat boundary value (exactly equal to threshold) as meeting the threshold', async () => {
567+
const customService = await buildServiceWithMinClaims(3);
568+
const prismaInCustom = (customService as any).prisma;
569+
570+
jest.spyOn(prismaInCustom.user, 'findUnique').mockResolvedValue({
571+
...mockUser,
572+
wallets: [],
573+
});
574+
575+
jest.spyOn(customService as any, 'gatherSignals').mockResolvedValue({
576+
worldcoinVerified: false,
577+
oldestWalletAgeMs: 0,
578+
totalStakedAmount: BigInt(0),
579+
claimsVotedOn: 3, // exactly at threshold
580+
claimsCorrect: 3,
581+
});
582+
583+
const { details } = await customService.computeSybilScore(mockUserId);
584+
expect(details.componentScores.accuracy).toBe(1);
585+
});
586+
});
587+
477588
describe('Edge cases', () => {
478589
it('should handle users with no wallets', async () => {
479590
const userNoWallets = {

src/sybil-resistance/sybil-resistance.service.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
23
import { PrismaService } from '../prisma/prisma.service';
34

45
/**
@@ -41,9 +42,17 @@ export class SybilResistanceService {
4142
// Scoring thresholds and normalization constants
4243
private readonly WALLET_AGE_THRESHOLD_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
4344
private readonly MIN_STAKING_FOR_FULL_SCORE = BigInt('1000000000000000000'); // 1 token (assuming 18 decimals)
44-
private readonly MIN_CLAIMS_FOR_ACCURACY_SCORE = 5;
45-
46-
constructor(private prisma: PrismaService) {}
45+
private readonly MIN_CLAIMS_FOR_ACCURACY_SCORE: number;
46+
47+
constructor(
48+
private prisma: PrismaService,
49+
private configService: ConfigService,
50+
) {
51+
this.MIN_CLAIMS_FOR_ACCURACY_SCORE = this.configService.get<number>(
52+
'sybil.minClaimsForAccuracyScore',
53+
5,
54+
);
55+
}
4756

4857
/**
4958
* Compute Sybil resistance score for a user

0 commit comments

Comments
 (0)