Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
46bf8aad6e
20 changed files with 1449 additions and 169 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"
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
76
src/app/api/giveaways/[id]/verify/route.ts
Normal file
76
src/app/api/giveaways/[id]/verify/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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.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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
148
tests/proof-separation.test.ts
Normal file
148
tests/proof-separation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
|
|
|||
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);
|
||||
});
|
||||
});
|
||||
278
tests/tampering-verification.test.ts
Normal file
278
tests/tampering-verification.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
162
tests/verification-api.test.ts
Normal file
162
tests/verification-api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue