Skip to content
Merged
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
7 changes: 7 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ model SybilScore {
@@index([compositeScore])
}

model SybilExplanation {
id String @id @default(uuid())
sybilScoreId String @unique
explanation String
createdAt DateTime @default(now())

sybilScore SybilScore @relation(fields: [sybilScoreId], references: [id], onDelete: Cascade)
model WorldIdVerification {
id String @id @default(uuid())
verifiedAt DateTime @default(now())
Expand Down
31 changes: 31 additions & 0 deletions src/sybil-resistance/sybil-resistance.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ describe('SybilResistanceService', () => {
findFirst: jest.fn(),
findMany: jest.fn(),
},
sybilExplanation: {
create: jest.fn(),
findFirst: jest.fn(),
},
},
},
{
Expand Down Expand Up @@ -167,10 +171,14 @@ describe('SybilResistanceService', () => {
};

jest.spyOn(prisma.sybilScore, 'create').mockResolvedValueOnce(mockScoreRecord);
jest.spyOn(prisma.sybilExplanation, 'create').mockResolvedValueOnce({ id: 'ex-1', sybilScoreId: 'score-1', explanation: 'exp' });

const result = await service.recordSybilScore(mockUserId);

expect(prisma.sybilScore.create).toHaveBeenCalled();
expect(prisma.sybilExplanation.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ sybilScoreId: 'score-1' }) }),
);
expect(result.userId).toBe(mockUserId);
expect(result.compositeScore).toBeDefined();
});
Expand Down Expand Up @@ -419,6 +427,29 @@ describe('SybilResistanceService', () => {
expect(result.details).toBeDefined();
});

it('should load explanation from SybilExplanation if not present in calculationDetails', async () => {
const mockScore = {
id: 'score-1',
userId: mockUserId,
compositeScore: 0.57,
worldcoinScore: 1.0,
walletAgeScore: 0.67,
stakingScore: 0.0,
accuracyScore: 0.0,
calculationDetails: JSON.stringify({ componentScores: { worldcoin: 1.0 } }),
createdAt: new Date(),
updatedAt: new Date(),
};

jest.spyOn(prisma.sybilScore, 'findFirst').mockResolvedValueOnce(mockScore);
jest.spyOn(prisma.sybilExplanation, 'findFirst').mockResolvedValueOnce({ id: 'ex-1', sybilScoreId: 'score-1', explanation: 'Stored explanation' });

const result = await service.getSybilScoreForVoting(mockUserId);

expect(result.details).toBeDefined();
expect(result.details.explanation).toBe('Stored explanation');
});

it('should indicate unverified status correctly', async () => {
const mockScore = {
id: 'score-1',
Expand Down
37 changes: 34 additions & 3 deletions src/sybil-resistance/sybil-resistance.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,17 +229,34 @@ Final score: ${Number(composite.toFixed(4))} (weighted average)`,
async recordSybilScore(userId: string): Promise<any> {
const { score: compositeScore, details } = await this.computeSybilScore(userId);

return this.prisma.sybilScore.create({
// Persist SybilScore without the potentially large `explanation` text
const detailsCopy: any = { ...details };
const explanationText = detailsCopy.explanation;
delete detailsCopy.explanation;

const scoreRecord = await this.prisma.sybilScore.create({
data: {
userId,
worldcoinScore: details.componentScores.worldcoin,
walletAgeScore: details.componentScores.walletAge,
stakingScore: details.componentScores.staking,
accuracyScore: details.componentScores.accuracy,
compositeScore,
calculationDetails: JSON.stringify(details),
calculationDetails: JSON.stringify(detailsCopy),
},
});

// Store explanation separately to avoid huge JSON columns
if (explanationText) {
await this.prisma.sybilExplanation.create({
data: {
sybilScoreId: scoreRecord.id,
explanation: explanationText,
},
});
}

return scoreRecord;
}

/**
Expand Down Expand Up @@ -369,11 +386,25 @@ Final score: ${Number(composite.toFixed(4))} (weighted average)`,
}> {
const score = await this.getLatestSybilScore(userId);

// Parse calculation details and, if explanation was stored separately, load it
let details = score.calculationDetails ? JSON.parse(score.calculationDetails) : null;
if (details && !details.explanation) {
// try to load explanation from separate table
try {
const expl = await this.prisma.sybilExplanation.findFirst({ where: { sybilScoreId: score.id } });
if (expl && expl.explanation) {
details.explanation = expl.explanation;
}
} catch (err) {
// ignore missing explanation
}
}

return {
userId,
score: score.compositeScore,
isVerified: score.worldcoinScore > 0,
details: score.calculationDetails ? JSON.parse(score.calculationDetails) : null,
details,
};
}
}
Loading