id)->lockForUpdate()->value('id'); $last = PollLedgerEntry::where('poll_id', $poll->id)->orderByDesc('sequence')->first(); $attributes = [ 'poll_id' => $poll->id, 'sequence' => $last ? $last->sequence + 1 : 1, 'format_version' => self::FORMAT_VERSION, 'entry_type' => $type, 'fingerprint' => $fingerprint, 'refers_to_sequence' => $refersToSequence, 'payload' => $payload, 'recorded_at' => $this->recordedAt(), 'previous_hash' => $last ? $last->hash : self::GENESIS_HASH, ]; $attributes['hash'] = $this->hash($attributes); return PollLedgerEntry::create($attributes); } /** * Ballot payload: the normalized answers as a list ordered by question id. * Free text is stored as its SHA-256 only: the public ledger must not reveal it (it could * identify people), while the hash still commits to it. The text itself is in poll_answers. * * @param array $normalized keyed by question id */ public function ballotPayload(array $normalized): array { ksort($normalized); $answers = []; foreach ($normalized as $questionId => $answer) { $text = $answer['text'] ?? null; $answers[] = [ 'question_id' => (int) $questionId, 'value' => $answer['value'] ?? null, 'text_sha256' => $text !== null && $text !== '' ? hash('sha256', $text) : null, ]; } return ['answers' => $answers]; } /** * Type-neutral tally: how often each distinct value was given per question, as a sorted list of * {question_id, value (canonical JSON), count}. A list rather than a map, so PHP and the browser * encode it identically (PHP turns maps with keys 0, 1, 2... into JSON lists). * * @param iterable $answers * @return array */ public function tally(iterable $answers): array { $counts = []; foreach ($answers as $answer) { if (($answer['value'] ?? null) === null) { continue; } $key = (int) $answer['question_id'] . "\0" . $this->canonical($answer['value']); $counts[$key] = ($counts[$key] ?? 0) + 1; } $tally = []; foreach ($counts as $key => $count) { [$questionId, $value] = explode("\0", $key, 2); $tally[] = ['question_id' => (int) $questionId, 'value' => $value, 'count' => $count]; } // Byte order (strcmp), not PHP's numeric-string comparison: the browser sorts the same way usort($tally, fn ($a, $b) => ($a['question_id'] <=> $b['question_id']) ?: strcmp($a['value'], $b['value'])); return $tally; } /** Tally of the ballots that count in the ledger. */ public function ledgerTally(Poll $poll): array { return $this->tally($this->activeBallots($poll)->flatMap(fn ($entry) => $entry->payload['answers'] ?? [])); } /** Tally of the published results (poll_answers of the current votes). */ public function publishedTally(Poll $poll): array { $answers = \App\Models\PollAnswer::whereIn('poll_response_id', $poll->responses()->select('id')) ->get(['poll_question_id', 'value']) ->map(fn ($answer) => ['question_id' => $answer->poll_question_id, 'value' => $answer->value]); return $this->tally($answers); } /** * Seal a closed poll: a final entry committing to the number of counted ballots and the tally. * Idempotent; withdrawals after the seal (deleted profiles) are appended after it. */ public function seal(Poll $poll): ?PollLedgerEntry { return DB::transaction(function () use ($poll) { $poll = Poll::whereKey($poll->id)->lockForUpdate()->first(); if (!$poll || $poll->sealed_at) { return null; } $entry = $this->append($poll, PollLedgerEntry::SEAL, [ 'active_ballots' => $this->activeBallots($poll)->count(), 'tally_sha256' => hash('sha256', $this->canonical($this->ledgerTally($poll))), ]); $poll->update(['sealed_at' => now(), 'seal_hash' => $entry->hash]); return $entry; }); } /** * An entry as published in the ledger JSON: exactly the hashed fields, plus its hash. */ public function publicEntry(PollLedgerEntry $entry): array { return [ 'v' => (int) $entry->format_version, 'poll_id' => (int) $entry->poll_id, 'sequence' => (int) $entry->sequence, 'entry_type' => $entry->entry_type, 'fingerprint' => $entry->fingerprint, 'refers_to_sequence' => $entry->refers_to_sequence !== null ? (int) $entry->refers_to_sequence : null, 'payload' => $entry->payload, 'recorded_at' => $entry->recorded_at, 'previous_hash' => $entry->previous_hash, 'hash' => $entry->hash, ]; } /** SHA-256 of the canonical form of an entry. */ public function hash(array $entry): string { $version = (int) ($entry['format_version'] ?? self::FORMAT_VERSION); if ($version !== self::FORMAT_VERSION) { throw new \UnexpectedValueException("Unknown poll ledger format version [$version]"); } return hash('sha256', $this->canonical([ 'v' => $version, 'poll_id' => (int) $entry['poll_id'], 'sequence' => (int) $entry['sequence'], 'entry_type' => $entry['entry_type'], 'fingerprint' => $entry['fingerprint'] ?? null, 'refers_to_sequence' => isset($entry['refers_to_sequence']) ? (int) $entry['refers_to_sequence'] : null, 'payload' => $entry['payload'] ?? null, 'recorded_at' => $this->timestamp($entry['recorded_at']), 'previous_hash' => $entry['previous_hash'], ])); } public function canonical($value): string { return json_encode( $this->sortKeys($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_LINE_TERMINATORS | JSON_THROW_ON_ERROR ); } private function sortKeys($value) { if (!is_array($value)) { return $value; } if (!array_is_list($value)) { ksort($value, SORT_STRING); } return array_map(fn ($item) => $this->sortKeys($item), $value); } /** * When an entry is recorded: the start of the day, in the site's time zone (security audit * F10). The ledger is public and an exact time could link a ballot to someone known to * have voted then; the order is kept by the sequence numbers. Part of the hash, so it is * rounded when recorded, not when shown. */ public function recordedAt(): string { return $this->timestamp(now(config('app.timezone'))->startOfDay()); } /** UTC, second precision: 2026-10-10T17:00:00Z (stored as is, so no timezone conversion on read) */ public function timestamp(\DateTimeInterface|string $dateTime): string { if (is_string($dateTime)) { return $dateTime; } return \Illuminate\Support\Carbon::instance($dateTime)->utc()->format('Y-m-d\TH:i:s\Z'); } /** * A new receipt code: 20 characters in groups of five, e.g. 7KQ2M-XH4PD-... */ public function newReceiptCode(): string { $code = ''; $max = strlen(self::RECEIPT_ALPHABET) - 1; for ($i = 0; $i < 20; $i++) { $code .= self::RECEIPT_ALPHABET[random_int(0, $max)]; } return implode('-', str_split($code, 5)); } /** Fingerprint stored in the ledger; tolerant of case, spaces and dashes in the entered code. */ public function fingerprint(string $receiptCode): string { return hash('sha256', preg_replace('/[^A-Z0-9]/', '', Str::upper($receiptCode))); } /** * Recompute the whole chain. * * @return array{valid: bool, entries: int, broken_at: ?int, head: string} */ public function verifyChain(Poll $poll): array { $previous = self::GENESIS_HASH; $expectedSequence = 1; $count = 0; foreach (PollLedgerEntry::where('poll_id', $poll->id)->orderBy('sequence')->cursor() as $entry) { $attributes = $entry->getAttributes(); $attributes['payload'] = $entry->payload; if ($entry->sequence !== $expectedSequence || $entry->previous_hash !== $previous || !hash_equals($this->hash($attributes), $entry->hash)) { return ['valid' => false, 'entries' => $count, 'broken_at' => $entry->sequence, 'head' => $previous]; } $previous = $entry->hash; $expectedSequence++; $count++; } return ['valid' => true, 'entries' => $count, 'broken_at' => null, 'head' => $previous]; } /** * Ballots that count: cast / change entries not replaced by a later change or withdraw. * * @return Collection */ public function activeBallots(Poll $poll): Collection { $entries = PollLedgerEntry::where('poll_id', $poll->id)->orderBy('sequence')->get(); $replaced = $entries->pluck('refers_to_sequence')->filter()->flip(); return $entries->filter(fn ($entry) => $entry->isBallot() && !$replaced->has($entry->sequence))->values(); } /** * Status of the ballot behind a receipt code. * * @return array{entry: PollLedgerEntry, status: string, replaced_by: ?PollLedgerEntry}|null * status: 'counted' | 'replaced' | 'withdrawn'; null when the code is unknown for this poll */ public function lookupReceipt(Poll $poll, string $receiptCode): ?array { $entry = PollLedgerEntry::where('poll_id', $poll->id) ->where('fingerprint', $this->fingerprint($receiptCode)) ->first(); if ($entry === null) { return null; } $replacedBy = PollLedgerEntry::where('poll_id', $poll->id) ->where('refers_to_sequence', $entry->sequence) ->first(); $status = match ($replacedBy?->entry_type) { null => 'counted', PollLedgerEntry::WITHDRAW => 'withdrawn', default => 'replaced', }; return ['entry' => $entry, 'status' => $status, 'replaced_by' => $replacedBy]; } }