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

This commit is contained in:
Ochenstarik 2026-08-17 23:55:01 +07:00
parent 0fb5fe3f8d
commit 1bc6650041
19 changed files with 1018 additions and 152 deletions

61
.github/workflows/ci.yml vendored Normal file
View file

@ -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"

View file

@ -101,38 +101,40 @@ model ParticipantSnapshot {
} }
model DrawResult { model DrawResult {
id String @id @default(cuid()) id String @id @default(cuid())
giveawayId String @unique giveawayId String @unique
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade) giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
snapshotId String @unique snapshotId String @unique
snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict) snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict)
winners Json // Array of Winner entities winners Json // Array of Winner entities
reserveWinners Json // Array of Winner entities reserveWinners Json // Array of Winner entities
winnerIds Json // string[] winnerIds Json // string[]
reserveWinnerIds Json // string[] reserveWinnerIds Json // string[]
totalEligibleCount Int totalEligibleCount Int
totalLoadedCount Int totalLoadedCount Int
seedUsed String seedUsed String
algorithmVersion String @default("HMAC_SHA256_FY_V1") algorithmVersion String @default("HMAC_SHA256_FY_V1")
auditHash String deterministicProofHash String
drawnAt DateTime @default(now()) auditEventHash String
drawnAt DateTime @default(now())
} }
model AuditRecord { model AuditRecord {
id String @id @default(cuid()) id String @id @default(cuid())
giveawayId String @unique giveawayId String @unique
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade) giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
snapshotId String @unique snapshotId String @unique
snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict) snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict)
algorithmVersion String @default("HMAC_SHA256_FY_V1") algorithmVersion String @default("HMAC_SHA256_FY_V1")
seed String seed String
participantsSnapshotHash String participantsSnapshotHash String
conditionsHash String conditionsHash String
auditHash String deterministicProofHash String
winnerIds Json // string[] auditEventHash String
reserveWinnerIds Json // string[] winnerIds Json // string[]
eligibleCount Int reserveWinnerIds Json // string[]
drawId String eligibleCount Int
drawnAt DateTime @default(now()) drawId String
verifiedAt DateTime @default(now()) drawnAt DateTime @default(now())
verifiedAt DateTime @default(now())
} }

View file

@ -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 });
}
}

View file

@ -12,7 +12,8 @@ import {
Check, Check,
Calendar, Calendar,
RefreshCw, RefreshCw,
Lock CheckCircle2,
AlertTriangle
} from 'lucide-react'; } from 'lucide-react';
import { StoredGiveaway } from '@/lib/giveaway-store'; import { StoredGiveaway } from '@/lib/giveaway-store';
@ -24,6 +25,8 @@ export default function GiveawayDetailPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [verifying, setVerifying] = useState(false);
const [verificationResult, setVerificationResult] = useState<any | null>(null);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@ -43,6 +46,20 @@ export default function GiveawayDetailPage() {
fetchGw(); fetchGw();
}, [id]); }, [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) { if (loading) {
return ( return (
<div className="py-20 text-center text-slate-400 text-sm"> <div className="py-20 text-center text-slate-400 text-sm">
@ -64,7 +81,6 @@ export default function GiveawayDetailPage() {
} }
const drawResult = giveaway.drawResult; const drawResult = giveaway.drawResult;
const snapshot = giveaway.latestSnapshot;
return ( return (
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
@ -167,35 +183,75 @@ export default function GiveawayDetailPage() {
{/* Provably Fair Audit Trail */} {/* Provably Fair Audit Trail */}
<div className="pt-4 border-t border-slate-800 space-y-3"> <div className="pt-4 border-t border-slate-800 space-y-3">
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-2 text-emerald-400 text-xs font-semibold"> <div className="flex items-center gap-2 text-emerald-400 text-xs font-semibold">
<ShieldCheck className="w-4 h-4" /> <ShieldCheck className="w-4 h-4" />
Публичный криптографический аудит (Provably Fair) Публичный криптографический аудит (Provably Fair)
</div> </div>
<button <div className="flex items-center gap-2">
onClick={() => { <button
navigator.clipboard.writeText(JSON.stringify({ onClick={handleVerify}
giveawayId: giveaway.id, disabled={verifying}
snapshotId: drawResult.snapshotId, className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5"
algorithmVersion: drawResult.algorithmVersion, >
seed: drawResult.seedUsed, <RefreshCw className={`w-3.5 h-3.5 ${verifying ? 'animate-spin' : ''}`} />
participantsSnapshotHash: drawResult.participantsSnapshotHash, Верифицировать результат
conditionsHash: drawResult.conditionsHash, </button>
auditHash: drawResult.auditHash, <button
winnerIds: drawResult.winnerIds, onClick={() => {
reserveWinnerIds: drawResult.reserveWinnerIds, navigator.clipboard.writeText(JSON.stringify({
drawnAt: drawResult.drawnAt, giveawayId: giveaway.id,
}, null, 2)); snapshotId: drawResult.snapshotId,
setCopied(true); algorithmVersion: drawResult.algorithmVersion,
setTimeout(() => setCopied(false), 2000); seed: drawResult.seedUsed,
}} participantsSnapshotHash: drawResult.participantsSnapshotHash,
className="text-xs text-slate-400 hover:text-white flex items-center gap-1" conditionsHash: drawResult.conditionsHash,
> deterministicProofHash: drawResult.deterministicProofHash,
{copied ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />} auditEventHash: drawResult.auditEventHash,
{copied ? 'Скопировано' : 'JSON аудита'} winnerIds: drawResult.winnerIds,
</button> reserveWinnerIds: drawResult.reserveWinnerIds,
drawnAt: drawResult.drawnAt,
}, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-xs font-medium transition-colors flex items-center gap-1"
>
{copied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? 'Скопировано' : 'JSON'}
</button>
</div>
</div> </div>
{/* Live Verification Banner if clicked */}
{verificationResult && (
<div className={`p-4 rounded-xl border text-xs space-y-1.5 ${
verificationResult.verified
? 'bg-emerald-950/40 border-emerald-500/40 text-emerald-300'
: 'bg-rose-950/40 border-rose-500/40 text-rose-300'
}`}>
<div className="flex items-center gap-2 font-bold text-sm">
{verificationResult.verified ? (
<>
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
Результат 100% подтвержден и математически доказуем!
</>
) : (
<>
<AlertTriangle className="w-4 h-4 text-rose-400" />
Несоответствие верификации!
</>
)}
</div>
<div className="grid grid-cols-2 gap-2 pt-1 font-mono text-[11px]">
<div>Победители совпали: {verificationResult.winnersMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
<div>Хеш слепка совпал: {verificationResult.snapshotHashMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
<div>Хеш условий совпал: {verificationResult.conditionsHashMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
<div>Proof Hash совпал: {verificationResult.deterministicProofHashMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800"> <div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-slate-400">Snapshot ID:</span> <span className="text-slate-400">Snapshot ID:</span>
@ -214,8 +270,12 @@ export default function GiveawayDetailPage() {
<p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p> <p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p>
</div> </div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 sm:col-span-2"> <div className="p-3 bg-slate-950 rounded-xl border border-slate-800 sm:col-span-2">
<span className="text-slate-400">Канонический auditHash:</span> <span className="text-slate-400">deterministicProofHash (воспроизводимый):</span>
<p className="font-mono text-indigo-300 break-all">{drawResult.auditHash}</p> <p className="font-mono text-indigo-300 break-all">{drawResult.deterministicProofHash}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 sm:col-span-2">
<span className="text-slate-400">auditEventHash (уникальный для события):</span>
<p className="font-mono text-slate-400 break-all">{drawResult.auditEventHash}</p>
</div> </div>
</div> </div>
</div> </div>

View file

@ -410,7 +410,7 @@ export default function NewGiveawayWizardPage() {
</div> </div>
</label> </label>
{/* Condition: Subscription (Active & Supported) */} {/* Condition: Subscription */}
<label className="flex items-start gap-3 p-4 rounded-xl bg-slate-950 border border-slate-800 hover:border-slate-700 cursor-pointer transition-colors"> <label className="flex items-start gap-3 p-4 rounded-xl bg-slate-950 border border-slate-800 hover:border-slate-700 cursor-pointer transition-colors">
<input <input
type="checkbox" type="checkbox"
@ -451,7 +451,7 @@ export default function NewGiveawayWizardPage() {
</div> </div>
</label> </label>
{/* Condition: Repost (Explicitly Marked Unsupported by Capability) */} {/* Condition: Repost (Disabled by capability) */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-slate-950/40 border border-slate-800/50 opacity-60 cursor-not-allowed"> <div className="flex items-start gap-3 p-4 rounded-xl bg-slate-950/40 border border-slate-800/50 opacity-60 cursor-not-allowed">
<input <input
type="checkbox" type="checkbox"
@ -689,7 +689,7 @@ export default function NewGiveawayWizardPage() {
<div> <div>
<h2 className="text-xl font-bold text-white mb-1">Шаг 4: Настройки жеребьевки</h2> <h2 className="text-xl font-bold text-white mb-1">Шаг 4: Настройки жеребьевки</h2>
<p className="text-xs sm:text-sm text-slate-400"> <p className="text-xs sm:text-sm text-slate-400">
Слепок участников зафиксирован (Статус: <span className="text-emerald-400 font-mono">SNAPSHOT_LOCKED</span>). Алгоритм: <span className="text-blue-400 font-mono">HMAC_SHA256_FY_V1</span> Слепок зафиксирован (<span className="text-emerald-400 font-mono">SNAPSHOT_LOCKED</span>). Алгоритм: <span className="text-amber-400 font-mono">HMAC_SHA256_FY_V1</span>
</p> </p>
</div> </div>
@ -926,8 +926,13 @@ export default function NewGiveawayWizardPage() {
</div> </div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 space-y-1 sm:col-span-2"> <div className="p-3 bg-slate-950 rounded-xl border border-slate-800 space-y-1 sm:col-span-2">
<span className="text-slate-400 font-medium">Канонический auditHash:</span> <span className="text-slate-400 font-medium">deterministicProofHash (воспроизводимый):</span>
<p className="font-mono text-indigo-300 break-all">{drawResult.auditHash}</p> <p className="font-mono text-indigo-300 break-all">{drawResult.deterministicProofHash}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 space-y-1 sm:col-span-2">
<span className="text-slate-400 font-medium">auditEventHash (уникальный для события):</span>
<p className="font-mono text-slate-400 break-all">{drawResult.auditEventHash}</p>
</div> </div>
</div> </div>

View file

@ -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; algorithmVersion: string;
giveawayId: string;
snapshotId: string; snapshotId: string;
seed: string;
participantsSnapshotHash: string; participantsSnapshotHash: string;
conditionsHash: string; conditionsHash: string;
seed: string;
winnerIds: string[]; winnerIds: string[];
reserveWinnerIds: string[]; reserveWinnerIds: string[];
eligibleCount: number; eligibleCount: number;
drawId: string; }): string {
drawnAt: 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 { }): string {
return sha256(canonicalStringify(data)); return sha256(canonicalStringify(data));
} }

View file

@ -1,10 +1,16 @@
import { createHash, randomBytes } from 'crypto'; import { createHash, randomBytes } from 'crypto';
import { FilteredParticipant, Winner } from '../types/participant'; 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 { 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 * 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) * Executes true partial Fisher-Yates shuffle V1 (HMAC_SHA256_FY_V1)
* with unbiased rejection sampling. * with in-place swap and unbiased rejection sampling.
*/ */
export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExecutionResult { export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExecutionResult {
const { const {
@ -40,7 +46,7 @@ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExe
throw new Error('Cannot conduct draw with 0 eligible participants in snapshot'); 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) => const pool = [...eligible].sort((a, b) =>
a.platformUserId.localeCompare(b.platformUserId) a.platformUserId.localeCompare(b.platformUserId)
); );
@ -55,39 +61,50 @@ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExe
const actualWinnersCount = Math.min(winnersCount, totalNeeded); const actualWinnersCount = Math.min(winnersCount, totalNeeded);
const actualReserveCount = Math.max(0, totalNeeded - actualWinnersCount); 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 winners: Winner[] = [];
const reserveWinners: Winner[] = []; const reserveWinners: Winner[] = [];
const winnerIds: string[] = []; const winnerIds: string[] = [];
const reserveWinnerIds: 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++) { for (let i = 0; i < actualWinnersCount; i++) {
const selectedIndex = stream.sampleUnbiasedIndex(pool.length); const selectedParticipant = pool[i];
const selectedParticipant = pool.splice(selectedIndex, 1)[0];
const proofHash = generateWinnerProofHash(seed, snapshotHash, i + 1, selectedParticipant.platformUserId); const proofHash = generateWinnerProofHash(seed, snapshotHash, i + 1, selectedParticipant.platformUserId);
winners.push({ winners.push({
position: i + 1, position: i + 1,
isReserve: false, isReserve: false,
participant: selectedParticipant, participant: selectedParticipant,
selectionIndex: selectedIndex, selectionIndex: i,
proofHash, proofHash,
}); });
winnerIds.push(selectedParticipant.platformUserId); winnerIds.push(selectedParticipant.platformUserId);
} }
// 4. Select Reserve Winners // 5. Map Reserve Winners from pool[actualWinnersCount ... totalNeeded - 1]
for (let i = 0; i < actualReserveCount; i++) { for (let i = 0; i < actualReserveCount; i++) {
const pos = winners.length + i + 1; const idx = actualWinnersCount + i;
const selectedIndex = stream.sampleUnbiasedIndex(pool.length); const pos = idx + 1;
const selectedParticipant = pool.splice(selectedIndex, 1)[0]; const selectedParticipant = pool[idx];
const proofHash = generateWinnerProofHash(seed, snapshotHash, pos, selectedParticipant.platformUserId); const proofHash = generateWinnerProofHash(seed, snapshotHash, pos, selectedParticipant.platformUserId);
reserveWinners.push({ reserveWinners.push({
position: pos, position: pos,
isReserve: true, isReserve: true,
participant: selectedParticipant, participant: selectedParticipant,
selectionIndex: selectedIndex, selectionIndex: idx,
proofHash, proofHash,
}); });
reserveWinnerIds.push(selectedParticipant.platformUserId); reserveWinnerIds.push(selectedParticipant.platformUserId);
@ -96,19 +113,24 @@ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExe
const drawId = 'draw_' + randomBytes(8).toString('hex'); const drawId = 'draw_' + randomBytes(8).toString('hex');
const drawnAt = new Date().toISOString(); const drawnAt = new Date().toISOString();
// 5. Compute canonical auditHash // 6. Compute deterministicProofHash (reproducible upon replay)
const auditHash = computeAuditHash({ const deterministicProofHash = computeDeterministicProofHash({
algorithmVersion: ALGORITHM_VERSION_V1, algorithmVersion: ALGORITHM_VERSION_V1,
giveawayId,
snapshotId: snapshot.id, snapshotId: snapshot.id,
seed,
participantsSnapshotHash: snapshotHash, participantsSnapshotHash: snapshotHash,
conditionsHash, conditionsHash,
seed,
winnerIds, winnerIds,
reserveWinnerIds, reserveWinnerIds,
eligibleCount: eligible.length, eligibleCount: eligible.length,
});
// 7. Compute auditEventHash (unique for this draw execution instance)
const auditEventHash = computeAuditEventHash({
giveawayId,
drawId, drawId,
drawnAt, drawnAt,
deterministicProofHash,
}); });
return { return {
@ -125,47 +147,69 @@ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExe
participantsSnapshotHash: snapshotHash, participantsSnapshotHash: snapshotHash,
conditionsHash, conditionsHash,
algorithmVersion: ALGORITHM_VERSION_V1, algorithmVersion: ALGORITHM_VERSION_V1,
deterministicProofHash,
auditEventHash,
drawnAt, drawnAt,
auditHash,
}; };
} }
/** /**
* Universal entrypoint (routes to active algorithm version V1) * Universal entrypoint (routes to algorithm version)
*/ */
export function executeDeterministicDraw(params: DrawExecutionParams): DrawExecutionResult { export function executeDeterministicDraw(params: DrawExecutionParams): DrawExecutionResult {
return executeDeterministicDrawV1(params); 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( export function verifyDrawResult(
snapshot: ParticipantSnapshotData, snapshot: ParticipantSnapshotData,
seed: string, seed: string,
claimedWinnersCount: number, claimedWinnersCount: number,
claimedReserveCount: number, claimedReserveCount: number,
claimedWinnerIds?: string[],
claimedDeterministicProofHash?: string,
algorithmVersion: string = ALGORITHM_VERSION_V1 algorithmVersion: string = ALGORITHM_VERSION_V1
): { winners: Winner[]; reserveWinners: Winner[]; winnerIds: string[]; reserveWinnerIds: string[]; auditHash: string } { ): VerificationResult {
if (algorithmVersion !== ALGORITHM_VERSION_V1) { if (algorithmVersion !== ALGORITHM_VERSION_V1) {
throw new Error(`Unsupported algorithm version for replay: ${algorithmVersion}`); throw new Error(`Unsupported algorithm version for replay: ${algorithmVersion}`);
} }
const result = executeDeterministicDrawV1({ const replayed = executeDeterministicDrawV1({
giveawayId: snapshot.giveawayId, giveawayId: snapshot.giveawayId,
snapshot, snapshot,
totalLoadedCount: snapshot.participantCount, totalLoadedCount: snapshot.participantCount,
winnersCount: claimedWinnersCount, winnersCount: claimedWinnersCount,
reserveWinnersCount: claimedReserveCount, reserveWinnersCount: claimedReserveCount,
seed, 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 { return {
winners: result.winners, verified,
reserveWinners: result.reserveWinners, algorithmVersion: ALGORITHM_VERSION_V1,
winnerIds: result.winnerIds, winnersMatch,
reserveWinnerIds: result.reserveWinnerIds, snapshotHashMatch,
auditHash: result.auditHash, conditionsHashMatch,
deterministicProofHashMatch,
expectedWinners: replayed.winners,
expectedReserveWinners: replayed.reserveWinners,
expectedWinnerIds: replayed.winnerIds,
expectedReserveWinnerIds: replayed.reserveWinnerIds,
expectedDeterministicProofHash: replayed.deterministicProofHash,
actualDeterministicProofHash: claimedDeterministicProofHash || replayed.deterministicProofHash,
}; };
} }

View file

@ -1,8 +1,18 @@
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { FilteredParticipant } from '../types/participant'; 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. * Generates a cryptographically secure random seed (128-bit / 32 hex chars) using CSPRNG.

View file

@ -1,7 +1,8 @@
import { FilterRules } from './giveaway'; import { FilterRules } from './giveaway';
import { FilteredParticipant, Winner } from './participant'; 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 { export interface ParticipantSnapshotData {
id: string; id: string;
@ -21,7 +22,7 @@ export interface DrawExecutionParams {
winnersCount: number; winnersCount: number;
reserveWinnersCount: number; reserveWinnersCount: number;
seed: string; seed: string;
filterRules: FilterRules; filterRules?: FilterRules;
} }
export interface DrawExecutionResult { export interface DrawExecutionResult {
@ -38,8 +39,9 @@ export interface DrawExecutionResult {
participantsSnapshotHash: string; participantsSnapshotHash: string;
conditionsHash: string; conditionsHash: string;
algorithmVersion: string; algorithmVersion: string;
deterministicProofHash: string;
auditEventHash: string;
drawnAt: string; // ISO String drawnAt: string; // ISO String
auditHash: string;
} }
export interface AuditRecordData { export interface AuditRecordData {
@ -50,7 +52,8 @@ export interface AuditRecordData {
seed: string; seed: string;
participantsSnapshotHash: string; participantsSnapshotHash: string;
conditionsHash: string; conditionsHash: string;
auditHash: string; deterministicProofHash: string;
auditEventHash: string;
winnerIds: string[]; winnerIds: string[];
reserveWinnerIds: string[]; reserveWinnerIds: string[];
eligibleCount: number; eligibleCount: number;
@ -58,3 +61,18 @@ export interface AuditRecordData {
drawnAt: string; drawnAt: string;
verifiedAt: 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;
}

View file

@ -1,13 +1,21 @@
import { IGiveawayRepository, GiveawayWithRelations, CreateGiveawayInput } from './repository/giveaway-repository'; import { IGiveawayRepository, GiveawayWithRelations, CreateGiveawayInput } from './repository/giveaway-repository';
import { PrismaGiveawayRepository } from './repository/prisma-repository'; import { PrismaGiveawayRepository } from './repository/prisma-repository';
import { MemoryGiveawayRepository } from './repository/memory-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 { FilteredParticipant } from '../core/types/participant';
import { DrawExecutionResult, ParticipantSnapshotData } from '../core/types/audit'; import { DrawExecutionResult, ParticipantSnapshotData } from '../core/types/audit';
export type StoredGiveaway = GiveawayWithRelations; 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 { export class GiveawayStore {
/** /**
@ -21,41 +29,23 @@ export class GiveawayStore {
return activeRepository; return activeRepository;
} }
/**
* Reset repository to environment default
*/
static resetToDefault(): void {
activeRepository = createDefaultRepository();
}
static async create(input: CreateGiveawayInput): Promise<StoredGiveaway> { static async create(input: CreateGiveawayInput): Promise<StoredGiveaway> {
try { return await activeRepository.createGiveaway(input);
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;
}
} }
static async getById(id: string): Promise<StoredGiveaway | null> { static async getById(id: string): Promise<StoredGiveaway | null> {
try { return await activeRepository.getGiveawayById(id);
return await activeRepository.getGiveawayById(id);
} catch (err) {
if (activeRepository instanceof PrismaGiveawayRepository) {
activeRepository = new MemoryGiveawayRepository();
return await activeRepository.getGiveawayById(id);
}
throw err;
}
} }
static async listAll(): Promise<StoredGiveaway[]> { static async listAll(): Promise<StoredGiveaway[]> {
try { return await activeRepository.listGiveaways();
return await activeRepository.listGiveaways();
} catch (err) {
if (activeRepository instanceof PrismaGiveawayRepository) {
activeRepository = new MemoryGiveawayRepository();
return await activeRepository.listGiveaways();
}
throw err;
}
} }
static async updateParticipants(id: string, participants: FilteredParticipant[]): Promise<StoredGiveaway> { static async updateParticipants(id: string, participants: FilteredParticipant[]): Promise<StoredGiveaway> {

View file

@ -55,10 +55,22 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
const snaps = this.snapshots.get(id) || []; const snaps = this.snapshots.get(id) || [];
const latest = snaps.length > 0 ? snaps[snaps.length - 1] : null; 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 { return {
...gw, ...gw,
snapshots: [...snaps], snapshots: [...snaps],
latestSnapshot: latest, latestSnapshot: latest,
drawResult,
}; };
} }

View file

@ -40,11 +40,19 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
})); }));
const latestSnapshot = snapshots.length > 0 const latestSnapshot = snapshots.length > 0
? snapshots.sort((a, b) => b.version - a.version)[0] ? [...snapshots].sort((a, b) => b.version - a.version)[0]
: null; : null;
let drawResult: DrawExecutionResult | null = null; let drawResult: DrawExecutionResult | null = null;
if (raw.drawResult) { 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 = { drawResult = {
drawId: raw.drawResult.id, drawId: raw.drawResult.id,
giveawayId: raw.drawResult.giveawayId, giveawayId: raw.drawResult.giveawayId,
@ -56,11 +64,12 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
totalEligibleCount: raw.drawResult.totalEligibleCount, totalEligibleCount: raw.drawResult.totalEligibleCount,
totalLoadedCount: raw.drawResult.totalLoadedCount, totalLoadedCount: raw.drawResult.totalLoadedCount,
seedUsed: raw.drawResult.seedUsed, seedUsed: raw.drawResult.seedUsed,
participantsSnapshotHash: latestSnapshot?.participantsSnapshotHash || '', participantsSnapshotHash: boundSnapshot?.participantsSnapshotHash || '',
conditionsHash: latestSnapshot?.conditionsHash || '', conditionsHash: boundSnapshot?.conditionsHash || '',
algorithmVersion: raw.drawResult.algorithmVersion, algorithmVersion: raw.drawResult.algorithmVersion,
deterministicProofHash: raw.drawResult.deterministicProofHash,
auditEventHash: raw.drawResult.auditEventHash,
drawnAt: raw.drawResult.drawnAt.toISOString(), drawnAt: raw.drawResult.drawnAt.toISOString(),
auditHash: raw.drawResult.auditHash,
}; };
} }
@ -113,7 +122,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
include: { include: {
participants: true, participants: true,
snapshots: true, snapshots: true,
drawResult: true, drawResult: {
include: { snapshot: true },
},
}, },
}); });
@ -128,7 +139,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
snapshots: { snapshots: {
orderBy: { version: 'desc' }, orderBy: { version: 'desc' },
}, },
drawResult: true, drawResult: {
include: { snapshot: true },
},
}, },
}); });
@ -143,7 +156,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
snapshots: { snapshots: {
orderBy: { version: 'desc' }, orderBy: { version: 'desc' },
}, },
drawResult: true, drawResult: {
include: { snapshot: true },
},
}, },
}); });
@ -162,7 +177,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
include: { include: {
participants: true, participants: true,
snapshots: true, snapshots: true,
drawResult: true, drawResult: {
include: { snapshot: true },
},
}, },
}); });
@ -176,10 +193,8 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
GiveawayFSM.assertCanModifyParticipants(current.status); GiveawayFSM.assertCanModifyParticipants(current.status);
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
// Clear previous live participants
await tx.participant.deleteMany({ where: { giveawayId: id } }); await tx.participant.deleteMany({ where: { giveawayId: id } });
// Insert new participants
if (participants.length > 0) { if (participants.length > 0) {
await tx.participant.createMany({ await tx.participant.createMany({
data: participants.map(p => ({ data: participants.map(p => ({
@ -311,7 +326,8 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
totalLoadedCount: result.totalLoadedCount, totalLoadedCount: result.totalLoadedCount,
seedUsed: result.seedUsed, seedUsed: result.seedUsed,
algorithmVersion: result.algorithmVersion, algorithmVersion: result.algorithmVersion,
auditHash: result.auditHash, deterministicProofHash: result.deterministicProofHash,
auditEventHash: result.auditEventHash,
drawnAt: new Date(result.drawnAt), drawnAt: new Date(result.drawnAt),
}, },
}); });
@ -325,7 +341,8 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
seed: result.seedUsed, seed: result.seedUsed,
participantsSnapshotHash: result.participantsSnapshotHash, participantsSnapshotHash: result.participantsSnapshotHash,
conditionsHash: result.conditionsHash, conditionsHash: result.conditionsHash,
auditHash: result.auditHash, deterministicProofHash: result.deterministicProofHash,
auditEventHash: result.auditEventHash,
winnerIds: result.winnerIds as any, winnerIds: result.winnerIds as any,
reserveWinnerIds: result.reserveWinnerIds as any, reserveWinnerIds: result.reserveWinnerIds as any,
eligibleCount: result.totalEligibleCount, eligibleCount: result.totalEligibleCount,

View file

@ -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);
}
});
});

View file

@ -106,7 +106,8 @@ describe('Repository Persistence & Lifecycle Scenario', () => {
expect(drawResult.winners.length).toBe(1); expect(drawResult.winners.length).toBe(1);
expect(drawResult.reserveWinners.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 // 5. Persist DrawResult and Audit
const finishedGw = await repo.saveDrawResultAndAudit(gw.id, snapshot.id, drawResult); 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).not.toBeNull();
expect(reloaded?.status).toBe('DRAWN'); expect(reloaded?.status).toBe('DRAWN');
expect(reloaded?.snapshots.length).toBe(1); 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); expect(reloaded?.drawResult?.winnerIds).toEqual(drawResult.winnerIds);
}); });
}); });

View file

@ -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);
});
});

View file

@ -72,6 +72,7 @@ describe('Deterministic Randomizer V1 (HMAC_SHA256_FY_V1)', () => {
expect(draw1.algorithmVersion).toBe(ALGORITHM_VERSION_V1); expect(draw1.algorithmVersion).toBe(ALGORITHM_VERSION_V1);
expect(draw1.participantsSnapshotHash).toBe(draw2.participantsSnapshotHash); expect(draw1.participantsSnapshotHash).toBe(draw2.participantsSnapshotHash);
expect(draw1.deterministicProofHash).toBe(draw2.deterministicProofHash);
expect(draw1.winnerIds).toEqual(draw2.winnerIds); expect(draw1.winnerIds).toEqual(draw2.winnerIds);
expect(draw1.reserveWinnerIds).toEqual(draw2.reserveWinnerIds); expect(draw1.reserveWinnerIds).toEqual(draw2.reserveWinnerIds);
expect(draw1.winners.map(w => w.participant.platformUserId)).toEqual( 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, 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.verified).toBe(true);
expect(verification.reserveWinnerIds).toEqual(originalDraw.reserveWinnerIds); expect(verification.expectedWinnerIds).toEqual(originalDraw.winnerIds);
expect(verification.winners.map(w => w.participant.platformUserId)).toEqual( expect(verification.expectedReserveWinnerIds).toEqual(originalDraw.reserveWinnerIds);
expect(verification.expectedWinners.map(w => w.participant.platformUserId)).toEqual(
originalDraw.winners.map(w => w.participant.platformUserId) originalDraw.winners.map(w => w.participant.platformUserId)
); );
}); });

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});