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 {
|
||||
id String @id @default(cuid())
|
||||
giveawayId String @unique
|
||||
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
|
||||
snapshotId String @unique
|
||||
snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict)
|
||||
winners Json // Array of Winner entities
|
||||
reserveWinners Json // Array of Winner entities
|
||||
winnerIds Json // string[]
|
||||
reserveWinnerIds Json // string[]
|
||||
totalEligibleCount Int
|
||||
totalLoadedCount Int
|
||||
seedUsed String
|
||||
algorithmVersion String @default("HMAC_SHA256_FY_V1")
|
||||
auditHash String
|
||||
drawnAt DateTime @default(now())
|
||||
id String @id @default(cuid())
|
||||
giveawayId String @unique
|
||||
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
|
||||
snapshotId String @unique
|
||||
snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict)
|
||||
winners Json // Array of Winner entities
|
||||
reserveWinners Json // Array of Winner entities
|
||||
winnerIds Json // string[]
|
||||
reserveWinnerIds Json // string[]
|
||||
totalEligibleCount Int
|
||||
totalLoadedCount Int
|
||||
seedUsed String
|
||||
algorithmVersion String @default("HMAC_SHA256_FY_V1")
|
||||
deterministicProofHash String
|
||||
auditEventHash String
|
||||
drawnAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model AuditRecord {
|
||||
id String @id @default(cuid())
|
||||
giveawayId String @unique
|
||||
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
|
||||
snapshotId String @unique
|
||||
snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict)
|
||||
algorithmVersion String @default("HMAC_SHA256_FY_V1")
|
||||
seed String
|
||||
id String @id @default(cuid())
|
||||
giveawayId String @unique
|
||||
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
|
||||
snapshotId String @unique
|
||||
snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict)
|
||||
algorithmVersion String @default("HMAC_SHA256_FY_V1")
|
||||
seed String
|
||||
participantsSnapshotHash String
|
||||
conditionsHash String
|
||||
auditHash String
|
||||
winnerIds Json // string[]
|
||||
reserveWinnerIds Json // string[]
|
||||
eligibleCount Int
|
||||
drawId String
|
||||
drawnAt DateTime @default(now())
|
||||
verifiedAt DateTime @default(now())
|
||||
conditionsHash String
|
||||
deterministicProofHash String
|
||||
auditEventHash String
|
||||
winnerIds Json // string[]
|
||||
reserveWinnerIds Json // string[]
|
||||
eligibleCount Int
|
||||
drawId String
|
||||
drawnAt DateTime @default(now())
|
||||
verifiedAt DateTime @default(now())
|
||||
}
|
||||
|
|
|
|||
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,
|
||||
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,35 +183,75 @@ 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,
|
||||
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 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="p-3 bg-slate-950 rounded-xl border border-slate-800">
|
||||
<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>
|
||||
</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,16 @@
|
|||
import { createHash, randomBytes } from 'crypto';
|
||||
import { FilteredParticipant, Winner } from '../types/participant';
|
||||
import { DrawExecutionParams, DrawExecutionResult, CURRENT_RANDOMIZER_ALGORITHM, ParticipantSnapshotData } from '../types/audit';
|
||||
import {
|
||||
DrawExecutionParams,
|
||||
DrawExecutionResult,
|
||||
ALGORITHM_HMAC_SHA256_FY_V1,
|
||||
ParticipantSnapshotData,
|
||||
VerificationResult
|
||||
} from '../types/audit';
|
||||
import { DeterministicHmacStream } from './unbiased-sampler';
|
||||
import { computeAuditHash } from './canonical';
|
||||
import { computeDeterministicProofHash, computeAuditEventHash } from './canonical';
|
||||
|
||||
export const ALGORITHM_VERSION_V1 = CURRENT_RANDOMIZER_ALGORITHM; // 'HMAC_SHA256_FY_V1'
|
||||
export const ALGORITHM_VERSION_V1 = ALGORITHM_HMAC_SHA256_FY_V1; // 'HMAC_SHA256_FY_V1'
|
||||
|
||||
/**
|
||||
* Generates an individual audit proof hash for a winner position
|
||||
|
|
@ -21,8 +27,8 @@ export function generateWinnerProofHash(
|
|||
}
|
||||
|
||||
/**
|
||||
* Executes deterministic Fisher-Yates selection V1 (HMAC_SHA256_FY_V1)
|
||||
* with unbiased rejection sampling.
|
||||
* Executes true partial Fisher-Yates shuffle V1 (HMAC_SHA256_FY_V1)
|
||||
* with in-place swap and unbiased rejection sampling.
|
||||
*/
|
||||
export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExecutionResult {
|
||||
const {
|
||||
|
|
@ -40,7 +46,7 @@ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExe
|
|||
throw new Error('Cannot conduct draw with 0 eligible participants in snapshot');
|
||||
}
|
||||
|
||||
// 1. Canonical sort to ensure exact invariant input order
|
||||
// 1. Canonical sort to guarantee exact invariant input ordering
|
||||
const pool = [...eligible].sort((a, b) =>
|
||||
a.platformUserId.localeCompare(b.platformUserId)
|
||||
);
|
||||
|
|
@ -55,39 +61,50 @@ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExe
|
|||
const actualWinnersCount = Math.min(winnersCount, totalNeeded);
|
||||
const actualReserveCount = Math.max(0, totalNeeded - actualWinnersCount);
|
||||
|
||||
// 3. True partial Fisher-Yates shuffle: swap pool[i] with pool[j] where j in [i, n-1]
|
||||
for (let i = 0; i < totalNeeded; i++) {
|
||||
const remainingCount = pool.length - i;
|
||||
const offset = stream.sampleUnbiasedIndex(remainingCount);
|
||||
const j = i + offset;
|
||||
|
||||
// In-place swap
|
||||
const temp = pool[i];
|
||||
pool[i] = pool[j];
|
||||
pool[j] = temp;
|
||||
}
|
||||
|
||||
const winners: Winner[] = [];
|
||||
const reserveWinners: Winner[] = [];
|
||||
const winnerIds: string[] = [];
|
||||
const reserveWinnerIds: string[] = [];
|
||||
|
||||
// 3. Select Main Winners (Fisher-Yates removal without replacement)
|
||||
// 4. Map Main Winners from pool[0 ... actualWinnersCount - 1]
|
||||
for (let i = 0; i < actualWinnersCount; i++) {
|
||||
const selectedIndex = stream.sampleUnbiasedIndex(pool.length);
|
||||
const selectedParticipant = pool.splice(selectedIndex, 1)[0];
|
||||
const selectedParticipant = pool[i];
|
||||
const proofHash = generateWinnerProofHash(seed, snapshotHash, i + 1, selectedParticipant.platformUserId);
|
||||
|
||||
winners.push({
|
||||
position: i + 1,
|
||||
isReserve: false,
|
||||
participant: selectedParticipant,
|
||||
selectionIndex: selectedIndex,
|
||||
selectionIndex: i,
|
||||
proofHash,
|
||||
});
|
||||
winnerIds.push(selectedParticipant.platformUserId);
|
||||
}
|
||||
|
||||
// 4. Select Reserve Winners
|
||||
// 5. Map Reserve Winners from pool[actualWinnersCount ... totalNeeded - 1]
|
||||
for (let i = 0; i < actualReserveCount; i++) {
|
||||
const pos = winners.length + i + 1;
|
||||
const selectedIndex = stream.sampleUnbiasedIndex(pool.length);
|
||||
const selectedParticipant = pool.splice(selectedIndex, 1)[0];
|
||||
const idx = actualWinnersCount + i;
|
||||
const pos = idx + 1;
|
||||
const selectedParticipant = pool[idx];
|
||||
const proofHash = generateWinnerProofHash(seed, snapshotHash, pos, selectedParticipant.platformUserId);
|
||||
|
||||
reserveWinners.push({
|
||||
position: pos,
|
||||
isReserve: true,
|
||||
participant: selectedParticipant,
|
||||
selectionIndex: selectedIndex,
|
||||
selectionIndex: idx,
|
||||
proofHash,
|
||||
});
|
||||
reserveWinnerIds.push(selectedParticipant.platformUserId);
|
||||
|
|
@ -96,19 +113,24 @@ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExe
|
|||
const drawId = 'draw_' + randomBytes(8).toString('hex');
|
||||
const drawnAt = new Date().toISOString();
|
||||
|
||||
// 5. Compute canonical auditHash
|
||||
const auditHash = computeAuditHash({
|
||||
// 6. Compute deterministicProofHash (reproducible upon replay)
|
||||
const deterministicProofHash = computeDeterministicProofHash({
|
||||
algorithmVersion: ALGORITHM_VERSION_V1,
|
||||
giveawayId,
|
||||
snapshotId: snapshot.id,
|
||||
seed,
|
||||
participantsSnapshotHash: snapshotHash,
|
||||
conditionsHash,
|
||||
seed,
|
||||
winnerIds,
|
||||
reserveWinnerIds,
|
||||
eligibleCount: eligible.length,
|
||||
});
|
||||
|
||||
// 7. Compute auditEventHash (unique for this draw execution instance)
|
||||
const auditEventHash = computeAuditEventHash({
|
||||
giveawayId,
|
||||
drawId,
|
||||
drawnAt,
|
||||
deterministicProofHash,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -125,47 +147,69 @@ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExe
|
|||
participantsSnapshotHash: snapshotHash,
|
||||
conditionsHash,
|
||||
algorithmVersion: ALGORITHM_VERSION_V1,
|
||||
deterministicProofHash,
|
||||
auditEventHash,
|
||||
drawnAt,
|
||||
auditHash,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal entrypoint (routes to active algorithm version V1)
|
||||
* Universal entrypoint (routes to algorithm version)
|
||||
*/
|
||||
export function executeDeterministicDraw(params: DrawExecutionParams): DrawExecutionResult {
|
||||
return executeDeterministicDrawV1(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-runs draw algorithm on a snapshot to verify identical outcome
|
||||
* Re-runs draw algorithm on a snapshot to verify identical outcome and hashes
|
||||
*/
|
||||
export function verifyDrawResult(
|
||||
snapshot: ParticipantSnapshotData,
|
||||
seed: string,
|
||||
claimedWinnersCount: number,
|
||||
claimedReserveCount: number,
|
||||
claimedWinnerIds?: string[],
|
||||
claimedDeterministicProofHash?: string,
|
||||
algorithmVersion: string = ALGORITHM_VERSION_V1
|
||||
): { winners: Winner[]; reserveWinners: Winner[]; winnerIds: string[]; reserveWinnerIds: string[]; auditHash: string } {
|
||||
): VerificationResult {
|
||||
if (algorithmVersion !== ALGORITHM_VERSION_V1) {
|
||||
throw new Error(`Unsupported algorithm version for replay: ${algorithmVersion}`);
|
||||
}
|
||||
|
||||
const result = executeDeterministicDrawV1({
|
||||
const replayed = executeDeterministicDrawV1({
|
||||
giveawayId: snapshot.giveawayId,
|
||||
snapshot,
|
||||
totalLoadedCount: snapshot.participantCount,
|
||||
winnersCount: claimedWinnersCount,
|
||||
reserveWinnersCount: claimedReserveCount,
|
||||
seed,
|
||||
filterRules: {} as any,
|
||||
});
|
||||
|
||||
const winnersMatch = claimedWinnerIds
|
||||
? JSON.stringify(replayed.winnerIds) === JSON.stringify(claimedWinnerIds)
|
||||
: true;
|
||||
|
||||
const deterministicProofHashMatch = claimedDeterministicProofHash
|
||||
? replayed.deterministicProofHash === claimedDeterministicProofHash
|
||||
: true;
|
||||
|
||||
const snapshotHashMatch = replayed.participantsSnapshotHash === snapshot.participantsSnapshotHash;
|
||||
const conditionsHashMatch = replayed.conditionsHash === snapshot.conditionsHash;
|
||||
|
||||
const verified = winnersMatch && deterministicProofHashMatch && snapshotHashMatch && conditionsHashMatch;
|
||||
|
||||
return {
|
||||
winners: result.winners,
|
||||
reserveWinners: result.reserveWinners,
|
||||
winnerIds: result.winnerIds,
|
||||
reserveWinnerIds: result.reserveWinnerIds,
|
||||
auditHash: result.auditHash,
|
||||
verified,
|
||||
algorithmVersion: ALGORITHM_VERSION_V1,
|
||||
winnersMatch,
|
||||
snapshotHashMatch,
|
||||
conditionsHashMatch,
|
||||
deterministicProofHashMatch,
|
||||
expectedWinners: replayed.winners,
|
||||
expectedReserveWinners: replayed.reserveWinners,
|
||||
expectedWinnerIds: replayed.winnerIds,
|
||||
expectedReserveWinnerIds: replayed.reserveWinnerIds,
|
||||
expectedDeterministicProofHash: replayed.deterministicProofHash,
|
||||
actualDeterministicProofHash: claimedDeterministicProofHash || replayed.deterministicProofHash,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -21,7 +22,7 @@ export interface DrawExecutionParams {
|
|||
winnersCount: number;
|
||||
reserveWinnersCount: number;
|
||||
seed: string;
|
||||
filterRules: FilterRules;
|
||||
filterRules?: FilterRules;
|
||||
}
|
||||
|
||||
export interface DrawExecutionResult {
|
||||
|
|
@ -38,8 +39,9 @@ export interface DrawExecutionResult {
|
|||
participantsSnapshotHash: string;
|
||||
conditionsHash: string;
|
||||
algorithmVersion: string;
|
||||
deterministicProofHash: string;
|
||||
auditEventHash: string;
|
||||
drawnAt: string; // ISO String
|
||||
auditHash: string;
|
||||
}
|
||||
|
||||
export interface AuditRecordData {
|
||||
|
|
@ -50,7 +52,8 @@ export interface AuditRecordData {
|
|||
seed: string;
|
||||
participantsSnapshotHash: string;
|
||||
conditionsHash: string;
|
||||
auditHash: string;
|
||||
deterministicProofHash: string;
|
||||
auditEventHash: string;
|
||||
winnerIds: string[];
|
||||
reserveWinnerIds: string[];
|
||||
eligibleCount: number;
|
||||
|
|
@ -58,3 +61,18 @@ export interface AuditRecordData {
|
|||
drawnAt: string;
|
||||
verifiedAt: string;
|
||||
}
|
||||
|
||||
export interface VerificationResult {
|
||||
verified: boolean;
|
||||
algorithmVersion: string;
|
||||
winnersMatch: boolean;
|
||||
snapshotHashMatch: boolean;
|
||||
conditionsHashMatch: boolean;
|
||||
deterministicProofHashMatch: boolean;
|
||||
expectedWinners: Winner[];
|
||||
expectedReserveWinners: Winner[];
|
||||
expectedWinnerIds: string[];
|
||||
expectedReserveWinnerIds: string[];
|
||||
expectedDeterministicProofHash: string;
|
||||
actualDeterministicProofHash: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,11 +40,19 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
}));
|
||||
|
||||
const latestSnapshot = snapshots.length > 0
|
||||
? snapshots.sort((a, b) => b.version - a.version)[0]
|
||||
? [...snapshots].sort((a, b) => b.version - a.version)[0]
|
||||
: null;
|
||||
|
||||
let drawResult: DrawExecutionResult | null = null;
|
||||
if (raw.drawResult) {
|
||||
// Strictly bind to the snapshot attached to drawResult
|
||||
const boundSnapshot = raw.drawResult.snapshot
|
||||
? {
|
||||
participantsSnapshotHash: raw.drawResult.snapshot.participantsSnapshotHash,
|
||||
conditionsHash: raw.drawResult.snapshot.conditionsHash,
|
||||
}
|
||||
: snapshots.find(s => s.id === raw.drawResult.snapshotId) || latestSnapshot;
|
||||
|
||||
drawResult = {
|
||||
drawId: raw.drawResult.id,
|
||||
giveawayId: raw.drawResult.giveawayId,
|
||||
|
|
@ -56,11 +64,12 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
totalEligibleCount: raw.drawResult.totalEligibleCount,
|
||||
totalLoadedCount: raw.drawResult.totalLoadedCount,
|
||||
seedUsed: raw.drawResult.seedUsed,
|
||||
participantsSnapshotHash: latestSnapshot?.participantsSnapshotHash || '',
|
||||
conditionsHash: latestSnapshot?.conditionsHash || '',
|
||||
participantsSnapshotHash: boundSnapshot?.participantsSnapshotHash || '',
|
||||
conditionsHash: boundSnapshot?.conditionsHash || '',
|
||||
algorithmVersion: raw.drawResult.algorithmVersion,
|
||||
deterministicProofHash: raw.drawResult.deterministicProofHash,
|
||||
auditEventHash: raw.drawResult.auditEventHash,
|
||||
drawnAt: raw.drawResult.drawnAt.toISOString(),
|
||||
auditHash: raw.drawResult.auditHash,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +122,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
include: {
|
||||
participants: true,
|
||||
snapshots: true,
|
||||
drawResult: true,
|
||||
drawResult: {
|
||||
include: { snapshot: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -128,7 +139,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
snapshots: {
|
||||
orderBy: { version: 'desc' },
|
||||
},
|
||||
drawResult: true,
|
||||
drawResult: {
|
||||
include: { snapshot: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -143,7 +156,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
snapshots: {
|
||||
orderBy: { version: 'desc' },
|
||||
},
|
||||
drawResult: true,
|
||||
drawResult: {
|
||||
include: { snapshot: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -162,7 +177,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
include: {
|
||||
participants: true,
|
||||
snapshots: true,
|
||||
drawResult: true,
|
||||
drawResult: {
|
||||
include: { snapshot: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -176,10 +193,8 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
GiveawayFSM.assertCanModifyParticipants(current.status);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Clear previous live participants
|
||||
await tx.participant.deleteMany({ where: { giveawayId: id } });
|
||||
|
||||
// Insert new participants
|
||||
if (participants.length > 0) {
|
||||
await tx.participant.createMany({
|
||||
data: participants.map(p => ({
|
||||
|
|
@ -311,7 +326,8 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
totalLoadedCount: result.totalLoadedCount,
|
||||
seedUsed: result.seedUsed,
|
||||
algorithmVersion: result.algorithmVersion,
|
||||
auditHash: result.auditHash,
|
||||
deterministicProofHash: result.deterministicProofHash,
|
||||
auditEventHash: result.auditEventHash,
|
||||
drawnAt: new Date(result.drawnAt),
|
||||
},
|
||||
});
|
||||
|
|
@ -325,7 +341,8 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
seed: result.seedUsed,
|
||||
participantsSnapshotHash: result.participantsSnapshotHash,
|
||||
conditionsHash: result.conditionsHash,
|
||||
auditHash: result.auditHash,
|
||||
deterministicProofHash: result.deterministicProofHash,
|
||||
auditEventHash: result.auditEventHash,
|
||||
winnerIds: result.winnerIds as any,
|
||||
reserveWinnerIds: result.reserveWinnerIds as any,
|
||||
eligibleCount: result.totalEligibleCount,
|
||||
|
|
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
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.participantsSnapshotHash).toBe(draw2.participantsSnapshotHash);
|
||||
expect(draw1.deterministicProofHash).toBe(draw2.deterministicProofHash);
|
||||
expect(draw1.winnerIds).toEqual(draw2.winnerIds);
|
||||
expect(draw1.reserveWinnerIds).toEqual(draw2.reserveWinnerIds);
|
||||
expect(draw1.winners.map(w => w.participant.platformUserId)).toEqual(
|
||||
|
|
@ -161,11 +162,20 @@ describe('Deterministic Randomizer V1 (HMAC_SHA256_FY_V1)', () => {
|
|||
filterRules: DEFAULT_FILTER_RULES,
|
||||
});
|
||||
|
||||
const verification = verifyDrawResult(snapshot, seed, 2, 2, ALGORITHM_VERSION_V1);
|
||||
const verification = verifyDrawResult(
|
||||
snapshot,
|
||||
seed,
|
||||
2,
|
||||
2,
|
||||
originalDraw.winnerIds,
|
||||
originalDraw.deterministicProofHash,
|
||||
ALGORITHM_VERSION_V1
|
||||
);
|
||||
|
||||
expect(verification.winnerIds).toEqual(originalDraw.winnerIds);
|
||||
expect(verification.reserveWinnerIds).toEqual(originalDraw.reserveWinnerIds);
|
||||
expect(verification.winners.map(w => w.participant.platformUserId)).toEqual(
|
||||
expect(verification.verified).toBe(true);
|
||||
expect(verification.expectedWinnerIds).toEqual(originalDraw.winnerIds);
|
||||
expect(verification.expectedReserveWinnerIds).toEqual(originalDraw.reserveWinnerIds);
|
||||
expect(verification.expectedWinners.map(w => w.participant.platformUserId)).toEqual(
|
||||
originalDraw.winners.map(w => w.participant.platformUserId)
|
||||
);
|
||||
});
|
||||
|
|
|
|||
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