diff --git a/src/sybil-resistance/sybil-resistance.service.spec.ts b/src/sybil-resistance/sybil-resistance.service.spec.ts index 8def08c..85b580e 100644 --- a/src/sybil-resistance/sybil-resistance.service.spec.ts +++ b/src/sybil-resistance/sybil-resistance.service.spec.ts @@ -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); diff --git a/src/sybil-resistance/sybil-resistance.service.ts b/src/sybil-resistance/sybil-resistance.service.ts index 0460442..7412d75 100644 --- a/src/sybil-resistance/sybil-resistance.service.ts +++ b/src/sybil-resistance/sybil-resistance.service.ts @@ -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( @@ -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); @@ -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 @@ -170,7 +173,7 @@ export class SybilResistanceService { worldcoin: worldcoinScore, walletAge: walletAgeScore, staking: stakingScore, - accuracy: accuracyScore, + accuracy: this.clampScore(accuracyScore), }; } @@ -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 */ @@ -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,