[1, 10, 30, 70, 150, 300, 500, 1000], 'ebs-prizedraw-v2' => [1, 31, 71, 251, 501, 1001, 2501, 5001], ]; function v_algo_for_wave(string $wave): string { return $wave === 'EBS-G1' ? 'ebs-prizedraw-v1' : 'ebs-prizedraw-v2'; } const V_TABLE = [ 'cybex_priam_v' => [0, 0, 0, 0, 0, 0, 1, 2], 'cybex_balios_s_lux' => [0, 0, 0, 0, 0, 1, 1, 2], 'cybex_eezy_s_twist2' => [0, 0, 0, 0, 1, 1, 2, 2], 'ebs_cert_50000' => [0, 0, 0, 0, 2, 3, 4, 6], 'pp_tatamia' => [0, 0, 0, 0, 1, 1, 2, 2], 'esspero_tour_s' => [0, 0, 0, 0, 1, 2, 3, 4], 'cybex_solution_t' => [0, 0, 0, 1, 1, 1, 2, 2], 'esspero_seat_profix' => [0, 0, 0, 1, 2, 3, 4, 5], 'antemi_classic_sleep' => [0, 0, 0, 1, 1, 2, 2, 3], 'esspero_air' => [0, 0, 0, 3, 3, 4, 5, 6], 'esspero_summer_line' => [0, 0, 1, 2, 3, 4, 5, 6], 'bb_lettino' => [0, 0, 1, 1, 2, 3, 4, 5], 'pp_backpack' => [0, 0, 1, 1, 2, 3, 4, 5], 'ramili_baby' => [0, 0, 1, 1, 2, 3, 4, 5], 'bb_cuore' => [0, 1, 1, 1, 2, 3, 4, 5], 'esspero_egg' => [0, 1, 1, 2, 2, 3, 4, 5], 'esspero_transformer_lux' => [1, 1, 2, 3, 4, 6, 8, 10], 'cybex_cupholder' => [0, 1, 2, 3, 4, 5, 6, 8], ]; const V_CHAIN = '8990e7a9aaed2ffed73dbd7092123d6f289930540d7651336225dc172e51b2ce'; const V_GENESIS = 1595431050; const V_PERIOD = 30; const V_RELAYS = ['https://api.drand.sh', 'https://drand.cloudflare.com']; function v_canonical($x): string { return json_encode($x, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } final class VStream { private string $seed; public int $counter = 0; public function __construct(string $seed) { $this->seed = $seed; } public function uniform(int $total): int { if ($total < 1 || $total > (1 << 62)) { throw new RangeException('total out of range'); } $bits = 1; while ($bits < 62 && (1 << $bits) < $total) { $bits++; } $mask = (1 << $bits) - 1; while (true) { $v = unpack('J', substr(hash('sha256', $this->seed.pack('J', $this->counter++), true), 0, 8))[1] & $mask; if ($v < $total) { return $v; } } } } function v_pick(array $weights, int $r): int { $acc = 0; foreach (array_values($weights) as $i => $w) { $acc += $w; if ($r < $acc) { return $i; } } throw new OutOfRangeException('r >= total'); } function v_weighted(array $pool, VStream $s): array { $ids = array_keys($pool); $weights = array_values($pool); $total = array_sum($weights); $r = $s->uniform($total); $i = v_pick($weights, $r); return [$ids[$i], $weights[$i], $total, $r]; } function v_draw(array $snap, string $seed, array $manual): array { $s = new VStream($seed); $balance = []; foreach ($snap['balance'] as [$c, $q]) { $balance[$c] = $q; } if (array_keys($balance) !== array_keys(V_TABLE)) { throw new RuntimeException('balance positions differ from the offer table'); } $pool = []; $win = []; foreach ($snap['entries'] as [$id, $w, $cat]) { if ($cat === 'R') { if (!is_int($w) || $w < 1 || $w > 5) { throw new RuntimeException('bad weight for entry '.$id); } $pool[$id] = $w; } else { $win[] = $id; } } ksort($pool); sort($win); $n = count($pool); $col = null; if (!isset(V_BANDS[$snap['algo'] ?? ''])) { throw new RuntimeException('unknown algorithm in the snapshot'); } foreach (V_BANDS[$snap['algo']] as $i => $lb) { if ($n >= $lb) { $col = $i; } } $quota = []; foreach (V_TABLE as $c => $row) { $quota[$c] = $col === null ? 0 : min($row[$col], $balance[$c]); } $slots = []; foreach ($quota as $c => $q) { for ($u = 1; $u <= $q; $u++) { $slots[$c.'#'.$u] = null; } } $mr = $manual['random'] ?? []; ksort($mr); foreach ($mr as $id => $c) { foreach ($slots as $k => $e) { if ($e === null && str_starts_with($k, $c.'#')) { $slots[$k] = [$id, $pool[$id], null, null, 'manual']; unset($pool[$id]); break; } } } foreach ($slots as $k => $e) { if ($e === null && $pool !== []) { [$id, $w, $t, $r] = v_weighted($pool, $s); $slots[$k] = [$id, $w, $t, $r, 'weighted']; unset($pool[$id]); } } $awarded = array_fill_keys(array_keys($balance), 0); $winners = []; foreach ($slots as $k => $e) { [$c, $u] = explode('#', $k); if ($e === null) { $winners[] = [$c, (int)$u, null, null, null, null, 'unfilled']; } else { $winners[] = [$c, (int)$u, $e[0], $e[1], $e[2], $e[3], $e[4]]; $awarded[$c]++; } } $reserve = []; for ($rank = 1; $rank <= $snap['reserve_size'] && $pool !== []; $rank++) { [$id, $w, $t, $r] = v_weighted($pool, $s); $reserve[] = [$rank, $id, $w, $t, $r]; unset($pool[$id]); } $rem = []; $remOut = []; foreach ($balance as $c => $q) { $rem[$c] = $q - $quota[$c]; $remOut[] = [$c, $rem[$c]]; } $w100 = []; $unitNo = array_fill_keys(array_keys($balance), 0); $mw = $manual['win100'] ?? []; foreach ($win as $id) { if (isset($mw[$id])) { [$c, $t, $r, $type] = [$mw[$id], null, null, 'manual']; } else { if (!in_array($snap['roulette_rule'], ['by_unit', 'by_position'], true)) { throw new RuntimeException('roulette rule is not set for a win100 entry'); } $codes = []; $ws = []; foreach ($rem as $c => $q) { if ($q > 0) { $codes[] = $c; $ws[] = $snap['roulette_rule'] === 'by_unit' ? $q : 1; } } $t = array_sum($ws); $r = $s->uniform($t); $c = $codes[v_pick($ws, $r)]; $type = 'roulette'; } $rem[$c]--; $unitNo[$c]++; $awarded[$c]++; $w100[] = [$id, $c, $unitNo[$c], $t, $r, $type]; } $nb = []; $qo = []; foreach ($balance as $c => $q) { $nb[] = [$c, $q - $awarded[$c]]; $qo[] = [$c, $quota[$c]]; } return [ 'n_random' => $n, 'n_win100' => count($win), 'column' => $col, 'quotas' => $qo, 'winners' => $winners, 'reserve' => $reserve, 'remainder_after_quota' => $remOut, 'win100' => $w100, 'new_balance' => $nb, 'stream_blocks' => $s->counter, ]; } function v_fetch(string $url): ?array { $ctx = stream_context_create(['http' => ['timeout' => 15], 'ssl' => ['verify_peer' => true]]); $body = @file_get_contents($url, false, $ctx); $j = is_string($body) ? json_decode($body, true) : null; return is_array($j) ? $j : null; } $file = $argv[1] ?? ''; $online = in_array('--online', $argv, true); if ($file === '' || !is_file($file)) { fwrite(STDERR, "Использование: php verify.php protocol.json [--online]\n"); exit(2); } $p = json_decode((string)file_get_contents($file), true, 512, JSON_THROW_ON_ERROR); $fail = 0; $check = static function (string $name, bool $ok, string $detail = '') use (&$fail): void { echo ($ok ? 'OK ' : 'FAIL ').$name.($detail !== '' ? ' — '.$detail : '')."\n"; if (!$ok) { $fail++; } }; $algo = v_algo_for_wave((string)($p['wave'] ?? '')); $check('алгоритм '.$algo.' для волны '.($p['wave'] ?? '?'), ($p['algo'] ?? '') === $algo && ($p['snapshot']['algo'] ?? '') === $algo && ($p['snapshot']['wave'] ?? '') === ($p['wave'] ?? '')); $prod = ($p['run_type'] ?? '') === 'production'; if (!$prod) { echo "ВНИМАНИЕ: тестовый протокол, не итог волны. Проверяется только воспроизводимость по seed.\n"; } $snapHash = hash('sha256', v_canonical($p['snapshot'])); $check('хеш снимка заявок', $snapHash === $p['snapshot_hash'], $snapHash); if ($prod) { $secret = hex2bin((string)$p['secret_hex']); $check('секрет соответствует обязательству', strlen((string)$secret) === 32 && hash('sha256', $secret) === $p['commit']['hash']); $close = (new DateTimeImmutable($p['wave_close'], new DateTimeZone('Europe/Moscow')))->getTimestamp(); $commitTs = (new DateTimeImmutable($p['commit']['committed_at'], new DateTimeZone('Europe/Moscow')))->getTimestamp(); $check('обязательство опубликовано до закрытия приёма', $commitTs < $close, $p['commit']['committed_at'].' < '.$p['wave_close']); $b = $p['beacon']; $expectedRound = intdiv($close - V_GENESIS, V_PERIOD) + 2; $check('раунд маяка — первый после закрытия приёма', (int)$b['round'] === $expectedRound, 'раунд '.$b['round'].', ожидался '.$expectedRound); $check('цепочка drand default', $b['chain_hash'] === V_CHAIN); $check('randomness = SHA-256(signature)', hash('sha256', hex2bin($b['signature'])) === $b['randomness']); if ($online) { $seen = 0; foreach (V_RELAYS as $relay) { $j = v_fetch($relay.'/public/'.$b['round']); if ($j !== null) { $seen++; $check('подпись раунда у '.$relay, ($j['signature'] ?? '') === $b['signature']); } } $check('маяк доступен онлайн', $seen > 0, $seen === 0 ? 'нет данных маяка' : ''); } $seed = hash('sha256', $secret.hex2bin($b['randomness']).$p['wave'].hex2bin($snapHash), true); $check('зерно', bin2hex($seed) === $p['seed_hex']); $manual = []; $check('ручных назначений нет', empty($p['manual'])); } else { $seed = hex2bin((string)$p['seed_hex']); $manual = $p['manual'] ?? []; } try { $replay = v_draw($p['snapshot'], $seed, is_array($manual) ? $manual : []); $check('повтор розыгрыша совпадает с протоколом', v_canonical($replay) === v_canonical($p['results'])); $check('хеш результата', hash('sha256', v_canonical($replay)) === $p['result_hash']); } catch (Throwable $e) { $check('повтор розыгрыша', false, $e->getMessage()); } $entries = []; foreach (array_merge( array_column($p['results']['winners'], 2), array_column($p['results']['reserve'], 1), array_column($p['results']['win100'], 0) ) as $id) { if ($id !== null) { $entries[] = $id; } } $check('одна заявка — не более одного приза или места в резерве', count($entries) === count(array_unique($entries))); if (!empty($p['corrections'])) { echo 'Исправлений: '.count($p['corrections']).' (версия протокола '.$p['protocol_version'].")\n"; } echo $fail === 0 ? "ИТОГ: протокол подтверждён\n" : "ИТОГ: найдено расхождений: {$fail}\n"; exit($fail === 0 ? 0 : 1);