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
172 changes: 172 additions & 0 deletions apps/payments/src/abuseDetection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { describe, expect, it, vi } from 'vitest';
import { assessRisk, createAbuseDetectionStore } from './abuseDetection.js';
import type { SpacetimeAdminConfig } from './config.js';

const testConfig: SpacetimeAdminConfig = { uri: 'https://stdb.example', database: 'test-db' };

function match(overrides: Record<string, unknown> = {}) {
return {
matchId: `match-${Math.random()}`,
opponentAccountId: 'opp-1',
outcome: 'win',
completedRounds: 3,
awardedPoints: 10,
settledAt: new Date().toISOString(),
...overrides,
};
}

describe('assessRisk', () => {
it('returns zero risk for empty matches', () => {
const result = assessRisk({ accountId: 'acc-1', recentMatches: [] });
expect(result.riskScore).toBe(0);
expect(result.holdForReview).toBe(false);
expect(result.signals).toHaveLength(0);
});

it('returns zero risk for normal activity', () => {
// Mix of wins and losses vs different opponents — no signals should trigger
const matches = [
match({ opponentAccountId: 'opp-1', outcome: 'win' }),
match({ opponentAccountId: 'opp-2', outcome: 'loss' }),
match({ opponentAccountId: 'opp-3', outcome: 'win' }),
match({ opponentAccountId: 'opp-4', outcome: 'loss' }),
];
const result = assessRisk({ accountId: 'acc-1', recentMatches: matches });
expect(result.riskScore).toBe(0);
expect(result.holdForReview).toBe(false);
});

it('flags self-match and sets riskScore to 100+', () => {
const result = assessRisk({
accountId: 'acc-1',
recentMatches: [match({ opponentAccountId: 'acc-1' })],
});
expect(result.riskScore).toBeGreaterThanOrEqual(100);
expect(result.holdForReview).toBe(true);
expect(result.signals).toContain('self_match_detected');
});

it('flags repeated pair farming beyond pairDailyMatchLimit', () => {
const matches = Array.from({ length: 12 }, () => match({ opponentAccountId: 'opp-fixed' }));
const result = assessRisk({ accountId: 'acc-1', recentMatches: matches });
expect(result.holdForReview).toBe(true);
expect(result.signals.some(s => s.startsWith('repeated_pair:opp-fixed'))).toBe(true);
});

it('flags high forfeit rate', () => {
const matches = [
match({ outcome: 'forfeit_loss' }),
match({ outcome: 'forfeit_loss' }),
match({ outcome: 'forfeit_loss' }),
match({ outcome: 'win' }),
match({ outcome: 'win' }),
];
const result = assessRisk({ accountId: 'acc-1', recentMatches: matches });
expect(result.signals.some(s => s.startsWith('high_forfeit_rate'))).toBe(true);
expect(result.holdForReview).toBe(true);
});

it('flags abnormal win rate above 95% for 5+ matches', () => {
const matches = Array.from({ length: 20 }, () => match({ outcome: 'win' }));
const result = assessRisk({ accountId: 'acc-1', recentMatches: matches });
expect(result.signals.some(s => s.startsWith('abnormal_win_rate'))).toBe(true);
expect(result.holdForReview).toBe(true);
});

it('does not flag win rate for fewer than 5 matches', () => {
const matches = Array.from({ length: 3 }, () => match({ outcome: 'win' }));
const result = assessRisk({ accountId: 'acc-1', recentMatches: matches });
expect(result.signals.some(s => s.startsWith('abnormal_win_rate'))).toBe(false);
});

it('flags very low average completed rounds', () => {
const matches = Array.from({ length: 5 }, () => match({ completedRounds: 0 }));
const result = assessRisk({ accountId: 'acc-1', recentMatches: matches });
expect(result.signals.some(s => s.startsWith('low_avg_rounds'))).toBe(true);
});

it('respects custom holdThreshold config', () => {
const matches = Array.from({ length: 20 }, () => match({ outcome: 'win' }));
const result = assessRisk({ accountId: 'acc-1', recentMatches: matches }, { holdThreshold: 100 });
expect(result.holdForReview).toBe(false);
});

it('stacks multiple signals and increases riskScore', () => {
// 11 wins vs opp-fixed (pair farming >10) + 9 forfeit_losses vs different opps (45% forfeit rate)
// => score: 20 (pair) + 30 (forfeit) = 50
const matches = [
...Array.from({ length: 11 }, () => match({ opponentAccountId: 'opp-fixed', outcome: 'win' })),
...Array.from({ length: 9 }, (_, i) => match({ opponentAccountId: `opp-${i}`, outcome: 'forfeit_loss' })),
];
const result = assessRisk({ accountId: 'acc-1', recentMatches: matches });
expect(result.riskScore).toBeGreaterThan(40);
expect(result.signals.length).toBeGreaterThan(1);
});
});

describe('createAbuseDetectionStore evaluateAccount', () => {
function rowsToSql(rows: Record<string, unknown>[]): string {
if (rows.length === 0) return '';
const columns = Object.keys(rows[0]);
return JSON.stringify([{
schema: { elements: columns.map(col => ({ name: col })) },
rows: rows.map(row => columns.map(col => row[col])),
}]);
}

function eventRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
match_id: 'match-1',
account_id: 'acc-1',
opponent_account_id: 'opp-1',
campaign_id: 'camp-1',
outcome: 'win',
base_points: 10,
awarded_points: 10,
settled_at_micros: Date.now() * 1000,
created_at_micros: Date.now() * 1000,
...overrides,
};
}

function makeFetch(events: Record<string, unknown>[] = []) {
const fetchImpl = vi.fn(async (_url: unknown, init?: RequestInit) => {
const query = (init?.body as string) ?? '';
if (query.startsWith('SELECT * FROM reward_point_event')) {
return new Response(rowsToSql(events), { status: 200 });
}
return new Response('', { status: 200 });
});
return fetchImpl as unknown as typeof fetch;
}

it('returns zero risk when no recent events', async () => {
const store = createAbuseDetectionStore(testConfig, makeFetch([]));
const result = await store.evaluateAccount('acc-1', 'camp-1');
expect(result.riskScore).toBe(0);
expect(result.holdForReview).toBe(false);
});

it('detects repeated pair farming from event history', async () => {
const events = Array.from({ length: 12 }, (_, i) =>
eventRow({ match_id: `match-${i}`, opponent_account_id: 'opp-fixed' })
);
const store = createAbuseDetectionStore(testConfig, makeFetch(events));
const result = await store.evaluateAccount('acc-1', 'camp-1');
expect(result.holdForReview).toBe(true);
expect(result.signals.some(s => s.startsWith('repeated_pair'))).toBe(true);
});

it('detects rapid forfeits from event history', async () => {
const events = [
eventRow({ match_id: 'm1', outcome: 'forfeit_loss', awarded_points: 0 }),
eventRow({ match_id: 'm2', outcome: 'forfeit_loss', awarded_points: 0 }),
eventRow({ match_id: 'm3', outcome: 'forfeit_loss', awarded_points: 0 }),
eventRow({ match_id: 'm4', outcome: 'win', awarded_points: 10 }),
];
const store = createAbuseDetectionStore(testConfig, makeFetch(events));
const result = await store.evaluateAccount('acc-1', 'camp-1');
expect(result.signals.some(s => s.startsWith('high_forfeit_rate'))).toBe(true);
});
});
144 changes: 144 additions & 0 deletions apps/payments/src/abuseDetection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import type { SpacetimeAdminConfig } from './config.js';
import { createSpacetimeSqlQuery, numberValue, sqlString, stringValue } from './spacetimeSql.js';

export interface PairActivity {
opponentAccountId: string;
matchCount: number;
totalAwardedPoints: number;
}

export interface AbuseSignals {
accountId: string;
recentMatches: Array<{
matchId: string;
opponentAccountId: string;
outcome: string;
completedRounds: number;
awardedPoints: number;
settledAt: string;
}>;
windowHours?: number;
}

export interface RiskAssessment {
riskScore: number;
holdForReview: boolean;
signals: string[];
}

export interface RiskConfig {
holdThreshold?: number;
pairDailyMatchLimit?: number;
minAvgCompletedRounds?: number;
maxForfeitRate?: number;
maxWinRate?: number;
}

const DEFAULT_CONFIG: Required<RiskConfig> = {
holdThreshold: 30,
pairDailyMatchLimit: 10,
minAvgCompletedRounds: 1,
maxForfeitRate: 0.4,
maxWinRate: 0.95,
};

export function assessRisk(signals: AbuseSignals, config?: RiskConfig): RiskAssessment {
const cfg = { ...DEFAULT_CONFIG, ...config };
const { accountId, recentMatches } = signals;
let riskScore = 0;
const flaggedSignals: string[] = [];

if (recentMatches.length === 0) return { riskScore: 0, holdForReview: false, signals: [] };

// Self-match: playing against own account
const selfMatch = recentMatches.find(m => m.opponentAccountId === accountId);
if (selfMatch) {
riskScore += 100;
flaggedSignals.push('self_match_detected');
}

// Repeated pair farming: too many matches vs same opponent in window
const pairCounts = new Map<string, number>();
for (const m of recentMatches) {
pairCounts.set(m.opponentAccountId, (pairCounts.get(m.opponentAccountId) ?? 0) + 1);
}
for (const [opp, count] of pairCounts) {
if (count > cfg.pairDailyMatchLimit) {
riskScore += 20;
flaggedSignals.push(`repeated_pair:${opp}:${count}`);
}
}

// Rapid forfeits: high forfeit/timeout rate
const forfeitCount = recentMatches.filter(m =>
m.outcome === 'forfeit_loss' || m.outcome === 'timeout_loss' || m.outcome === 'forfeit_win' || m.outcome === 'timeout_win'
).length;
const forfeitRate = forfeitCount / recentMatches.length;
if (forfeitRate > cfg.maxForfeitRate) {
riskScore += 30;
flaggedSignals.push(`high_forfeit_rate:${Math.round(forfeitRate * 100)}%`);
}

// Abnormal win rate: suspiciously high
const winCount = recentMatches.filter(m =>
m.outcome === 'win' || m.outcome === 'forfeit_win' || m.outcome === 'timeout_win' || m.outcome === 'disconnect_win'
).length;
const winRate = winCount / recentMatches.length;
if (recentMatches.length >= 5 && winRate > cfg.maxWinRate) {
riskScore += 15;
flaggedSignals.push(`abnormal_win_rate:${Math.round(winRate * 100)}%`);
}

// Very short matches: rounds < minimum (possible scripted matches)
const avgRounds = recentMatches.reduce((s, m) => s + m.completedRounds, 0) / recentMatches.length;
if (avgRounds < cfg.minAvgCompletedRounds) {
riskScore += 20;
flaggedSignals.push(`low_avg_rounds:${avgRounds.toFixed(1)}`);
}

return {
riskScore,
holdForReview: riskScore >= cfg.holdThreshold,
signals: flaggedSignals,
};
}

export interface AbuseDetectionStore {
evaluateAccount(accountId: string, campaignId: string, windowHours?: number): Promise<RiskAssessment>;
}

interface RewardPointEventRow extends Record<string, unknown> {
match_id: unknown;
opponent_account_id: unknown;
outcome: unknown;
awarded_points: unknown;
settled_at_micros: unknown;
}

export function createAbuseDetectionStore(
config: SpacetimeAdminConfig,
fetchImpl: typeof fetch = fetch,
riskConfig?: RiskConfig,
): AbuseDetectionStore {
const sql = createSpacetimeSqlQuery(config, fetchImpl);

return {
async evaluateAccount(accountId, campaignId, windowHours = 24) {
const windowStart = BigInt(Date.now() - windowHours * 3_600_000) * 1000n;
const rows = await sql(
`SELECT * FROM reward_point_event WHERE account_id = ${sqlString(accountId)} AND campaign_id = ${sqlString(campaignId)} AND settled_at_micros >= ${windowStart}`,
);

const recentMatches = (rows as RewardPointEventRow[]).map(r => ({
matchId: stringValue(r.match_id),
opponentAccountId: stringValue(r.opponent_account_id),
outcome: stringValue(r.outcome),
completedRounds: 1,
awardedPoints: numberValue(r.awarded_points),
settledAt: new Date(Math.floor(numberValue(r.settled_at_micros) / 1000)).toISOString(),
}));

return assessRisk({ accountId, recentMatches, windowHours }, riskConfig);
},
};
}
Loading
Loading