// The receipt of a vote on the Ğ1 chain (ADR 0008): the ballot itself — its // lines and its salt — as the person who asked for it downloads it // (`GET /api/ballots/{id}/receipt`). Checked here, in the browser, without asking // the server anything: the canonical text, its blake2b-256 seal, and the comment // `ld1:` + seal that the chain carries. The same rules as the engine // (crates/ld-engine/src/ballot.rs, `canonical` and `seal`). import { blake2b } from '@noble/hashes/blake2.js'; export type ReceiptChoice = 'for' | 'against' | 'none'; export interface ReceiptLine { proposalId: string; revision: number; choice: ReceiptChoice; /** For reading only: the article and the title of the version voted. */ article?: string; title?: string | null; } export interface Sealed { canonical: string; digest: string; comment: string; /** What the indexer finds the comment by: `0x` + blake2b-256 of the comment. */ remarkHash: string; } export type ReceiptCheck = | { kind: 'unreadable' } | { kind: 'ok'; comment: string; remarkHash: string; block: number | null; lines: ReceiptLine[] } | { kind: 'mismatch'; comment: string; computed: string }; const CHOICES: readonly string[] = ['for', 'against', 'none']; /** Printable ASCII without spaces: what reads back, one field per line. */ const PLAIN = /^[\x21-\x7e]+$/; const SALT = /^[0-9a-f]{32}$/; const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); export function blake2_256(text: string): string { return hex(blake2b(new TextEncoder().encode(text), { dkLen: 32 })); } /** The ballot as it is hashed; `null` if it cannot be one. */ export function canonical(doc: string, author: string | null, lines: ReceiptLine[], salt: string): string | null { if (!PLAIN.test(doc) || (author !== null && !PLAIN.test(author)) || !SALT.test(salt)) return null; const sorted = [...lines].sort((a, b) => (a.proposalId < b.proposalId ? -1 : a.proposalId > b.proposalId ? 1 : 0)); if (!sorted.length) return null; for (const [i, l] of sorted.entries()) { if (!PLAIN.test(l.proposalId) || (i > 0 && sorted[i - 1]?.proposalId === l.proposalId)) return null; if (!Number.isInteger(l.revision) || l.revision < 0 || l.revision > 0xffff_ffff || !CHOICES.includes(l.choice)) return null; } let out = `ld1\ndoc ${doc}\nauthor ${author ?? '-'}\n`; for (const l of sorted) out += `line ${l.proposalId} ${l.revision} ${l.choice}\n`; return `${out}salt ${salt}\n`; } export function seal(doc: string, author: string | null, lines: ReceiptLine[], salt: string): Sealed | null { const text = canonical(doc, author, lines, salt); if (text === null) return null; const digest = blake2_256(text); const comment = `ld1:${digest}`; return { canonical: text, digest, comment, remarkHash: `0x${blake2_256(comment)}` }; } const isLine = (l: unknown): l is ReceiptLine => { const v = l as Partial | null; return !!v && typeof v.proposalId === 'string' && typeof v.revision === 'number' && typeof v.choice === 'string'; }; /** Reads a receipt (its text, as downloaded) and recomputes its seal. */ export function checkReceipt(text: string): ReceiptCheck { let raw: unknown; try { raw = JSON.parse(text); } catch { return { kind: 'unreadable' }; } const r = raw as Record | null; if (!r || typeof r !== 'object' || Array.isArray(r) || r.format !== 'ld1') return { kind: 'unreadable' }; const { doc, author, lines, salt, comment, block } = r; if (typeof doc !== 'string' || typeof salt !== 'string' || typeof comment !== 'string') return { kind: 'unreadable' }; if (author !== null && typeof author !== 'string') return { kind: 'unreadable' }; if (!Array.isArray(lines) || !lines.every(isLine)) return { kind: 'unreadable' }; const s = seal(doc, author, lines, salt); if (!s) return { kind: 'unreadable' }; if (s.comment !== comment) return { kind: 'mismatch', comment, computed: s.comment }; return { kind: 'ok', comment, remarkHash: s.remarkHash, block: typeof block === 'number' ? block : null, lines }; }