From 1bc6650041b0ecea6668916485092ac39ce0ee38 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Mon, 17 Aug 2026 23:55:01 +0700 Subject: [PATCH 1/2] feat(core): Phase 1.2 Final Core Audit Fixes - storage driver policy, snapshot relation binding, true Fisher-Yates HMAC_SHA256_FY_V1, proof/event hash separation, verification API, and GitHub Actions CI --- .github/workflows/ci.yml | 61 +++++++++ prisma/schema.prisma | 62 ++++----- src/app/api/giveaways/[id]/verify/route.ts | 66 ++++++++++ src/app/giveaways/[id]/page.tsx | 114 ++++++++++++---- src/app/giveaways/new/page.tsx | 15 ++- src/core/randomizer/canonical.ts | 21 ++- src/core/randomizer/deterministic.ts | 104 ++++++++++----- src/core/randomizer/hasher.ts | 14 +- src/core/types/audit.ts | 26 +++- src/lib/giveaway-store.ts | 50 +++---- src/lib/repository/memory-repository.ts | 12 ++ src/lib/repository/prisma-repository.ts | 41 ++++-- tests/fisher-yates.test.ts | 94 ++++++++++++++ tests/persistence.test.ts | 6 +- tests/proof-separation.test.ts | 139 ++++++++++++++++++++ tests/randomizer.test.ts | 18 ++- tests/snapshot-binding.test.ts | 109 ++++++++++++++++ tests/storage-driver.test.ts | 74 +++++++++++ tests/verification-api.test.ts | 144 +++++++++++++++++++++ 19 files changed, 1018 insertions(+), 152 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/app/api/giveaways/[id]/verify/route.ts create mode 100644 tests/fisher-yates.test.ts create mode 100644 tests/proof-separation.test.ts create mode 100644 tests/snapshot-binding.test.ts create mode 100644 tests/storage-driver.test.ts create mode 100644 tests/verification-api.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2ba9cce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: randomayzer + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/randomayzer?schema=public" + STORAGE_DRIVER: "memory" + NODE_ENV: "test" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma Client + run: npx prisma generate + + - name: Push Database Schema + run: npx prisma db push + + - name: Run Unit & Integration Tests + run: npm test + + - name: Run ESLint + run: npm run lint + + - name: Run Production Build + run: npm run build + env: + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/randomayzer?schema=public" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aafe636..e934c8d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -101,38 +101,40 @@ model ParticipantSnapshot { } model DrawResult { - id String @id @default(cuid()) - giveawayId String @unique - giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade) - snapshotId String @unique - snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict) - winners Json // Array of Winner entities - reserveWinners Json // Array of Winner entities - winnerIds Json // string[] - reserveWinnerIds Json // string[] - totalEligibleCount Int - totalLoadedCount Int - seedUsed String - algorithmVersion String @default("HMAC_SHA256_FY_V1") - auditHash String - drawnAt DateTime @default(now()) + id String @id @default(cuid()) + giveawayId String @unique + giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade) + snapshotId String @unique + snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict) + winners Json // Array of Winner entities + reserveWinners Json // Array of Winner entities + winnerIds Json // string[] + reserveWinnerIds Json // string[] + totalEligibleCount Int + totalLoadedCount Int + seedUsed String + algorithmVersion String @default("HMAC_SHA256_FY_V1") + deterministicProofHash String + auditEventHash String + drawnAt DateTime @default(now()) } model AuditRecord { - id String @id @default(cuid()) - giveawayId String @unique - giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade) - snapshotId String @unique - snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict) - algorithmVersion String @default("HMAC_SHA256_FY_V1") - seed String + id String @id @default(cuid()) + giveawayId String @unique + giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade) + snapshotId String @unique + snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict) + algorithmVersion String @default("HMAC_SHA256_FY_V1") + seed String participantsSnapshotHash String - conditionsHash String - auditHash String - winnerIds Json // string[] - reserveWinnerIds Json // string[] - eligibleCount Int - drawId String - drawnAt DateTime @default(now()) - verifiedAt DateTime @default(now()) + conditionsHash String + deterministicProofHash String + auditEventHash String + winnerIds Json // string[] + reserveWinnerIds Json // string[] + eligibleCount Int + drawId String + drawnAt DateTime @default(now()) + verifiedAt DateTime @default(now()) } diff --git a/src/app/api/giveaways/[id]/verify/route.ts b/src/app/api/giveaways/[id]/verify/route.ts new file mode 100644 index 0000000..205f12d --- /dev/null +++ b/src/app/api/giveaways/[id]/verify/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { GiveawayStore } from '@/lib/giveaway-store'; +import { verifyDrawResult } from '@/core/randomizer/deterministic'; + +export async function GET( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + const { id } = params; + const giveaway = await GiveawayStore.getById(id); + + if (!giveaway) { + return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 }); + } + + const drawResult = giveaway.drawResult; + if (!drawResult) { + return NextResponse.json({ + error: 'Giveaway has not been drawn yet. Nothing to verify.' + }, { status: 400 }); + } + + // Find the snapshot associated with this draw + const snapshot = giveaway.snapshots.find(s => s.id === drawResult.snapshotId) + || giveaway.latestSnapshot; + + if (!snapshot) { + return NextResponse.json({ + error: `Participant snapshot "${drawResult.snapshotId}" not found for this giveaway` + }, { status: 404 }); + } + + const claimedWinnersCount = drawResult.winners.length; + const claimedReserveCount = drawResult.reserveWinners.length; + + // Run independent cryptographic replay verification + const verification = verifyDrawResult( + snapshot, + drawResult.seedUsed, + claimedWinnersCount, + claimedReserveCount, + drawResult.winnerIds, + drawResult.deterministicProofHash, + drawResult.algorithmVersion + ); + + return NextResponse.json({ + verified: verification.verified, + giveawayId: id, + snapshotId: snapshot.id, + algorithmVersion: verification.algorithmVersion, + winnersMatch: verification.winnersMatch, + snapshotHashMatch: verification.snapshotHashMatch, + conditionsHashMatch: verification.conditionsHashMatch, + deterministicProofHashMatch: verification.deterministicProofHashMatch, + expectedWinnerIds: verification.expectedWinnerIds, + expectedReserveWinnerIds: verification.expectedReserveWinnerIds, + deterministicProofHash: verification.expectedDeterministicProofHash, + auditEventHash: drawResult.auditEventHash, + drawnAt: drawResult.drawnAt, + }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/giveaways/[id]/page.tsx b/src/app/giveaways/[id]/page.tsx index 98f7f22..71fbf8c 100644 --- a/src/app/giveaways/[id]/page.tsx +++ b/src/app/giveaways/[id]/page.tsx @@ -12,7 +12,8 @@ import { Check, Calendar, RefreshCw, - Lock + CheckCircle2, + AlertTriangle } from 'lucide-react'; import { StoredGiveaway } from '@/lib/giveaway-store'; @@ -24,6 +25,8 @@ export default function GiveawayDetailPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); + const [verifying, setVerifying] = useState(false); + const [verificationResult, setVerificationResult] = useState(null); useEffect(() => { if (!id) return; @@ -43,6 +46,20 @@ export default function GiveawayDetailPage() { fetchGw(); }, [id]); + const handleVerify = async () => { + if (!id) return; + try { + setVerifying(true); + const res = await fetch(`/api/giveaways/${id}/verify`); + const data = await res.json(); + setVerificationResult(data); + } catch (err: any) { + alert(err.message); + } finally { + setVerifying(false); + } + }; + if (loading) { return (
@@ -64,7 +81,6 @@ export default function GiveawayDetailPage() { } const drawResult = giveaway.drawResult; - const snapshot = giveaway.latestSnapshot; return (
@@ -167,35 +183,75 @@ export default function GiveawayDetailPage() { {/* Provably Fair Audit Trail */}
-
+
Публичный криптографический аудит (Provably Fair)
- +
+ + +
+ {/* Live Verification Banner if clicked */} + {verificationResult && ( +
+
+ {verificationResult.verified ? ( + <> + + Результат 100% подтвержден и математически доказуем! + + ) : ( + <> + + Несоответствие верификации! + + )} +
+
+
Победители совпали: {verificationResult.winnersMatch ? 'ДА ✓' : 'НЕТ ✗'}
+
Хеш слепка совпал: {verificationResult.snapshotHashMatch ? 'ДА ✓' : 'НЕТ ✗'}
+
Хеш условий совпал: {verificationResult.conditionsHashMatch ? 'ДА ✓' : 'НЕТ ✗'}
+
Proof Hash совпал: {verificationResult.deterministicProofHashMatch ? 'ДА ✓' : 'НЕТ ✗'}
+
+
+ )} +
Snapshot ID: @@ -214,8 +270,12 @@ export default function GiveawayDetailPage() {

{drawResult.participantsSnapshotHash}

- Канонический auditHash: -

{drawResult.auditHash}

+ deterministicProofHash (воспроизводимый): +

{drawResult.deterministicProofHash}

+
+
+ auditEventHash (уникальный для события): +

{drawResult.auditEventHash}

diff --git a/src/app/giveaways/new/page.tsx b/src/app/giveaways/new/page.tsx index aeed216..f957d82 100644 --- a/src/app/giveaways/new/page.tsx +++ b/src/app/giveaways/new/page.tsx @@ -410,7 +410,7 @@ export default function NewGiveawayWizardPage() {
- {/* Condition: Subscription (Active & Supported) */} + {/* Condition: Subscription */} - {/* Condition: Repost (Explicitly Marked Unsupported by Capability) */} + {/* Condition: Repost (Disabled by capability) */}

Шаг 4: Настройки жеребьевки

- Слепок участников зафиксирован (Статус: SNAPSHOT_LOCKED). Алгоритм: HMAC_SHA256_FY_V1 + Слепок зафиксирован (SNAPSHOT_LOCKED). Алгоритм: HMAC_SHA256_FY_V1

@@ -926,8 +926,13 @@ export default function NewGiveawayWizardPage() {
- Канонический auditHash: -

{drawResult.auditHash}

+ deterministicProofHash (воспроизводимый): +

{drawResult.deterministicProofHash}

+
+ +
+ auditEventHash (уникальный для события): +

{drawResult.auditEventHash}

diff --git a/src/core/randomizer/canonical.ts b/src/core/randomizer/canonical.ts index eb52c62..f9b4834 100644 --- a/src/core/randomizer/canonical.ts +++ b/src/core/randomizer/canonical.ts @@ -71,20 +71,29 @@ export function computeParticipantsSnapshotHash(participants: FilteredParticipan } /** - * Computes canonical auditHash for the audit record + * Computes deterministic proof hash (reproducible on replay) */ -export function computeAuditHash(data: { +export function computeDeterministicProofHash(data: { algorithmVersion: string; - giveawayId: string; snapshotId: string; - seed: string; participantsSnapshotHash: string; conditionsHash: string; + seed: string; winnerIds: string[]; reserveWinnerIds: string[]; eligibleCount: number; - drawId: string; - drawnAt: string; +}): string { + return sha256(canonicalStringify(data)); +} + +/** + * Computes unique audit event hash (binds specific execution event metadata to the deterministic proof) + */ +export function computeAuditEventHash(data: { + giveawayId: string; + drawId: string; + drawnAt: string; + deterministicProofHash: string; }): string { return sha256(canonicalStringify(data)); } diff --git a/src/core/randomizer/deterministic.ts b/src/core/randomizer/deterministic.ts index 4997faf..e0b0aa7 100644 --- a/src/core/randomizer/deterministic.ts +++ b/src/core/randomizer/deterministic.ts @@ -1,10 +1,16 @@ import { createHash, randomBytes } from 'crypto'; import { FilteredParticipant, Winner } from '../types/participant'; -import { DrawExecutionParams, DrawExecutionResult, CURRENT_RANDOMIZER_ALGORITHM, ParticipantSnapshotData } from '../types/audit'; +import { + DrawExecutionParams, + DrawExecutionResult, + ALGORITHM_HMAC_SHA256_FY_V1, + ParticipantSnapshotData, + VerificationResult +} from '../types/audit'; import { DeterministicHmacStream } from './unbiased-sampler'; -import { computeAuditHash } from './canonical'; +import { computeDeterministicProofHash, computeAuditEventHash } from './canonical'; -export const ALGORITHM_VERSION_V1 = CURRENT_RANDOMIZER_ALGORITHM; // 'HMAC_SHA256_FY_V1' +export const ALGORITHM_VERSION_V1 = ALGORITHM_HMAC_SHA256_FY_V1; // 'HMAC_SHA256_FY_V1' /** * Generates an individual audit proof hash for a winner position @@ -21,8 +27,8 @@ export function generateWinnerProofHash( } /** - * Executes deterministic Fisher-Yates selection V1 (HMAC_SHA256_FY_V1) - * with unbiased rejection sampling. + * Executes true partial Fisher-Yates shuffle V1 (HMAC_SHA256_FY_V1) + * with in-place swap and unbiased rejection sampling. */ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExecutionResult { const { @@ -40,7 +46,7 @@ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExe throw new Error('Cannot conduct draw with 0 eligible participants in snapshot'); } - // 1. Canonical sort to ensure exact invariant input order + // 1. Canonical sort to guarantee exact invariant input ordering const pool = [...eligible].sort((a, b) => a.platformUserId.localeCompare(b.platformUserId) ); @@ -55,39 +61,50 @@ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExe const actualWinnersCount = Math.min(winnersCount, totalNeeded); const actualReserveCount = Math.max(0, totalNeeded - actualWinnersCount); + // 3. True partial Fisher-Yates shuffle: swap pool[i] with pool[j] where j in [i, n-1] + for (let i = 0; i < totalNeeded; i++) { + const remainingCount = pool.length - i; + const offset = stream.sampleUnbiasedIndex(remainingCount); + const j = i + offset; + + // In-place swap + const temp = pool[i]; + pool[i] = pool[j]; + pool[j] = temp; + } + const winners: Winner[] = []; const reserveWinners: Winner[] = []; const winnerIds: string[] = []; const reserveWinnerIds: string[] = []; - // 3. Select Main Winners (Fisher-Yates removal without replacement) + // 4. Map Main Winners from pool[0 ... actualWinnersCount - 1] for (let i = 0; i < actualWinnersCount; i++) { - const selectedIndex = stream.sampleUnbiasedIndex(pool.length); - const selectedParticipant = pool.splice(selectedIndex, 1)[0]; + const selectedParticipant = pool[i]; const proofHash = generateWinnerProofHash(seed, snapshotHash, i + 1, selectedParticipant.platformUserId); winners.push({ position: i + 1, isReserve: false, participant: selectedParticipant, - selectionIndex: selectedIndex, + selectionIndex: i, proofHash, }); winnerIds.push(selectedParticipant.platformUserId); } - // 4. Select Reserve Winners + // 5. Map Reserve Winners from pool[actualWinnersCount ... totalNeeded - 1] for (let i = 0; i < actualReserveCount; i++) { - const pos = winners.length + i + 1; - const selectedIndex = stream.sampleUnbiasedIndex(pool.length); - const selectedParticipant = pool.splice(selectedIndex, 1)[0]; + const idx = actualWinnersCount + i; + const pos = idx + 1; + const selectedParticipant = pool[idx]; const proofHash = generateWinnerProofHash(seed, snapshotHash, pos, selectedParticipant.platformUserId); reserveWinners.push({ position: pos, isReserve: true, participant: selectedParticipant, - selectionIndex: selectedIndex, + selectionIndex: idx, proofHash, }); reserveWinnerIds.push(selectedParticipant.platformUserId); @@ -96,19 +113,24 @@ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExe const drawId = 'draw_' + randomBytes(8).toString('hex'); const drawnAt = new Date().toISOString(); - // 5. Compute canonical auditHash - const auditHash = computeAuditHash({ + // 6. Compute deterministicProofHash (reproducible upon replay) + const deterministicProofHash = computeDeterministicProofHash({ algorithmVersion: ALGORITHM_VERSION_V1, - giveawayId, snapshotId: snapshot.id, - seed, participantsSnapshotHash: snapshotHash, conditionsHash, + seed, winnerIds, reserveWinnerIds, eligibleCount: eligible.length, + }); + + // 7. Compute auditEventHash (unique for this draw execution instance) + const auditEventHash = computeAuditEventHash({ + giveawayId, drawId, drawnAt, + deterministicProofHash, }); return { @@ -125,47 +147,69 @@ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExe participantsSnapshotHash: snapshotHash, conditionsHash, algorithmVersion: ALGORITHM_VERSION_V1, + deterministicProofHash, + auditEventHash, drawnAt, - auditHash, }; } /** - * Universal entrypoint (routes to active algorithm version V1) + * Universal entrypoint (routes to algorithm version) */ export function executeDeterministicDraw(params: DrawExecutionParams): DrawExecutionResult { return executeDeterministicDrawV1(params); } /** - * Re-runs draw algorithm on a snapshot to verify identical outcome + * Re-runs draw algorithm on a snapshot to verify identical outcome and hashes */ export function verifyDrawResult( snapshot: ParticipantSnapshotData, seed: string, claimedWinnersCount: number, claimedReserveCount: number, + claimedWinnerIds?: string[], + claimedDeterministicProofHash?: string, algorithmVersion: string = ALGORITHM_VERSION_V1 -): { winners: Winner[]; reserveWinners: Winner[]; winnerIds: string[]; reserveWinnerIds: string[]; auditHash: string } { +): VerificationResult { if (algorithmVersion !== ALGORITHM_VERSION_V1) { throw new Error(`Unsupported algorithm version for replay: ${algorithmVersion}`); } - const result = executeDeterministicDrawV1({ + const replayed = executeDeterministicDrawV1({ giveawayId: snapshot.giveawayId, snapshot, totalLoadedCount: snapshot.participantCount, winnersCount: claimedWinnersCount, reserveWinnersCount: claimedReserveCount, seed, - filterRules: {} as any, }); + const winnersMatch = claimedWinnerIds + ? JSON.stringify(replayed.winnerIds) === JSON.stringify(claimedWinnerIds) + : true; + + const deterministicProofHashMatch = claimedDeterministicProofHash + ? replayed.deterministicProofHash === claimedDeterministicProofHash + : true; + + const snapshotHashMatch = replayed.participantsSnapshotHash === snapshot.participantsSnapshotHash; + const conditionsHashMatch = replayed.conditionsHash === snapshot.conditionsHash; + + const verified = winnersMatch && deterministicProofHashMatch && snapshotHashMatch && conditionsHashMatch; + return { - winners: result.winners, - reserveWinners: result.reserveWinners, - winnerIds: result.winnerIds, - reserveWinnerIds: result.reserveWinnerIds, - auditHash: result.auditHash, + verified, + algorithmVersion: ALGORITHM_VERSION_V1, + winnersMatch, + snapshotHashMatch, + conditionsHashMatch, + deterministicProofHashMatch, + expectedWinners: replayed.winners, + expectedReserveWinners: replayed.reserveWinners, + expectedWinnerIds: replayed.winnerIds, + expectedReserveWinnerIds: replayed.reserveWinnerIds, + expectedDeterministicProofHash: replayed.deterministicProofHash, + actualDeterministicProofHash: claimedDeterministicProofHash || replayed.deterministicProofHash, }; } diff --git a/src/core/randomizer/hasher.ts b/src/core/randomizer/hasher.ts index 9b75bca..9cd1157 100644 --- a/src/core/randomizer/hasher.ts +++ b/src/core/randomizer/hasher.ts @@ -1,8 +1,18 @@ import { randomBytes } from 'crypto'; import { FilteredParticipant } from '../types/participant'; -import { computeParticipantsSnapshotHash, computeConditionsHash, computeAuditHash } from './canonical'; +import { + computeParticipantsSnapshotHash, + computeConditionsHash, + computeDeterministicProofHash, + computeAuditEventHash +} from './canonical'; -export { computeParticipantsSnapshotHash, computeConditionsHash, computeAuditHash }; +export { + computeParticipantsSnapshotHash, + computeConditionsHash, + computeDeterministicProofHash, + computeAuditEventHash +}; /** * Generates a cryptographically secure random seed (128-bit / 32 hex chars) using CSPRNG. diff --git a/src/core/types/audit.ts b/src/core/types/audit.ts index 2b41bfe..364156c 100644 --- a/src/core/types/audit.ts +++ b/src/core/types/audit.ts @@ -1,7 +1,8 @@ import { FilterRules } from './giveaway'; import { FilteredParticipant, Winner } from './participant'; -export const CURRENT_RANDOMIZER_ALGORITHM = 'HMAC_SHA256_FY_V1'; +export const ALGORITHM_HMAC_SHA256_FY_V1 = 'HMAC_SHA256_FY_V1'; +export const CURRENT_RANDOMIZER_ALGORITHM = ALGORITHM_HMAC_SHA256_FY_V1; export interface ParticipantSnapshotData { id: string; @@ -21,7 +22,7 @@ export interface DrawExecutionParams { winnersCount: number; reserveWinnersCount: number; seed: string; - filterRules: FilterRules; + filterRules?: FilterRules; } export interface DrawExecutionResult { @@ -38,8 +39,9 @@ export interface DrawExecutionResult { participantsSnapshotHash: string; conditionsHash: string; algorithmVersion: string; + deterministicProofHash: string; + auditEventHash: string; drawnAt: string; // ISO String - auditHash: string; } export interface AuditRecordData { @@ -50,7 +52,8 @@ export interface AuditRecordData { seed: string; participantsSnapshotHash: string; conditionsHash: string; - auditHash: string; + deterministicProofHash: string; + auditEventHash: string; winnerIds: string[]; reserveWinnerIds: string[]; eligibleCount: number; @@ -58,3 +61,18 @@ export interface AuditRecordData { drawnAt: string; verifiedAt: string; } + +export interface VerificationResult { + verified: boolean; + algorithmVersion: string; + winnersMatch: boolean; + snapshotHashMatch: boolean; + conditionsHashMatch: boolean; + deterministicProofHashMatch: boolean; + expectedWinners: Winner[]; + expectedReserveWinners: Winner[]; + expectedWinnerIds: string[]; + expectedReserveWinnerIds: string[]; + expectedDeterministicProofHash: string; + actualDeterministicProofHash: string; +} diff --git a/src/lib/giveaway-store.ts b/src/lib/giveaway-store.ts index 1408856..16523c5 100644 --- a/src/lib/giveaway-store.ts +++ b/src/lib/giveaway-store.ts @@ -1,13 +1,21 @@ import { IGiveawayRepository, GiveawayWithRelations, CreateGiveawayInput } from './repository/giveaway-repository'; import { PrismaGiveawayRepository } from './repository/prisma-repository'; import { MemoryGiveawayRepository } from './repository/memory-repository'; -import { FilterRules, GiveawayStatusType } from '../core/types/giveaway'; +import { FilterRules } from '../core/types/giveaway'; import { FilteredParticipant } from '../core/types/participant'; import { DrawExecutionResult, ParticipantSnapshotData } from '../core/types/audit'; export type StoredGiveaway = GiveawayWithRelations; -let activeRepository: IGiveawayRepository = new PrismaGiveawayRepository(); +// Select initial repository based on explicit STORAGE_DRIVER configuration +function createDefaultRepository(): IGiveawayRepository { + if (process.env.STORAGE_DRIVER === 'memory') { + return new MemoryGiveawayRepository(); + } + return new PrismaGiveawayRepository(); +} + +let activeRepository: IGiveawayRepository = createDefaultRepository(); export class GiveawayStore { /** @@ -21,41 +29,23 @@ export class GiveawayStore { return activeRepository; } + /** + * Reset repository to environment default + */ + static resetToDefault(): void { + activeRepository = createDefaultRepository(); + } + static async create(input: CreateGiveawayInput): Promise { - try { - return await activeRepository.createGiveaway(input); - } catch (err) { - if (activeRepository instanceof PrismaGiveawayRepository) { - console.warn('Prisma DB error, falling back to memory repository:', (err as Error).message); - activeRepository = new MemoryGiveawayRepository(); - return await activeRepository.createGiveaway(input); - } - throw err; - } + return await activeRepository.createGiveaway(input); } static async getById(id: string): Promise { - try { - return await activeRepository.getGiveawayById(id); - } catch (err) { - if (activeRepository instanceof PrismaGiveawayRepository) { - activeRepository = new MemoryGiveawayRepository(); - return await activeRepository.getGiveawayById(id); - } - throw err; - } + return await activeRepository.getGiveawayById(id); } static async listAll(): Promise { - try { - return await activeRepository.listGiveaways(); - } catch (err) { - if (activeRepository instanceof PrismaGiveawayRepository) { - activeRepository = new MemoryGiveawayRepository(); - return await activeRepository.listGiveaways(); - } - throw err; - } + return await activeRepository.listGiveaways(); } static async updateParticipants(id: string, participants: FilteredParticipant[]): Promise { diff --git a/src/lib/repository/memory-repository.ts b/src/lib/repository/memory-repository.ts index 11f2ff6..1829126 100644 --- a/src/lib/repository/memory-repository.ts +++ b/src/lib/repository/memory-repository.ts @@ -55,10 +55,22 @@ export class MemoryGiveawayRepository implements IGiveawayRepository { const snaps = this.snapshots.get(id) || []; const latest = snaps.length > 0 ? snaps[snaps.length - 1] : null; + let drawResult = gw.drawResult; + if (drawResult) { + // Strictly bind to the snapshot referenced by snapshotId + const boundSnapshot = snaps.find(s => s.id === drawResult?.snapshotId) || latest; + drawResult = { + ...drawResult, + participantsSnapshotHash: boundSnapshot?.participantsSnapshotHash || '', + conditionsHash: boundSnapshot?.conditionsHash || '', + }; + } + return { ...gw, snapshots: [...snaps], latestSnapshot: latest, + drawResult, }; } diff --git a/src/lib/repository/prisma-repository.ts b/src/lib/repository/prisma-repository.ts index cbc81ad..9bf2228 100644 --- a/src/lib/repository/prisma-repository.ts +++ b/src/lib/repository/prisma-repository.ts @@ -40,11 +40,19 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { })); const latestSnapshot = snapshots.length > 0 - ? snapshots.sort((a, b) => b.version - a.version)[0] + ? [...snapshots].sort((a, b) => b.version - a.version)[0] : null; let drawResult: DrawExecutionResult | null = null; if (raw.drawResult) { + // Strictly bind to the snapshot attached to drawResult + const boundSnapshot = raw.drawResult.snapshot + ? { + participantsSnapshotHash: raw.drawResult.snapshot.participantsSnapshotHash, + conditionsHash: raw.drawResult.snapshot.conditionsHash, + } + : snapshots.find(s => s.id === raw.drawResult.snapshotId) || latestSnapshot; + drawResult = { drawId: raw.drawResult.id, giveawayId: raw.drawResult.giveawayId, @@ -56,11 +64,12 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { totalEligibleCount: raw.drawResult.totalEligibleCount, totalLoadedCount: raw.drawResult.totalLoadedCount, seedUsed: raw.drawResult.seedUsed, - participantsSnapshotHash: latestSnapshot?.participantsSnapshotHash || '', - conditionsHash: latestSnapshot?.conditionsHash || '', + participantsSnapshotHash: boundSnapshot?.participantsSnapshotHash || '', + conditionsHash: boundSnapshot?.conditionsHash || '', algorithmVersion: raw.drawResult.algorithmVersion, + deterministicProofHash: raw.drawResult.deterministicProofHash, + auditEventHash: raw.drawResult.auditEventHash, drawnAt: raw.drawResult.drawnAt.toISOString(), - auditHash: raw.drawResult.auditHash, }; } @@ -113,7 +122,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { include: { participants: true, snapshots: true, - drawResult: true, + drawResult: { + include: { snapshot: true }, + }, }, }); @@ -128,7 +139,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { snapshots: { orderBy: { version: 'desc' }, }, - drawResult: true, + drawResult: { + include: { snapshot: true }, + }, }, }); @@ -143,7 +156,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { snapshots: { orderBy: { version: 'desc' }, }, - drawResult: true, + drawResult: { + include: { snapshot: true }, + }, }, }); @@ -162,7 +177,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { include: { participants: true, snapshots: true, - drawResult: true, + drawResult: { + include: { snapshot: true }, + }, }, }); @@ -176,10 +193,8 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { GiveawayFSM.assertCanModifyParticipants(current.status); await prisma.$transaction(async (tx) => { - // Clear previous live participants await tx.participant.deleteMany({ where: { giveawayId: id } }); - // Insert new participants if (participants.length > 0) { await tx.participant.createMany({ data: participants.map(p => ({ @@ -311,7 +326,8 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { totalLoadedCount: result.totalLoadedCount, seedUsed: result.seedUsed, algorithmVersion: result.algorithmVersion, - auditHash: result.auditHash, + deterministicProofHash: result.deterministicProofHash, + auditEventHash: result.auditEventHash, drawnAt: new Date(result.drawnAt), }, }); @@ -325,7 +341,8 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { seed: result.seedUsed, participantsSnapshotHash: result.participantsSnapshotHash, conditionsHash: result.conditionsHash, - auditHash: result.auditHash, + deterministicProofHash: result.deterministicProofHash, + auditEventHash: result.auditEventHash, winnerIds: result.winnerIds as any, reserveWinnerIds: result.reserveWinnerIds as any, eligibleCount: result.totalEligibleCount, diff --git a/tests/fisher-yates.test.ts b/tests/fisher-yates.test.ts new file mode 100644 index 0000000..cc918c3 --- /dev/null +++ b/tests/fisher-yates.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { executeDeterministicDrawV1, ALGORITHM_VERSION_V1 } from '../src/core/randomizer/deterministic'; +import { computeParticipantsSnapshotHash, computeConditionsHash } from '../src/core/randomizer/canonical'; +import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; +import { FilteredParticipant } from '../src/core/types/participant'; +import { ParticipantSnapshotData } from '../src/core/types/audit'; + +describe('True Partial Fisher-Yates (HMAC_SHA256_FY_V1)', () => { + function createTestSnapshot(size: number): ParticipantSnapshotData { + const participants: FilteredParticipant[] = Array.from({ length: size }, (_, i) => ({ + platformUserId: String(i + 1), + firstName: `User${i + 1}`, + lastName: `Surname${i + 1}`, + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + })); + + return { + id: `snap-fy-${size}`, + giveawayId: 'gw-fy-1', + version: 1, + createdAt: '2026-08-17T12:00:00.000Z', + eligibleParticipants: participants, + participantCount: size, + participantsSnapshotHash: computeParticipantsSnapshotHash(participants), + conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES), + }; + } + + it('should have algorithmVersion set strictly to HMAC_SHA256_FY_V1', () => { + expect(ALGORITHM_VERSION_V1).toBe('HMAC_SHA256_FY_V1'); + }); + + it('should select k distinct elements in 0..k-1 range with correct positions', () => { + const snapshot = createTestSnapshot(20); + const result = executeDeterministicDrawV1({ + giveawayId: 'gw-fy-1', + snapshot, + totalLoadedCount: 20, + winnersCount: 3, + reserveWinnersCount: 2, + seed: 'fy-test-seed-1', + }); + + expect(result.winners.length).toBe(3); + expect(result.reserveWinners.length).toBe(2); + + expect(result.winners[0].position).toBe(1); + expect(result.winners[1].position).toBe(2); + expect(result.winners[2].position).toBe(3); + + expect(result.reserveWinners[0].position).toBe(4); + expect(result.reserveWinners[1].position).toBe(5); + + const allChosen = [...result.winnerIds, ...result.reserveWinnerIds]; + const uniqueChosen = new Set(allChosen); + expect(uniqueChosen.size).toBe(5); + }); + + it('should produce invariant selection across 100 replays', () => { + const snapshot = createTestSnapshot(10); + const seed = 'deterministic-invariant-test-seed'; + + const baseline = executeDeterministicDrawV1({ + giveawayId: 'gw-fy-1', + snapshot, + totalLoadedCount: 10, + winnersCount: 2, + reserveWinnersCount: 1, + seed, + }); + + for (let i = 0; i < 100; i++) { + const current = executeDeterministicDrawV1({ + giveawayId: 'gw-fy-1', + snapshot, + totalLoadedCount: 10, + winnersCount: 2, + reserveWinnersCount: 1, + seed, + }); + + expect(current.winnerIds).toEqual(baseline.winnerIds); + expect(current.reserveWinnerIds).toEqual(baseline.reserveWinnerIds); + expect(current.deterministicProofHash).toBe(baseline.deterministicProofHash); + } + }); +}); diff --git a/tests/persistence.test.ts b/tests/persistence.test.ts index 99332d6..bf5b933 100644 --- a/tests/persistence.test.ts +++ b/tests/persistence.test.ts @@ -106,7 +106,8 @@ describe('Repository Persistence & Lifecycle Scenario', () => { expect(drawResult.winners.length).toBe(1); expect(drawResult.reserveWinners.length).toBe(1); - expect(drawResult.auditHash).toBeDefined(); + expect(drawResult.deterministicProofHash).toBeDefined(); + expect(drawResult.auditEventHash).toBeDefined(); // 5. Persist DrawResult and Audit const finishedGw = await repo.saveDrawResultAndAudit(gw.id, snapshot.id, drawResult); @@ -121,7 +122,8 @@ describe('Repository Persistence & Lifecycle Scenario', () => { expect(reloaded).not.toBeNull(); expect(reloaded?.status).toBe('DRAWN'); expect(reloaded?.snapshots.length).toBe(1); - expect(reloaded?.drawResult?.auditHash).toBe(drawResult.auditHash); + expect(reloaded?.drawResult?.deterministicProofHash).toBe(drawResult.deterministicProofHash); + expect(reloaded?.drawResult?.auditEventHash).toBe(drawResult.auditEventHash); expect(reloaded?.drawResult?.winnerIds).toEqual(drawResult.winnerIds); }); }); diff --git a/tests/proof-separation.test.ts b/tests/proof-separation.test.ts new file mode 100644 index 0000000..db4eec1 --- /dev/null +++ b/tests/proof-separation.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest'; +import { executeDeterministicDrawV1, verifyDrawResult } from '../src/core/randomizer/deterministic'; +import { computeParticipantsSnapshotHash, computeConditionsHash } from '../src/core/randomizer/canonical'; +import { FilterRules, DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; +import { FilteredParticipant } from '../src/core/types/participant'; +import { ParticipantSnapshotData } from '../src/core/types/audit'; + +describe('DeterministicProofHash & AuditEventHash Separation', () => { + const participants: FilteredParticipant[] = [ + { + platformUserId: '1', + firstName: 'Алексей', + lastName: 'Смирнов', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + { + platformUserId: '2', + firstName: 'Елена', + lastName: 'Кузнецова', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + { + platformUserId: '3', + firstName: 'Михаил', + lastName: 'Попов', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + ]; + + const snapshot: ParticipantSnapshotData = { + id: 'snap-proof-test-1', + giveawayId: 'gw-1', + version: 1, + createdAt: '2026-08-17T12:00:00.000Z', + eligibleParticipants: participants, + participantCount: 3, + participantsSnapshotHash: computeParticipantsSnapshotHash(participants), + conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES), + }; + + const seed = 'test-seed-separation-123'; + + it('should generate identical deterministicProofHash across multiple executions with same inputs', () => { + const draw1 = executeDeterministicDrawV1({ + giveawayId: 'gw-1', + snapshot, + totalLoadedCount: 3, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + const draw2 = executeDeterministicDrawV1({ + giveawayId: 'gw-1', + snapshot, + totalLoadedCount: 3, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + // deterministicProofHash must be 100% identical and reproducible + expect(draw1.deterministicProofHash).toBe(draw2.deterministicProofHash); + expect(draw1.winnerIds).toEqual(draw2.winnerIds); + expect(draw1.reserveWinnerIds).toEqual(draw2.reserveWinnerIds); + }); + + it('should generate distinct auditEventHash for separate draw events (different drawId/timestamp)', () => { + const drawA = executeDeterministicDrawV1({ + giveawayId: 'gw-1', + snapshot, + totalLoadedCount: 3, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + const drawB = executeDeterministicDrawV1({ + giveawayId: 'gw-1', + snapshot, + totalLoadedCount: 3, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + // auditEventHash must be unique per event execution + expect(drawA.drawId).not.toBe(drawB.drawId); + expect(drawA.auditEventHash).not.toBe(drawB.auditEventHash); + }); + + it('should verify that verifyDrawResult successfully matches deterministicProofHash upon independent replay', () => { + const originalDraw = executeDeterministicDrawV1({ + giveawayId: 'gw-1', + snapshot, + totalLoadedCount: 3, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + const verification = verifyDrawResult( + snapshot, + seed, + 1, + 1, + originalDraw.winnerIds, + originalDraw.deterministicProofHash + ); + + expect(verification.verified).toBe(true); + expect(verification.winnersMatch).toBe(true); + expect(verification.deterministicProofHashMatch).toBe(true); + expect(verification.snapshotHashMatch).toBe(true); + expect(verification.conditionsHashMatch).toBe(true); + expect(verification.expectedDeterministicProofHash).toBe(originalDraw.deterministicProofHash); + }); +}); diff --git a/tests/randomizer.test.ts b/tests/randomizer.test.ts index 0b8fdc7..6d4480c 100644 --- a/tests/randomizer.test.ts +++ b/tests/randomizer.test.ts @@ -72,6 +72,7 @@ describe('Deterministic Randomizer V1 (HMAC_SHA256_FY_V1)', () => { expect(draw1.algorithmVersion).toBe(ALGORITHM_VERSION_V1); expect(draw1.participantsSnapshotHash).toBe(draw2.participantsSnapshotHash); + expect(draw1.deterministicProofHash).toBe(draw2.deterministicProofHash); expect(draw1.winnerIds).toEqual(draw2.winnerIds); expect(draw1.reserveWinnerIds).toEqual(draw2.reserveWinnerIds); expect(draw1.winners.map(w => w.participant.platformUserId)).toEqual( @@ -161,11 +162,20 @@ describe('Deterministic Randomizer V1 (HMAC_SHA256_FY_V1)', () => { filterRules: DEFAULT_FILTER_RULES, }); - const verification = verifyDrawResult(snapshot, seed, 2, 2, ALGORITHM_VERSION_V1); + const verification = verifyDrawResult( + snapshot, + seed, + 2, + 2, + originalDraw.winnerIds, + originalDraw.deterministicProofHash, + ALGORITHM_VERSION_V1 + ); - expect(verification.winnerIds).toEqual(originalDraw.winnerIds); - expect(verification.reserveWinnerIds).toEqual(originalDraw.reserveWinnerIds); - expect(verification.winners.map(w => w.participant.platformUserId)).toEqual( + expect(verification.verified).toBe(true); + expect(verification.expectedWinnerIds).toEqual(originalDraw.winnerIds); + expect(verification.expectedReserveWinnerIds).toEqual(originalDraw.reserveWinnerIds); + expect(verification.expectedWinners.map(w => w.participant.platformUserId)).toEqual( originalDraw.winners.map(w => w.participant.platformUserId) ); }); diff --git a/tests/snapshot-binding.test.ts b/tests/snapshot-binding.test.ts new file mode 100644 index 0000000..572e707 --- /dev/null +++ b/tests/snapshot-binding.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository'; +import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; +import { FilteredParticipant } from '../src/core/types/participant'; +import { executeDeterministicDrawV1 } from '../src/core/randomizer/deterministic'; + +describe('DrawResult Snapshot Binding Regression Tests', () => { + const participantsV1: FilteredParticipant[] = [ + { + platformUserId: '101', + firstName: 'Пользователь', + lastName: 'Один', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + { + platformUserId: '102', + firstName: 'Пользователь', + lastName: 'Два', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + ]; + + const participantsV2: FilteredParticipant[] = [ + ...participantsV1, + { + platformUserId: '103', + firstName: 'Пользователь', + lastName: 'Три', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + ]; + + it('should guarantee that DrawResult remains bound to snapshot v1 even when another snapshot v2 exists', async () => { + const repo = new MemoryGiveawayRepository(); + + // 1. Create giveaway + const gw = await repo.createGiveaway({ + sourceUrl: 'https://vk.com/wall-100_200', + post: { + platform: 'VK', + ownerId: '-100', + postId: '200', + sourceUrl: 'https://vk.com/wall-100_200', + title: 'Тестовый розыгрыш', + text: 'Текст', + likesCount: 50, + commentsCount: 20, + repostsCount: 10, + }, + filterRules: DEFAULT_FILTER_RULES, + }); + + // 2. Create snapshot V1 + const snapshotV1 = await repo.createAndLockSnapshot(gw.id, participantsV1, DEFAULT_FILTER_RULES); + expect(snapshotV1.version).toBe(1); + const hashV1 = snapshotV1.participantsSnapshotHash; + + // 3. Unlock / simulate revision and create Snapshot V2 + await repo.updateStatus(gw.id, 'READY'); + const snapshotV2 = await repo.createAndLockSnapshot(gw.id, participantsV2, { + ...DEFAULT_FILTER_RULES, + requireComment: true, + }); + expect(snapshotV2.version).toBe(2); + expect(snapshotV2.participantsSnapshotHash).not.toBe(hashV1); + + // 4. Conduct Draw explicitly bound to snapshot V1 (e.g. historical draw verification) + const seed = 'test-snapshot-binding-seed'; + const drawResult = executeDeterministicDrawV1({ + giveawayId: gw.id, + snapshot: snapshotV1, + totalLoadedCount: 2, + winnersCount: 1, + reserveWinnersCount: 0, + seed, + }); + + await repo.saveDrawResultAndAudit(gw.id, snapshotV1.id, drawResult); + + // 5. Reload giveaway and assert that DrawResult references snapshot V1 and its hashes! + const reloaded = await repo.getGiveawayById(gw.id); + expect(reloaded?.drawResult?.snapshotId).toBe(snapshotV1.id); + expect(reloaded?.drawResult?.participantsSnapshotHash).toBe(hashV1); + expect(reloaded?.drawResult?.conditionsHash).toBe(snapshotV1.conditionsHash); + expect(reloaded?.drawResult?.deterministicProofHash).toBe(drawResult.deterministicProofHash); + expect(reloaded?.snapshots.length).toBe(2); + }); +}); diff --git a/tests/storage-driver.test.ts b/tests/storage-driver.test.ts new file mode 100644 index 0000000..b59557d --- /dev/null +++ b/tests/storage-driver.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { GiveawayStore } from '../src/lib/giveaway-store'; +import { PrismaGiveawayRepository } from '../src/lib/repository/prisma-repository'; +import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository'; +import { IGiveawayRepository } from '../src/lib/repository/giveaway-repository'; + +describe('Storage Driver Policy & No Silent Fallback', () => { + beforeEach(() => { + delete process.env.STORAGE_DRIVER; + GiveawayStore.resetToDefault(); + }); + + afterEach(() => { + delete process.env.STORAGE_DRIVER; + GiveawayStore.resetToDefault(); + }); + + it('should default to PrismaGiveawayRepository when STORAGE_DRIVER is not memory', () => { + const repo = GiveawayStore.getRepository(); + expect(repo).toBeInstanceOf(PrismaGiveawayRepository); + }); + + it('should use MemoryGiveawayRepository when STORAGE_DRIVER=memory is explicitly configured', () => { + process.env.STORAGE_DRIVER = 'memory'; + GiveawayStore.resetToDefault(); + + const repo = GiveawayStore.getRepository(); + expect(repo).toBeInstanceOf(MemoryGiveawayRepository); + }); + + it('should throw database errors explicitly without silently falling back to memory', async () => { + // Mock a failing Prisma repository + const failingDbRepo: IGiveawayRepository = { + createGiveaway: async () => { + throw new Error('P1001: Can\'t reach database server at `localhost:5432`'); + }, + getGiveawayById: async () => { + throw new Error('Database connection timeout'); + }, + listGiveaways: async () => { + throw new Error('Database connection failed'); + }, + updateStatus: async () => { throw new Error('DB error'); }, + saveParticipants: async () => { throw new Error('DB error'); }, + createAndLockSnapshot: async () => { throw new Error('DB error'); }, + getLatestSnapshot: async () => { throw new Error('DB error'); }, + saveDrawResultAndAudit: async () => { throw new Error('DB error'); }, + }; + + GiveawayStore.setRepository(failingDbRepo); + + // Assert that calling create throws the exact database error + await expect( + GiveawayStore.create({ + sourceUrl: 'https://vk.com/wall-1_1', + post: { + platform: 'VK', + ownerId: '-1', + postId: '1', + sourceUrl: 'https://vk.com/wall-1_1', + title: 'Test', + text: 'Text', + likesCount: 10, + commentsCount: 5, + repostsCount: 2, + }, + filterRules: {} as any, + }) + ).rejects.toThrow(/Can't reach database server/); + + // Assert active repository remains the failing one and did not silently switch to memory + expect(GiveawayStore.getRepository()).toBe(failingDbRepo); + }); +}); diff --git a/tests/verification-api.test.ts b/tests/verification-api.test.ts new file mode 100644 index 0000000..731875f --- /dev/null +++ b/tests/verification-api.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest'; +import { verifyDrawResult, executeDeterministicDrawV1 } from '../src/core/randomizer/deterministic'; +import { computeParticipantsSnapshotHash, computeConditionsHash } from '../src/core/randomizer/canonical'; +import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; +import { FilteredParticipant } from '../src/core/types/participant'; +import { ParticipantSnapshotData } from '../src/core/types/audit'; + +describe('Verification API Replay Engine', () => { + const participants: FilteredParticipant[] = [ + { + platformUserId: '10', + firstName: 'Победитель', + lastName: 'Один', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + { + platformUserId: '20', + firstName: 'Победитель', + lastName: 'Два', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + { + platformUserId: '30', + firstName: 'Победитель', + lastName: 'Три', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + ]; + + const snapshot: ParticipantSnapshotData = { + id: 'snap-verif-1', + giveawayId: 'gw-verif-1', + version: 1, + createdAt: '2026-08-17T12:00:00.000Z', + eligibleParticipants: participants, + participantCount: 3, + participantsSnapshotHash: computeParticipantsSnapshotHash(participants), + conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES), + }; + + const seed = 'verification-engine-seed'; + + it('should return verified: true when claimed winners and proof hash match replay', () => { + const draw = executeDeterministicDrawV1({ + giveawayId: 'gw-verif-1', + snapshot, + totalLoadedCount: 3, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + const result = verifyDrawResult( + snapshot, + seed, + 1, + 1, + draw.winnerIds, + draw.deterministicProofHash, + draw.algorithmVersion + ); + + expect(result.verified).toBe(true); + expect(result.winnersMatch).toBe(true); + expect(result.deterministicProofHashMatch).toBe(true); + expect(result.snapshotHashMatch).toBe(true); + expect(result.conditionsHashMatch).toBe(true); + expect(result.expectedWinnerIds).toEqual(draw.winnerIds); + }); + + it('should return verified: false and winnersMatch: false if claimed winners differ', () => { + const draw = executeDeterministicDrawV1({ + giveawayId: 'gw-verif-1', + snapshot, + totalLoadedCount: 3, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + const fakeWinnerIds = ['9999']; // Tampered winners + + const result = verifyDrawResult( + snapshot, + seed, + 1, + 1, + fakeWinnerIds, + draw.deterministicProofHash, + draw.algorithmVersion + ); + + expect(result.verified).toBe(false); + expect(result.winnersMatch).toBe(false); + }); + + it('should return verified: false if deterministicProofHash was tampered with', () => { + const draw = executeDeterministicDrawV1({ + giveawayId: 'gw-verif-1', + snapshot, + totalLoadedCount: 3, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + const fakeProofHash = '0000000000000000000000000000000000000000000000000000000000000000'; + + const result = verifyDrawResult( + snapshot, + seed, + 1, + 1, + draw.winnerIds, + fakeProofHash, + draw.algorithmVersion + ); + + expect(result.verified).toBe(false); + expect(result.deterministicProofHashMatch).toBe(false); + expect(result.winnersMatch).toBe(true); + }); +}); From 26e82fcf8d8e5855ac9e46fa8af21ca7daacc36f Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Tue, 18 Aug 2026 00:15:30 +0700 Subject: [PATCH 2/2] feat(core): Phase 1.3 Public Verification Integrity - drawId persistence, real participant snapshot and rules recalculation, reserve winners check, auditEventHash verification, strict snapshot lookup, and anti-tampering test suite --- prisma/schema.prisma | 2 + src/app/api/giveaways/[id]/verify/route.ts | 36 ++- src/app/giveaways/[id]/page.tsx | 17 +- src/core/randomizer/deterministic.ts | 129 +++++++--- src/core/types/audit.ts | 25 +- src/lib/repository/memory-repository.ts | 1 + src/lib/repository/prisma-repository.ts | 9 +- tests/proof-separation.test.ts | 27 +- tests/randomizer.test.ts | 20 +- tests/tampering-verification.test.ts | 278 +++++++++++++++++++++ tests/verification-api.test.ts | 64 +++-- 11 files changed, 511 insertions(+), 97 deletions(-) create mode 100644 tests/tampering-verification.test.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e934c8d..0aec025 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -89,6 +89,7 @@ model ParticipantSnapshot { version Int @default(1) createdAt DateTime @default(now()) eligibleParticipants Json // Canonical JSON array of FilteredParticipant + filterRulesSnapshot Json // Canonical JSON snapshot of FilterRules participantCount Int participantsSnapshotHash String conditionsHash String @@ -102,6 +103,7 @@ model ParticipantSnapshot { model DrawResult { id String @id @default(cuid()) + drawId String // Original domain drawId (e.g. draw_8f9102ab...) giveawayId String @unique giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade) snapshotId String @unique diff --git a/src/app/api/giveaways/[id]/verify/route.ts b/src/app/api/giveaways/[id]/verify/route.ts index 205f12d..14494a5 100644 --- a/src/app/api/giveaways/[id]/verify/route.ts +++ b/src/app/api/giveaways/[id]/verify/route.ts @@ -21,13 +21,14 @@ export async function GET( }, { status: 400 }); } - // Find the snapshot associated with this draw - const snapshot = giveaway.snapshots.find(s => s.id === drawResult.snapshotId) - || giveaway.latestSnapshot; + // Strict snapshot lookup: DO NOT fallback to latestSnapshot + const snapshot = giveaway.snapshots.find(s => s.id === drawResult.snapshotId); if (!snapshot) { return NextResponse.json({ - error: `Participant snapshot "${drawResult.snapshotId}" not found for this giveaway` + error: `Integrity Error: Participant snapshot "${drawResult.snapshotId}" referenced by draw does not exist in storage`, + verified: false, + snapshotFound: false, }, { status: 404 }); } @@ -35,29 +36,38 @@ export async function GET( const claimedReserveCount = drawResult.reserveWinners.length; // Run independent cryptographic replay verification - const verification = verifyDrawResult( + const verification = verifyDrawResult({ + giveawayId: id, + drawId: drawResult.drawId, + drawnAt: drawResult.drawnAt, snapshot, - drawResult.seedUsed, + seed: drawResult.seedUsed, claimedWinnersCount, claimedReserveCount, - drawResult.winnerIds, - drawResult.deterministicProofHash, - drawResult.algorithmVersion - ); + claimedWinnerIds: drawResult.winnerIds, + claimedReserveWinnerIds: drawResult.reserveWinnerIds, + claimedDeterministicProofHash: drawResult.deterministicProofHash, + claimedAuditEventHash: drawResult.auditEventHash, + algorithmVersion: drawResult.algorithmVersion, + }); return NextResponse.json({ verified: verification.verified, giveawayId: id, + drawId: drawResult.drawId, snapshotId: snapshot.id, algorithmVersion: verification.algorithmVersion, + algorithmSupported: verification.algorithmSupported, + participantsSnapshotIntegrity: verification.participantsSnapshotIntegrity, + conditionsIntegrity: verification.conditionsIntegrity, winnersMatch: verification.winnersMatch, - snapshotHashMatch: verification.snapshotHashMatch, - conditionsHashMatch: verification.conditionsHashMatch, + reserveWinnersMatch: verification.reserveWinnersMatch, deterministicProofHashMatch: verification.deterministicProofHashMatch, + auditEventHashMatch: verification.auditEventHashMatch, expectedWinnerIds: verification.expectedWinnerIds, expectedReserveWinnerIds: verification.expectedReserveWinnerIds, deterministicProofHash: verification.expectedDeterministicProofHash, - auditEventHash: drawResult.auditEventHash, + auditEventHash: verification.expectedAuditEventHash, drawnAt: drawResult.drawnAt, }); } catch (error: any) { diff --git a/src/app/giveaways/[id]/page.tsx b/src/app/giveaways/[id]/page.tsx index 71fbf8c..7e995f2 100644 --- a/src/app/giveaways/[id]/page.tsx +++ b/src/app/giveaways/[id]/page.tsx @@ -201,6 +201,7 @@ export default function GiveawayDetailPage() { onClick={() => { navigator.clipboard.writeText(JSON.stringify({ giveawayId: giveaway.id, + drawId: drawResult.drawId, snapshotId: drawResult.snapshotId, algorithmVersion: drawResult.algorithmVersion, seed: drawResult.seedUsed, @@ -243,16 +244,22 @@ export default function GiveawayDetailPage() { )} -
+
+
Целостность участников: {verificationResult.participantsSnapshotIntegrity ? 'ДА ✓' : 'НЕТ ✗'}
+
Целостность условий: {verificationResult.conditionsIntegrity ? 'ДА ✓' : 'НЕТ ✗'}
Победители совпали: {verificationResult.winnersMatch ? 'ДА ✓' : 'НЕТ ✗'}
-
Хеш слепка совпал: {verificationResult.snapshotHashMatch ? 'ДА ✓' : 'НЕТ ✗'}
-
Хеш условий совпал: {verificationResult.conditionsHashMatch ? 'ДА ✓' : 'НЕТ ✗'}
+
Резерв совпал: {verificationResult.reserveWinnersMatch ? 'ДА ✓' : 'НЕТ ✗'}
Proof Hash совпал: {verificationResult.deterministicProofHashMatch ? 'ДА ✓' : 'НЕТ ✗'}
+
Event Hash совпал: {verificationResult.auditEventHashMatch ? 'ДА ✓' : 'НЕТ ✗'}
)}
+
+ Draw ID: +

{drawResult.drawId}

+
Snapshot ID:

{drawResult.snapshotId}

@@ -269,6 +276,10 @@ export default function GiveawayDetailPage() { Snapshot Hash:

{drawResult.participantsSnapshotHash}

+
+ Conditions Hash: +

{drawResult.conditionsHash}

+
deterministicProofHash (воспроизводимый):

{drawResult.deterministicProofHash}

diff --git a/src/core/randomizer/deterministic.ts b/src/core/randomizer/deterministic.ts index e0b0aa7..611c3ec 100644 --- a/src/core/randomizer/deterministic.ts +++ b/src/core/randomizer/deterministic.ts @@ -5,10 +5,16 @@ import { DrawExecutionResult, ALGORITHM_HMAC_SHA256_FY_V1, ParticipantSnapshotData, + VerificationParams, VerificationResult } from '../types/audit'; import { DeterministicHmacStream } from './unbiased-sampler'; -import { computeDeterministicProofHash, computeAuditEventHash } from './canonical'; +import { + computeDeterministicProofHash, + computeAuditEventHash, + computeParticipantsSnapshotHash, + computeConditionsHash +} from './canonical'; export const ALGORITHM_VERSION_V1 = ALGORITHM_HMAC_SHA256_FY_V1; // 'HMAC_SHA256_FY_V1' @@ -161,55 +167,102 @@ export function executeDeterministicDraw(params: DrawExecutionParams): DrawExecu } /** - * Re-runs draw algorithm on a snapshot to verify identical outcome and hashes + * Re-runs draw algorithm on a snapshot to verify identical outcome and all cryptographic integrity hashes */ -export function verifyDrawResult( - snapshot: ParticipantSnapshotData, - seed: string, - claimedWinnersCount: number, - claimedReserveCount: number, - claimedWinnerIds?: string[], - claimedDeterministicProofHash?: string, - algorithmVersion: string = ALGORITHM_VERSION_V1 -): VerificationResult { - if (algorithmVersion !== ALGORITHM_VERSION_V1) { - throw new Error(`Unsupported algorithm version for replay: ${algorithmVersion}`); +export function verifyDrawResult(params: VerificationParams): VerificationResult { + const { + giveawayId, + drawId, + drawnAt, + snapshot, + seed, + claimedWinnersCount, + claimedReserveCount, + claimedWinnerIds = [], + claimedReserveWinnerIds = [], + claimedDeterministicProofHash = '', + claimedAuditEventHash = '', + algorithmVersion = ALGORITHM_VERSION_V1, + } = params; + + const algorithmSupported = (algorithmVersion === ALGORITHM_VERSION_V1); + + // 1. Check Participant Snapshot Integrity: real recalculation from array + const computedSnapshotHash = computeParticipantsSnapshotHash(snapshot.eligibleParticipants || []); + const participantsSnapshotIntegrity = (computedSnapshotHash === snapshot.participantsSnapshotHash); + + // 2. Check Conditions Integrity: real recalculation from filter rules snapshot + const computedConditionsHash = computeConditionsHash(snapshot.filterRulesSnapshot || {} as any); + const conditionsIntegrity = (computedConditionsHash === snapshot.conditionsHash); + + // 3. Replay randomizer + let replayed: DrawExecutionResult | null = null; + let replayError = false; + + try { + replayed = executeDeterministicDrawV1({ + giveawayId, + snapshot, + totalLoadedCount: snapshot.participantCount, + winnersCount: claimedWinnersCount, + reserveWinnersCount: claimedReserveCount, + seed, + }); + } catch { + replayError = true; } - const replayed = executeDeterministicDrawV1({ - giveawayId: snapshot.giveawayId, - snapshot, - totalLoadedCount: snapshot.participantCount, - winnersCount: claimedWinnersCount, - reserveWinnersCount: claimedReserveCount, - seed, - }); - - const winnersMatch = claimedWinnerIds + const winnersMatch = !replayError && replayed !== null ? JSON.stringify(replayed.winnerIds) === JSON.stringify(claimedWinnerIds) - : true; + : false; - const deterministicProofHashMatch = claimedDeterministicProofHash + const reserveWinnersMatch = !replayError && replayed !== null + ? JSON.stringify(replayed.reserveWinnerIds) === JSON.stringify(claimedReserveWinnerIds) + : false; + + const deterministicProofHashMatch = !replayError && replayed !== null ? replayed.deterministicProofHash === claimedDeterministicProofHash - : true; + : false; - const snapshotHashMatch = replayed.participantsSnapshotHash === snapshot.participantsSnapshotHash; - const conditionsHashMatch = replayed.conditionsHash === snapshot.conditionsHash; + // 4. Check Audit Event Hash: recomputed from giveawayId, drawId, drawnAt, and proof hash + const expectedAuditEventHash = (!replayError && replayed !== null) + ? computeAuditEventHash({ + giveawayId, + drawId, + drawnAt, + deterministicProofHash: replayed.deterministicProofHash, + }) + : ''; - const verified = winnersMatch && deterministicProofHashMatch && snapshotHashMatch && conditionsHashMatch; + const auditEventHashMatch = (expectedAuditEventHash === claimedAuditEventHash); + + const verified = ( + algorithmSupported && + participantsSnapshotIntegrity && + conditionsIntegrity && + winnersMatch && + reserveWinnersMatch && + deterministicProofHashMatch && + auditEventHashMatch + ); return { verified, - algorithmVersion: ALGORITHM_VERSION_V1, + algorithmVersion, + algorithmSupported, + participantsSnapshotIntegrity, + conditionsIntegrity, winnersMatch, - snapshotHashMatch, - conditionsHashMatch, + reserveWinnersMatch, deterministicProofHashMatch, - expectedWinners: replayed.winners, - expectedReserveWinners: replayed.reserveWinners, - expectedWinnerIds: replayed.winnerIds, - expectedReserveWinnerIds: replayed.reserveWinnerIds, - expectedDeterministicProofHash: replayed.deterministicProofHash, - actualDeterministicProofHash: claimedDeterministicProofHash || replayed.deterministicProofHash, + auditEventHashMatch, + expectedWinners: replayed?.winners || [], + expectedReserveWinners: replayed?.reserveWinners || [], + expectedWinnerIds: replayed?.winnerIds || [], + expectedReserveWinnerIds: replayed?.reserveWinnerIds || [], + expectedDeterministicProofHash: replayed?.deterministicProofHash || '', + expectedAuditEventHash, + actualDeterministicProofHash: claimedDeterministicProofHash, + actualAuditEventHash: claimedAuditEventHash, }; } diff --git a/src/core/types/audit.ts b/src/core/types/audit.ts index 364156c..aa897d2 100644 --- a/src/core/types/audit.ts +++ b/src/core/types/audit.ts @@ -10,6 +10,7 @@ export interface ParticipantSnapshotData { version: number; createdAt: string; eligibleParticipants: FilteredParticipant[]; + filterRulesSnapshot: FilterRules; participantCount: number; participantsSnapshotHash: string; conditionsHash: string; @@ -62,17 +63,37 @@ export interface AuditRecordData { verifiedAt: string; } +export interface VerificationParams { + giveawayId: string; + drawId: string; + drawnAt: string; + snapshot: ParticipantSnapshotData; + seed: string; + claimedWinnersCount: number; + claimedReserveCount: number; + claimedWinnerIds: string[]; + claimedReserveWinnerIds: string[]; + claimedDeterministicProofHash: string; + claimedAuditEventHash: string; + algorithmVersion?: string; +} + export interface VerificationResult { verified: boolean; algorithmVersion: string; + algorithmSupported: boolean; + participantsSnapshotIntegrity: boolean; + conditionsIntegrity: boolean; winnersMatch: boolean; - snapshotHashMatch: boolean; - conditionsHashMatch: boolean; + reserveWinnersMatch: boolean; deterministicProofHashMatch: boolean; + auditEventHashMatch: boolean; expectedWinners: Winner[]; expectedReserveWinners: Winner[]; expectedWinnerIds: string[]; expectedReserveWinnerIds: string[]; expectedDeterministicProofHash: string; + expectedAuditEventHash: string; actualDeterministicProofHash: string; + actualAuditEventHash: string; } diff --git a/src/lib/repository/memory-repository.ts b/src/lib/repository/memory-repository.ts index 1829126..0d0e549 100644 --- a/src/lib/repository/memory-repository.ts +++ b/src/lib/repository/memory-repository.ts @@ -131,6 +131,7 @@ export class MemoryGiveawayRepository implements IGiveawayRepository { version: newVersion, createdAt: new Date().toISOString(), eligibleParticipants: [...eligibleParticipants], + filterRulesSnapshot: { ...rules }, participantCount: eligibleParticipants.length, participantsSnapshotHash, conditionsHash, diff --git a/src/lib/repository/prisma-repository.ts b/src/lib/repository/prisma-repository.ts index 9bf2228..37dbf66 100644 --- a/src/lib/repository/prisma-repository.ts +++ b/src/lib/repository/prisma-repository.ts @@ -34,6 +34,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { version: s.version, createdAt: s.createdAt.toISOString(), eligibleParticipants: s.eligibleParticipants as FilteredParticipant[], + filterRulesSnapshot: s.filterRulesSnapshot as FilterRules, participantCount: s.participantCount, participantsSnapshotHash: s.participantsSnapshotHash, conditionsHash: s.conditionsHash, @@ -54,7 +55,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { : snapshots.find(s => s.id === raw.drawResult.snapshotId) || latestSnapshot; drawResult = { - drawId: raw.drawResult.id, + drawId: raw.drawResult.drawId || raw.drawResult.id, giveawayId: raw.drawResult.giveawayId, snapshotId: raw.drawResult.snapshotId, winners: raw.drawResult.winners as any, @@ -256,6 +257,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { giveawayId: id, version: newVersion, eligibleParticipants: eligibleParticipants as any, + filterRulesSnapshot: rules as any, participantCount: eligibleParticipants.length, participantsSnapshotHash, conditionsHash, @@ -276,6 +278,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { version: snapshot.version, createdAt: snapshot.createdAt.toISOString(), eligibleParticipants: eligibleParticipants, + filterRulesSnapshot: rules, participantCount: snapshot.participantCount, participantsSnapshotHash: snapshot.participantsSnapshotHash, conditionsHash: snapshot.conditionsHash, @@ -296,6 +299,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { version: snap.version, createdAt: snap.createdAt.toISOString(), eligibleParticipants: snap.eligibleParticipants as any, + filterRulesSnapshot: snap.filterRulesSnapshot as any, participantCount: snap.participantCount, participantsSnapshotHash: snap.participantsSnapshotHash, conditionsHash: snap.conditionsHash, @@ -313,9 +317,10 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { GiveawayFSM.assertCanDraw(current.status); await prisma.$transaction(async (tx) => { - // 1. Create DrawResult + // 1. Create DrawResult with original drawId await tx.drawResult.create({ data: { + drawId: result.drawId, giveawayId: id, snapshotId: snapshotId, winners: result.winners as any, diff --git a/tests/proof-separation.test.ts b/tests/proof-separation.test.ts index db4eec1..b7591b0 100644 --- a/tests/proof-separation.test.ts +++ b/tests/proof-separation.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { executeDeterministicDrawV1, verifyDrawResult } from '../src/core/randomizer/deterministic'; import { computeParticipantsSnapshotHash, computeConditionsHash } from '../src/core/randomizer/canonical'; -import { FilterRules, DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; +import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; import { FilteredParticipant } from '../src/core/types/participant'; import { ParticipantSnapshotData } from '../src/core/types/audit'; @@ -54,6 +54,7 @@ describe('DeterministicProofHash & AuditEventHash Separation', () => { version: 1, createdAt: '2026-08-17T12:00:00.000Z', eligibleParticipants: participants, + filterRulesSnapshot: { ...DEFAULT_FILTER_RULES }, participantCount: 3, participantsSnapshotHash: computeParticipantsSnapshotHash(participants), conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES), @@ -120,20 +121,28 @@ describe('DeterministicProofHash & AuditEventHash Separation', () => { seed, }); - const verification = verifyDrawResult( + const verification = verifyDrawResult({ + giveawayId: 'gw-1', + drawId: originalDraw.drawId, + drawnAt: originalDraw.drawnAt, snapshot, seed, - 1, - 1, - originalDraw.winnerIds, - originalDraw.deterministicProofHash - ); + claimedWinnersCount: 1, + claimedReserveCount: 1, + claimedWinnerIds: originalDraw.winnerIds, + claimedReserveWinnerIds: originalDraw.reserveWinnerIds, + claimedDeterministicProofHash: originalDraw.deterministicProofHash, + claimedAuditEventHash: originalDraw.auditEventHash, + algorithmVersion: originalDraw.algorithmVersion, + }); expect(verification.verified).toBe(true); expect(verification.winnersMatch).toBe(true); + expect(verification.reserveWinnersMatch).toBe(true); expect(verification.deterministicProofHashMatch).toBe(true); - expect(verification.snapshotHashMatch).toBe(true); - expect(verification.conditionsHashMatch).toBe(true); + expect(verification.auditEventHashMatch).toBe(true); + expect(verification.participantsSnapshotIntegrity).toBe(true); + expect(verification.conditionsIntegrity).toBe(true); expect(verification.expectedDeterministicProofHash).toBe(originalDraw.deterministicProofHash); }); }); diff --git a/tests/randomizer.test.ts b/tests/randomizer.test.ts index 6d4480c..f480eb0 100644 --- a/tests/randomizer.test.ts +++ b/tests/randomizer.test.ts @@ -29,6 +29,7 @@ function createMockSnapshot(count: number): ParticipantSnapshotData { version: 1, createdAt: new Date().toISOString(), eligibleParticipants: eligible, + filterRulesSnapshot: { ...DEFAULT_FILTER_RULES }, participantCount: count, participantsSnapshotHash: computeParticipantsSnapshotHash(eligible), conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES), @@ -162,15 +163,20 @@ describe('Deterministic Randomizer V1 (HMAC_SHA256_FY_V1)', () => { filterRules: DEFAULT_FILTER_RULES, }); - const verification = verifyDrawResult( + const verification = verifyDrawResult({ + giveawayId: 'gw-audit', + drawId: originalDraw.drawId, + drawnAt: originalDraw.drawnAt, snapshot, seed, - 2, - 2, - originalDraw.winnerIds, - originalDraw.deterministicProofHash, - ALGORITHM_VERSION_V1 - ); + claimedWinnersCount: 2, + claimedReserveCount: 2, + claimedWinnerIds: originalDraw.winnerIds, + claimedReserveWinnerIds: originalDraw.reserveWinnerIds, + claimedDeterministicProofHash: originalDraw.deterministicProofHash, + claimedAuditEventHash: originalDraw.auditEventHash, + algorithmVersion: ALGORITHM_VERSION_V1, + }); expect(verification.verified).toBe(true); expect(verification.expectedWinnerIds).toEqual(originalDraw.winnerIds); diff --git a/tests/tampering-verification.test.ts b/tests/tampering-verification.test.ts new file mode 100644 index 0000000..4ab46fc --- /dev/null +++ b/tests/tampering-verification.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect } from 'vitest'; +import { executeDeterministicDrawV1, verifyDrawResult } from '../src/core/randomizer/deterministic'; +import { computeParticipantsSnapshotHash, computeConditionsHash } from '../src/core/randomizer/canonical'; +import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; +import { FilteredParticipant } from '../src/core/types/participant'; +import { ParticipantSnapshotData } from '../src/core/types/audit'; + +describe('Public Verification Integrity & Anti-Tampering Test Suite', () => { + const originalParticipants: FilteredParticipant[] = [ + { + platformUserId: '101', + firstName: 'Иван', + lastName: 'Петров', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + { + platformUserId: '102', + firstName: 'Анна', + lastName: 'Сидорова', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + { + platformUserId: '103', + firstName: 'Сергей', + lastName: 'Смирнов', + source: 'LIKES', + liked: true, + commented: false, + commentsCount: 0, + reposted: false, + subscribed: true, + eligible: true, + exclusionReason: null, + }, + ]; + + const validSnapshot: ParticipantSnapshotData = { + id: 'snap-tamper-baseline', + giveawayId: 'gw-tamper-1', + version: 1, + createdAt: '2026-08-18T00:00:00.000Z', + eligibleParticipants: JSON.parse(JSON.stringify(originalParticipants)), + filterRulesSnapshot: { ...DEFAULT_FILTER_RULES }, + participantCount: 3, + participantsSnapshotHash: computeParticipantsSnapshotHash(originalParticipants), + conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES), + }; + + const seed = 'anti-tampering-master-seed-2026'; + + const baselineDraw = executeDeterministicDrawV1({ + giveawayId: 'gw-tamper-1', + snapshot: validSnapshot, + totalLoadedCount: 3, + winnersCount: 1, + reserveWinnersCount: 1, + seed, + }); + + it('1. Baseline check: authentic draw result must pass 100% verification', () => { + const result = verifyDrawResult({ + giveawayId: 'gw-tamper-1', + drawId: baselineDraw.drawId, + drawnAt: baselineDraw.drawnAt, + snapshot: validSnapshot, + seed, + claimedWinnersCount: 1, + claimedReserveCount: 1, + claimedWinnerIds: baselineDraw.winnerIds, + claimedReserveWinnerIds: baselineDraw.reserveWinnerIds, + claimedDeterministicProofHash: baselineDraw.deterministicProofHash, + claimedAuditEventHash: baselineDraw.auditEventHash, + algorithmVersion: baselineDraw.algorithmVersion, + }); + + expect(result.verified).toBe(true); + expect(result.participantsSnapshotIntegrity).toBe(true); + expect(result.conditionsIntegrity).toBe(true); + expect(result.winnersMatch).toBe(true); + expect(result.reserveWinnersMatch).toBe(true); + expect(result.deterministicProofHashMatch).toBe(true); + expect(result.auditEventHashMatch).toBe(true); + }); + + it('2. Tampering test: modifying a participant name/ID in snapshot must fail participantsSnapshotIntegrity', () => { + const tamperedSnapshot: ParticipantSnapshotData = { + ...validSnapshot, + eligibleParticipants: [ + { + ...originalParticipants[0], + firstName: 'Хакер', // Tampered name! + }, + originalParticipants[1], + originalParticipants[2], + ], + }; + + const result = verifyDrawResult({ + giveawayId: 'gw-tamper-1', + drawId: baselineDraw.drawId, + drawnAt: baselineDraw.drawnAt, + snapshot: tamperedSnapshot, + seed, + claimedWinnersCount: 1, + claimedReserveCount: 1, + claimedWinnerIds: baselineDraw.winnerIds, + claimedReserveWinnerIds: baselineDraw.reserveWinnerIds, + claimedDeterministicProofHash: baselineDraw.deterministicProofHash, + claimedAuditEventHash: baselineDraw.auditEventHash, + algorithmVersion: baselineDraw.algorithmVersion, + }); + + expect(result.verified).toBe(false); + expect(result.participantsSnapshotIntegrity).toBe(false); + }); + + it('3. Tampering test: modifying a filter rule in snapshot must fail conditionsIntegrity', () => { + const tamperedSnapshot: ParticipantSnapshotData = { + ...validSnapshot, + filterRulesSnapshot: { + ...DEFAULT_FILTER_RULES, + requireComment: true, // Tampered rule! + }, + }; + + const result = verifyDrawResult({ + giveawayId: 'gw-tamper-1', + drawId: baselineDraw.drawId, + drawnAt: baselineDraw.drawnAt, + snapshot: tamperedSnapshot, + seed, + claimedWinnersCount: 1, + claimedReserveCount: 1, + claimedWinnerIds: baselineDraw.winnerIds, + claimedReserveWinnerIds: baselineDraw.reserveWinnerIds, + claimedDeterministicProofHash: baselineDraw.deterministicProofHash, + claimedAuditEventHash: baselineDraw.auditEventHash, + algorithmVersion: baselineDraw.algorithmVersion, + }); + + expect(result.verified).toBe(false); + expect(result.conditionsIntegrity).toBe(false); + }); + + it('4. Tampering test: modifying winnerIds must fail winnersMatch', () => { + const result = verifyDrawResult({ + giveawayId: 'gw-tamper-1', + drawId: baselineDraw.drawId, + drawnAt: baselineDraw.drawnAt, + snapshot: validSnapshot, + seed, + claimedWinnersCount: 1, + claimedReserveCount: 1, + claimedWinnerIds: ['fake-winner-id-999'], // Tampered winner! + claimedReserveWinnerIds: baselineDraw.reserveWinnerIds, + claimedDeterministicProofHash: baselineDraw.deterministicProofHash, + claimedAuditEventHash: baselineDraw.auditEventHash, + algorithmVersion: baselineDraw.algorithmVersion, + }); + + expect(result.verified).toBe(false); + expect(result.winnersMatch).toBe(false); + }); + + it('5. Tampering test: modifying reserveWinnerIds must fail reserveWinnersMatch', () => { + const result = verifyDrawResult({ + giveawayId: 'gw-tamper-1', + drawId: baselineDraw.drawId, + drawnAt: baselineDraw.drawnAt, + snapshot: validSnapshot, + seed, + claimedWinnersCount: 1, + claimedReserveCount: 1, + claimedWinnerIds: baselineDraw.winnerIds, + claimedReserveWinnerIds: ['fake-reserve-id-777'], // Tampered reserve winner! + claimedDeterministicProofHash: baselineDraw.deterministicProofHash, + claimedAuditEventHash: baselineDraw.auditEventHash, + algorithmVersion: baselineDraw.algorithmVersion, + }); + + expect(result.verified).toBe(false); + expect(result.reserveWinnersMatch).toBe(false); + }); + + it('6. Tampering test: modifying seed must fail replay and deterministicProofHashMatch', () => { + const result = verifyDrawResult({ + giveawayId: 'gw-tamper-1', + drawId: baselineDraw.drawId, + drawnAt: baselineDraw.drawnAt, + snapshot: validSnapshot, + seed: 'tampered-seed-999', + claimedWinnersCount: 1, + claimedReserveCount: 1, + claimedWinnerIds: baselineDraw.winnerIds, + claimedReserveWinnerIds: baselineDraw.reserveWinnerIds, + claimedDeterministicProofHash: baselineDraw.deterministicProofHash, + claimedAuditEventHash: baselineDraw.auditEventHash, + algorithmVersion: baselineDraw.algorithmVersion, + }); + + expect(result.verified).toBe(false); + expect(result.deterministicProofHashMatch).toBe(false); + }); + + it('7. Tampering test: modifying drawId must fail auditEventHashMatch', () => { + const result = verifyDrawResult({ + giveawayId: 'gw-tamper-1', + drawId: 'tampered-draw-id-xyz', // Tampered drawId! + drawnAt: baselineDraw.drawnAt, + snapshot: validSnapshot, + seed, + claimedWinnersCount: 1, + claimedReserveCount: 1, + claimedWinnerIds: baselineDraw.winnerIds, + claimedReserveWinnerIds: baselineDraw.reserveWinnerIds, + claimedDeterministicProofHash: baselineDraw.deterministicProofHash, + claimedAuditEventHash: baselineDraw.auditEventHash, + algorithmVersion: baselineDraw.algorithmVersion, + }); + + expect(result.verified).toBe(false); + expect(result.auditEventHashMatch).toBe(false); + }); + + it('8. Tampering test: modifying drawnAt timestamp must fail auditEventHashMatch', () => { + const result = verifyDrawResult({ + giveawayId: 'gw-tamper-1', + drawId: baselineDraw.drawId, + drawnAt: '2026-08-19T00:00:00.000Z', // Tampered timestamp! + snapshot: validSnapshot, + seed, + claimedWinnersCount: 1, + claimedReserveCount: 1, + claimedWinnerIds: baselineDraw.winnerIds, + claimedReserveWinnerIds: baselineDraw.reserveWinnerIds, + claimedDeterministicProofHash: baselineDraw.deterministicProofHash, + claimedAuditEventHash: baselineDraw.auditEventHash, + algorithmVersion: baselineDraw.algorithmVersion, + }); + + expect(result.verified).toBe(false); + expect(result.auditEventHashMatch).toBe(false); + }); + + it('9. Tampering test: modifying deterministicProofHash directly must fail deterministicProofHashMatch', () => { + const result = verifyDrawResult({ + giveawayId: 'gw-tamper-1', + drawId: baselineDraw.drawId, + drawnAt: baselineDraw.drawnAt, + snapshot: validSnapshot, + seed, + claimedWinnersCount: 1, + claimedReserveCount: 1, + claimedWinnerIds: baselineDraw.winnerIds, + claimedReserveWinnerIds: baselineDraw.reserveWinnerIds, + claimedDeterministicProofHash: '1111111111111111111111111111111111111111111111111111111111111111', + claimedAuditEventHash: baselineDraw.auditEventHash, + algorithmVersion: baselineDraw.algorithmVersion, + }); + + expect(result.verified).toBe(false); + expect(result.deterministicProofHashMatch).toBe(false); + }); +}); diff --git a/tests/verification-api.test.ts b/tests/verification-api.test.ts index 731875f..04982c6 100644 --- a/tests/verification-api.test.ts +++ b/tests/verification-api.test.ts @@ -54,6 +54,7 @@ describe('Verification API Replay Engine', () => { version: 1, createdAt: '2026-08-17T12:00:00.000Z', eligibleParticipants: participants, + filterRulesSnapshot: { ...DEFAULT_FILTER_RULES }, participantCount: 3, participantsSnapshotHash: computeParticipantsSnapshotHash(participants), conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES), @@ -71,21 +72,28 @@ describe('Verification API Replay Engine', () => { seed, }); - const result = verifyDrawResult( + const result = verifyDrawResult({ + giveawayId: 'gw-verif-1', + drawId: draw.drawId, + drawnAt: draw.drawnAt, snapshot, seed, - 1, - 1, - draw.winnerIds, - draw.deterministicProofHash, - draw.algorithmVersion - ); + claimedWinnersCount: 1, + claimedReserveCount: 1, + claimedWinnerIds: draw.winnerIds, + claimedReserveWinnerIds: draw.reserveWinnerIds, + claimedDeterministicProofHash: draw.deterministicProofHash, + claimedAuditEventHash: draw.auditEventHash, + algorithmVersion: draw.algorithmVersion, + }); expect(result.verified).toBe(true); expect(result.winnersMatch).toBe(true); + expect(result.reserveWinnersMatch).toBe(true); expect(result.deterministicProofHashMatch).toBe(true); - expect(result.snapshotHashMatch).toBe(true); - expect(result.conditionsHashMatch).toBe(true); + expect(result.auditEventHashMatch).toBe(true); + expect(result.participantsSnapshotIntegrity).toBe(true); + expect(result.conditionsIntegrity).toBe(true); expect(result.expectedWinnerIds).toEqual(draw.winnerIds); }); @@ -101,15 +109,20 @@ describe('Verification API Replay Engine', () => { const fakeWinnerIds = ['9999']; // Tampered winners - const result = verifyDrawResult( + const result = verifyDrawResult({ + giveawayId: 'gw-verif-1', + drawId: draw.drawId, + drawnAt: draw.drawnAt, snapshot, seed, - 1, - 1, - fakeWinnerIds, - draw.deterministicProofHash, - draw.algorithmVersion - ); + claimedWinnersCount: 1, + claimedReserveCount: 1, + claimedWinnerIds: fakeWinnerIds, + claimedReserveWinnerIds: draw.reserveWinnerIds, + claimedDeterministicProofHash: draw.deterministicProofHash, + claimedAuditEventHash: draw.auditEventHash, + algorithmVersion: draw.algorithmVersion, + }); expect(result.verified).toBe(false); expect(result.winnersMatch).toBe(false); @@ -127,15 +140,20 @@ describe('Verification API Replay Engine', () => { const fakeProofHash = '0000000000000000000000000000000000000000000000000000000000000000'; - const result = verifyDrawResult( + const result = verifyDrawResult({ + giveawayId: 'gw-verif-1', + drawId: draw.drawId, + drawnAt: draw.drawnAt, snapshot, seed, - 1, - 1, - draw.winnerIds, - fakeProofHash, - draw.algorithmVersion - ); + claimedWinnersCount: 1, + claimedReserveCount: 1, + claimedWinnerIds: draw.winnerIds, + claimedReserveWinnerIds: draw.reserveWinnerIds, + claimedDeterministicProofHash: fakeProofHash, + claimedAuditEventHash: draw.auditEventHash, + algorithmVersion: draw.algorithmVersion, + }); expect(result.verified).toBe(false); expect(result.deterministicProofHashMatch).toBe(false);