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:
parent
0fb5fe3f8d
commit
1bc6650041
19 changed files with 1018 additions and 152 deletions
61
.github/workflows/ci.yml
vendored
Normal file
61
.github/workflows/ci.yml
vendored
Normal 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"
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
66
src/app/api/giveaways/[id]/verify/route.ts
Normal file
66
src/app/api/giveaways/[id]/verify/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
94
tests/fisher-yates.test.ts
Normal file
94
tests/fisher-yates.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
139
tests/proof-separation.test.ts
Normal file
139
tests/proof-separation.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
109
tests/snapshot-binding.test.ts
Normal file
109
tests/snapshot-binding.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
tests/storage-driver.test.ts
Normal file
74
tests/storage-driver.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
144
tests/verification-api.test.ts
Normal file
144
tests/verification-api.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue