Merge remote-tracking branch 'origin/main'

This commit is contained in:
Ochenstarik 2026-08-18 00:42:06 +07:00
commit 46bf8aad6e
20 changed files with 1449 additions and 169 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

@ -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
@ -101,38 +102,41 @@ 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())
drawId String // Original domain drawId (e.g. draw_8f9102ab...)
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())
}

View file

@ -0,0 +1,76 @@
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 });
}
// Strict snapshot lookup: DO NOT fallback to latestSnapshot
const snapshot = giveaway.snapshots.find(s => s.id === drawResult.snapshotId);
if (!snapshot) {
return NextResponse.json({
error: `Integrity Error: Participant snapshot "${drawResult.snapshotId}" referenced by draw does not exist in storage`,
verified: false,
snapshotFound: false,
}, { status: 404 });
}
const claimedWinnersCount = drawResult.winners.length;
const claimedReserveCount = drawResult.reserveWinners.length;
// Run independent cryptographic replay verification
const verification = verifyDrawResult({
giveawayId: id,
drawId: drawResult.drawId,
drawnAt: drawResult.drawnAt,
snapshot,
seed: drawResult.seedUsed,
claimedWinnersCount,
claimedReserveCount,
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,
reserveWinnersMatch: verification.reserveWinnersMatch,
deterministicProofHashMatch: verification.deterministicProofHashMatch,
auditEventHashMatch: verification.auditEventHashMatch,
expectedWinnerIds: verification.expectedWinnerIds,
expectedReserveWinnerIds: verification.expectedReserveWinnerIds,
deterministicProofHash: verification.expectedDeterministicProofHash,
auditEventHash: verification.expectedAuditEventHash,
drawnAt: drawResult.drawnAt,
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View file

@ -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<string | null>(null);
const [copied, setCopied] = useState(false);
const [verifying, setVerifying] = useState(false);
const [verificationResult, setVerificationResult] = useState<any | null>(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 (
<div className="py-20 text-center text-slate-400 text-sm">
@ -64,7 +81,6 @@ export default function GiveawayDetailPage() {
}
const drawResult = giveaway.drawResult;
const snapshot = giveaway.latestSnapshot;
return (
<div className="max-w-4xl mx-auto space-y-6">
@ -167,36 +183,83 @@ export default function GiveawayDetailPage() {
{/* Provably Fair Audit Trail */}
<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">
<ShieldCheck className="w-4 h-4" />
Публичный криптографический аудит (Provably Fair)
</div>
<button
onClick={() => {
navigator.clipboard.writeText(JSON.stringify({
giveawayId: giveaway.id,
snapshotId: drawResult.snapshotId,
algorithmVersion: drawResult.algorithmVersion,
seed: drawResult.seedUsed,
participantsSnapshotHash: drawResult.participantsSnapshotHash,
conditionsHash: drawResult.conditionsHash,
auditHash: drawResult.auditHash,
winnerIds: drawResult.winnerIds,
reserveWinnerIds: drawResult.reserveWinnerIds,
drawnAt: drawResult.drawnAt,
}, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
className="text-xs text-slate-400 hover:text-white flex items-center gap-1"
>
{copied ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
{copied ? 'Скопировано' : 'JSON аудита'}
</button>
<div className="flex items-center gap-2">
<button
onClick={handleVerify}
disabled={verifying}
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"
>
<RefreshCw className={`w-3.5 h-3.5 ${verifying ? 'animate-spin' : ''}`} />
Верифицировать результат
</button>
<button
onClick={() => {
navigator.clipboard.writeText(JSON.stringify({
giveawayId: giveaway.id,
drawId: drawResult.drawId,
snapshotId: drawResult.snapshotId,
algorithmVersion: drawResult.algorithmVersion,
seed: drawResult.seedUsed,
participantsSnapshotHash: drawResult.participantsSnapshotHash,
conditionsHash: drawResult.conditionsHash,
deterministicProofHash: drawResult.deterministicProofHash,
auditEventHash: drawResult.auditEventHash,
winnerIds: drawResult.winnerIds,
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>
{/* 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 sm:grid-cols-3 gap-2 pt-1 font-mono text-[11px]">
<div>Целостность участников: {verificationResult.participantsSnapshotIntegrity ? 'ДА ✓' : 'НЕТ ✗'}</div>
<div>Целостность условий: {verificationResult.conditionsIntegrity ? 'ДА ✓' : 'НЕТ ✗'}</div>
<div>Победители совпали: {verificationResult.winnersMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
<div>Резерв совпал: {verificationResult.reserveWinnersMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
<div>Proof Hash совпал: {verificationResult.deterministicProofHashMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
<div>Event Hash совпал: {verificationResult.auditEventHashMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
</div>
</div>
)}
<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">
<span className="text-slate-400">Draw ID:</span>
<p className="font-mono text-amber-300 break-all">{drawResult.drawId}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-slate-400">Snapshot ID:</span>
<p className="font-mono text-slate-300 break-all">{drawResult.snapshotId}</p>
@ -213,9 +276,17 @@ export default function GiveawayDetailPage() {
<span className="text-slate-400">Snapshot Hash:</span>
<p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-slate-400">Conditions Hash:</span>
<p className="font-mono text-purple-400 break-all">{drawResult.conditionsHash}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 sm:col-span-2">
<span className="text-slate-400">Канонический auditHash:</span>
<p className="font-mono text-indigo-300 break-all">{drawResult.auditHash}</p>
<span className="text-slate-400">deterministicProofHash (воспроизводимый):</span>
<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>

View file

@ -410,7 +410,7 @@ export default function NewGiveawayWizardPage() {
</div>
</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">
<input
type="checkbox"
@ -451,7 +451,7 @@ export default function NewGiveawayWizardPage() {
</div>
</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">
<input
type="checkbox"
@ -689,7 +689,7 @@ export default function NewGiveawayWizardPage() {
<div>
<h2 className="text-xl font-bold text-white mb-1">Шаг 4: Настройки жеребьевки</h2>
<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>
</div>
@ -926,8 +926,13 @@ export default function NewGiveawayWizardPage() {
</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">Канонический auditHash:</span>
<p className="font-mono text-indigo-300 break-all">{drawResult.auditHash}</p>
<span className="text-slate-400 font-medium">deterministicProofHash (воспроизводимый):</span>
<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>

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

View file

@ -1,10 +1,22 @@
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,
VerificationParams,
VerificationResult
} from '../types/audit';
import { DeterministicHmacStream } from './unbiased-sampler';
import { computeAuditHash } from './canonical';
import {
computeDeterministicProofHash,
computeAuditEventHash,
computeParticipantsSnapshotHash,
computeConditionsHash
} 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 +33,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 +52,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 +67,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 +119,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 +153,116 @@ 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 all cryptographic integrity hashes
*/
export function verifyDrawResult(
snapshot: ParticipantSnapshotData,
seed: string,
claimedWinnersCount: number,
claimedReserveCount: number,
algorithmVersion: string = ALGORITHM_VERSION_V1
): { winners: Winner[]; reserveWinners: Winner[]; winnerIds: string[]; reserveWinnerIds: string[]; auditHash: string } {
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 result = executeDeterministicDrawV1({
giveawayId: snapshot.giveawayId,
snapshot,
totalLoadedCount: snapshot.participantCount,
winnersCount: claimedWinnersCount,
reserveWinnersCount: claimedReserveCount,
seed,
filterRules: {} as any,
});
const winnersMatch = !replayError && replayed !== null
? JSON.stringify(replayed.winnerIds) === JSON.stringify(claimedWinnerIds)
: false;
const reserveWinnersMatch = !replayError && replayed !== null
? JSON.stringify(replayed.reserveWinnerIds) === JSON.stringify(claimedReserveWinnerIds)
: false;
const deterministicProofHashMatch = !replayError && replayed !== null
? replayed.deterministicProofHash === claimedDeterministicProofHash
: false;
// 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 auditEventHashMatch = (expectedAuditEventHash === claimedAuditEventHash);
const verified = (
algorithmSupported &&
participantsSnapshotIntegrity &&
conditionsIntegrity &&
winnersMatch &&
reserveWinnersMatch &&
deterministicProofHashMatch &&
auditEventHashMatch
);
return {
winners: result.winners,
reserveWinners: result.reserveWinners,
winnerIds: result.winnerIds,
reserveWinnerIds: result.reserveWinnerIds,
auditHash: result.auditHash,
verified,
algorithmVersion,
algorithmSupported,
participantsSnapshotIntegrity,
conditionsIntegrity,
winnersMatch,
reserveWinnersMatch,
deterministicProofHashMatch,
auditEventHashMatch,
expectedWinners: replayed?.winners || [],
expectedReserveWinners: replayed?.reserveWinners || [],
expectedWinnerIds: replayed?.winnerIds || [],
expectedReserveWinnerIds: replayed?.reserveWinnerIds || [],
expectedDeterministicProofHash: replayed?.deterministicProofHash || '',
expectedAuditEventHash,
actualDeterministicProofHash: claimedDeterministicProofHash,
actualAuditEventHash: claimedAuditEventHash,
};
}

View file

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

View file

@ -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;
@ -9,6 +10,7 @@ export interface ParticipantSnapshotData {
version: number;
createdAt: string;
eligibleParticipants: FilteredParticipant[];
filterRulesSnapshot: FilterRules;
participantCount: number;
participantsSnapshotHash: string;
conditionsHash: string;
@ -21,7 +23,7 @@ export interface DrawExecutionParams {
winnersCount: number;
reserveWinnersCount: number;
seed: string;
filterRules: FilterRules;
filterRules?: FilterRules;
}
export interface DrawExecutionResult {
@ -38,8 +40,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 +53,8 @@ export interface AuditRecordData {
seed: string;
participantsSnapshotHash: string;
conditionsHash: string;
auditHash: string;
deterministicProofHash: string;
auditEventHash: string;
winnerIds: string[];
reserveWinnerIds: string[];
eligibleCount: number;
@ -58,3 +62,38 @@ export interface AuditRecordData {
drawnAt: string;
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;
reserveWinnersMatch: boolean;
deterministicProofHashMatch: boolean;
auditEventHashMatch: boolean;
expectedWinners: Winner[];
expectedReserveWinners: Winner[];
expectedWinnerIds: string[];
expectedReserveWinnerIds: string[];
expectedDeterministicProofHash: string;
expectedAuditEventHash: string;
actualDeterministicProofHash: string;
actualAuditEventHash: string;
}

View file

@ -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<StoredGiveaway> {
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<StoredGiveaway | null> {
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<StoredGiveaway[]> {
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<StoredGiveaway> {

View file

@ -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,
};
}
@ -119,6 +131,7 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
version: newVersion,
createdAt: new Date().toISOString(),
eligibleParticipants: [...eligibleParticipants],
filterRulesSnapshot: { ...rules },
participantCount: eligibleParticipants.length,
participantsSnapshotHash,
conditionsHash,

View file

@ -34,19 +34,28 @@ 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,
}));
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,
drawId: raw.drawResult.drawId || raw.drawResult.id,
giveawayId: raw.drawResult.giveawayId,
snapshotId: raw.drawResult.snapshotId,
winners: raw.drawResult.winners as any,
@ -56,11 +65,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 +123,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
include: {
participants: true,
snapshots: true,
drawResult: true,
drawResult: {
include: { snapshot: true },
},
},
});
@ -128,7 +140,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
snapshots: {
orderBy: { version: 'desc' },
},
drawResult: true,
drawResult: {
include: { snapshot: true },
},
},
});
@ -143,7 +157,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
snapshots: {
orderBy: { version: 'desc' },
},
drawResult: true,
drawResult: {
include: { snapshot: true },
},
},
});
@ -162,7 +178,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
include: {
participants: true,
snapshots: true,
drawResult: true,
drawResult: {
include: { snapshot: true },
},
},
});
@ -176,10 +194,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 => ({
@ -241,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,
@ -261,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,
@ -281,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,
@ -298,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,
@ -311,7 +331,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 +346,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,

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

View file

@ -0,0 +1,148 @@
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('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,
filterRulesSnapshot: { ...DEFAULT_FILTER_RULES },
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({
giveawayId: 'gw-1',
drawId: originalDraw.drawId,
drawnAt: originalDraw.drawnAt,
snapshot,
seed,
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.auditEventHashMatch).toBe(true);
expect(verification.participantsSnapshotIntegrity).toBe(true);
expect(verification.conditionsIntegrity).toBe(true);
expect(verification.expectedDeterministicProofHash).toBe(originalDraw.deterministicProofHash);
});
});

View file

@ -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),
@ -72,6 +73,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 +163,25 @@ 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({
giveawayId: 'gw-audit',
drawId: originalDraw.drawId,
drawnAt: originalDraw.drawnAt,
snapshot,
seed,
claimedWinnersCount: 2,
claimedReserveCount: 2,
claimedWinnerIds: originalDraw.winnerIds,
claimedReserveWinnerIds: originalDraw.reserveWinnerIds,
claimedDeterministicProofHash: originalDraw.deterministicProofHash,
claimedAuditEventHash: originalDraw.auditEventHash,
algorithmVersion: 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)
);
});

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

View file

@ -0,0 +1,162 @@
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,
filterRulesSnapshot: { ...DEFAULT_FILTER_RULES },
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({
giveawayId: 'gw-verif-1',
drawId: draw.drawId,
drawnAt: draw.drawnAt,
snapshot,
seed,
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.auditEventHashMatch).toBe(true);
expect(result.participantsSnapshotIntegrity).toBe(true);
expect(result.conditionsIntegrity).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({
giveawayId: 'gw-verif-1',
drawId: draw.drawId,
drawnAt: draw.drawnAt,
snapshot,
seed,
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);
});
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({
giveawayId: 'gw-verif-1',
drawId: draw.drawId,
drawnAt: draw.drawnAt,
snapshot,
seed,
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);
expect(result.winnersMatch).toBe(true);
});
});