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
15 changes: 15 additions & 0 deletions src/sybil-resistance/sybil-resistance.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,21 @@ describe('SybilResistanceService', () => {
expect(details.componentScores.accuracy).toBeLessThanOrEqual(1);
});

it('should clamp the composite score to a maximum of 1.0 when normalized values overflow', async () => {
jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser);
jest.spyOn(service as any, 'normalizeSignals').mockReturnValue({
worldcoin: 2.0,
walletAge: 2.0,
staking: 2.0,
accuracy: 2.0,
});

const { score, details } = await service.computeSybilScore(mockUserId);

expect(score).toBe(1.0);
expect(details.explanation).toContain('Final score: 1.0000');
});

it('should produce deterministic scores for same input', async () => {
jest.spyOn(prisma.user, 'findUnique').mockResolvedValueOnce(mockUser);

Expand Down
46 changes: 35 additions & 11 deletions src/sybil-resistance/sybil-resistance.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export class SybilResistanceService {
// Scoring thresholds and normalization constants
private readonly WALLET_AGE_THRESHOLD_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
private readonly MIN_STAKING_FOR_FULL_SCORE = BigInt('1000000000000000000'); // 1 token (assuming 18 decimals)
private readonly FIXED_POINT_SCALE = 1000000000n;
private readonly MIN_CLAIMS_FOR_ACCURACY_SCORE: number;

constructor(
Expand Down Expand Up @@ -79,8 +80,10 @@ export class SybilResistanceService {
// 3. Normalize each signal to 0-1 range
const normalizedScores = this.normalizeSignals(signals);

// 4. Apply weighted combination
const compositeScore = this.weightedCombination(normalizedScores);
// 4. Apply weighted combination and clamp to valid score range
const compositeScore = this.clampScore(
this.weightedCombination(normalizedScores),
);

// 5. Create detailed calculation record
const details = this.createCalculationDetails(normalizedScores);
Expand Down Expand Up @@ -144,20 +147,20 @@ export class SybilResistanceService {
accuracy: number;
} {
// Worldcoin: binary (0 or 1)
const worldcoinScore = signals.worldcoinVerified ? 1.0 : 0.0;
const worldcoinScore = this.clampScore(signals.worldcoinVerified ? 1.0 : 0.0);

// Wallet Age: sigmoid-like curve with 90-day threshold
const walletAgeScore = Math.min(
signals.oldestWalletAgeMs / this.WALLET_AGE_THRESHOLD_MS,
1.0,
// Wallet Age: smooth sigmoid-like scaling using fixed point math
const walletAgeScore = this.clampScore(
Number(
this.calculateFixedPointSigmoid(signals.oldestWalletAgeMs),
),
);

// Staking: logarithmic scaling to avoid whales dominating
// Uses log1p to handle 0 gracefully
const stakingScore = Math.min(
const stakingScore = this.clampScore(
Math.log1p(Number(signals.totalStakedAmount)) /
Math.log1p(Number(this.MIN_STAKING_FOR_FULL_SCORE)),
1.0,
);

// Accuracy: ratio of correct votes, with minimum threshold
Expand All @@ -170,7 +173,7 @@ export class SybilResistanceService {
worldcoin: worldcoinScore,
walletAge: walletAgeScore,
staking: stakingScore,
accuracy: accuracyScore,
accuracy: this.clampScore(accuracyScore),
};
}

Expand All @@ -191,6 +194,25 @@ export class SybilResistanceService {
);
}

/**
* Compute a smooth sigmoid-like score for wallet age using fixed-point arithmetic.
* This avoids floating precision issues while still producing a 0.0-1.0 shape.
*/
private calculateFixedPointSigmoid(ageMs: number): number {
const ageScore = BigInt(Math.max(0, ageMs));
const x = ageScore * this.FIXED_POINT_SCALE / BigInt(this.WALLET_AGE_THRESHOLD_MS);
// Use a simple fixed-point approximation: x / (1 + x)
const scaled = (x * this.FIXED_POINT_SCALE) / (this.FIXED_POINT_SCALE + x);
return Number(scaled) / Number(this.FIXED_POINT_SCALE);
}

/**
* Clamp any score to the 0.0-1.0 range
*/
private clampScore(value: number): number {
return Math.max(0.0, Math.min(1.0, value));
}

/**
* Create detailed calculation record for explainability
*/
Expand All @@ -200,7 +222,9 @@ export class SybilResistanceService {
staking: number;
accuracy: number;
}): CalculationDetails {
const composite = this.weightedCombination(normalizedScores);
const composite = this.clampScore(
this.weightedCombination(normalizedScores),
);

return {
worldcoinWeight: this.WORLDCOIN_WEIGHT,
Expand Down
Loading